From 453c4154c0c222c70da053e2405155e006ad09c6 Mon Sep 17 00:00:00 2001 From: Lakr <2333owo@gmail.com> Date: Wed, 7 Oct 2026 21:59:41 +0900 Subject: [PATCH 1/3] feat!: LookInside 3.0, a Swift Host with activation built in LookInside 3.0 is a rewrite of the Mac app in Swift. - Activation runs inside the app (Packages/LookInsideActivation). The separate lookinside-auth-server helper is no longer installed, launched or updated. Existing activations carry over: the app reads the same state file and keychain key, so the first signing asks for the login keychain password once (choose Always Allow), and going back to 2.3.x keeps working. - The connection to inspected apps uses our own frame transport and usbmuxd client instead of the vendored Peertalk. The bytes on the wire are unchanged, so Servers 0.2.9 and earlier still connect, and LookInside Server 1.0.0 is supported. - LookinCore is mirrored from LookInside-Server 1.0.0 (C headers and Swift); the Objective-C Host, ReactiveObjC, ShortCocoa and the bundled LookinServer sources are gone. - Gesture Debug and Attach to App on Device are carried over, the latter on the new usbmuxd client. - The MCP bridge and the bundled lookinside-mcp CLI are removed. - Requires macOS 14 or later. - Release builds are checked for debug overrides and scanned for private keys; CI runs gitleaks. BREAKING CHANGE: requires macOS 14; the MCP server is no longer included. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 64 +- .../workflows/publish-existing-appcast.yml | 22 + .github/workflows/release.yml | 112 +- Configuration/Version.xcconfig | 2 +- LookInside.xcodeproj/project.pbxproj | 855 +-- .../xcshareddata/swiftpm/Package.resolved | 38 +- .../xcshareddata/swiftpm/Package.resolved | 38 +- LookInside/About/LKAboutViewController.h | 13 - LookInside/About/LKAboutViewController.m | 152 - LookInside/About/LKAboutWindowController.h | 13 - LookInside/About/LKAboutWindowController.m | 41 - .../About/LKAboutWindowController.swift | 193 + LookInside/AppDelegate.h | 14 - LookInside/AppDelegate.m | 219 - LookInside/AppDelegate.swift | 205 + .../LookinAttributesGroup+LookinClient.h | 21 - .../LookinAttributesGroup+LookinClient.m | 33 - .../LookinAttributesGroup+LookinClient.swift | 30 + .../LookinAttributesSection+LookinClient.h | 17 - .../LookinAttributesSection+LookinClient.m | 13 - .../LookinAutoLayoutConstraint+LookinClient.h | 18 - .../LookinAutoLayoutConstraint+LookinClient.m | 129 - ...kinAutoLayoutConstraint+LookinClient.swift | 61 + ...LookinCustomDisplayItemInfo+LookinClient.h | 21 - ...LookinCustomDisplayItemInfo+LookinClient.m | 13 - .../Category/LookinDisplayItem+LookinClient.h | 81 - .../Category/LookinDisplayItem+LookinClient.m | 623 --- .../LookinDisplayItem+LookinClient.swift | 402 ++ .../Base/Category/LookinObject+LookinClient.h | 19 - .../Base/Category/LookinObject+LookinClient.m | 31 - .../Category/LookinObject+LookinClient.swift | 20 + .../Base/Category/NSAppearance+LookinClient.h | 15 - .../Base/Category/NSAppearance+LookinClient.m | 27 - .../Category/NSAppearance+LookinClient.swift | 14 + .../Base/Category/NSArray+LookinClient.h | 16 - .../Base/Category/NSArray+LookinClient.m | 26 - .../Base/Category/NSArray+LookinClient.swift | 19 + .../Base/Category/NSButton+LookinClient.h | 17 - .../Base/Category/NSButton+LookinClient.m | 34 - .../Base/Category/NSButton+LookinClient.swift | 33 + .../Base/Category/NSColor+LookinClient.h | 25 - .../Base/Category/NSColor+LookinClient.m | 123 - .../Base/Category/NSColor+LookinClient.swift | 58 + .../Base/Category/NSControl+LookinClient.h | 22 - .../Base/Category/NSControl+LookinClient.m | 30 - .../Category/NSControl+LookinClient.swift | 27 + .../Category/NSPointerArray+LookinClient.h | 17 - .../Category/NSPointerArray+LookinClient.m | 37 - .../Base/Category/NSString+LookinClient.h | 17 - .../Base/Category/NSString+LookinClient.m | 22 - .../Base/Category/NSString+LookinClient.swift | 32 + LookInside/Base/Category/NSString+Score.h | 30 - LookInside/Base/Category/NSString+Score.m | 164 - .../Base/Category/NSView+LookinClient.h | 22 - .../Base/Category/NSView+LookinClient.m | 43 - .../Base/Category/NSView+LookinClient.swift | 46 + .../Base/Category/ShortCocoa+LookinClient.h | 14 - .../Base/Category/ShortCocoa+LookinClient.m | 117 - .../InputSearch/LKInputSearchSuggestionItem.h | 16 - .../InputSearch/LKInputSearchSuggestionItem.m | 13 - .../LKInputSearchSuggestionWindowController.h | 17 - .../LKInputSearchSuggestionWindowController.m | 29 - .../LKInputSearchSuggestionsContentView.h | 23 - .../LKInputSearchSuggestionsContentView.m | 110 - .../LKInputSearchSuggestionsRowView.h | 18 - .../LKInputSearchSuggestionsRowView.m | 52 - .../Base/InputSearch/LKInputSearchView.h | 33 - .../Base/InputSearch/LKInputSearchView.m | 155 - .../Base/InputSearch/LKInputSearchView.swift | 341 ++ .../NSView+LKPeripheralLayout.swift | 196 + LookInside/Base/LKBaseControl.h | 30 - LookInside/Base/LKBaseControl.m | 91 - LookInside/Base/LKBaseControl.swift | 94 + LookInside/Base/LKBaseView.h | 66 - LookInside/Base/LKBaseView.m | 226 - LookInside/Base/LKBaseView.swift | 235 + LookInside/Base/LKBaseViewController.h | 34 - LookInside/Base/LKBaseViewController.m | 139 - LookInside/Base/LKBaseViewController.swift | 108 + LookInside/Base/LKColorIndicatorLayer.h | 19 - LookInside/Base/LKColorIndicatorLayer.m | 108 - LookInside/Base/LKColorIndicatorLayer.swift | 100 + LookInside/Base/LKConnectionTipsBinding.swift | 46 + LookInside/Base/LKDeviceIconProvider.swift | 10 +- LookInside/Base/LKFrameLayout.swift | 479 ++ LookInside/Base/LKImageControl.h | 18 - LookInside/Base/LKImageControl.m | 50 - LookInside/Base/LKImageTextView.h | 18 - LookInside/Base/LKImageTextView.m | 37 - LookInside/Base/LKImageTextView.swift | 42 + LookInside/Base/LKLabel.h | 21 - LookInside/Base/LKLabel.m | 61 - LookInside/Base/LKLabel.swift | 67 + LookInside/Base/LKNumberInputView.h | 33 - LookInside/Base/LKNumberInputView.m | 161 - LookInside/Base/LKNumberInputView.swift | 145 + LookInside/Base/LKOutlineItem.h | 31 - LookInside/Base/LKOutlineItem.m | 51 - LookInside/Base/LKOutlineRowView.h | 44 - LookInside/Base/LKOutlineRowView.m | 131 - LookInside/Base/LKOutlineRowView.swift | 133 + LookInside/Base/LKOutlineView.h | 35 - LookInside/Base/LKOutlineView.m | 128 - LookInside/Base/LKPanelContentView.h | 29 - LookInside/Base/LKPanelContentView.m | 95 - LookInside/Base/LKPerformanceReporter.h | 25 - LookInside/Base/LKPerformanceReporter.m | 73 - LookInside/Base/LKPerformanceReporter.swift | 30 + LookInside/Base/LKPopPanel.h | 15 - LookInside/Base/LKPopPanel.m | 34 - LookInside/Base/LKPopPanel.swift | 31 + LookInside/Base/LKProgressIndicatorView.h | 26 - LookInside/Base/LKProgressIndicatorView.m | 82 - LookInside/Base/LKProgressIndicatorView.swift | 89 + LookInside/Base/LKSplitView.h | 15 - LookInside/Base/LKSplitView.m | 39 - LookInside/Base/LKSplitView.swift | 27 + LookInside/Base/LKTableRowView.h | 31 - LookInside/Base/LKTableRowView.m | 95 - LookInside/Base/LKTableRowView.swift | 123 + LookInside/Base/LKTableView.h | 52 - LookInside/Base/LKTableView.m | 316 -- LookInside/Base/LKTableView.swift | 312 ++ .../LKTableViewHorizontalScrollWidthManager.h | 19 - .../LKTableViewHorizontalScrollWidthManager.m | 22 - ...ableViewHorizontalScrollWidthManager.swift | 25 + LookInside/Base/LKTextControl.h | 21 - LookInside/Base/LKTextControl.m | 88 - LookInside/Base/LKTextControl.swift | 91 + LookInside/Base/LKTextFieldView.h | 27 - LookInside/Base/LKTextFieldView.m | 137 - LookInside/Base/LKTextFieldView.swift | 148 + LookInside/Base/LKTextsMenuView.h | 35 - LookInside/Base/LKTextsMenuView.m | 209 - LookInside/Base/LKTextsMenuView.swift | 178 + LookInside/Base/LKTipsView.h | 52 - LookInside/Base/LKTipsView.m | 294 - LookInside/Base/LKTipsView.swift | 279 + LookInside/Base/LKTwoColors.h | 24 - LookInside/Base/LKTwoColors.m | 29 - LookInside/Base/LKTwoColors.swift | 36 + LookInside/Base/LKUserActionManager.h | 37 - LookInside/Base/LKUserActionManager.m | 62 - LookInside/Base/LKUserActionManager.swift | 58 + LookInside/Base/LKWindow.h | 17 - LookInside/Base/LKWindow.m | 44 - LookInside/Base/LKWindow.swift | 35 + LookInside/Base/LKWindowController.h | 14 - LookInside/Base/LKWindowController.m | 17 - LookInside/Base/LKWindowController.swift | 16 + .../Base/LookinClient-Bridging-Header.h | 58 +- .../Base/Message/LKPeripheralAlerts.swift | 45 + LookInside/Base/Message/LookinMsgAttribute.h | 83 - LookInside/Base/Message/LookinMsgAttribute.m | 195 - .../Base/Message/LookinMsgAttribute.swift | 200 + .../Base/Message/LookinMsgTargetAction.h | 26 - .../Base/Message/LookinMsgTargetAction.m | 37 - LookInside/Connection/LKAppsManager.h | 22 - LookInside/Connection/LKAppsManager.m | 123 - LookInside/Connection/LKAppsManager.swift | 153 + LookInside/Connection/LKChannel.swift | 105 + .../Connection/LKConnectionErrors.swift | 122 + .../LKConnectionManager+License.swift | 233 + LookInside/Connection/LKConnectionManager.h | 48 - LookInside/Connection/LKConnectionManager.m | 896 --- .../Connection/LKConnectionManager.swift | 590 ++ LookInside/Connection/LKConnectionRequest.h | 38 - LookInside/Connection/LKConnectionRequest.m | 32 - .../Connection/LKConnectionRequest.swift | 97 + LookInside/Connection/LKInspectableApp.h | 83 - LookInside/Connection/LKInspectableApp.m | 200 - LookInside/Connection/LKInspectableApp.swift | 288 + .../Connection/LKServerUpgradeHint.swift | 50 + LookInside/Connection/LookinLiveDocument.h | 79 - LookInside/Connection/LookinLiveDocument.m | 293 - .../Connection/LookinLiveDocument.swift | 212 + .../Connection/LookinLiveDocumentController.h | 45 - .../Connection/LookinLiveDocumentController.m | 97 - .../LookinLiveDocumentController.swift | 64 + LookInside/Console/LKConsoleDataSource.h | 44 - LookInside/Console/LKConsoleDataSource.m | 193 - LookInside/Console/LKConsoleDataSource.swift | 169 + .../Console/LKConsoleDataSourceRowItem.h | 26 - .../Console/LKConsoleDataSourceRowItem.m | 14 - LookInside/Console/LKConsoleInputRowView.h | 19 - LookInside/Console/LKConsoleInputRowView.m | 147 - LookInside/Console/LKConsoleReturnRowView.h | 15 - LookInside/Console/LKConsoleReturnRowView.m | 42 - LookInside/Console/LKConsoleRowViews.swift | 215 + .../LKConsoleSelectPopoverController.h | 24 - .../LKConsoleSelectPopoverController.m | 237 - .../LKConsoleSelectPopoverController.swift | 330 ++ .../LKConsoleSelectPopoverItemControl.h | 21 - .../LKConsoleSelectPopoverItemControl.m | 96 - LookInside/Console/LKConsoleSubmitRowView.h | 13 - LookInside/Console/LKConsoleSubmitRowView.m | 41 - LookInside/Console/LKConsoleViewController.h | 25 - LookInside/Console/LKConsoleViewController.m | 214 - .../Console/LKConsoleViewController.swift | 189 + LookInside/DanceScript/DanceScriptManager.h | 17 - LookInside/DanceScript/DanceScriptManager.m | 119 - .../DanceScript/DanceScriptManager.swift | 111 + .../LKConstraintPopoverController.h | 25 - .../LKConstraintPopoverController.m | 133 - .../LKConstraintPopoverController.swift | 118 + ...DashboardAttributeConstraintsItemControl.h | 17 - ...DashboardAttributeConstraintsItemControl.m | 92 - .../LKDashboardAttributeConstraintsView.h | 13 - .../LKDashboardAttributeConstraintsView.m | 142 - .../LKDashboardAttributeConstraintsView.swift | 163 + .../JSON/LKDashboardAttributeJsonView.h | 19 - .../JSON/LKDashboardAttributeJsonView.m | 71 - .../JSON/LKJSONAttributeContentView.h | 21 - .../JSON/LKJSONAttributeContentView.m | 206 - .../AttributeView/JSON/LKJSONAttributeItem.h | 22 - .../AttributeView/JSON/LKJSONAttributeItem.m | 37 - .../JSON/LKJSONAttributeItem.swift | 66 + .../JSON/LKJSONAttributeViewController.h | 15 - .../JSON/LKJSONAttributeViewController.m | 166 - .../JSON/LKJSONAttributeViews.swift | 299 + .../JSON/LKJSONAttributeWindowController.h | 13 - .../JSON/LKJSONAttributeWindowController.m | 34 - .../LKDashboardAttributeClassView.h | 13 - .../LKDashboardAttributeClassView.m | 25 - .../LKDashboardAttributeColorView.h | 13 - .../LKDashboardAttributeColorView.m | 235 - .../LKDashboardAttributeColorView.swift | 209 + .../LKDashboardAttributeEnumsView.h | 13 - .../LKDashboardAttributeEnumsView.m | 199 - .../LKDashboardAttributeEnumsView.swift | 153 + .../LKDashboardAttributeGeometryViews.swift | 268 + .../LKDashboardAttributeInsetsView.h | 13 - .../LKDashboardAttributeInsetsView.m | 169 - .../LKDashboardAttributeNumberInputView.h | 17 - .../LKDashboardAttributeNumberInputView.m | 239 - .../LKDashboardAttributeNumberInputView.swift | 197 + .../LKDashboardAttributeOpenImageView.h | 13 - .../LKDashboardAttributeOpenImageView.m | 118 - .../LKDashboardAttributePointView.h | 13 - .../LKDashboardAttributePointView.m | 143 - ...shboardAttributePrivateDiscriminatorView.h | 10 - ...shboardAttributePrivateDiscriminatorView.m | 601 -- ...ardAttributePrivateDiscriminatorView.swift | 538 ++ .../LKDashboardAttributeReadOnlyViews.swift | 254 + .../LKDashboardAttributeRectView.h | 13 - .../LKDashboardAttributeRectView.m | 189 - .../LKDashboardAttributeRelationView.h | 13 - .../LKDashboardAttributeRelationView.m | 67 - .../LKDashboardAttributeRowsCountView.h | 13 - .../LKDashboardAttributeRowsCountView.m | 85 - .../LKDashboardAttributeShadowView.h | 17 - .../LKDashboardAttributeShadowView.m | 136 - .../LKDashboardAttributeSizeView.h | 13 - .../LKDashboardAttributeSizeView.m | 153 - .../LKDashboardAttributeStringArrayView.h | 16 - .../LKDashboardAttributeStringArrayView.m | 154 - .../LKDashboardAttributeStringArrayView.swift | 187 + .../LKDashboardAttributeSwitchView.h | 13 - .../LKDashboardAttributeSwitchView.m | 95 - .../LKDashboardAttributeSwitchView.swift | 88 + .../LKDashboardAttributeTextView.h | 13 - .../LKDashboardAttributeTextView.m | 165 - .../LKDashboardAttributeTextView.swift | 138 + .../AttributeView/LKDashboardAttributeView.h | 36 - .../AttributeView/LKDashboardAttributeView.m | 41 - .../LKDashboardAttributeView.swift | 103 + .../LKDashboardTextControlEditingFlag.h | 21 - .../LKDashboardTextControlEditingFlag.m | 26 - .../LKDashboardAccessoryWindowController.h | 29 - .../LKDashboardAccessoryWindowController.m | 84 - ...LKDashboardAccessoryWindowController.swift | 77 + .../Dashboard/LKDashboardCardTitleControl.h | 18 - .../Dashboard/LKDashboardCardTitleControl.m | 56 - LookInside/Dashboard/LKDashboardCardView.h | 39 - LookInside/Dashboard/LKDashboardCardView.m | 512 -- .../Dashboard/LKDashboardCardView.swift | 493 ++ LookInside/Dashboard/LKDashboardLayout.swift | 436 ++ .../Dashboard/LKDashboardModification.swift | 177 + LookInside/Dashboard/LKDashboardSectionView.h | 29 - LookInside/Dashboard/LKDashboardSectionView.m | 418 -- .../Dashboard/LKDashboardSectionView.swift | 393 ++ .../Dashboard/LKDashboardSectionViewPool.h | 23 - .../Dashboard/LKDashboardSectionViewPool.m | 65 - .../Dashboard/LKDashboardViewController.h | 38 - .../Dashboard/LKDashboardViewController.m | 581 -- .../Dashboard/LKDashboardViewController.swift | 502 ++ LookInside/Dashboard/LKEnumListRegistry.h | 27 - LookInside/Dashboard/LKEnumListRegistry.m | 689 --- LookInside/Dashboard/LKEnumListRegistry.swift | 708 +++ .../Dashboard/Search/LKDashboardHeaderView.h | 29 - .../Dashboard/Search/LKDashboardHeaderView.m | 171 - .../Search/LKDashboardHeaderView.swift | 172 + .../Search/LKDashboardSearchCardView.h | 13 - .../Search/LKDashboardSearchCardView.m | 36 - .../LKDashboardSearchMethodsDataSource.h | 25 - .../LKDashboardSearchMethodsDataSource.m | 58 - .../Search/LKDashboardSearchMethodsView.h | 27 - .../Search/LKDashboardSearchMethodsView.m | 136 - .../Search/LKDashboardSearchPropView.h | 26 - .../Search/LKDashboardSearchPropView.m | 161 - .../Search/LKDashboardSearchViews.swift | 315 ++ LookInside/DebugE2E/LKDebugE2EDump.swift | 405 ++ .../DebugE2E/LKDebugE2EDumpWriter.swift | 174 + LookInside/DebugE2E/LKDebugUISnapshots.swift | 541 ++ LookInside/DebugE2E/LKDebugWindowImage.swift | 108 + .../DebugE2E/LookinSnapshotNormalizer.swift | 672 +++ LookInside/DerivedSource/README.md | 4 +- LookInside/Export/LKExportAccessoryView.h | 16 - LookInside/Export/LKExportAccessoryView.m | 103 - LookInside/Export/LKExportAccessoryView.swift | 103 + LookInside/Export/LKExportManager.h | 21 - LookInside/Export/LKExportManager.m | 150 - LookInside/Export/LKExportManager.swift | 121 + .../GestureDebug/LKGestureDebugSession.swift | 61 +- .../GestureDebug/LKGestureDebugView.swift | 10 +- .../LKNativeInteractionDetailView.swift | 2 +- .../GestureDebug/LKSuggestionsView.swift | 2 +- LookInside/Hierarchy/LKDanceUIAttrMaker.h | 16 - LookInside/Hierarchy/LKDanceUIAttrMaker.m | 73 - LookInside/Hierarchy/LKDanceUIAttrMaker.swift | 53 + LookInside/Hierarchy/LKHierarchyController.h | 24 - LookInside/Hierarchy/LKHierarchyController.m | 123 - .../Hierarchy/LKHierarchyController.swift | 115 + .../Hierarchy/LKHierarchyDataSource+KeyDown.h | 19 - .../Hierarchy/LKHierarchyDataSource+KeyDown.m | 67 - .../LKHierarchyDataSource+KeyDown.swift | 43 + LookInside/Hierarchy/LKHierarchyDataSource.h | 154 - LookInside/Hierarchy/LKHierarchyDataSource.m | 1274 ----- .../Hierarchy/LKHierarchyDataSource.swift | 1176 ++++ .../Hierarchy/LKHierarchyFrameLayout.swift | 263 + .../LKHierarchyHandlersPopoverController.h | 19 - .../LKHierarchyHandlersPopoverController.m | 79 - ...LKHierarchyHandlersPopoverController.swift | 264 + .../LKHierarchyHandlersPopoverItemView.h | 20 - .../LKHierarchyHandlersPopoverItemView.m | 210 - LookInside/Hierarchy/LKHierarchyRowView.h | 25 - LookInside/Hierarchy/LKHierarchyRowView.m | 512 -- LookInside/Hierarchy/LKHierarchyRowView.swift | 451 ++ LookInside/Hierarchy/LKHierarchyView.h | 61 - LookInside/Hierarchy/LKHierarchyView.m | 666 --- LookInside/Hierarchy/LKHierarchyView.swift | 649 +++ LookInside/Hierarchy/LKSyncSignal.swift | 73 + .../Device/LKAttachedDeviceMonitor.swift | 86 +- .../Device/LKDeviceControlClient.swift | 170 +- .../Device/LKDeviceControlWire.swift | 35 +- .../Device/LKDeviceInjectionFlow.swift | 29 +- .../LKInjectableFrameworkInstaller.swift | 8 +- LookInside/Injection/LKInjectionFlow.swift | 6 +- .../Injection/LKInjectionStartGate.swift | 2 +- LookInside/Launch/LKLaunchAppView.h | 20 - LookInside/Launch/LKLaunchAppView.m | 239 - LookInside/Launch/LKLaunchAppView.swift | 234 + LookInside/Launch/LKLaunchRules.swift | 36 + LookInside/Launch/LKLaunchViewController.h | 17 - LookInside/Launch/LKLaunchViewController.m | 296 - .../Launch/LKLaunchViewController.swift | 282 + LookInside/Launch/LKLaunchWindowController.h | 19 - LookInside/Launch/LKLaunchWindowController.m | 39 - .../Launch/LKLaunchWindowController.swift | 45 + LookInside/Localizable.xcstrings | 401 +- .../LKMCPBridgeAttributeEncoder.swift | 432 -- .../LKMCPBridgeAttributeValueDecoder.swift | 392 -- .../MCPBridge/LKMCPBridgeConnection.swift | 230 - .../MCPBridge/LKMCPBridgeDetailsService.swift | 409 -- .../MCPBridge/LKMCPBridgeEventPublisher.swift | 334 -- LookInside/MCPBridge/LKMCPBridgeFrame.swift | 183 - .../LKMCPBridgeInspectionModels.swift | 524 -- .../LKMCPBridgeInspectionService.swift | 355 -- .../LKMCPBridgeInvocationService.swift | 312 -- .../MCPBridge/LKMCPBridgeListenSocket.swift | 157 - .../LKMCPBridgeLiveDocumentLookup.swift | 99 - .../LKMCPBridgeModificationService.swift | 396 -- .../LKMCPBridgeObjectIdentifierList.swift | 89 - .../MCPBridge/LKMCPBridgeRACBridge.swift | 179 - .../MCPBridge/LKMCPBridgeRefreshService.swift | 247 - .../LKMCPBridgeScreenshotService.swift | 544 -- .../MCPBridge/LKMCPBridgeSearchQuery.swift | 162 - .../MCPBridge/LKMCPBridgeSearchService.swift | 318 -- .../LKMCPBridgeSecureContentDetector.swift | 46 - .../LKMCPBridgeSelectorService.swift | 327 -- LookInside/MCPBridge/LKMCPBridgeServer.swift | 441 -- LookInside/Manager/LKAppMenuManager.h | 37 - LookInside/Manager/LKAppMenuManager.m | 585 -- LookInside/Manager/LKAppMenuManager.swift | 502 ++ LookInside/Manager/LKHelper.h | 118 - LookInside/Manager/LKHelper.m | 230 - LookInside/Manager/LKHelper.swift | 182 + LookInside/Manager/LKNavigationManager.h | 43 - LookInside/Manager/LKNavigationManager.m | 144 - LookInside/Manager/LKNavigationManager.swift | 129 + LookInside/Manager/LKPreferenceManager.h | 147 - LookInside/Manager/LKPreferenceManager.m | 848 --- LookInside/Manager/LKPreferenceManager.swift | 730 +++ LookInside/Measure/LKMeasureController.h | 17 - LookInside/Measure/LKMeasureController.m | 218 - LookInside/Measure/LKMeasureController.swift | 228 + LookInside/Measure/LKMeasureResultLineData.h | 33 - LookInside/Measure/LKMeasureResultLineData.m | 35 - LookInside/Measure/LKMeasureResultView.h | 15 - LookInside/Measure/LKMeasureResultView.m | 496 -- LookInside/Measure/LKMeasureResultView.swift | 218 + LookInside/Other/LKVersionComparer.h | 23 - LookInside/Other/LKVersionComparer.m | 30 - LookInside/Other/LKVersionComparer.swift | 32 + LookInside/Preference/LKPreferenceView.swift | 4 +- .../Preference/LKPreferenceViewController.h | 13 - .../Preference/LKPreferenceViewController.m | 45 - .../Preference/LKPreferenceWindowController.h | 13 - .../Preference/LKPreferenceWindowController.m | 34 - .../LKPreferenceWindowController.swift | 64 + LookInside/PrefixHeaders.pch | 49 - ...iscriminatorSettingsWindowController.swift | 4 +- .../LKPrivateDiscriminatorStore.swift | 58 +- .../NSArray+RACSequenceAdditions.h | 24 - .../NSArray+RACSequenceAdditions.m | 18 - .../NSControl+RACCommandSupport.h | 26 - .../NSControl+RACCommandSupport.m | 57 - .../NSControl+RACTextSignalSupport.h | 28 - .../NSControl+RACTextSignalSupport.m | 38 - LookInside/ReactiveObjC/NSData+RACSupport.h | 26 - LookInside/ReactiveObjC/NSData+RACSupport.m | 35 - .../NSDictionary+RACSequenceAdditions.h | 35 - .../NSDictionary+RACSequenceAdditions.m | 34 - .../NSEnumerator+RACSequenceAdditions.h | 24 - .../NSEnumerator+RACSequenceAdditions.m | 22 - .../ReactiveObjC/NSFileHandle+RACSupport.h | 23 - .../ReactiveObjC/NSFileHandle+RACSupport.m | 40 - .../NSIndexSet+RACSequenceAdditions.h | 25 - .../NSIndexSet+RACSequenceAdditions.m | 18 - .../NSInvocation+RACTypeParsing.h | 56 - .../NSInvocation+RACTypeParsing.m | 232 - .../NSNotificationCenter+RACSupport.h | 22 - .../NSNotificationCenter+RACSupport.m | 31 - .../ReactiveObjC/NSObject+RACAppKitBindings.h | 35 - .../ReactiveObjC/NSObject+RACAppKitBindings.m | 146 - .../ReactiveObjC/NSObject+RACDeallocating.h | 30 - .../ReactiveObjC/NSObject+RACDeallocating.m | 103 - .../ReactiveObjC/NSObject+RACDescription.h | 16 - .../ReactiveObjC/NSObject+RACDescription.m | 50 - .../ReactiveObjC/NSObject+RACKVOWrapper.h | 46 - .../ReactiveObjC/NSObject+RACKVOWrapper.m | 200 - LookInside/ReactiveObjC/NSObject+RACLifting.h | 51 - LookInside/ReactiveObjC/NSObject+RACLifting.m | 78 - .../NSObject+RACPropertySubscribing.h | 120 - .../NSObject+RACPropertySubscribing.m | 84 - .../ReactiveObjC/NSObject+RACSelectorSignal.h | 84 - .../ReactiveObjC/NSObject+RACSelectorSignal.m | 329 -- .../NSOrderedSet+RACSequenceAdditions.h | 24 - .../NSOrderedSet+RACSequenceAdditions.m | 19 - .../ReactiveObjC/NSSet+RACSequenceAdditions.h | 24 - .../ReactiveObjC/NSSet+RACSequenceAdditions.m | 19 - .../NSString+RACKeyPathUtilities.h | 34 - .../NSString+RACKeyPathUtilities.m | 36 - .../NSString+RACSequenceAdditions.h | 25 - .../NSString+RACSequenceAdditions.m | 18 - LookInside/ReactiveObjC/NSString+RACSupport.h | 26 - LookInside/ReactiveObjC/NSString+RACSupport.m | 37 - .../ReactiveObjC/NSText+RACSignalSupport.h | 23 - .../ReactiveObjC/NSText+RACSignalSupport.m | 38 - .../ReactiveObjC/NSURLConnection+RACSupport.h | 30 - .../ReactiveObjC/NSURLConnection+RACSupport.m | 54 - .../ReactiveObjC/NSUserDefaults+RACSupport.h | 31 - .../ReactiveObjC/NSUserDefaults+RACSupport.m | 58 - LookInside/ReactiveObjC/RACAnnotations.h | 11 - LookInside/ReactiveObjC/RACArraySequence.h | 18 - LookInside/ReactiveObjC/RACArraySequence.m | 125 - LookInside/ReactiveObjC/RACBehaviorSubject.h | 22 - LookInside/ReactiveObjC/RACBehaviorSubject.m | 56 - LookInside/ReactiveObjC/RACBlockTrampoline.h | 30 - LookInside/ReactiveObjC/RACBlockTrampoline.m | 155 - LookInside/ReactiveObjC/RACChannel.h | 77 - LookInside/ReactiveObjC/RACChannel.m | 88 - LookInside/ReactiveObjC/RACCommand.h | 116 - LookInside/ReactiveObjC/RACCommand.m | 197 - .../ReactiveObjC/RACCompoundDisposable.h | 52 - .../ReactiveObjC/RACCompoundDisposable.m | 250 - .../RACCompoundDisposableProvider.d | 4 - .../RACCompoundDisposableProvider.h | 10 - LookInside/ReactiveObjC/RACDelegateProxy.h | 32 - LookInside/ReactiveObjC/RACDelegateProxy.m | 75 - LookInside/ReactiveObjC/RACDisposable.h | 39 - LookInside/ReactiveObjC/RACDisposable.m | 82 - LookInside/ReactiveObjC/RACDynamicSequence.h | 20 - LookInside/ReactiveObjC/RACDynamicSequence.m | 197 - LookInside/ReactiveObjC/RACDynamicSignal.h | 17 - LookInside/ReactiveObjC/RACDynamicSignal.m | 54 - LookInside/ReactiveObjC/RACEXTKeyPathCoding.h | 1 - .../ReactiveObjC/RACEXTRuntimeExtensions.h | 1 - LookInside/ReactiveObjC/RACEXTScope.h | 1 - LookInside/ReactiveObjC/RACEagerSequence.h | 14 - LookInside/ReactiveObjC/RACEagerSequence.m | 66 - LookInside/ReactiveObjC/RACEmptySequence.h | 16 - LookInside/ReactiveObjC/RACEmptySequence.m | 71 - LookInside/ReactiveObjC/RACEmptySignal.h | 17 - LookInside/ReactiveObjC/RACEmptySignal.m | 62 - LookInside/ReactiveObjC/RACErrorSignal.h | 17 - LookInside/ReactiveObjC/RACErrorSignal.m | 47 - LookInside/ReactiveObjC/RACEvent.h | 55 - LookInside/ReactiveObjC/RACEvent.m | 112 - LookInside/ReactiveObjC/RACGroupedSignal.h | 23 - LookInside/ReactiveObjC/RACGroupedSignal.m | 25 - .../ReactiveObjC/RACImmediateScheduler.h | 14 - .../ReactiveObjC/RACImmediateScheduler.m | 54 - LookInside/ReactiveObjC/RACIndexSetSequence.h | 16 - LookInside/ReactiveObjC/RACIndexSetSequence.m | 111 - LookInside/ReactiveObjC/RACKVOChannel.h | 107 - LookInside/ReactiveObjC/RACKVOChannel.m | 207 - LookInside/ReactiveObjC/RACKVOProxy.h | 34 - LookInside/ReactiveObjC/RACKVOProxy.m | 69 - LookInside/ReactiveObjC/RACKVOTrampoline.h | 31 - LookInside/ReactiveObjC/RACKVOTrampoline.m | 109 - .../RACMulticastConnection+Private.h | 17 - .../ReactiveObjC/RACMulticastConnection.h | 53 - .../ReactiveObjC/RACMulticastConnection.m | 86 - .../ReactiveObjC/RACPassthroughSubscriber.h | 29 - .../ReactiveObjC/RACPassthroughSubscriber.m | 106 - .../ReactiveObjC/RACQueueScheduler+Subclass.h | 44 - LookInside/ReactiveObjC/RACQueueScheduler.h | 22 - LookInside/ReactiveObjC/RACQueueScheduler.m | 106 - LookInside/ReactiveObjC/RACReplaySubject.h | 26 - LookInside/ReactiveObjC/RACReplaySubject.m | 111 - LookInside/ReactiveObjC/RACReturnSignal.h | 17 - LookInside/ReactiveObjC/RACReturnSignal.m | 90 - .../ReactiveObjC/RACScheduler+Private.h | 38 - .../ReactiveObjC/RACScheduler+Subclass.h | 33 - LookInside/ReactiveObjC/RACScheduler.h | 146 - LookInside/ReactiveObjC/RACScheduler.m | 211 - LookInside/ReactiveObjC/RACScopedDisposable.h | 22 - LookInside/ReactiveObjC/RACScopedDisposable.m | 32 - LookInside/ReactiveObjC/RACSequence.h | 419 -- LookInside/ReactiveObjC/RACSequence.m | 371 -- LookInside/ReactiveObjC/RACSerialDisposable.h | 47 - LookInside/ReactiveObjC/RACSerialDisposable.m | 121 - .../ReactiveObjC/RACSignal+Operations.h | 714 --- .../ReactiveObjC/RACSignal+Operations.m | 1331 ----- LookInside/ReactiveObjC/RACSignal.h | 508 -- LookInside/ReactiveObjC/RACSignal.m | 410 -- LookInside/ReactiveObjC/RACSignalProvider.d | 5 - LookInside/ReactiveObjC/RACSignalProvider.h | 13 - LookInside/ReactiveObjC/RACSignalSequence.h | 19 - LookInside/ReactiveObjC/RACSignalSequence.m | 79 - LookInside/ReactiveObjC/RACStream+Private.h | 23 - LookInside/ReactiveObjC/RACStream.h | 337 -- LookInside/ReactiveObjC/RACStream.m | 355 -- LookInside/ReactiveObjC/RACStringSequence.h | 18 - LookInside/ReactiveObjC/RACStringSequence.m | 65 - LookInside/ReactiveObjC/RACSubject.h | 29 - LookInside/ReactiveObjC/RACSubject.m | 126 - .../ReactiveObjC/RACSubscriber+Private.h | 17 - LookInside/ReactiveObjC/RACSubscriber.h | 55 - LookInside/ReactiveObjC/RACSubscriber.m | 111 - .../RACSubscriptingAssignmentTrampoline.h | 58 - .../RACSubscriptingAssignmentTrampoline.m | 42 - .../ReactiveObjC/RACSubscriptionScheduler.h | 19 - .../ReactiveObjC/RACSubscriptionScheduler.m | 53 - .../ReactiveObjC/RACTargetQueueScheduler.h | 28 - .../ReactiveObjC/RACTargetQueueScheduler.m | 31 - LookInside/ReactiveObjC/RACTestScheduler.h | 46 - LookInside/ReactiveObjC/RACTestScheduler.m | 221 - LookInside/ReactiveObjC/RACTuple.h | 251 - LookInside/ReactiveObjC/RACTuple.m | 436 -- LookInside/ReactiveObjC/RACTupleSequence.h | 18 - LookInside/ReactiveObjC/RACTupleSequence.m | 68 - LookInside/ReactiveObjC/RACUnarySequence.h | 16 - LookInside/ReactiveObjC/RACUnarySequence.m | 81 - LookInside/ReactiveObjC/RACUnit.h | 23 - LookInside/ReactiveObjC/RACUnit.m | 25 - LookInside/ReactiveObjC/RACValueTransformer.h | 16 - LookInside/ReactiveObjC/RACValueTransformer.m | 42 - LookInside/ReactiveObjC/RACmetamacros.h | 1 - LookInside/ReactiveObjC/ReactiveObjC.h | 68 - .../extobjc/RACEXTKeyPathCoding.h | 68 - .../extobjc/RACEXTRuntimeExtensions.h | 122 - .../extobjc/RACEXTRuntimeExtensions.m | 234 - LookInside/ReactiveObjC/extobjc/RACEXTScope.h | 118 - .../ReactiveObjC/extobjc/RACmetamacros.h | 661 --- LookInside/Read/LKReadHierarchyController.h | 13 - LookInside/Read/LKReadHierarchyController.m | 33 - .../Read/LKReadHierarchyController.swift | 35 + LookInside/Read/LKReadHierarchyDataSource.h | 17 - LookInside/Read/LKReadHierarchyDataSource.m | 62 - .../Read/LKReadHierarchyDataSource.swift | 49 + LookInside/Read/LKReadScreenshotLookup.swift | 31 + LookInside/Read/LKReadViewController.h | 22 - LookInside/Read/LKReadViewController.m | 149 - LookInside/Read/LKReadViewController.swift | 139 + LookInside/Read/LKReadWindowController.h | 27 - LookInside/Read/LKReadWindowController.m | 251 - LookInside/Read/LKReadWindowController.swift | 282 + LookInside/Read/LookinArchiveCoding.swift | 57 + LookInside/Read/LookinArchiveDocument.h | 30 - LookInside/Read/LookinArchiveDocument.m | 89 - LookInside/Read/LookinArchiveDocument.swift | 71 + LookInside/ShortCocoa/ShortCocoa+Layout.h | 306 - LookInside/ShortCocoa/ShortCocoa+Layout.m | 1159 ---- LookInside/ShortCocoa/ShortCocoa+Others.h | 95 - LookInside/ShortCocoa/ShortCocoa+Others.m | 120 - LookInside/ShortCocoa/ShortCocoa+Private.h | 75 - LookInside/ShortCocoa/ShortCocoa+Private.m | 416 -- LookInside/ShortCocoa/ShortCocoa+String.h | 147 - LookInside/ShortCocoa/ShortCocoa+String.m | 153 - LookInside/ShortCocoa/ShortCocoa+UIToolkit.h | 240 - LookInside/ShortCocoa/ShortCocoa+UIToolkit.m | 397 -- LookInside/ShortCocoa/ShortCocoa.h | 80 - LookInside/ShortCocoa/ShortCocoaCore.h | 90 - LookInside/ShortCocoa/ShortCocoaCore.m | 59 - LookInside/ShortCocoa/ShortCocoaDefines.h | 52 - LookInside/Static/LKMessageManager.h | 32 - LookInside/Static/LKMessageManager.m | 70 - LookInside/Static/LKMessageManager.swift | 56 + .../LKReloadItemAndChildrenUpdateTaskMaker.h | 21 - .../LKReloadItemAndChildrenUpdateTaskMaker.m | 52 - .../LKReloadSingleItemUpdateTaskMaker.h | 21 - .../LKReloadSingleItemUpdateTaskMaker.m | 76 - .../Static/LKStaticAsyncUpdateManager.h | 60 - .../Static/LKStaticAsyncUpdateManager.m | 586 -- .../Static/LKStaticAsyncUpdateManager.swift | 638 +++ LookInside/Static/LKStaticConstants.swift | 29 + .../Static/LKStaticHierarchyController.h | 13 - .../Static/LKStaticHierarchyController.m | 31 - .../Static/LKStaticHierarchyController.swift | 31 + .../Static/LKStaticHierarchyDataSource.h | 28 - .../Static/LKStaticHierarchyDataSource.m | 211 - .../Static/LKStaticHierarchyDataSource.swift | 214 + LookInside/Static/LKStaticLayout.swift | 93 + LookInside/Static/LKStaticViewController.h | 38 - LookInside/Static/LKStaticViewController.m | 476 -- .../Static/LKStaticViewController.swift | 605 ++ LookInside/Static/LKStaticWindowController.h | 103 - LookInside/Static/LKStaticWindowController.m | 902 --- .../Static/LKStaticWindowController.swift | 864 +++ LookInside/Static/Preview/LKDisplayItemNode.h | 31 - LookInside/Static/Preview/LKDisplayItemNode.m | 430 -- .../Static/Preview/LKDisplayItemNode.swift | 418 ++ .../Static/Preview/LKPreviewController.h | 23 - .../Static/Preview/LKPreviewController.m | 761 --- .../Static/Preview/LKPreviewController.swift | 642 +++ .../Preview/LKPreviewPanGestureRecognizer.h | 26 - .../Preview/LKPreviewPanGestureRecognizer.m | 13 - .../Static/Preview/LKPreviewStageView.h | 24 - .../Static/Preview/LKPreviewStageView.m | 37 - .../Static/Preview/LKPreviewStageView.swift | 59 + LookInside/Static/Preview/LKPreviewView.h | 69 - LookInside/Static/Preview/LKPreviewView.m | 453 -- LookInside/Static/Preview/LKPreviewView.swift | 380 ++ .../LKSwiftUIHierarchyDisplayMode.h | 33 - .../LKSwiftUIHierarchyDisplayMode.m | 40 - .../LKSwiftUIHierarchyDisplayMode.swift | 50 + ...LKSwiftUISupportAuthServerPathPolicy.swift | 53 - .../LKSwiftUISupportGatekeeper.swift | 1543 ++--- .../LKSwiftUISupportInstaller.swift | 657 --- .../LKSwiftUISupportLocalLicenseProbe.swift | 89 - .../LKSwiftUISupportLogger.swift | 3 +- .../Toolbar/LKMenuPopoverAppsListController.h | 27 - .../Toolbar/LKMenuPopoverAppsListController.m | 160 - .../LKMenuPopoverAppsListController.swift | 123 + .../Toolbar/LKMenuPopoverSettingController.h | 19 - .../Toolbar/LKMenuPopoverSettingController.m | 170 - .../LKMenuPopoverSettingController.swift | 143 + LookInside/Toolbar/LKToolbarRules.swift | 82 + LookInside/Toolbar/LKViewFrameLayout.swift | 208 + LookInside/Toolbar/LKWindowToolbarAppButton.h | 17 - LookInside/Toolbar/LKWindowToolbarAppButton.m | 150 - .../Toolbar/LKWindowToolbarAppButton.swift | 129 + LookInside/Toolbar/LKWindowToolbarHelper.h | 51 - LookInside/Toolbar/LKWindowToolbarHelper.m | 361 -- .../Toolbar/LKWindowToolbarHelper.swift | 243 + LookInside/Toolbar/LKWindowToolbarScaleView.h | 17 - LookInside/Toolbar/LKWindowToolbarScaleView.m | 38 - .../Toolbar/LKWindowToolbarScaleView.swift | 49 + .../LKXcodeViewHierarchyAttributes.swift | 54 +- .../LKXcodeViewHierarchyBundleReader.swift | 22 +- .../LKXcodeViewHierarchyConverter.swift | 56 +- .../LKXcodeViewHierarchyDocument.swift | 30 +- .../LKXcodeViewHierarchyGzip.swift | 8 +- .../LKXcodeViewHierarchyLayerArchive.swift | 4 +- .../LKXcodeViewHierarchyLayerTopology.swift | 4 +- .../LKXcodeViewHierarchyObjectGraph.swift | 18 +- .../LKXcodeViewHierarchyPixelRecovery.swift | 25 +- .../LKXcodeViewHierarchyValue.swift | 53 +- LookInside/main.m | 20 - Package.swift | 66 +- Packages/LookInsideActivation/.swift-format | 17 + Packages/LookInsideActivation/Package.swift | 37 + .../Core/Activation/ActivationArtifact.swift | 13 + .../Core/Activation/ActivationPath.swift | 4 + .../Core/Activation/ActivationSession.swift | 32 + .../ClientActivationChallenge.swift | 23 + .../Core/Activation/DeviceBinding.swift | 38 + .../Core/Activation/DeviceBindingStatus.swift | 5 + .../Core/Activation/DeviceFingerprint.swift | 18 + .../Core/Activation/EntitlementStatus.swift | 113 + ...ntermediateCertificateIssuanceReason.swift | 5 + .../IntermediateCertificateLease.swift | 50 + .../IntermediateCertificateLeaseStatus.swift | 6 + .../Activation/SecureTimestampToken.swift | 24 + .../Activation/SignedActivationEnvelope.swift | 39 + .../CertificateSigningRequestBuilder.swift | 180 + .../Core/EmbeddedTrustedRoots.swift | 59 + .../LookInsideActivation/Core/Errors.swift | 39 + .../Core/License/CertificateChain.swift | 28 + .../Core/License/LicenseAudience.swift | 5 + .../Core/License/LicenseClass.swift | 4 + ...nvelope+IntermediateCertificateLease.swift | 24 + .../Core/License/LicenseEnvelope.swift | 45 + .../Core/License/LicenseLifecycleState.swift | 16 + .../LookInsideActivation/Core/Policy.swift | 32 + .../LookInsideActivation/Core/Protocols.swift | 68 + .../ActivationSessionRefreshRequest.swift | 7 + .../Requests/EntitlementStatusRequest.swift | 18 + .../Core/Requests/HostActivationRequest.swift | 21 + .../Requests/HostActivationResponse.swift | 15 + .../IntermediateCertificateIssueRequest.swift | 15 + .../Core/Requests/PurchaseClaimRequest.swift | 11 + .../Requests/SecureTimestampRequest.swift | 23 + .../Core/Requests/TrialIssueRequest.swift | 7 + .../Core/Validators.swift | 83 + .../Host/HostActivationCoordinator.swift | 100 + .../Model/ActivationModel.swift | 500 ++ .../Model/ActivationUIConfiguration.swift | 45 + .../Model/KeychainAccessNotice.swift | 52 + .../Remote/APIModels/APIEnvelope.swift | 3 + .../Remote/APIModels/APIErrorEnvelope.swift | 3 + .../Remote/APIModels/APIErrorPayload.swift | 4 + .../ActivationSessionRefreshPayload.swift | 9 + .../ActivationSessionTokenClaims.swift | 29 + .../IntermediateCertificateIssuePayload.swift | 13 + .../IntermediateCertificatePayload.swift | 27 + .../APIModels/LicenseSummaryPayload.swift | 35 + .../LicenseSummaryRequestPayload.swift | 7 + .../APIModels/PurchaseClaimPayload.swift | 26 + .../APIModels/PurchaseClaimResponse.swift | 11 + .../APIModels/SecureTimestampPayload.swift | 13 + .../SecureTimestampRequestPayload.swift | 35 + .../Remote/APIModels/TrialIssuePayload.swift | 20 + .../LookInsideAuthenticatorAPIClient.swift | 195 + .../LookInsideAuthenticatorAPIMapping.swift | 234 + .../LookInsideAuthenticatorAPITransport.swift | 86 + .../Resources/Localizable.xcstrings | 384 ++ .../Runtime/ActivationAccessDecision.swift | 38 + .../Runtime/ActivationAccessEvaluator.swift | 203 + .../ActivationCertificateRenewer.swift | 214 + .../ActivationChallengeSignature.swift | 64 + .../Runtime/ActivationChallengeSigner.swift | 65 + .../Runtime/ActivationConfiguration.swift | 88 + .../Runtime/ActivationDebugOverrides.swift | 139 + .../Runtime/ActivationDeviceFingerprint.swift | 60 + .../Runtime/ActivationError.swift | 88 + .../Runtime/ActivationLogger.swift | 9 + .../Runtime/ActivationPersistedState.swift | 38 + .../Runtime/ActivationResources.swift | 9 + .../Runtime/ActivationRuntime.swift | 694 +++ .../Runtime/ActivationServiceProxies.swift | 69 + .../Runtime/ActivationSigningGate.swift | 356 ++ .../Runtime/ActivationSigningPolicy.swift | 381 ++ .../Runtime/ActivationStateCoding.swift | 27 + .../Runtime/ActivationStateStore.swift | 234 + .../Runtime/DataHexCoding.swift | 19 + .../Runtime/InMemoryReplayProtector.swift | 18 + .../Runtime/IntermediateKeyStore.swift | 315 ++ .../Runtime/KeychainAccessMarkers.swift | 67 + .../LocalActivationMaterialIssuer.swift | 47 + .../Runtime/TrustedApplicationSignature.swift | 166 + .../Server/ServerSecureTimestampService.swift | 26 + .../ActivationView.swift | 79 + .../ActivationWindowController.swift | 130 + .../ActivationWindowCoordinator.swift | 279 + .../Bundle+ActivationUI.swift | 9 + .../LicenseStatusBadge.swift | 51 + .../LicenseStatusView.swift | 41 + .../Views/ActivationFormView.swift | 86 + .../Views/KeychainAccessExplainerView.swift | 42 + .../Views/LabeledRow.swift | 17 + .../Views/LicenseDetailRow.swift | 27 + .../Views/LicenseDetailRows.swift | 94 + .../Views/LicenseSummaryView.swift | 82 + ...ChallengeSignatureCompatibilityTests.swift | 154 + .../KeyStoreCompatibilityTests.swift | 90 + .../StateFileCompatibilityTests.swift | 121 + .../Fixtures/challenge-input.json | 7 + .../Fixtures/helper-check-access.json | 1 + .../helper-sign-challenge-expired.json | 1 + .../Fixtures/helper-sign-challenge.json | 1 + .../Fixtures/helper-state-full.json | 1 + .../Fixtures/helper-state-trial.json | 1 + .../olduser-state.helper-reencoded.json | 1 + .../Fixtures/olduser-state.json | 1 + .../ActivationAccessEvaluatorTests.swift | 392 ++ .../ActivationCertificateRenewerTests.swift | 325 ++ .../ActivationChallengeSignerTests.swift | 173 + .../Ported/ActivationErrorTests.swift | 30 + .../Ported/ActivationModelTests.swift | 571 ++ .../Ported/ActivationPolicyTests.swift | 18 + .../Ported/ActivationStateStoreTests.swift | 166 + .../Ported/ActivationSupportModelsTests.swift | 86 + .../ActivationWindowCoordinatorTests.swift | 78 + .../ActivationWindowPlacementTests.swift | 53 + .../Ported/EntitlementDisplayStateTests.swift | 167 + .../HostActivationCoordinatorTests.swift | 155 + .../Ported/LicenseSummaryMappingTests.swift | 54 + .../ServerSecureTimestampServiceTests.swift | 59 + .../Ported/ValidatorsTests.swift | 220 + .../Runtime/ActivationRuntimeTests.swift | 278 + .../ActivationWindowBehaviorTests.swift | 67 + .../Runtime/KeyUseRulesTests.swift | 895 +++ .../Runtime/KeychainAccessTests.swift | 356 ++ .../Runtime/SigningPolicyTests.swift | 377 ++ .../Runtime/SilentRenewalTests.swift | 222 + .../Runtime/StateSharingTests.swift | 208 + .../Support/HelperFixtures.swift | 282 + .../Support/TestSupport.swift | 431 ++ .../Support/UITestSupport.swift | 84 + Packages/LookInsideHostCore/Package.swift | 25 + .../LookInsideHostCore/AppShellPolicies.swift | 183 + .../LookInsideHostCore/AsyncBroadcaster.swift | 118 + .../ClientDisplayText.swift | 239 + .../ConnectionPolicies.swift | 189 + .../LookInsideHostCore/ConsoleHistory.swift | 46 + .../LookInsideHostCore/ExportNaming.swift | 46 + .../LookInsideHostCore/HierarchyOutline.swift | 228 + .../LicenseHandshakeFlow.swift | 241 + .../LookInsideHostCore/MeasureGuides.swift | 198 + .../LookInsideHostCore/PreviewGeometry.swift | 95 + .../ServerUpgradeHint.swift | 110 + .../LookInsideHostCore/StringScore.swift | 129 + .../AppShellPoliciesTests.swift | 100 + .../AsyncBroadcasterTests.swift | 237 + .../ClientDisplayTextTests.swift | 127 + .../ConnectionPoliciesTests.swift | 243 + .../HierarchyOutlineTests.swift | 248 + .../LicenseHandshakeFlowTests.swift | 348 ++ .../PeripheralLogicTests.swift | 140 + .../PreviewGeometryTests.swift | 68 + .../ServerUpgradeHintTests.swift | 81 + README.md | 14 +- Resources/Licenses/Peertalk.txt | 34 - Resources/Licenses/ReactiveObjC.md | 19 - Resources/Licenses/ShortCocoa.md | 20 - Scripts/ViewHierarchyProbe/main.swift | 52 +- Scripts/build-and-run-debug.sh | 15 - Scripts/build-release-from-tag.sh | 16 - Scripts/check-host-release-clean.sh | 223 + Scripts/package-lookinserver.sh | 163 - Scripts/prepare-debug-auth-server.sh | 117 - Scripts/prepare-mcp-cli.sh | 114 - Scripts/run-gitleaks.sh | 139 + Scripts/scan-private-keys.sh | 367 ++ Scripts/sync-derived-source.sh | 2 +- Scripts/test-dashboard.sh | 52 + Scripts/test-gesture-models.sh | 2 +- Scripts/test-launch-toolbar-read.sh | 82 + Scripts/test-mcp-bridge.sh | 61 - Scripts/test-release-workflow.sh | 25 +- Scripts/test-scan-private-keys.sh | 261 + Scripts/test-security-gates.sh | 37 - Scripts/test-xcode-viewhierarchy.sh | 21 +- Scripts/test.sh | 1 - Sources/LookinCore/Category/CALayer+Lookin.h | 23 - Sources/LookinCore/Category/CALayer+Lookin.m | 70 - Sources/LookinCore/Category/Color+Lookin.h | 29 - Sources/LookinCore/Category/Color+Lookin.m | 42 - Sources/LookinCore/Category/Image+Lookin.h | 27 - Sources/LookinCore/Category/Image+Lookin.m | 41 - Sources/LookinCore/Category/NSArray+Lookin.h | 72 - Sources/LookinCore/Category/NSArray+Lookin.m | 300 - Sources/LookinCore/Category/NSObject+Lookin.h | 108 - Sources/LookinCore/Category/NSObject+Lookin.m | 239 - Sources/LookinCore/Category/NSSet+Lookin.h | 39 - Sources/LookinCore/Category/NSSet+Lookin.m | 81 - Sources/LookinCore/Category/NSString+Lookin.h | 42 - Sources/LookinCore/Category/NSString+Lookin.m | 116 - Sources/LookinCore/Category/NSValue+Lookin.m | 111 - Sources/LookinCore/LKS_MultiplatformAdapter.h | 74 - Sources/LookinCore/LKS_MultiplatformAdapter.m | 214 - Sources/LookinCore/LookinAppInfo.h | 77 - Sources/LookinCore/LookinAppInfo.m | 412 -- Sources/LookinCore/LookinAttribute.h | 48 - Sources/LookinCore/LookinAttribute.m | 64 - .../LookinCore/LookinAttributeModification.h | 31 - .../LookinCore/LookinAttributeModification.m | 40 - Sources/LookinCore/LookinAttributesGroup.h | 54 - Sources/LookinCore/LookinAttributesGroup.m | 111 - Sources/LookinCore/LookinAttributesSection.h | 35 - Sources/LookinCore/LookinAttributesSection.m | 56 - .../LookinCore/LookinAutoLayoutConstraint.h | 65 - .../LookinCore/LookinAutoLayoutConstraint.m | 125 - .../LookinCore/LookinConnectionAttachment.h | 24 - .../LookinCore/LookinConnectionAttachment.m | 52 - .../LookinConnectionResponseAttachment.h | 36 - .../LookinConnectionResponseAttachment.m | 62 - .../LookinCore/LookinCustomAttrModification.h | 20 - .../LookinCore/LookinCustomAttrModification.m | 34 - .../LookinCore/LookinCustomDisplayItemInfo.h | 39 - .../LookinCore/LookinCustomDisplayItemInfo.m | 74 - Sources/LookinCore/LookinDashboardBlueprint.h | 93 - Sources/LookinCore/LookinDashboardBlueprint.m | 4952 ----------------- Sources/LookinCore/LookinDisplayItem.h | 307 - Sources/LookinCore/LookinDisplayItem.m | 595 -- Sources/LookinCore/LookinDisplayItemDetail.h | 67 - Sources/LookinCore/LookinDisplayItemDetail.m | 71 - Sources/LookinCore/LookinEventHandler.h | 43 - Sources/LookinCore/LookinEventHandler.m | 71 - Sources/LookinCore/LookinHierarchyFile.h | 32 - Sources/LookinCore/LookinHierarchyFile.m | 64 - Sources/LookinCore/LookinHierarchyInfo.h | 47 - Sources/LookinCore/LookinHierarchyInfo.m | 125 - Sources/LookinCore/LookinObject.h | 42 - Sources/LookinCore/LookinObject.m | 87 - .../LookinCore/LookinStaticAsyncUpdateTask.h | 67 - .../LookinCore/LookinStaticAsyncUpdateTask.m | 105 - Sources/LookinCore/LookinTuple.h | 31 - Sources/LookinCore/LookinTuple.m | 93 - Sources/LookinCore/LookinWeakContainer.h | 23 - Sources/LookinCore/LookinWeakContainer.m | 43 - .../LookinCore/Peertalk/Lookin_PTChannel.h | 136 - .../LookinCore/Peertalk/Lookin_PTChannel.m | 675 --- .../LookinCore/Peertalk/Lookin_PTPrivate.h | 20 - .../LookinCore/Peertalk/Lookin_PTProtocol.h | 122 - .../LookinCore/Peertalk/Lookin_PTProtocol.m | 433 -- Sources/LookinCore/Peertalk/Lookin_PTUSBHub.h | 88 - Sources/LookinCore/Peertalk/Lookin_PTUSBHub.m | 682 --- Sources/LookinCore/Peertalk/Peertalk.h | 28 - Sources/LookinCore/Shim/LookinRuntimeShim.m | 194 + .../{ => include}/LookinAttrIdentifiers.h | 0 .../LookinCore/{ => include}/LookinAttrType.h | 0 .../{ => include}/LookinCodingValueType.h | 0 Sources/LookinCore/include/LookinCore.h | 40 +- Sources/LookinCore/include/LookinCoreTypes.h | 178 + .../LookinCore/{ => include}/LookinDefines.h | 0 .../{ => include}/LookinHitTargetSize.h | 0 .../LookinCore/include/LookinRuntimeShim.h | 112 + .../{Category => include}/NSValue+Lookin.h | 23 +- .../Category/CALayer+Lookin.swift | 88 + .../Category/Color+Lookin.swift | 56 + .../Category/Image+Lookin.swift | 37 + .../Category/NSArray+Lookin.swift | 301 + .../Category/NSObject+Lookin.swift | 297 + .../Category/NSSet+Lookin.swift | 87 + .../Category/NSString+Lookin.swift | 158 + .../Category/NSValue+Lookin.swift | 157 + .../LKS_MultiplatformAdapter.swift | 229 + .../LookInsideServerLogger.swift | 82 + Sources/LookinCoreImpl/LookinAppInfo.swift | 462 ++ Sources/LookinCoreImpl/LookinAttribute.swift | 84 + .../LookinAttributeModification.swift | 64 + .../LookinAttributesGroup.swift | 119 + .../LookinAttributesSection.swift | 62 + .../LookinAutoLayoutConstraint.swift | 167 + .../LookinConnectionAttachment.swift | 49 + .../LookinConnectionResponseAttachment.swift | 72 + .../LookinCoreModelSupport.swift | 104 + .../LookinCustomAttrModification.swift | 51 + .../LookinCustomDisplayItemInfo.swift | 100 + .../LookinDashboardBlueprint+AttrInfo.swift | 585 ++ .../LookinDashboardBlueprint+Groups.swift | 726 +++ .../LookinDashboardBlueprint+Sections.swift | 708 +++ .../LookinDashboardBlueprint.swift | 252 + Sources/LookinCoreImpl/LookinDefines.swift | 123 + .../LookinCoreImpl/LookinDisplayItem.swift | 834 +++ .../LookinDisplayItemDetail.swift | 107 + .../LookinCoreImpl/LookinEventHandler.swift | 90 + .../LookinCoreImpl/LookinHierarchyFile.swift | 100 + .../LookinCoreImpl/LookinHierarchyInfo.swift | 122 + Sources/LookinCoreImpl/LookinIvarTrace.swift | 82 + Sources/LookinCoreImpl/LookinObject.swift | 111 + .../LookinStaticAsyncUpdateTask.swift | 130 + Sources/LookinCoreImpl/LookinTuple.swift | 129 + .../LookinCoreImpl/LookinWeakContainer.swift | 55 + .../Transport/LookinFrame.swift | 132 + .../Transport/LookinFrameChannel.swift | 271 + .../Transport/LookinFrameDecoder.swift | 183 + .../Transport/LookinFrameListener.swift | 146 + .../Transport/LookinSocket.swift | 99 + .../Transport/LookinUSBMux.swift | 239 + .../Transport/LookinUSBMuxClient.swift | 370 ++ .../Server/Category/CALayer+LookinServer.h | 38 - .../Server/Category/CALayer+LookinServer.m | 376 -- .../Server/Category/NSButton+LookinServer.h | 22 - .../Server/Category/NSButton+LookinServer.m | 16 - .../Category/NSCollectionView+LookinServer.h | 22 - .../Category/NSCollectionView+LookinServer.m | 42 - .../Category/NSDatePicker+LookinServer.h | 24 - .../Category/NSDatePicker+LookinServer.m | 31 - .../Server/Category/NSObject+LookinServer.h | 40 - .../Server/Category/NSObject+LookinServer.m | 98 - .../Category/NSScrollView+LookinServer.h | 24 - .../Category/NSScrollView+LookinServer.m | 30 - .../Server/Category/NSWindow+LookinServer.h | 57 - .../Server/Category/NSWindow+LookinServer.m | 201 - .../Category/UIBlurEffect+LookinServer.h | 20 - .../Category/UIBlurEffect+LookinServer.m | 56 - .../Server/Category/UIColor+LookinServer.h | 25 - .../Server/Category/UIColor+LookinServer.m | 183 - .../Server/Category/UIImage+LookinServer.h | 21 - .../Server/Category/UIImage+LookinServer.m | 106 - .../Category/UIImageView+LookinServer.h | 19 - .../Category/UIImageView+LookinServer.m | 30 - .../Server/Category/UILabel+LookinServer.h | 20 - .../Server/Category/UILabel+LookinServer.m | 28 - .../Category/UITableView+LookinServer.h | 18 - .../Category/UITableView+LookinServer.m | 28 - .../Category/UITextField+LookinServer.h | 20 - .../Category/UITextField+LookinServer.m | 28 - .../Server/Category/UITextView+LookinServer.h | 20 - .../Server/Category/UITextView+LookinServer.m | 28 - .../Server/Category/UIView+LookinServer.h | 67 - .../Server/Category/UIView+LookinServer.m | 447 -- .../Category/UIViewController+LookinServer.h | 18 - .../Category/UIViewController+LookinServer.m | 71 - .../UIVisualEffectView+LookinServer.h | 20 - .../UIVisualEffectView+LookinServer.m | 32 - .../Category/UIWindowScene+LookinServer.h | 93 - .../Category/UIWindowScene+LookinServer.m | 396 -- .../Server/Connection/LKS_ConnectionManager.h | 34 - .../Server/Connection/LKS_ConnectionManager.m | 292 - .../Server/Connection/LKS_RequestHandler.h | 20 - .../Server/Connection/LKS_RequestHandler.m | 261 - .../LKS_AttrModificationPatchHandler.h | 25 - .../LKS_AttrModificationPatchHandler.m | 83 - .../LKS_CustomAttrModificationHandler.h | 19 - .../LKS_CustomAttrModificationHandler.m | 155 - .../LKS_HierarchyDetailsHandler.h | 29 - .../LKS_HierarchyDetailsHandler.m | 266 - .../LKS_InbuiltAttrModificationHandler.h | 22 - .../LKS_InbuiltAttrModificationHandler.m | 292 - Sources/LookinServer/Server/LookinServer.h | 19 - Sources/LookinServer/Server/LookinServer.m | 10 - .../Server/Others/LKSConfigManager.h | 38 - .../Server/Others/LKSConfigManager.m | 214 - .../Server/Others/LKS_AttrGroupsMaker.h | 38 - .../Server/Others/LKS_AttrGroupsMaker.m | 616 -- .../Server/Others/LKS_CustomAttrGroupsMaker.h | 30 - .../Server/Others/LKS_CustomAttrGroupsMaker.m | 517 -- .../Others/LKS_CustomAttrSetterManager.h | 62 - .../Others/LKS_CustomAttrSetterManager.m | 117 - .../Others/LKS_CustomDisplayItemsMaker.h | 35 - .../Others/LKS_CustomDisplayItemsMaker.m | 185 - .../Server/Others/LKS_EventHandlerMaker.h | 21 - .../Server/Others/LKS_EventHandlerMaker.m | 337 -- .../Server/Others/LKS_ExportManager.h | 20 - .../Server/Others/LKS_ExportManager.m | 199 - .../Others/LKS_GestureTargetActionsSearcher.h | 36 - .../Others/LKS_GestureTargetActionsSearcher.m | 52 - .../LookinServer/Server/Others/LKS_Helper.h | 28 - .../LookinServer/Server/Others/LKS_Helper.m | 37 - .../Others/LKS_HierarchyDisplayItemsMaker.h | 33 - .../Others/LKS_HierarchyDisplayItemsMaker.m | 554 -- .../Server/Others/LKS_ObjectRegistry.h | 22 - .../Server/Others/LKS_ObjectRegistry.m | 61 - .../Server/Others/LKS_TraceManager.h | 27 - .../Server/Others/LKS_TraceManager.m | 393 -- .../Others/LookinHierarchyInfo+LookinServer.h | 8 - .../Others/LookinHierarchyInfo+LookinServer.m | 8 - .../Server/Others/LookinServerDefines.h | 24 - .../Shared/Category/CALayer+Lookin.h | 23 - .../Shared/Category/CALayer+Lookin.m | 70 - .../Shared/Category/Color+Lookin.h | 27 - .../Shared/Category/Color+Lookin.m | 42 - .../Shared/Category/Image+Lookin.h | 25 - .../Shared/Category/Image+Lookin.m | 41 - .../Shared/Category/NSArray+Lookin.h | 72 - .../Shared/Category/NSArray+Lookin.m | 300 - .../Shared/Category/NSObject+Lookin.h | 108 - .../Shared/Category/NSObject+Lookin.m | 238 - .../Shared/Category/NSSet+Lookin.h | 39 - .../Shared/Category/NSSet+Lookin.m | 81 - .../Shared/Category/NSString+Lookin.h | 42 - .../Shared/Category/NSString+Lookin.m | 117 - .../Shared/Category/NSValue+Lookin.h | 50 - .../Shared/Category/NSValue+Lookin.m | 111 - Sources/LookinServer/Shared/LookinAppInfo.h | 72 - Sources/LookinServer/Shared/LookinAppInfo.m | 277 - .../Shared/LookinAttrIdentifiers.h | 635 --- .../Shared/LookinAttrIdentifiers.m | 620 --- Sources/LookinServer/Shared/LookinAttrType.h | 50 - Sources/LookinServer/Shared/LookinAttribute.h | 48 - Sources/LookinServer/Shared/LookinAttribute.m | 64 - .../Shared/LookinAttributeModification.h | 31 - .../Shared/LookinAttributeModification.m | 40 - .../Shared/LookinAttributesGroup.h | 41 - .../Shared/LookinAttributesGroup.m | 92 - .../Shared/LookinAttributesSection.h | 35 - .../Shared/LookinAttributesSection.m | 56 - .../Shared/LookinAutoLayoutConstraint.h | 51 - .../Shared/LookinAutoLayoutConstraint.m | 113 - .../Shared/LookinCodingValueType.h | 30 - .../Shared/LookinConnectionAttachment.h | 24 - .../Shared/LookinConnectionAttachment.m | 52 - .../LookinConnectionResponseAttachment.h | 36 - .../LookinConnectionResponseAttachment.m | 62 - .../Shared/LookinCustomAttrModification.h | 20 - .../Shared/LookinCustomAttrModification.m | 34 - .../Shared/LookinCustomDisplayItemInfo.h | 21 - .../Shared/LookinCustomDisplayItemInfo.m | 59 - .../Shared/LookinDashboardBlueprint.h | 79 - .../Shared/LookinDashboardBlueprint.m | 3414 ------------ Sources/LookinServer/Shared/LookinDefines.h | 187 - .../LookinServer/Shared/LookinDisplayItem.h | 191 - .../LookinServer/Shared/LookinDisplayItem.m | 476 -- .../Shared/LookinDisplayItemDetail.h | 51 - .../Shared/LookinDisplayItemDetail.m | 71 - .../LookinServer/Shared/LookinEventHandler.h | 43 - .../LookinServer/Shared/LookinEventHandler.m | 71 - .../LookinServer/Shared/LookinHierarchyFile.h | 32 - .../LookinServer/Shared/LookinHierarchyFile.m | 64 - .../LookinServer/Shared/LookinHierarchyInfo.h | 47 - .../LookinServer/Shared/LookinHierarchyInfo.m | 100 - Sources/LookinServer/Shared/LookinObject.h | 42 - Sources/LookinServer/Shared/LookinObject.m | 82 - .../Shared/LookinStaticAsyncUpdateTask.h | 67 - .../Shared/LookinStaticAsyncUpdateTask.m | 105 - Sources/LookinServer/Shared/LookinTuple.h | 31 - Sources/LookinServer/Shared/LookinTuple.m | 93 - .../LookinServer/Shared/LookinWeakContainer.h | 23 - .../LookinServer/Shared/LookinWeakContainer.m | 43 - .../Shared/Peertalk/Lookin_PTChannel.h | 136 - .../Shared/Peertalk/Lookin_PTChannel.m | 675 --- .../Shared/Peertalk/Lookin_PTPrivate.h | 20 - .../Shared/Peertalk/Lookin_PTProtocol.h | 122 - .../Shared/Peertalk/Lookin_PTProtocol.m | 428 -- .../Shared/Peertalk/Lookin_PTUSBHub.h | 88 - .../Shared/Peertalk/Lookin_PTUSBHub.m | 674 --- .../LookinServer/Shared/Peertalk/Peertalk.h | 28 - Sources/LookinServer/include/LookinServer.h | 7 - .../LookinServerBase/LookInsideServerLogger.m | 74 +- Sources/LookinServerBase/LookinIvarTrace.h | 33 +- Sources/LookinServerBase/LookinIvarTrace.m | 70 - .../LookinIvarTraceConstants.m | 16 + Sources/LookinServerBase/LookinServerBase.h | 13 + .../LookinServerInjected.m | 48 - .../LKS_SwiftTraceManager.swift | 117 - .../Fixtures/enum-list-registry.json | 88 + .../LKDashboardTests-Bridging-Header.h | 18 + Tests/Dashboard/LKDashboardTests.swift | 312 ++ .../LKMCPBridgeListenSocketTests.swift | 251 - ...LKMCPBridgeObjectIdentifierListTests.swift | 170 - .../LKMCPBridgeSearchQueryTests.swift | 225 - Tests/Read/Fixtures/legacy-host.lookin | Bin 0 -> 11731 bytes .../LaunchToolbarReadTests-Bridging-Header.h | 17 + Tests/Read/LaunchToolbarReadTests.swift | 288 + Tests/Read/LookinArchiveFixtureGenerator.m | 166 + ...ftUISupportAuthServerPathPolicyTests.swift | 60 - ...tUISupportInstallerCancellationTests.swift | 58 - ...SwiftUISupportLocalLicenseProbeTests.swift | 67 - ...wHierarchyConverterTests-Bridging-Header.h | 24 +- .../LKXcodeViewHierarchyConverterTests.swift | 32 +- .../LKXcodeViewHierarchyGzipTests.swift | 16 +- ...LKXcodeViewHierarchyObjectGraphTests.swift | 8 +- ...XcodeViewHierarchyPixelRecoveryTests.swift | 18 +- .../LKXcodeViewHierarchyValueTests.swift | 28 +- docs/mcp.md | 181 - 1151 files changed, 56721 insertions(+), 92879 deletions(-) delete mode 100644 LookInside/About/LKAboutViewController.h delete mode 100644 LookInside/About/LKAboutViewController.m delete mode 100644 LookInside/About/LKAboutWindowController.h delete mode 100644 LookInside/About/LKAboutWindowController.m create mode 100644 LookInside/About/LKAboutWindowController.swift delete mode 100644 LookInside/AppDelegate.h delete mode 100644 LookInside/AppDelegate.m create mode 100644 LookInside/AppDelegate.swift delete mode 100644 LookInside/Base/Category/LookinAttributesGroup+LookinClient.h delete mode 100644 LookInside/Base/Category/LookinAttributesGroup+LookinClient.m create mode 100644 LookInside/Base/Category/LookinAttributesGroup+LookinClient.swift delete mode 100644 LookInside/Base/Category/LookinAttributesSection+LookinClient.h delete mode 100644 LookInside/Base/Category/LookinAttributesSection+LookinClient.m delete mode 100644 LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.h delete mode 100644 LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.m create mode 100644 LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.swift delete mode 100644 LookInside/Base/Category/LookinCustomDisplayItemInfo+LookinClient.h delete mode 100644 LookInside/Base/Category/LookinCustomDisplayItemInfo+LookinClient.m delete mode 100644 LookInside/Base/Category/LookinDisplayItem+LookinClient.h delete mode 100644 LookInside/Base/Category/LookinDisplayItem+LookinClient.m create mode 100644 LookInside/Base/Category/LookinDisplayItem+LookinClient.swift delete mode 100644 LookInside/Base/Category/LookinObject+LookinClient.h delete mode 100644 LookInside/Base/Category/LookinObject+LookinClient.m create mode 100644 LookInside/Base/Category/LookinObject+LookinClient.swift delete mode 100644 LookInside/Base/Category/NSAppearance+LookinClient.h delete mode 100644 LookInside/Base/Category/NSAppearance+LookinClient.m create mode 100644 LookInside/Base/Category/NSAppearance+LookinClient.swift delete mode 100644 LookInside/Base/Category/NSArray+LookinClient.h delete mode 100644 LookInside/Base/Category/NSArray+LookinClient.m create mode 100644 LookInside/Base/Category/NSArray+LookinClient.swift delete mode 100644 LookInside/Base/Category/NSButton+LookinClient.h delete mode 100644 LookInside/Base/Category/NSButton+LookinClient.m create mode 100644 LookInside/Base/Category/NSButton+LookinClient.swift delete mode 100644 LookInside/Base/Category/NSColor+LookinClient.h delete mode 100644 LookInside/Base/Category/NSColor+LookinClient.m create mode 100644 LookInside/Base/Category/NSColor+LookinClient.swift delete mode 100644 LookInside/Base/Category/NSControl+LookinClient.h delete mode 100644 LookInside/Base/Category/NSControl+LookinClient.m create mode 100644 LookInside/Base/Category/NSControl+LookinClient.swift delete mode 100644 LookInside/Base/Category/NSPointerArray+LookinClient.h delete mode 100644 LookInside/Base/Category/NSPointerArray+LookinClient.m delete mode 100644 LookInside/Base/Category/NSString+LookinClient.h delete mode 100644 LookInside/Base/Category/NSString+LookinClient.m create mode 100644 LookInside/Base/Category/NSString+LookinClient.swift delete mode 100755 LookInside/Base/Category/NSString+Score.h delete mode 100755 LookInside/Base/Category/NSString+Score.m delete mode 100644 LookInside/Base/Category/NSView+LookinClient.h delete mode 100644 LookInside/Base/Category/NSView+LookinClient.m create mode 100644 LookInside/Base/Category/NSView+LookinClient.swift delete mode 100644 LookInside/Base/Category/ShortCocoa+LookinClient.h delete mode 100644 LookInside/Base/Category/ShortCocoa+LookinClient.m delete mode 100644 LookInside/Base/InputSearch/LKInputSearchSuggestionItem.h delete mode 100644 LookInside/Base/InputSearch/LKInputSearchSuggestionItem.m delete mode 100644 LookInside/Base/InputSearch/LKInputSearchSuggestionWindowController.h delete mode 100644 LookInside/Base/InputSearch/LKInputSearchSuggestionWindowController.m delete mode 100644 LookInside/Base/InputSearch/LKInputSearchSuggestionsContentView.h delete mode 100644 LookInside/Base/InputSearch/LKInputSearchSuggestionsContentView.m delete mode 100644 LookInside/Base/InputSearch/LKInputSearchSuggestionsRowView.h delete mode 100644 LookInside/Base/InputSearch/LKInputSearchSuggestionsRowView.m delete mode 100644 LookInside/Base/InputSearch/LKInputSearchView.h delete mode 100644 LookInside/Base/InputSearch/LKInputSearchView.m create mode 100644 LookInside/Base/InputSearch/LKInputSearchView.swift create mode 100644 LookInside/Base/InputSearch/NSView+LKPeripheralLayout.swift delete mode 100644 LookInside/Base/LKBaseControl.h delete mode 100644 LookInside/Base/LKBaseControl.m create mode 100644 LookInside/Base/LKBaseControl.swift delete mode 100644 LookInside/Base/LKBaseView.h delete mode 100644 LookInside/Base/LKBaseView.m create mode 100644 LookInside/Base/LKBaseView.swift delete mode 100644 LookInside/Base/LKBaseViewController.h delete mode 100644 LookInside/Base/LKBaseViewController.m create mode 100644 LookInside/Base/LKBaseViewController.swift delete mode 100644 LookInside/Base/LKColorIndicatorLayer.h delete mode 100644 LookInside/Base/LKColorIndicatorLayer.m create mode 100644 LookInside/Base/LKColorIndicatorLayer.swift create mode 100644 LookInside/Base/LKConnectionTipsBinding.swift create mode 100644 LookInside/Base/LKFrameLayout.swift delete mode 100644 LookInside/Base/LKImageControl.h delete mode 100644 LookInside/Base/LKImageControl.m delete mode 100644 LookInside/Base/LKImageTextView.h delete mode 100644 LookInside/Base/LKImageTextView.m create mode 100644 LookInside/Base/LKImageTextView.swift delete mode 100644 LookInside/Base/LKLabel.h delete mode 100644 LookInside/Base/LKLabel.m create mode 100644 LookInside/Base/LKLabel.swift delete mode 100644 LookInside/Base/LKNumberInputView.h delete mode 100644 LookInside/Base/LKNumberInputView.m create mode 100644 LookInside/Base/LKNumberInputView.swift delete mode 100644 LookInside/Base/LKOutlineItem.h delete mode 100644 LookInside/Base/LKOutlineItem.m delete mode 100644 LookInside/Base/LKOutlineRowView.h delete mode 100644 LookInside/Base/LKOutlineRowView.m create mode 100644 LookInside/Base/LKOutlineRowView.swift delete mode 100644 LookInside/Base/LKOutlineView.h delete mode 100644 LookInside/Base/LKOutlineView.m delete mode 100644 LookInside/Base/LKPanelContentView.h delete mode 100644 LookInside/Base/LKPanelContentView.m delete mode 100644 LookInside/Base/LKPerformanceReporter.h delete mode 100644 LookInside/Base/LKPerformanceReporter.m create mode 100644 LookInside/Base/LKPerformanceReporter.swift delete mode 100644 LookInside/Base/LKPopPanel.h delete mode 100644 LookInside/Base/LKPopPanel.m create mode 100644 LookInside/Base/LKPopPanel.swift delete mode 100644 LookInside/Base/LKProgressIndicatorView.h delete mode 100644 LookInside/Base/LKProgressIndicatorView.m create mode 100644 LookInside/Base/LKProgressIndicatorView.swift delete mode 100644 LookInside/Base/LKSplitView.h delete mode 100644 LookInside/Base/LKSplitView.m create mode 100644 LookInside/Base/LKSplitView.swift delete mode 100644 LookInside/Base/LKTableRowView.h delete mode 100644 LookInside/Base/LKTableRowView.m create mode 100644 LookInside/Base/LKTableRowView.swift delete mode 100644 LookInside/Base/LKTableView.h delete mode 100644 LookInside/Base/LKTableView.m create mode 100644 LookInside/Base/LKTableView.swift delete mode 100644 LookInside/Base/LKTableViewHorizontalScrollWidthManager.h delete mode 100644 LookInside/Base/LKTableViewHorizontalScrollWidthManager.m create mode 100644 LookInside/Base/LKTableViewHorizontalScrollWidthManager.swift delete mode 100644 LookInside/Base/LKTextControl.h delete mode 100644 LookInside/Base/LKTextControl.m create mode 100644 LookInside/Base/LKTextControl.swift delete mode 100644 LookInside/Base/LKTextFieldView.h delete mode 100644 LookInside/Base/LKTextFieldView.m create mode 100644 LookInside/Base/LKTextFieldView.swift delete mode 100644 LookInside/Base/LKTextsMenuView.h delete mode 100644 LookInside/Base/LKTextsMenuView.m create mode 100644 LookInside/Base/LKTextsMenuView.swift delete mode 100644 LookInside/Base/LKTipsView.h delete mode 100644 LookInside/Base/LKTipsView.m create mode 100644 LookInside/Base/LKTipsView.swift delete mode 100644 LookInside/Base/LKTwoColors.h delete mode 100644 LookInside/Base/LKTwoColors.m create mode 100644 LookInside/Base/LKTwoColors.swift delete mode 100644 LookInside/Base/LKUserActionManager.h delete mode 100644 LookInside/Base/LKUserActionManager.m create mode 100644 LookInside/Base/LKUserActionManager.swift delete mode 100644 LookInside/Base/LKWindow.h delete mode 100644 LookInside/Base/LKWindow.m create mode 100644 LookInside/Base/LKWindow.swift delete mode 100644 LookInside/Base/LKWindowController.h delete mode 100644 LookInside/Base/LKWindowController.m create mode 100644 LookInside/Base/LKWindowController.swift create mode 100644 LookInside/Base/Message/LKPeripheralAlerts.swift delete mode 100644 LookInside/Base/Message/LookinMsgAttribute.h delete mode 100644 LookInside/Base/Message/LookinMsgAttribute.m create mode 100644 LookInside/Base/Message/LookinMsgAttribute.swift delete mode 100644 LookInside/Base/Message/LookinMsgTargetAction.h delete mode 100644 LookInside/Base/Message/LookinMsgTargetAction.m delete mode 100644 LookInside/Connection/LKAppsManager.h delete mode 100644 LookInside/Connection/LKAppsManager.m create mode 100644 LookInside/Connection/LKAppsManager.swift create mode 100644 LookInside/Connection/LKChannel.swift create mode 100644 LookInside/Connection/LKConnectionErrors.swift create mode 100644 LookInside/Connection/LKConnectionManager+License.swift delete mode 100644 LookInside/Connection/LKConnectionManager.h delete mode 100644 LookInside/Connection/LKConnectionManager.m create mode 100644 LookInside/Connection/LKConnectionManager.swift delete mode 100644 LookInside/Connection/LKConnectionRequest.h delete mode 100644 LookInside/Connection/LKConnectionRequest.m create mode 100644 LookInside/Connection/LKConnectionRequest.swift delete mode 100644 LookInside/Connection/LKInspectableApp.h delete mode 100644 LookInside/Connection/LKInspectableApp.m create mode 100644 LookInside/Connection/LKInspectableApp.swift create mode 100644 LookInside/Connection/LKServerUpgradeHint.swift delete mode 100644 LookInside/Connection/LookinLiveDocument.h delete mode 100644 LookInside/Connection/LookinLiveDocument.m create mode 100644 LookInside/Connection/LookinLiveDocument.swift delete mode 100644 LookInside/Connection/LookinLiveDocumentController.h delete mode 100644 LookInside/Connection/LookinLiveDocumentController.m create mode 100644 LookInside/Connection/LookinLiveDocumentController.swift delete mode 100644 LookInside/Console/LKConsoleDataSource.h delete mode 100644 LookInside/Console/LKConsoleDataSource.m create mode 100644 LookInside/Console/LKConsoleDataSource.swift delete mode 100644 LookInside/Console/LKConsoleDataSourceRowItem.h delete mode 100644 LookInside/Console/LKConsoleDataSourceRowItem.m delete mode 100644 LookInside/Console/LKConsoleInputRowView.h delete mode 100644 LookInside/Console/LKConsoleInputRowView.m delete mode 100644 LookInside/Console/LKConsoleReturnRowView.h delete mode 100644 LookInside/Console/LKConsoleReturnRowView.m create mode 100644 LookInside/Console/LKConsoleRowViews.swift delete mode 100644 LookInside/Console/LKConsoleSelectPopoverController.h delete mode 100644 LookInside/Console/LKConsoleSelectPopoverController.m create mode 100644 LookInside/Console/LKConsoleSelectPopoverController.swift delete mode 100644 LookInside/Console/LKConsoleSelectPopoverItemControl.h delete mode 100644 LookInside/Console/LKConsoleSelectPopoverItemControl.m delete mode 100644 LookInside/Console/LKConsoleSubmitRowView.h delete mode 100644 LookInside/Console/LKConsoleSubmitRowView.m delete mode 100644 LookInside/Console/LKConsoleViewController.h delete mode 100644 LookInside/Console/LKConsoleViewController.m create mode 100644 LookInside/Console/LKConsoleViewController.swift delete mode 100644 LookInside/DanceScript/DanceScriptManager.h delete mode 100644 LookInside/DanceScript/DanceScriptManager.m create mode 100644 LookInside/DanceScript/DanceScriptManager.swift delete mode 100644 LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.h delete mode 100644 LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.m create mode 100644 LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.swift delete mode 100644 LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.h delete mode 100644 LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.m delete mode 100644 LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.h delete mode 100644 LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.m create mode 100644 LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.swift delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.h delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.m delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeContentView.h delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeContentView.m delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.h delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.m create mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.swift delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViewController.h delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViewController.m create mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViews.swift delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.h delete mode 100644 LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeClassView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeClassView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.m create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.swift delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.m create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.swift create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeGeometryViews.swift delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeInsetsView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeInsetsView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.m create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.swift delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeOpenImageView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeOpenImageView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributePointView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributePointView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.m create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.swift create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeReadOnlyViews.swift delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeRectView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeRectView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeRelationView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeRelationView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeRowsCountView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeRowsCountView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeShadowView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeShadowView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeSizeView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeSizeView.m delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.m create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.swift delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.m create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.swift delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.m create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.swift delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeView.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeView.m create mode 100644 LookInside/Dashboard/AttributeView/LKDashboardAttributeView.swift delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardTextControlEditingFlag.h delete mode 100644 LookInside/Dashboard/AttributeView/LKDashboardTextControlEditingFlag.m delete mode 100644 LookInside/Dashboard/LKDashboardAccessoryWindowController.h delete mode 100644 LookInside/Dashboard/LKDashboardAccessoryWindowController.m create mode 100644 LookInside/Dashboard/LKDashboardAccessoryWindowController.swift delete mode 100644 LookInside/Dashboard/LKDashboardCardTitleControl.h delete mode 100644 LookInside/Dashboard/LKDashboardCardTitleControl.m delete mode 100644 LookInside/Dashboard/LKDashboardCardView.h delete mode 100644 LookInside/Dashboard/LKDashboardCardView.m create mode 100644 LookInside/Dashboard/LKDashboardCardView.swift create mode 100644 LookInside/Dashboard/LKDashboardLayout.swift create mode 100644 LookInside/Dashboard/LKDashboardModification.swift delete mode 100644 LookInside/Dashboard/LKDashboardSectionView.h delete mode 100644 LookInside/Dashboard/LKDashboardSectionView.m create mode 100644 LookInside/Dashboard/LKDashboardSectionView.swift delete mode 100644 LookInside/Dashboard/LKDashboardSectionViewPool.h delete mode 100644 LookInside/Dashboard/LKDashboardSectionViewPool.m delete mode 100644 LookInside/Dashboard/LKDashboardViewController.h delete mode 100644 LookInside/Dashboard/LKDashboardViewController.m create mode 100644 LookInside/Dashboard/LKDashboardViewController.swift delete mode 100644 LookInside/Dashboard/LKEnumListRegistry.h delete mode 100644 LookInside/Dashboard/LKEnumListRegistry.m create mode 100644 LookInside/Dashboard/LKEnumListRegistry.swift delete mode 100644 LookInside/Dashboard/Search/LKDashboardHeaderView.h delete mode 100644 LookInside/Dashboard/Search/LKDashboardHeaderView.m create mode 100644 LookInside/Dashboard/Search/LKDashboardHeaderView.swift delete mode 100644 LookInside/Dashboard/Search/LKDashboardSearchCardView.h delete mode 100644 LookInside/Dashboard/Search/LKDashboardSearchCardView.m delete mode 100644 LookInside/Dashboard/Search/LKDashboardSearchMethodsDataSource.h delete mode 100644 LookInside/Dashboard/Search/LKDashboardSearchMethodsDataSource.m delete mode 100644 LookInside/Dashboard/Search/LKDashboardSearchMethodsView.h delete mode 100644 LookInside/Dashboard/Search/LKDashboardSearchMethodsView.m delete mode 100644 LookInside/Dashboard/Search/LKDashboardSearchPropView.h delete mode 100644 LookInside/Dashboard/Search/LKDashboardSearchPropView.m create mode 100644 LookInside/Dashboard/Search/LKDashboardSearchViews.swift create mode 100644 LookInside/DebugE2E/LKDebugE2EDump.swift create mode 100644 LookInside/DebugE2E/LKDebugE2EDumpWriter.swift create mode 100644 LookInside/DebugE2E/LKDebugUISnapshots.swift create mode 100644 LookInside/DebugE2E/LKDebugWindowImage.swift create mode 100644 LookInside/DebugE2E/LookinSnapshotNormalizer.swift delete mode 100644 LookInside/Export/LKExportAccessoryView.h delete mode 100644 LookInside/Export/LKExportAccessoryView.m create mode 100644 LookInside/Export/LKExportAccessoryView.swift delete mode 100644 LookInside/Export/LKExportManager.h delete mode 100644 LookInside/Export/LKExportManager.m create mode 100644 LookInside/Export/LKExportManager.swift delete mode 100644 LookInside/Hierarchy/LKDanceUIAttrMaker.h delete mode 100644 LookInside/Hierarchy/LKDanceUIAttrMaker.m create mode 100644 LookInside/Hierarchy/LKDanceUIAttrMaker.swift delete mode 100644 LookInside/Hierarchy/LKHierarchyController.h delete mode 100644 LookInside/Hierarchy/LKHierarchyController.m create mode 100644 LookInside/Hierarchy/LKHierarchyController.swift delete mode 100644 LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.h delete mode 100644 LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.m create mode 100644 LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.swift delete mode 100644 LookInside/Hierarchy/LKHierarchyDataSource.h delete mode 100644 LookInside/Hierarchy/LKHierarchyDataSource.m create mode 100644 LookInside/Hierarchy/LKHierarchyDataSource.swift create mode 100644 LookInside/Hierarchy/LKHierarchyFrameLayout.swift delete mode 100644 LookInside/Hierarchy/LKHierarchyHandlersPopoverController.h delete mode 100644 LookInside/Hierarchy/LKHierarchyHandlersPopoverController.m create mode 100644 LookInside/Hierarchy/LKHierarchyHandlersPopoverController.swift delete mode 100644 LookInside/Hierarchy/LKHierarchyHandlersPopoverItemView.h delete mode 100644 LookInside/Hierarchy/LKHierarchyHandlersPopoverItemView.m delete mode 100644 LookInside/Hierarchy/LKHierarchyRowView.h delete mode 100644 LookInside/Hierarchy/LKHierarchyRowView.m create mode 100644 LookInside/Hierarchy/LKHierarchyRowView.swift delete mode 100644 LookInside/Hierarchy/LKHierarchyView.h delete mode 100644 LookInside/Hierarchy/LKHierarchyView.m create mode 100644 LookInside/Hierarchy/LKHierarchyView.swift create mode 100644 LookInside/Hierarchy/LKSyncSignal.swift delete mode 100644 LookInside/Launch/LKLaunchAppView.h delete mode 100644 LookInside/Launch/LKLaunchAppView.m create mode 100644 LookInside/Launch/LKLaunchAppView.swift create mode 100644 LookInside/Launch/LKLaunchRules.swift delete mode 100644 LookInside/Launch/LKLaunchViewController.h delete mode 100644 LookInside/Launch/LKLaunchViewController.m create mode 100644 LookInside/Launch/LKLaunchViewController.swift delete mode 100644 LookInside/Launch/LKLaunchWindowController.h delete mode 100644 LookInside/Launch/LKLaunchWindowController.m create mode 100644 LookInside/Launch/LKLaunchWindowController.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeAttributeEncoder.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeAttributeValueDecoder.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeConnection.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeDetailsService.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeEventPublisher.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeFrame.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeInspectionModels.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeInspectionService.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeInvocationService.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeListenSocket.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeLiveDocumentLookup.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeModificationService.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeRACBridge.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeRefreshService.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeScreenshotService.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeSearchService.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeSecureContentDetector.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeSelectorService.swift delete mode 100644 LookInside/MCPBridge/LKMCPBridgeServer.swift delete mode 100644 LookInside/Manager/LKAppMenuManager.h delete mode 100644 LookInside/Manager/LKAppMenuManager.m create mode 100644 LookInside/Manager/LKAppMenuManager.swift delete mode 100644 LookInside/Manager/LKHelper.h delete mode 100644 LookInside/Manager/LKHelper.m create mode 100644 LookInside/Manager/LKHelper.swift delete mode 100644 LookInside/Manager/LKNavigationManager.h delete mode 100644 LookInside/Manager/LKNavigationManager.m create mode 100644 LookInside/Manager/LKNavigationManager.swift delete mode 100644 LookInside/Manager/LKPreferenceManager.h delete mode 100644 LookInside/Manager/LKPreferenceManager.m create mode 100644 LookInside/Manager/LKPreferenceManager.swift delete mode 100644 LookInside/Measure/LKMeasureController.h delete mode 100644 LookInside/Measure/LKMeasureController.m create mode 100644 LookInside/Measure/LKMeasureController.swift delete mode 100644 LookInside/Measure/LKMeasureResultLineData.h delete mode 100644 LookInside/Measure/LKMeasureResultLineData.m delete mode 100644 LookInside/Measure/LKMeasureResultView.h delete mode 100644 LookInside/Measure/LKMeasureResultView.m create mode 100644 LookInside/Measure/LKMeasureResultView.swift delete mode 100644 LookInside/Other/LKVersionComparer.h delete mode 100644 LookInside/Other/LKVersionComparer.m create mode 100644 LookInside/Other/LKVersionComparer.swift delete mode 100644 LookInside/Preference/LKPreferenceViewController.h delete mode 100644 LookInside/Preference/LKPreferenceViewController.m delete mode 100644 LookInside/Preference/LKPreferenceWindowController.h delete mode 100644 LookInside/Preference/LKPreferenceWindowController.m create mode 100644 LookInside/Preference/LKPreferenceWindowController.swift delete mode 100644 LookInside/PrefixHeaders.pch delete mode 100644 LookInside/ReactiveObjC/NSArray+RACSequenceAdditions.h delete mode 100644 LookInside/ReactiveObjC/NSArray+RACSequenceAdditions.m delete mode 100644 LookInside/ReactiveObjC/NSControl+RACCommandSupport.h delete mode 100644 LookInside/ReactiveObjC/NSControl+RACCommandSupport.m delete mode 100644 LookInside/ReactiveObjC/NSControl+RACTextSignalSupport.h delete mode 100644 LookInside/ReactiveObjC/NSControl+RACTextSignalSupport.m delete mode 100644 LookInside/ReactiveObjC/NSData+RACSupport.h delete mode 100644 LookInside/ReactiveObjC/NSData+RACSupport.m delete mode 100644 LookInside/ReactiveObjC/NSDictionary+RACSequenceAdditions.h delete mode 100644 LookInside/ReactiveObjC/NSDictionary+RACSequenceAdditions.m delete mode 100644 LookInside/ReactiveObjC/NSEnumerator+RACSequenceAdditions.h delete mode 100644 LookInside/ReactiveObjC/NSEnumerator+RACSequenceAdditions.m delete mode 100644 LookInside/ReactiveObjC/NSFileHandle+RACSupport.h delete mode 100644 LookInside/ReactiveObjC/NSFileHandle+RACSupport.m delete mode 100644 LookInside/ReactiveObjC/NSIndexSet+RACSequenceAdditions.h delete mode 100644 LookInside/ReactiveObjC/NSIndexSet+RACSequenceAdditions.m delete mode 100644 LookInside/ReactiveObjC/NSInvocation+RACTypeParsing.h delete mode 100644 LookInside/ReactiveObjC/NSInvocation+RACTypeParsing.m delete mode 100644 LookInside/ReactiveObjC/NSNotificationCenter+RACSupport.h delete mode 100644 LookInside/ReactiveObjC/NSNotificationCenter+RACSupport.m delete mode 100644 LookInside/ReactiveObjC/NSObject+RACAppKitBindings.h delete mode 100644 LookInside/ReactiveObjC/NSObject+RACAppKitBindings.m delete mode 100644 LookInside/ReactiveObjC/NSObject+RACDeallocating.h delete mode 100644 LookInside/ReactiveObjC/NSObject+RACDeallocating.m delete mode 100644 LookInside/ReactiveObjC/NSObject+RACDescription.h delete mode 100644 LookInside/ReactiveObjC/NSObject+RACDescription.m delete mode 100644 LookInside/ReactiveObjC/NSObject+RACKVOWrapper.h delete mode 100644 LookInside/ReactiveObjC/NSObject+RACKVOWrapper.m delete mode 100644 LookInside/ReactiveObjC/NSObject+RACLifting.h delete mode 100644 LookInside/ReactiveObjC/NSObject+RACLifting.m delete mode 100644 LookInside/ReactiveObjC/NSObject+RACPropertySubscribing.h delete mode 100644 LookInside/ReactiveObjC/NSObject+RACPropertySubscribing.m delete mode 100644 LookInside/ReactiveObjC/NSObject+RACSelectorSignal.h delete mode 100644 LookInside/ReactiveObjC/NSObject+RACSelectorSignal.m delete mode 100644 LookInside/ReactiveObjC/NSOrderedSet+RACSequenceAdditions.h delete mode 100644 LookInside/ReactiveObjC/NSOrderedSet+RACSequenceAdditions.m delete mode 100644 LookInside/ReactiveObjC/NSSet+RACSequenceAdditions.h delete mode 100644 LookInside/ReactiveObjC/NSSet+RACSequenceAdditions.m delete mode 100644 LookInside/ReactiveObjC/NSString+RACKeyPathUtilities.h delete mode 100644 LookInside/ReactiveObjC/NSString+RACKeyPathUtilities.m delete mode 100644 LookInside/ReactiveObjC/NSString+RACSequenceAdditions.h delete mode 100644 LookInside/ReactiveObjC/NSString+RACSequenceAdditions.m delete mode 100644 LookInside/ReactiveObjC/NSString+RACSupport.h delete mode 100644 LookInside/ReactiveObjC/NSString+RACSupport.m delete mode 100644 LookInside/ReactiveObjC/NSText+RACSignalSupport.h delete mode 100644 LookInside/ReactiveObjC/NSText+RACSignalSupport.m delete mode 100644 LookInside/ReactiveObjC/NSURLConnection+RACSupport.h delete mode 100644 LookInside/ReactiveObjC/NSURLConnection+RACSupport.m delete mode 100644 LookInside/ReactiveObjC/NSUserDefaults+RACSupport.h delete mode 100644 LookInside/ReactiveObjC/NSUserDefaults+RACSupport.m delete mode 100644 LookInside/ReactiveObjC/RACAnnotations.h delete mode 100644 LookInside/ReactiveObjC/RACArraySequence.h delete mode 100644 LookInside/ReactiveObjC/RACArraySequence.m delete mode 100644 LookInside/ReactiveObjC/RACBehaviorSubject.h delete mode 100644 LookInside/ReactiveObjC/RACBehaviorSubject.m delete mode 100644 LookInside/ReactiveObjC/RACBlockTrampoline.h delete mode 100644 LookInside/ReactiveObjC/RACBlockTrampoline.m delete mode 100644 LookInside/ReactiveObjC/RACChannel.h delete mode 100644 LookInside/ReactiveObjC/RACChannel.m delete mode 100644 LookInside/ReactiveObjC/RACCommand.h delete mode 100644 LookInside/ReactiveObjC/RACCommand.m delete mode 100644 LookInside/ReactiveObjC/RACCompoundDisposable.h delete mode 100644 LookInside/ReactiveObjC/RACCompoundDisposable.m delete mode 100644 LookInside/ReactiveObjC/RACCompoundDisposableProvider.d delete mode 100644 LookInside/ReactiveObjC/RACCompoundDisposableProvider.h delete mode 100644 LookInside/ReactiveObjC/RACDelegateProxy.h delete mode 100644 LookInside/ReactiveObjC/RACDelegateProxy.m delete mode 100644 LookInside/ReactiveObjC/RACDisposable.h delete mode 100644 LookInside/ReactiveObjC/RACDisposable.m delete mode 100644 LookInside/ReactiveObjC/RACDynamicSequence.h delete mode 100644 LookInside/ReactiveObjC/RACDynamicSequence.m delete mode 100644 LookInside/ReactiveObjC/RACDynamicSignal.h delete mode 100644 LookInside/ReactiveObjC/RACDynamicSignal.m delete mode 100644 LookInside/ReactiveObjC/RACEXTKeyPathCoding.h delete mode 100644 LookInside/ReactiveObjC/RACEXTRuntimeExtensions.h delete mode 100644 LookInside/ReactiveObjC/RACEXTScope.h delete mode 100644 LookInside/ReactiveObjC/RACEagerSequence.h delete mode 100644 LookInside/ReactiveObjC/RACEagerSequence.m delete mode 100644 LookInside/ReactiveObjC/RACEmptySequence.h delete mode 100644 LookInside/ReactiveObjC/RACEmptySequence.m delete mode 100644 LookInside/ReactiveObjC/RACEmptySignal.h delete mode 100644 LookInside/ReactiveObjC/RACEmptySignal.m delete mode 100644 LookInside/ReactiveObjC/RACErrorSignal.h delete mode 100644 LookInside/ReactiveObjC/RACErrorSignal.m delete mode 100644 LookInside/ReactiveObjC/RACEvent.h delete mode 100644 LookInside/ReactiveObjC/RACEvent.m delete mode 100644 LookInside/ReactiveObjC/RACGroupedSignal.h delete mode 100644 LookInside/ReactiveObjC/RACGroupedSignal.m delete mode 100644 LookInside/ReactiveObjC/RACImmediateScheduler.h delete mode 100644 LookInside/ReactiveObjC/RACImmediateScheduler.m delete mode 100644 LookInside/ReactiveObjC/RACIndexSetSequence.h delete mode 100644 LookInside/ReactiveObjC/RACIndexSetSequence.m delete mode 100644 LookInside/ReactiveObjC/RACKVOChannel.h delete mode 100644 LookInside/ReactiveObjC/RACKVOChannel.m delete mode 100644 LookInside/ReactiveObjC/RACKVOProxy.h delete mode 100644 LookInside/ReactiveObjC/RACKVOProxy.m delete mode 100644 LookInside/ReactiveObjC/RACKVOTrampoline.h delete mode 100644 LookInside/ReactiveObjC/RACKVOTrampoline.m delete mode 100644 LookInside/ReactiveObjC/RACMulticastConnection+Private.h delete mode 100644 LookInside/ReactiveObjC/RACMulticastConnection.h delete mode 100644 LookInside/ReactiveObjC/RACMulticastConnection.m delete mode 100644 LookInside/ReactiveObjC/RACPassthroughSubscriber.h delete mode 100644 LookInside/ReactiveObjC/RACPassthroughSubscriber.m delete mode 100644 LookInside/ReactiveObjC/RACQueueScheduler+Subclass.h delete mode 100644 LookInside/ReactiveObjC/RACQueueScheduler.h delete mode 100644 LookInside/ReactiveObjC/RACQueueScheduler.m delete mode 100644 LookInside/ReactiveObjC/RACReplaySubject.h delete mode 100644 LookInside/ReactiveObjC/RACReplaySubject.m delete mode 100644 LookInside/ReactiveObjC/RACReturnSignal.h delete mode 100644 LookInside/ReactiveObjC/RACReturnSignal.m delete mode 100644 LookInside/ReactiveObjC/RACScheduler+Private.h delete mode 100644 LookInside/ReactiveObjC/RACScheduler+Subclass.h delete mode 100644 LookInside/ReactiveObjC/RACScheduler.h delete mode 100644 LookInside/ReactiveObjC/RACScheduler.m delete mode 100644 LookInside/ReactiveObjC/RACScopedDisposable.h delete mode 100644 LookInside/ReactiveObjC/RACScopedDisposable.m delete mode 100644 LookInside/ReactiveObjC/RACSequence.h delete mode 100644 LookInside/ReactiveObjC/RACSequence.m delete mode 100644 LookInside/ReactiveObjC/RACSerialDisposable.h delete mode 100644 LookInside/ReactiveObjC/RACSerialDisposable.m delete mode 100644 LookInside/ReactiveObjC/RACSignal+Operations.h delete mode 100644 LookInside/ReactiveObjC/RACSignal+Operations.m delete mode 100644 LookInside/ReactiveObjC/RACSignal.h delete mode 100644 LookInside/ReactiveObjC/RACSignal.m delete mode 100644 LookInside/ReactiveObjC/RACSignalProvider.d delete mode 100644 LookInside/ReactiveObjC/RACSignalProvider.h delete mode 100644 LookInside/ReactiveObjC/RACSignalSequence.h delete mode 100644 LookInside/ReactiveObjC/RACSignalSequence.m delete mode 100644 LookInside/ReactiveObjC/RACStream+Private.h delete mode 100644 LookInside/ReactiveObjC/RACStream.h delete mode 100644 LookInside/ReactiveObjC/RACStream.m delete mode 100644 LookInside/ReactiveObjC/RACStringSequence.h delete mode 100644 LookInside/ReactiveObjC/RACStringSequence.m delete mode 100644 LookInside/ReactiveObjC/RACSubject.h delete mode 100644 LookInside/ReactiveObjC/RACSubject.m delete mode 100644 LookInside/ReactiveObjC/RACSubscriber+Private.h delete mode 100644 LookInside/ReactiveObjC/RACSubscriber.h delete mode 100644 LookInside/ReactiveObjC/RACSubscriber.m delete mode 100644 LookInside/ReactiveObjC/RACSubscriptingAssignmentTrampoline.h delete mode 100644 LookInside/ReactiveObjC/RACSubscriptingAssignmentTrampoline.m delete mode 100644 LookInside/ReactiveObjC/RACSubscriptionScheduler.h delete mode 100644 LookInside/ReactiveObjC/RACSubscriptionScheduler.m delete mode 100644 LookInside/ReactiveObjC/RACTargetQueueScheduler.h delete mode 100644 LookInside/ReactiveObjC/RACTargetQueueScheduler.m delete mode 100644 LookInside/ReactiveObjC/RACTestScheduler.h delete mode 100644 LookInside/ReactiveObjC/RACTestScheduler.m delete mode 100644 LookInside/ReactiveObjC/RACTuple.h delete mode 100644 LookInside/ReactiveObjC/RACTuple.m delete mode 100644 LookInside/ReactiveObjC/RACTupleSequence.h delete mode 100644 LookInside/ReactiveObjC/RACTupleSequence.m delete mode 100644 LookInside/ReactiveObjC/RACUnarySequence.h delete mode 100644 LookInside/ReactiveObjC/RACUnarySequence.m delete mode 100644 LookInside/ReactiveObjC/RACUnit.h delete mode 100644 LookInside/ReactiveObjC/RACUnit.m delete mode 100644 LookInside/ReactiveObjC/RACValueTransformer.h delete mode 100644 LookInside/ReactiveObjC/RACValueTransformer.m delete mode 100644 LookInside/ReactiveObjC/RACmetamacros.h delete mode 100644 LookInside/ReactiveObjC/ReactiveObjC.h delete mode 100644 LookInside/ReactiveObjC/extobjc/RACEXTKeyPathCoding.h delete mode 100644 LookInside/ReactiveObjC/extobjc/RACEXTRuntimeExtensions.h delete mode 100644 LookInside/ReactiveObjC/extobjc/RACEXTRuntimeExtensions.m delete mode 100644 LookInside/ReactiveObjC/extobjc/RACEXTScope.h delete mode 100644 LookInside/ReactiveObjC/extobjc/RACmetamacros.h delete mode 100644 LookInside/Read/LKReadHierarchyController.h delete mode 100644 LookInside/Read/LKReadHierarchyController.m create mode 100644 LookInside/Read/LKReadHierarchyController.swift delete mode 100644 LookInside/Read/LKReadHierarchyDataSource.h delete mode 100644 LookInside/Read/LKReadHierarchyDataSource.m create mode 100644 LookInside/Read/LKReadHierarchyDataSource.swift create mode 100644 LookInside/Read/LKReadScreenshotLookup.swift delete mode 100644 LookInside/Read/LKReadViewController.h delete mode 100644 LookInside/Read/LKReadViewController.m create mode 100644 LookInside/Read/LKReadViewController.swift delete mode 100644 LookInside/Read/LKReadWindowController.h delete mode 100644 LookInside/Read/LKReadWindowController.m create mode 100644 LookInside/Read/LKReadWindowController.swift create mode 100644 LookInside/Read/LookinArchiveCoding.swift delete mode 100644 LookInside/Read/LookinArchiveDocument.h delete mode 100644 LookInside/Read/LookinArchiveDocument.m create mode 100644 LookInside/Read/LookinArchiveDocument.swift delete mode 100644 LookInside/ShortCocoa/ShortCocoa+Layout.h delete mode 100644 LookInside/ShortCocoa/ShortCocoa+Layout.m delete mode 100644 LookInside/ShortCocoa/ShortCocoa+Others.h delete mode 100644 LookInside/ShortCocoa/ShortCocoa+Others.m delete mode 100644 LookInside/ShortCocoa/ShortCocoa+Private.h delete mode 100644 LookInside/ShortCocoa/ShortCocoa+Private.m delete mode 100644 LookInside/ShortCocoa/ShortCocoa+String.h delete mode 100644 LookInside/ShortCocoa/ShortCocoa+String.m delete mode 100644 LookInside/ShortCocoa/ShortCocoa+UIToolkit.h delete mode 100644 LookInside/ShortCocoa/ShortCocoa+UIToolkit.m delete mode 100644 LookInside/ShortCocoa/ShortCocoa.h delete mode 100644 LookInside/ShortCocoa/ShortCocoaCore.h delete mode 100644 LookInside/ShortCocoa/ShortCocoaCore.m delete mode 100644 LookInside/ShortCocoa/ShortCocoaDefines.h delete mode 100644 LookInside/Static/LKMessageManager.h delete mode 100644 LookInside/Static/LKMessageManager.m create mode 100644 LookInside/Static/LKMessageManager.swift delete mode 100644 LookInside/Static/LKReloadItemAndChildrenUpdateTaskMaker.h delete mode 100644 LookInside/Static/LKReloadItemAndChildrenUpdateTaskMaker.m delete mode 100644 LookInside/Static/LKReloadSingleItemUpdateTaskMaker.h delete mode 100644 LookInside/Static/LKReloadSingleItemUpdateTaskMaker.m delete mode 100644 LookInside/Static/LKStaticAsyncUpdateManager.h delete mode 100644 LookInside/Static/LKStaticAsyncUpdateManager.m create mode 100644 LookInside/Static/LKStaticAsyncUpdateManager.swift create mode 100644 LookInside/Static/LKStaticConstants.swift delete mode 100644 LookInside/Static/LKStaticHierarchyController.h delete mode 100644 LookInside/Static/LKStaticHierarchyController.m create mode 100644 LookInside/Static/LKStaticHierarchyController.swift delete mode 100644 LookInside/Static/LKStaticHierarchyDataSource.h delete mode 100644 LookInside/Static/LKStaticHierarchyDataSource.m create mode 100644 LookInside/Static/LKStaticHierarchyDataSource.swift create mode 100644 LookInside/Static/LKStaticLayout.swift delete mode 100644 LookInside/Static/LKStaticViewController.h delete mode 100644 LookInside/Static/LKStaticViewController.m create mode 100644 LookInside/Static/LKStaticViewController.swift delete mode 100644 LookInside/Static/LKStaticWindowController.h delete mode 100644 LookInside/Static/LKStaticWindowController.m create mode 100644 LookInside/Static/LKStaticWindowController.swift delete mode 100644 LookInside/Static/Preview/LKDisplayItemNode.h delete mode 100644 LookInside/Static/Preview/LKDisplayItemNode.m create mode 100644 LookInside/Static/Preview/LKDisplayItemNode.swift delete mode 100644 LookInside/Static/Preview/LKPreviewController.h delete mode 100644 LookInside/Static/Preview/LKPreviewController.m create mode 100644 LookInside/Static/Preview/LKPreviewController.swift delete mode 100644 LookInside/Static/Preview/LKPreviewPanGestureRecognizer.h delete mode 100644 LookInside/Static/Preview/LKPreviewPanGestureRecognizer.m delete mode 100644 LookInside/Static/Preview/LKPreviewStageView.h delete mode 100644 LookInside/Static/Preview/LKPreviewStageView.m create mode 100644 LookInside/Static/Preview/LKPreviewStageView.swift delete mode 100644 LookInside/Static/Preview/LKPreviewView.h delete mode 100644 LookInside/Static/Preview/LKPreviewView.m create mode 100644 LookInside/Static/Preview/LKPreviewView.swift delete mode 100644 LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.h delete mode 100644 LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.m create mode 100644 LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.swift delete mode 100644 LookInside/SwiftUISupport/LKSwiftUISupportAuthServerPathPolicy.swift delete mode 100644 LookInside/SwiftUISupport/LKSwiftUISupportInstaller.swift delete mode 100644 LookInside/SwiftUISupport/LKSwiftUISupportLocalLicenseProbe.swift delete mode 100644 LookInside/Toolbar/LKMenuPopoverAppsListController.h delete mode 100644 LookInside/Toolbar/LKMenuPopoverAppsListController.m create mode 100644 LookInside/Toolbar/LKMenuPopoverAppsListController.swift delete mode 100644 LookInside/Toolbar/LKMenuPopoverSettingController.h delete mode 100644 LookInside/Toolbar/LKMenuPopoverSettingController.m create mode 100644 LookInside/Toolbar/LKMenuPopoverSettingController.swift create mode 100644 LookInside/Toolbar/LKToolbarRules.swift create mode 100644 LookInside/Toolbar/LKViewFrameLayout.swift delete mode 100644 LookInside/Toolbar/LKWindowToolbarAppButton.h delete mode 100644 LookInside/Toolbar/LKWindowToolbarAppButton.m create mode 100644 LookInside/Toolbar/LKWindowToolbarAppButton.swift delete mode 100644 LookInside/Toolbar/LKWindowToolbarHelper.h delete mode 100644 LookInside/Toolbar/LKWindowToolbarHelper.m create mode 100644 LookInside/Toolbar/LKWindowToolbarHelper.swift delete mode 100644 LookInside/Toolbar/LKWindowToolbarScaleView.h delete mode 100644 LookInside/Toolbar/LKWindowToolbarScaleView.m create mode 100644 LookInside/Toolbar/LKWindowToolbarScaleView.swift delete mode 100644 LookInside/main.m create mode 100644 Packages/LookInsideActivation/.swift-format create mode 100644 Packages/LookInsideActivation/Package.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationArtifact.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationPath.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationSession.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ClientActivationChallenge.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceBinding.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceBindingStatus.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceFingerprint.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/EntitlementStatus.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateIssuanceReason.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateLease.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateLeaseStatus.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/SecureTimestampToken.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/SignedActivationEnvelope.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/CertificateSigningRequestBuilder.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/EmbeddedTrustedRoots.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Errors.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/CertificateChain.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseAudience.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseClass.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseEnvelope+IntermediateCertificateLease.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseEnvelope.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseLifecycleState.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Policy.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Protocols.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/ActivationSessionRefreshRequest.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/EntitlementStatusRequest.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/HostActivationRequest.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/HostActivationResponse.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/IntermediateCertificateIssueRequest.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/PurchaseClaimRequest.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/SecureTimestampRequest.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/TrialIssueRequest.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Validators.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Host/HostActivationCoordinator.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Model/ActivationModel.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Model/ActivationUIConfiguration.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Model/KeychainAccessNotice.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIEnvelope.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIErrorEnvelope.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIErrorPayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/ActivationSessionRefreshPayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/ActivationSessionTokenClaims.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/IntermediateCertificateIssuePayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/IntermediateCertificatePayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/LicenseSummaryPayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/LicenseSummaryRequestPayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/PurchaseClaimPayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/PurchaseClaimResponse.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/SecureTimestampPayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/SecureTimestampRequestPayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/TrialIssuePayload.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPIClient.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPIMapping.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPITransport.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Resources/Localizable.xcstrings create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationAccessDecision.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationAccessEvaluator.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationCertificateRenewer.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationChallengeSignature.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationChallengeSigner.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationConfiguration.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationDebugOverrides.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationDeviceFingerprint.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationError.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationLogger.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationPersistedState.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationResources.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationRuntime.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationServiceProxies.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationSigningGate.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationSigningPolicy.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationStateCoding.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationStateStore.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/DataHexCoding.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/InMemoryReplayProtector.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/IntermediateKeyStore.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/KeychainAccessMarkers.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/LocalActivationMaterialIssuer.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/TrustedApplicationSignature.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivation/Server/ServerSecureTimestampService.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationView.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationWindowController.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationWindowCoordinator.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/Bundle+ActivationUI.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/LicenseStatusBadge.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/LicenseStatusView.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/ActivationFormView.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/KeychainAccessExplainerView.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LabeledRow.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseDetailRow.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseDetailRows.swift create mode 100644 Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseSummaryView.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/ChallengeSignatureCompatibilityTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/KeyStoreCompatibilityTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/StateFileCompatibilityTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/challenge-input.json create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-check-access.json create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-sign-challenge-expired.json create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-sign-challenge.json create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-state-full.json create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-state-trial.json create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/olduser-state.helper-reencoded.json create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/olduser-state.json create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationAccessEvaluatorTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationCertificateRenewerTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationChallengeSignerTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationErrorTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationModelTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationPolicyTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationStateStoreTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationSupportModelsTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationWindowCoordinatorTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationWindowPlacementTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/EntitlementDisplayStateTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/HostActivationCoordinatorTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/LicenseSummaryMappingTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ServerSecureTimestampServiceTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ValidatorsTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/ActivationRuntimeTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/ActivationWindowBehaviorTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/KeyUseRulesTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/KeychainAccessTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/SigningPolicyTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/SilentRenewalTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/StateSharingTests.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/HelperFixtures.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/TestSupport.swift create mode 100644 Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/UITestSupport.swift create mode 100644 Packages/LookInsideHostCore/Package.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/AppShellPolicies.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/AsyncBroadcaster.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/ClientDisplayText.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/ConnectionPolicies.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/ConsoleHistory.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/ExportNaming.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/HierarchyOutline.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/LicenseHandshakeFlow.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/MeasureGuides.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreviewGeometry.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/ServerUpgradeHint.swift create mode 100644 Packages/LookInsideHostCore/Sources/LookInsideHostCore/StringScore.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AppShellPoliciesTests.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AsyncBroadcasterTests.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ClientDisplayTextTests.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ConnectionPoliciesTests.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/HierarchyOutlineTests.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/LicenseHandshakeFlowTests.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PeripheralLogicTests.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreviewGeometryTests.swift create mode 100644 Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ServerUpgradeHintTests.swift delete mode 100644 Resources/Licenses/Peertalk.txt delete mode 100644 Resources/Licenses/ReactiveObjC.md delete mode 100644 Resources/Licenses/ShortCocoa.md create mode 100755 Scripts/check-host-release-clean.sh delete mode 100755 Scripts/package-lookinserver.sh delete mode 100755 Scripts/prepare-debug-auth-server.sh delete mode 100755 Scripts/prepare-mcp-cli.sh create mode 100755 Scripts/run-gitleaks.sh create mode 100755 Scripts/scan-private-keys.sh create mode 100755 Scripts/test-dashboard.sh create mode 100755 Scripts/test-launch-toolbar-read.sh delete mode 100755 Scripts/test-mcp-bridge.sh create mode 100755 Scripts/test-scan-private-keys.sh delete mode 100644 Sources/LookinCore/Category/CALayer+Lookin.h delete mode 100644 Sources/LookinCore/Category/CALayer+Lookin.m delete mode 100644 Sources/LookinCore/Category/Color+Lookin.h delete mode 100644 Sources/LookinCore/Category/Color+Lookin.m delete mode 100644 Sources/LookinCore/Category/Image+Lookin.h delete mode 100644 Sources/LookinCore/Category/Image+Lookin.m delete mode 100644 Sources/LookinCore/Category/NSArray+Lookin.h delete mode 100644 Sources/LookinCore/Category/NSArray+Lookin.m delete mode 100644 Sources/LookinCore/Category/NSObject+Lookin.h delete mode 100644 Sources/LookinCore/Category/NSObject+Lookin.m delete mode 100644 Sources/LookinCore/Category/NSSet+Lookin.h delete mode 100644 Sources/LookinCore/Category/NSSet+Lookin.m delete mode 100644 Sources/LookinCore/Category/NSString+Lookin.h delete mode 100644 Sources/LookinCore/Category/NSString+Lookin.m delete mode 100644 Sources/LookinCore/Category/NSValue+Lookin.m delete mode 100644 Sources/LookinCore/LKS_MultiplatformAdapter.h delete mode 100644 Sources/LookinCore/LKS_MultiplatformAdapter.m delete mode 100644 Sources/LookinCore/LookinAppInfo.h delete mode 100644 Sources/LookinCore/LookinAppInfo.m delete mode 100644 Sources/LookinCore/LookinAttribute.h delete mode 100644 Sources/LookinCore/LookinAttribute.m delete mode 100644 Sources/LookinCore/LookinAttributeModification.h delete mode 100644 Sources/LookinCore/LookinAttributeModification.m delete mode 100644 Sources/LookinCore/LookinAttributesGroup.h delete mode 100644 Sources/LookinCore/LookinAttributesGroup.m delete mode 100644 Sources/LookinCore/LookinAttributesSection.h delete mode 100644 Sources/LookinCore/LookinAttributesSection.m delete mode 100644 Sources/LookinCore/LookinAutoLayoutConstraint.h delete mode 100644 Sources/LookinCore/LookinAutoLayoutConstraint.m delete mode 100644 Sources/LookinCore/LookinConnectionAttachment.h delete mode 100644 Sources/LookinCore/LookinConnectionAttachment.m delete mode 100644 Sources/LookinCore/LookinConnectionResponseAttachment.h delete mode 100644 Sources/LookinCore/LookinConnectionResponseAttachment.m delete mode 100644 Sources/LookinCore/LookinCustomAttrModification.h delete mode 100644 Sources/LookinCore/LookinCustomAttrModification.m delete mode 100644 Sources/LookinCore/LookinCustomDisplayItemInfo.h delete mode 100644 Sources/LookinCore/LookinCustomDisplayItemInfo.m delete mode 100644 Sources/LookinCore/LookinDashboardBlueprint.h delete mode 100644 Sources/LookinCore/LookinDashboardBlueprint.m delete mode 100644 Sources/LookinCore/LookinDisplayItem.h delete mode 100644 Sources/LookinCore/LookinDisplayItem.m delete mode 100644 Sources/LookinCore/LookinDisplayItemDetail.h delete mode 100644 Sources/LookinCore/LookinDisplayItemDetail.m delete mode 100644 Sources/LookinCore/LookinEventHandler.h delete mode 100644 Sources/LookinCore/LookinEventHandler.m delete mode 100644 Sources/LookinCore/LookinHierarchyFile.h delete mode 100644 Sources/LookinCore/LookinHierarchyFile.m delete mode 100644 Sources/LookinCore/LookinHierarchyInfo.h delete mode 100644 Sources/LookinCore/LookinHierarchyInfo.m delete mode 100644 Sources/LookinCore/LookinObject.h delete mode 100644 Sources/LookinCore/LookinObject.m delete mode 100644 Sources/LookinCore/LookinStaticAsyncUpdateTask.h delete mode 100644 Sources/LookinCore/LookinStaticAsyncUpdateTask.m delete mode 100644 Sources/LookinCore/LookinTuple.h delete mode 100644 Sources/LookinCore/LookinTuple.m delete mode 100644 Sources/LookinCore/LookinWeakContainer.h delete mode 100644 Sources/LookinCore/LookinWeakContainer.m delete mode 100644 Sources/LookinCore/Peertalk/Lookin_PTChannel.h delete mode 100644 Sources/LookinCore/Peertalk/Lookin_PTChannel.m delete mode 100644 Sources/LookinCore/Peertalk/Lookin_PTPrivate.h delete mode 100644 Sources/LookinCore/Peertalk/Lookin_PTProtocol.h delete mode 100644 Sources/LookinCore/Peertalk/Lookin_PTProtocol.m delete mode 100644 Sources/LookinCore/Peertalk/Lookin_PTUSBHub.h delete mode 100644 Sources/LookinCore/Peertalk/Lookin_PTUSBHub.m delete mode 100644 Sources/LookinCore/Peertalk/Peertalk.h create mode 100644 Sources/LookinCore/Shim/LookinRuntimeShim.m rename Sources/LookinCore/{ => include}/LookinAttrIdentifiers.h (100%) rename Sources/LookinCore/{ => include}/LookinAttrType.h (100%) rename Sources/LookinCore/{ => include}/LookinCodingValueType.h (100%) create mode 100644 Sources/LookinCore/include/LookinCoreTypes.h rename Sources/LookinCore/{ => include}/LookinDefines.h (100%) rename Sources/LookinCore/{ => include}/LookinHitTargetSize.h (100%) create mode 100644 Sources/LookinCore/include/LookinRuntimeShim.h rename Sources/LookinCore/{Category => include}/NSValue+Lookin.h (55%) create mode 100644 Sources/LookinCoreImpl/Category/CALayer+Lookin.swift create mode 100644 Sources/LookinCoreImpl/Category/Color+Lookin.swift create mode 100644 Sources/LookinCoreImpl/Category/Image+Lookin.swift create mode 100644 Sources/LookinCoreImpl/Category/NSArray+Lookin.swift create mode 100644 Sources/LookinCoreImpl/Category/NSObject+Lookin.swift create mode 100644 Sources/LookinCoreImpl/Category/NSSet+Lookin.swift create mode 100644 Sources/LookinCoreImpl/Category/NSString+Lookin.swift create mode 100644 Sources/LookinCoreImpl/Category/NSValue+Lookin.swift create mode 100644 Sources/LookinCoreImpl/LKS_MultiplatformAdapter.swift create mode 100644 Sources/LookinCoreImpl/LookInsideServerLogger.swift create mode 100644 Sources/LookinCoreImpl/LookinAppInfo.swift create mode 100644 Sources/LookinCoreImpl/LookinAttribute.swift create mode 100644 Sources/LookinCoreImpl/LookinAttributeModification.swift create mode 100644 Sources/LookinCoreImpl/LookinAttributesGroup.swift create mode 100644 Sources/LookinCoreImpl/LookinAttributesSection.swift create mode 100644 Sources/LookinCoreImpl/LookinAutoLayoutConstraint.swift create mode 100644 Sources/LookinCoreImpl/LookinConnectionAttachment.swift create mode 100644 Sources/LookinCoreImpl/LookinConnectionResponseAttachment.swift create mode 100644 Sources/LookinCoreImpl/LookinCoreModelSupport.swift create mode 100644 Sources/LookinCoreImpl/LookinCustomAttrModification.swift create mode 100644 Sources/LookinCoreImpl/LookinCustomDisplayItemInfo.swift create mode 100644 Sources/LookinCoreImpl/LookinDashboardBlueprint+AttrInfo.swift create mode 100644 Sources/LookinCoreImpl/LookinDashboardBlueprint+Groups.swift create mode 100644 Sources/LookinCoreImpl/LookinDashboardBlueprint+Sections.swift create mode 100644 Sources/LookinCoreImpl/LookinDashboardBlueprint.swift create mode 100644 Sources/LookinCoreImpl/LookinDefines.swift create mode 100644 Sources/LookinCoreImpl/LookinDisplayItem.swift create mode 100644 Sources/LookinCoreImpl/LookinDisplayItemDetail.swift create mode 100644 Sources/LookinCoreImpl/LookinEventHandler.swift create mode 100644 Sources/LookinCoreImpl/LookinHierarchyFile.swift create mode 100644 Sources/LookinCoreImpl/LookinHierarchyInfo.swift create mode 100644 Sources/LookinCoreImpl/LookinIvarTrace.swift create mode 100644 Sources/LookinCoreImpl/LookinObject.swift create mode 100644 Sources/LookinCoreImpl/LookinStaticAsyncUpdateTask.swift create mode 100644 Sources/LookinCoreImpl/LookinTuple.swift create mode 100644 Sources/LookinCoreImpl/LookinWeakContainer.swift create mode 100644 Sources/LookinCoreImpl/Transport/LookinFrame.swift create mode 100644 Sources/LookinCoreImpl/Transport/LookinFrameChannel.swift create mode 100644 Sources/LookinCoreImpl/Transport/LookinFrameDecoder.swift create mode 100644 Sources/LookinCoreImpl/Transport/LookinFrameListener.swift create mode 100644 Sources/LookinCoreImpl/Transport/LookinSocket.swift create mode 100644 Sources/LookinCoreImpl/Transport/LookinUSBMux.swift create mode 100644 Sources/LookinCoreImpl/Transport/LookinUSBMuxClient.swift delete mode 100644 Sources/LookinServer/Server/Category/CALayer+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/CALayer+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/NSButton+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/NSButton+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/NSCollectionView+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/NSCollectionView+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/NSDatePicker+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/NSDatePicker+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/NSObject+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/NSObject+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/NSScrollView+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/NSScrollView+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/NSWindow+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/NSWindow+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UIBlurEffect+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UIBlurEffect+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UIColor+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UIColor+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UIImage+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UIImage+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UIImageView+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UIImageView+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UILabel+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UILabel+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UITableView+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UITableView+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UITextField+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UITextField+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UITextView+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UITextView+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UIView+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UIView+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UIViewController+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UIViewController+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UIVisualEffectView+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UIVisualEffectView+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Connection/LKS_ConnectionManager.h delete mode 100644 Sources/LookinServer/Server/Connection/LKS_ConnectionManager.m delete mode 100644 Sources/LookinServer/Server/Connection/LKS_RequestHandler.h delete mode 100644 Sources/LookinServer/Server/Connection/LKS_RequestHandler.m delete mode 100644 Sources/LookinServer/Server/Connection/RequestHandler/LKS_AttrModificationPatchHandler.h delete mode 100644 Sources/LookinServer/Server/Connection/RequestHandler/LKS_AttrModificationPatchHandler.m delete mode 100644 Sources/LookinServer/Server/Connection/RequestHandler/LKS_CustomAttrModificationHandler.h delete mode 100644 Sources/LookinServer/Server/Connection/RequestHandler/LKS_CustomAttrModificationHandler.m delete mode 100644 Sources/LookinServer/Server/Connection/RequestHandler/LKS_HierarchyDetailsHandler.h delete mode 100644 Sources/LookinServer/Server/Connection/RequestHandler/LKS_HierarchyDetailsHandler.m delete mode 100644 Sources/LookinServer/Server/Connection/RequestHandler/LKS_InbuiltAttrModificationHandler.h delete mode 100644 Sources/LookinServer/Server/Connection/RequestHandler/LKS_InbuiltAttrModificationHandler.m delete mode 100644 Sources/LookinServer/Server/LookinServer.h delete mode 100644 Sources/LookinServer/Server/LookinServer.m delete mode 100644 Sources/LookinServer/Server/Others/LKSConfigManager.h delete mode 100644 Sources/LookinServer/Server/Others/LKSConfigManager.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_AttrGroupsMaker.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_AttrGroupsMaker.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_CustomAttrGroupsMaker.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_CustomAttrGroupsMaker.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_CustomAttrSetterManager.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_CustomAttrSetterManager.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_CustomDisplayItemsMaker.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_CustomDisplayItemsMaker.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_EventHandlerMaker.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_EventHandlerMaker.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_ExportManager.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_ExportManager.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_GestureTargetActionsSearcher.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_GestureTargetActionsSearcher.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_Helper.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_Helper.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_HierarchyDisplayItemsMaker.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_HierarchyDisplayItemsMaker.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_ObjectRegistry.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_ObjectRegistry.m delete mode 100644 Sources/LookinServer/Server/Others/LKS_TraceManager.h delete mode 100644 Sources/LookinServer/Server/Others/LKS_TraceManager.m delete mode 100644 Sources/LookinServer/Server/Others/LookinHierarchyInfo+LookinServer.h delete mode 100644 Sources/LookinServer/Server/Others/LookinHierarchyInfo+LookinServer.m delete mode 100644 Sources/LookinServer/Server/Others/LookinServerDefines.h delete mode 100644 Sources/LookinServer/Shared/Category/CALayer+Lookin.h delete mode 100644 Sources/LookinServer/Shared/Category/CALayer+Lookin.m delete mode 100644 Sources/LookinServer/Shared/Category/Color+Lookin.h delete mode 100644 Sources/LookinServer/Shared/Category/Color+Lookin.m delete mode 100644 Sources/LookinServer/Shared/Category/Image+Lookin.h delete mode 100644 Sources/LookinServer/Shared/Category/Image+Lookin.m delete mode 100644 Sources/LookinServer/Shared/Category/NSArray+Lookin.h delete mode 100644 Sources/LookinServer/Shared/Category/NSArray+Lookin.m delete mode 100644 Sources/LookinServer/Shared/Category/NSObject+Lookin.h delete mode 100644 Sources/LookinServer/Shared/Category/NSObject+Lookin.m delete mode 100644 Sources/LookinServer/Shared/Category/NSSet+Lookin.h delete mode 100644 Sources/LookinServer/Shared/Category/NSSet+Lookin.m delete mode 100644 Sources/LookinServer/Shared/Category/NSString+Lookin.h delete mode 100644 Sources/LookinServer/Shared/Category/NSString+Lookin.m delete mode 100644 Sources/LookinServer/Shared/Category/NSValue+Lookin.h delete mode 100644 Sources/LookinServer/Shared/Category/NSValue+Lookin.m delete mode 100644 Sources/LookinServer/Shared/LookinAppInfo.h delete mode 100644 Sources/LookinServer/Shared/LookinAppInfo.m delete mode 100644 Sources/LookinServer/Shared/LookinAttrIdentifiers.h delete mode 100644 Sources/LookinServer/Shared/LookinAttrIdentifiers.m delete mode 100644 Sources/LookinServer/Shared/LookinAttrType.h delete mode 100644 Sources/LookinServer/Shared/LookinAttribute.h delete mode 100644 Sources/LookinServer/Shared/LookinAttribute.m delete mode 100644 Sources/LookinServer/Shared/LookinAttributeModification.h delete mode 100644 Sources/LookinServer/Shared/LookinAttributeModification.m delete mode 100644 Sources/LookinServer/Shared/LookinAttributesGroup.h delete mode 100644 Sources/LookinServer/Shared/LookinAttributesGroup.m delete mode 100644 Sources/LookinServer/Shared/LookinAttributesSection.h delete mode 100644 Sources/LookinServer/Shared/LookinAttributesSection.m delete mode 100644 Sources/LookinServer/Shared/LookinAutoLayoutConstraint.h delete mode 100644 Sources/LookinServer/Shared/LookinAutoLayoutConstraint.m delete mode 100644 Sources/LookinServer/Shared/LookinCodingValueType.h delete mode 100644 Sources/LookinServer/Shared/LookinConnectionAttachment.h delete mode 100644 Sources/LookinServer/Shared/LookinConnectionAttachment.m delete mode 100644 Sources/LookinServer/Shared/LookinConnectionResponseAttachment.h delete mode 100644 Sources/LookinServer/Shared/LookinConnectionResponseAttachment.m delete mode 100644 Sources/LookinServer/Shared/LookinCustomAttrModification.h delete mode 100644 Sources/LookinServer/Shared/LookinCustomAttrModification.m delete mode 100644 Sources/LookinServer/Shared/LookinCustomDisplayItemInfo.h delete mode 100644 Sources/LookinServer/Shared/LookinCustomDisplayItemInfo.m delete mode 100644 Sources/LookinServer/Shared/LookinDashboardBlueprint.h delete mode 100644 Sources/LookinServer/Shared/LookinDashboardBlueprint.m delete mode 100644 Sources/LookinServer/Shared/LookinDefines.h delete mode 100644 Sources/LookinServer/Shared/LookinDisplayItem.h delete mode 100644 Sources/LookinServer/Shared/LookinDisplayItem.m delete mode 100644 Sources/LookinServer/Shared/LookinDisplayItemDetail.h delete mode 100644 Sources/LookinServer/Shared/LookinDisplayItemDetail.m delete mode 100644 Sources/LookinServer/Shared/LookinEventHandler.h delete mode 100644 Sources/LookinServer/Shared/LookinEventHandler.m delete mode 100644 Sources/LookinServer/Shared/LookinHierarchyFile.h delete mode 100644 Sources/LookinServer/Shared/LookinHierarchyFile.m delete mode 100644 Sources/LookinServer/Shared/LookinHierarchyInfo.h delete mode 100644 Sources/LookinServer/Shared/LookinHierarchyInfo.m delete mode 100644 Sources/LookinServer/Shared/LookinObject.h delete mode 100644 Sources/LookinServer/Shared/LookinObject.m delete mode 100644 Sources/LookinServer/Shared/LookinStaticAsyncUpdateTask.h delete mode 100644 Sources/LookinServer/Shared/LookinStaticAsyncUpdateTask.m delete mode 100644 Sources/LookinServer/Shared/LookinTuple.h delete mode 100644 Sources/LookinServer/Shared/LookinTuple.m delete mode 100644 Sources/LookinServer/Shared/LookinWeakContainer.h delete mode 100644 Sources/LookinServer/Shared/LookinWeakContainer.m delete mode 100644 Sources/LookinServer/Shared/Peertalk/Lookin_PTChannel.h delete mode 100644 Sources/LookinServer/Shared/Peertalk/Lookin_PTChannel.m delete mode 100644 Sources/LookinServer/Shared/Peertalk/Lookin_PTPrivate.h delete mode 100644 Sources/LookinServer/Shared/Peertalk/Lookin_PTProtocol.h delete mode 100644 Sources/LookinServer/Shared/Peertalk/Lookin_PTProtocol.m delete mode 100644 Sources/LookinServer/Shared/Peertalk/Lookin_PTUSBHub.h delete mode 100644 Sources/LookinServer/Shared/Peertalk/Lookin_PTUSBHub.m delete mode 100644 Sources/LookinServer/Shared/Peertalk/Peertalk.h delete mode 100644 Sources/LookinServer/include/LookinServer.h delete mode 100644 Sources/LookinServerBase/LookinIvarTrace.m create mode 100644 Sources/LookinServerBase/LookinIvarTraceConstants.m create mode 100644 Sources/LookinServerBase/LookinServerBase.h delete mode 100644 Sources/LookinServerInjected/LookinServerInjected.m delete mode 100644 Sources/LookinServerSwift/LKS_SwiftTraceManager.swift create mode 100644 Tests/Dashboard/Fixtures/enum-list-registry.json create mode 100644 Tests/Dashboard/LKDashboardTests-Bridging-Header.h create mode 100644 Tests/Dashboard/LKDashboardTests.swift delete mode 100644 Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift delete mode 100644 Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift delete mode 100644 Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift create mode 100644 Tests/Read/Fixtures/legacy-host.lookin create mode 100644 Tests/Read/LaunchToolbarReadTests-Bridging-Header.h create mode 100644 Tests/Read/LaunchToolbarReadTests.swift create mode 100644 Tests/Read/LookinArchiveFixtureGenerator.m delete mode 100644 Tests/Security/LKSwiftUISupportAuthServerPathPolicyTests.swift delete mode 100644 Tests/Security/LKSwiftUISupportInstallerCancellationTests.swift delete mode 100644 Tests/Security/LKSwiftUISupportLocalLicenseProbeTests.swift delete mode 100644 docs/mcp.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a161db12..abb94c5b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,33 +16,9 @@ concurrency: jobs: build: - name: ${{ matrix.name }} + name: macOS runs-on: macos-26 timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - name: macOS - slug: macos - destination: generic/platform=macOS - extra_args: ARCHS=arm64e ONLY_ACTIVE_ARCH=YES - - name: iOS - slug: ios - destination: generic/platform=iOS - extra_args: "" - - name: iOS Simulator - slug: ios-sim - destination: generic/platform=iOS Simulator - extra_args: "" - - name: tvOS - slug: tvos - destination: generic/platform=tvOS - extra_args: "" - - name: tvOS Simulator - slug: tvos-sim - destination: generic/platform=tvOS Simulator - extra_args: "" steps: - name: Checkout @@ -52,29 +28,29 @@ jobs: run: xcodebuild -version - name: Validate Release Workflow - if: matrix.slug == 'macos' run: bash Scripts/test-release-workflow.sh - - name: Install xcbeautify - run: brew install xcbeautify - - name: Sync Derived Source run: bash Scripts/sync-derived-source.sh + # The package only mirrors LookinCore for the app (the Server is built + # and published from LookInside-Server). - name: Build SwiftPM Products - if: matrix.slug == 'macos' - run: | - swift build -c debug --product LookinServerDynamic - swift build -c debug --product LookinServerInjected + run: swift build -c debug --product LookinShared + + secret_scan: + name: Secret Scan + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Test Private Key Scanner + run: bash Scripts/test-scan-private-keys.sh - - name: Build LookinServerInjected - run: | - xcodebuild \ - -workspace LookInside.xcworkspace \ - -scheme LookinServerInjected \ - -configuration Debug \ - -destination "${{ matrix.destination }}" \ - -derivedDataPath "$RUNNER_TEMP/DerivedData/${{ matrix.slug }}" \ - CODE_SIGNING_ALLOWED=NO \ - ${{ matrix.extra_args }} \ - build | xcbeautify + - name: Scan Repository For Secrets + run: bash Scripts/run-gitleaks.sh . diff --git a/.github/workflows/publish-existing-appcast.yml b/.github/workflows/publish-existing-appcast.yml index 8ee82da8..63303d4e 100644 --- a/.github/workflows/publish-existing-appcast.yml +++ b/.github/workflows/publish-existing-appcast.yml @@ -79,6 +79,9 @@ jobs: shasum -a 256 "$app_zip" | tee dist/checksums.txt echo "app_zip=$app_zip" >> "$GITHUB_OUTPUT" + - name: Scan App Zip For Private Keys + run: bash app/Scripts/scan-private-keys.sh "${{ steps.artifact.outputs.app_zip }}" + - name: Validate Sparkle Publishing Secrets env: LOOKINSIDE_WEB_RELEASE_TOKEN: ${{ secrets.LOOKINSIDE_WEB_RELEASE_TOKEN }} @@ -105,6 +108,10 @@ jobs: ref: main path: secrets token: ${{ secrets.LOOKINSIDE_SECRETS_READ_TOKEN }} + sparse-checkout: sparkle + sparse-checkout-cone-mode: true + filter: blob:none + persist-credentials: false - name: Export GitHub Release Notes env: @@ -152,6 +159,21 @@ jobs: --version "${{ inputs.version }}" \ --asset-download-url "${{ inputs.asset_download_url }}" + - name: Scan Web Changes For Private Keys + run: | + set -euo pipefail + bash app/Scripts/scan-private-keys.sh web/public + sparkle_key="$(tr -d '[:space:]' <"$RUNNER_TEMP/sparkle-private-ed-key.txt")" + [[ -n "$sparkle_key" ]] || { echo "Decrypted Sparkle private key is empty." >&2; exit 1; } + if grep -rqF -- "$sparkle_key" web; then + echo "The Sparkle private key was written into the Web repository." >&2 + exit 1 + fi + + - name: Remove Sparkle Private Key + if: always() + run: rm -f "$RUNNER_TEMP/sparkle-private-ed-key.txt" + - name: Commit Web Appcast working-directory: web run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0e85583..c5aedb5a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,8 +23,6 @@ jobs: artifact_name: ${{ steps.context.outputs.artifact_name }} source_ref: ${{ steps.context.outputs.source_ref }} injector_ref: ${{ steps.context.outputs.injector_ref }} - mcp_ref: ${{ steps.context.outputs.mcp_ref }} - release_ref: ${{ steps.context.outputs.release_ref }} steps: - name: Validate Trigger Actor @@ -42,7 +40,6 @@ jobs: BUILD_NUMBER="${{ github.event.client_payload.build_number }}" SOURCE_REF="${{ github.event.client_payload.source_ref }}" INJECTOR_REF="${{ github.event.client_payload.injector_ref }}" - MCP_REF="${{ github.event.client_payload.mcp_ref }}" RELEASE_REF="${{ github.event.client_payload.release_ref }}" PARENT_REPOSITORY="${{ github.event.client_payload.parent_repository }}" PARENT_REF="${{ github.event.client_payload.parent_ref }}" @@ -65,7 +62,6 @@ jobs: [[ "$PARENT_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid parent SHA." >&2; exit 1; } [[ "$SOURCE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid Host source SHA." >&2; exit 1; } [[ "$INJECTOR_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid Injector source SHA." >&2; exit 1; } - [[ "$MCP_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid MCP source SHA." >&2; exit 1; } [[ "$RELEASE_REF" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid Release source SHA." >&2; exit 1; } [[ "$RELEASE_ID" == "release/app/$VERSION@$PARENT_SHA/host" ]] || { echo "Release id does not identify the parent tag and commit." >&2 @@ -79,8 +75,6 @@ jobs: echo "artifact_name=LookInside-${VERSION}-release" echo "source_ref=${SOURCE_REF}" echo "injector_ref=${INJECTOR_REF}" - echo "mcp_ref=${MCP_REF}" - echo "release_ref=${RELEASE_REF}" } >>"${GITHUB_OUTPUT}" build_package: @@ -101,6 +95,9 @@ jobs: fetch-depth: 0 ref: ${{ needs.prepare.outputs.source_ref }} + - name: Scan Repository For Secrets + run: bash Scripts/run-gitleaks.sh . + - name: Show Xcode Version run: xcodebuild -version @@ -133,21 +130,6 @@ jobs: git -C ../LookInside-Injector checkout --detach "${{ needs.prepare.outputs.injector_ref }}" [[ "$(git -C ../LookInside-Injector rev-parse HEAD)" == "${{ needs.prepare.outputs.injector_ref }}" ]] - - name: Checkout MCP Server - env: - LOOKINSIDE_MCP_READ_TOKEN: ${{ secrets.LOOKINSIDE_PRIVATE_READ_TOKEN || secrets.LOOKINSIDE_WEB_RELEASE_TOKEN || secrets.LOOKINSIDE_INJECTOR_READ_TOKEN }} - run: | - set -euo pipefail - if [[ -z "${LOOKINSIDE_MCP_READ_TOKEN}" ]]; then - echo "LOOKINSIDE_PRIVATE_READ_TOKEN, LOOKINSIDE_WEB_RELEASE_TOKEN, or LOOKINSIDE_INJECTOR_READ_TOKEN is required to checkout LookInside-MCP." >&2 - exit 1 - fi - git clone --no-checkout \ - "https://x-access-token:${LOOKINSIDE_MCP_READ_TOKEN}@github.com/LookInsideApp/LookInside-MCP.git" \ - ../LookInside-MCP - git -C ../LookInside-MCP checkout --detach "${{ needs.prepare.outputs.mcp_ref }}" - [[ "$(git -C ../LookInside-MCP rev-parse HEAD)" == "${{ needs.prepare.outputs.mcp_ref }}" ]] - - name: Configure Private Package Access env: LOOKINSIDE_PRIVATE_READ_TOKEN: ${{ secrets.LOOKINSIDE_PRIVATE_READ_TOKEN || secrets.LOOKINSIDE_WEB_RELEASE_TOKEN || secrets.LOOKINSIDE_INJECTOR_READ_TOKEN }} @@ -185,6 +167,11 @@ jobs: --tag "${{ needs.prepare.outputs.build_version }}" \ --stage archive-app + - name: Check Release Build For Debug Overrides + run: | + bash Scripts/check-host-release-clean.sh --release-strict \ + "build/releases/${{ needs.prepare.outputs.build_version }}/LookInside.xcarchive" + - name: Package Unsigned Archive run: | set -euo pipefail @@ -193,6 +180,11 @@ jobs: -C "${release_dir}" \ LookInside.xcarchive + - name: Scan Unsigned Archive For Private Keys + run: | + bash Scripts/scan-private-keys.sh \ + "build/releases/${{ needs.prepare.outputs.build_version }}/LookInside.xcarchive.tar.gz" + - name: Upload Unsigned Artifacts uses: actions/upload-artifact@v4 with: @@ -263,6 +255,16 @@ jobs: --tag "${{ needs.prepare.outputs.build_version }}" \ --stage notarize-app + - name: Scan Notarized App For Private Keys And Debug Overrides + run: | + set -euo pipefail + shopt -s nullglob + release_dir="build/releases/${{ needs.prepare.outputs.build_version }}" + app_zips=("${release_dir}"/LookInside-*-macOS-app.zip) + [[ ${#app_zips[@]} -gt 0 ]] || { echo "No notarized app zip to scan." >&2; exit 1; } + bash Scripts/scan-private-keys.sh "${app_zips[@]}" + bash Scripts/check-host-release-clean.sh --release-strict "${app_zips[@]}" + - name: Upload Notarized App uses: actions/upload-artifact@v4 with: @@ -277,6 +279,14 @@ jobs: timeout-minutes: 20 steps: + - name: Checkout Release Scripts + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.source_ref }} + path: app + sparse-checkout: Scripts + persist-credentials: false + - name: Download App Artifact uses: actions/download-artifact@v4 with: @@ -288,6 +298,9 @@ jobs: cd dist shasum -a 256 ./*.zip > checksums.txt + - name: Scan Release Artifacts For Private Keys + run: bash app/Scripts/scan-private-keys.sh dist + - name: Upload Notarized Artifacts uses: actions/upload-artifact@v4 with: @@ -393,42 +406,10 @@ jobs: ref: main path: secrets token: ${{ secrets.LOOKINSIDE_SECRETS_READ_TOKEN }} - - - name: Checkout Release Tools - uses: actions/checkout@v4 - with: - repository: LookInsideApp/LookInside-Release - ref: ${{ needs.prepare.outputs.release_ref }} - path: release-tools - token: ${{ secrets.LOOKINSIDE_WEB_RELEASE_TOKEN }} - - - name: Download Pinned Auth Server Publication - env: - GH_TOKEN: ${{ secrets.LOOKINSIDE_WEB_RELEASE_TOKEN }} - run: | - set -euo pipefail - mkdir -p auth-artifact - for asset in \ - lookinside-auth-server.app.zip \ - lookinside-auth-server.app.zip.sha256 \ - lookinside-auth-server.app.zip.version; do - gh release download storage \ - --repo LookInsideApp/LookInside-Release \ - --pattern "$asset" \ - --dir auth-artifact - done - [[ "$(tr -d '[:space:]' &2 - exit 1 - } - - - name: Stage Auth Server Metadata Into Web Repository - run: | - set -euo pipefail - bash release-tools/Scripts/sync-auth-server-assets-to-web.sh \ - --web-dir web \ - --asset auth-artifact/lookinside-auth-server.app.zip \ - --url https://github.com/LookInsideApp/LookInside-Release/releases/download/storage/lookinside-auth-server.app.zip + sparse-checkout: sparkle + sparse-checkout-cone-mode: true + filter: blob:none + persist-credentials: false - name: Export GitHub Release Notes env: @@ -477,13 +458,28 @@ jobs: --version "${{ needs.prepare.outputs.build_version }}" \ --asset-download-url "$ASSET_DOWNLOAD_URL" + - name: Scan Web Changes For Private Keys + run: | + set -euo pipefail + bash app/Scripts/scan-private-keys.sh web/public + sparkle_key="$(tr -d '[:space:]' <"$RUNNER_TEMP/sparkle-private-ed-key.txt")" + [[ -n "$sparkle_key" ]] || { echo "Decrypted Sparkle private key is empty." >&2; exit 1; } + if grep -rqF -- "$sparkle_key" web; then + echo "The Sparkle private key was written into the Web repository." >&2 + exit 1 + fi + + - name: Remove Sparkle Private Key + if: always() + run: rm -f "$RUNNER_TEMP/sparkle-private-ed-key.txt" + - name: Commit Web Appcast working-directory: web run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add public/appcast.xml public/downloads/manifest.json public/downloads/auth-server public/downloads/lookinside + git add public/appcast.xml public/downloads/manifest.json public/downloads/lookinside if git diff --cached --quiet; then echo "No Web appcast changes to publish." exit 0 diff --git a/Configuration/Version.xcconfig b/Configuration/Version.xcconfig index 84d4e678..ca514825 100644 --- a/Configuration/Version.xcconfig +++ b/Configuration/Version.xcconfig @@ -3,5 +3,5 @@ // LookInside // -MARKETING_VERSION = 2.3.13 +MARKETING_VERSION = 3.0.0 CURRENT_PROJECT_VERSION = 45 diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index c661ba2e..f232624e 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -19,6 +19,9 @@ E972100D2FC2E5C0003AAEA6 /* InjectionServiceInterface in Frameworks */ = {isa = PBXBuildFile; productRef = E972100C2FC2E5C0003AAEA6 /* InjectionServiceInterface */; }; E9FE990C3026C657008692AF /* UIFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = E9FE990B3026C657008692AF /* UIFoundation */; }; E9FE990E3026C657008692AF /* UIFoundationToolbox in Frameworks */ = {isa = PBXBuildFile; productRef = E9FE990D3026C657008692AF /* UIFoundationToolbox */; }; + 15FFB4D96CB7375162952FFE /* LookInsideActivation in Frameworks */ = {isa = PBXBuildFile; productRef = 4CF60A9D44E04A6BE91035EF /* LookInsideActivation */; }; + 79CC3F736CA547232F1827F4 /* LookInsideActivationUI in Frameworks */ = {isa = PBXBuildFile; productRef = F05DCA7C93CF923C02603F72 /* LookInsideActivationUI */; }; + C0DE5A1E2FA0000100A1B2C3 /* LookInsideHostCore in Frameworks */ = {isa = PBXBuildFile; productRef = C0DE5A1F2FA0000100A1B2C3 /* LookInsideHostCore */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -29,66 +32,62 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + C0DE5A312FA0000100A1B2C3 /* Exceptions for "DerivedSource" folder in "LookInside" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + README.md, + ); + target = AA48F29A21CC0CA50032EC2C /* LookInside */; + }; 50EAE1F32F71C01A00EBE063 /* Exceptions for "LookInside" folder in "LookInside" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( - About/LKAboutViewController.m, - About/LKAboutWindowController.m, - AppDelegate.m, + About/LKAboutWindowController.swift, + AppDelegate.swift, AppIcon.icon, Assets.xcassets, - "Base/Category/LookinAttributesGroup+LookinClient.m", - "Base/Category/LookinAttributesSection+LookinClient.m", - "Base/Category/LookinAutoLayoutConstraint+LookinClient.m", - "Base/Category/LookinCustomDisplayItemInfo+LookinClient.m", - "Base/Category/LookinDisplayItem+LookinClient.m", - "Base/Category/LookinObject+LookinClient.m", - "Base/Category/NSAppearance+LookinClient.m", - "Base/Category/NSArray+LookinClient.m", - "Base/Category/NSButton+LookinClient.m", - "Base/Category/NSColor+LookinClient.m", - "Base/Category/NSControl+LookinClient.m", - "Base/Category/NSPointerArray+LookinClient.m", - "Base/Category/NSString+LookinClient.m", - "Base/Category/NSString+Score.m", - "Base/Category/NSView+LookinClient.m", - "Base/Category/ShortCocoa+LookinClient.m", - Base/InputSearch/LKInputSearchSuggestionItem.m, - Base/InputSearch/LKInputSearchSuggestionsContentView.m, - Base/InputSearch/LKInputSearchSuggestionsRowView.m, - Base/InputSearch/LKInputSearchSuggestionWindowController.m, - Base/InputSearch/LKInputSearchView.m, - Base/LKBaseControl.m, - Base/LKBaseView.m, - Base/LKBaseViewController.m, - Base/LKColorIndicatorLayer.m, + "Base/Category/LookinAttributesGroup+LookinClient.swift", + "Base/Category/LookinAutoLayoutConstraint+LookinClient.swift", + "Base/Category/LookinDisplayItem+LookinClient.swift", + "Base/Category/LookinObject+LookinClient.swift", + "Base/Category/NSAppearance+LookinClient.swift", + "Base/Category/NSArray+LookinClient.swift", + "Base/Category/NSButton+LookinClient.swift", + "Base/Category/NSColor+LookinClient.swift", + "Base/Category/NSControl+LookinClient.swift", + "Base/Category/NSString+LookinClient.swift", + "Base/Category/NSView+LookinClient.swift", + Base/InputSearch/LKInputSearchView.swift, + "Base/InputSearch/NSView+LKPeripheralLayout.swift", + Base/LKBaseControl.swift, + Base/LKBaseView.swift, + Base/LKBaseViewController.swift, + Base/LKColorIndicatorLayer.swift, + Base/LKConnectionTipsBinding.swift, Base/LKDeviceIconProvider.swift, - Base/LKImageControl.m, - Base/LKImageTextView.m, - Base/LKLabel.m, - Base/LKNumberInputView.m, - Base/LKOutlineItem.m, - Base/LKOutlineRowView.m, - Base/LKOutlineView.m, - Base/LKPanelContentView.m, - Base/LKPerformanceReporter.m, - Base/LKPopPanel.m, - Base/LKProgressIndicatorView.m, - Base/LKSplitView.m, + Base/LKFrameLayout.swift, + Base/LKImageTextView.swift, + Base/LKLabel.swift, + Base/LKNumberInputView.swift, + Base/LKOutlineRowView.swift, + Base/LKPerformanceReporter.swift, + Base/LKPopPanel.swift, + Base/LKProgressIndicatorView.swift, + Base/LKSplitView.swift, Base/LKSwiftDemangler.swift, - Base/LKTableRowView.m, - Base/LKTableView.m, - Base/LKTableViewHorizontalScrollWidthManager.m, - Base/LKTextControl.m, - Base/LKTextFieldView.m, - Base/LKTextsMenuView.m, - Base/LKTipsView.m, - Base/LKTwoColors.m, - Base/LKUserActionManager.m, - Base/LKWindow.m, - Base/LKWindowController.m, - Base/Message/LookinMsgAttribute.m, - Base/Message/LookinMsgTargetAction.m, + Base/LKTableRowView.swift, + Base/LKTableView.swift, + Base/LKTableViewHorizontalScrollWidthManager.swift, + Base/LKTextControl.swift, + Base/LKTextFieldView.swift, + Base/LKTextsMenuView.swift, + Base/LKTipsView.swift, + Base/LKTwoColors.swift, + Base/LKUserActionManager.swift, + Base/LKWindow.swift, + Base/LKWindowController.swift, + Base/Message/LKPeripheralAlerts.swift, + Base/Message/LookinMsgAttribute.swift, Common/Installer/LKInstallerArchiveExtractor.swift, Common/Installer/LKInstallerCancellation.swift, Common/Installer/LKInstallerCodeSignature.swift, @@ -96,104 +95,68 @@ Common/Installer/LKInstallerFilesystem.swift, Common/Installer/LKInstallerLogger.swift, Common/Installer/LKInstallerProgressWindowController.swift, - Connection/LKAppsManager.m, - Connection/LKConnectionManager.m, - Connection/LKConnectionRequest.m, - Connection/LKInspectableApp.m, - Connection/LookinLiveDocument.m, - Connection/LookinLiveDocumentController.m, - Console/LKConsoleDataSource.m, - Console/LKConsoleDataSourceRowItem.m, - Console/LKConsoleInputRowView.m, - Console/LKConsoleReturnRowView.m, - Console/LKConsoleSelectPopoverController.m, - Console/LKConsoleSelectPopoverItemControl.m, - Console/LKConsoleSubmitRowView.m, - Console/LKConsoleViewController.m, - DanceScript/DanceScriptManager.m, - Dashboard/AttributeView/Constraint/LKConstraintPopoverController.m, - Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.m, - Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.m, - Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.m, - Dashboard/AttributeView/JSON/LKJSONAttributeContentView.m, - Dashboard/AttributeView/JSON/LKJSONAttributeItem.m, - Dashboard/AttributeView/JSON/LKJSONAttributeViewController.m, - Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.m, - Dashboard/AttributeView/LKDashboardAttributeClassView.m, - Dashboard/AttributeView/LKDashboardAttributeColorView.m, - Dashboard/AttributeView/LKDashboardAttributeEnumsView.m, - Dashboard/AttributeView/LKDashboardAttributeInsetsView.m, - Dashboard/AttributeView/LKDashboardAttributeNumberInputView.m, - Dashboard/AttributeView/LKDashboardAttributeOpenImageView.m, - Dashboard/AttributeView/LKDashboardAttributePointView.m, - Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.m, - Dashboard/AttributeView/LKDashboardAttributeRectView.m, - Dashboard/AttributeView/LKDashboardAttributeRelationView.m, - Dashboard/AttributeView/LKDashboardAttributeRowsCountView.m, - Dashboard/AttributeView/LKDashboardAttributeShadowView.m, - Dashboard/AttributeView/LKDashboardAttributeSizeView.m, - Dashboard/AttributeView/LKDashboardAttributeStringArrayView.m, - Dashboard/AttributeView/LKDashboardAttributeSwitchView.m, - Dashboard/AttributeView/LKDashboardAttributeTextView.m, - Dashboard/AttributeView/LKDashboardAttributeView.m, - Dashboard/AttributeView/LKDashboardTextControlEditingFlag.m, - Dashboard/LKDashboardAccessoryWindowController.m, - Dashboard/LKDashboardCardTitleControl.m, - Dashboard/LKDashboardCardView.m, - Dashboard/LKDashboardSectionView.m, - Dashboard/LKDashboardSectionViewPool.m, - Dashboard/LKDashboardViewController.m, - Dashboard/LKEnumListRegistry.m, - Dashboard/Search/LKDashboardHeaderView.m, - Dashboard/Search/LKDashboardSearchCardView.m, - Dashboard/Search/LKDashboardSearchMethodsDataSource.m, - Dashboard/Search/LKDashboardSearchMethodsView.m, - Dashboard/Search/LKDashboardSearchPropView.m, - "DerivedSource/LookinCore/Category/CALayer+Lookin.m", - "DerivedSource/LookinCore/Category/Color+Lookin.m", - "DerivedSource/LookinCore/Category/Image+Lookin.m", - "DerivedSource/LookinCore/Category/NSArray+Lookin.m", - "DerivedSource/LookinCore/Category/NSObject+Lookin.m", - "DerivedSource/LookinCore/Category/NSSet+Lookin.m", - "DerivedSource/LookinCore/Category/NSString+Lookin.m", - "DerivedSource/LookinCore/Category/NSValue+Lookin.m", - DerivedSource/LookinCore/LKS_MultiplatformAdapter.m, - DerivedSource/LookinCore/LookinAppInfo.m, - DerivedSource/LookinCore/LookinAttribute.m, - DerivedSource/LookinCore/LookinAttributeModification.m, - DerivedSource/LookinCore/LookinAttributesGroup.m, - DerivedSource/LookinCore/LookinAttributesSection.m, - DerivedSource/LookinCore/LookinAttrIdentifiers.m, - DerivedSource/LookinCore/LookinAutoLayoutConstraint.m, - DerivedSource/LookinCore/LookinConnectionAttachment.m, - DerivedSource/LookinCore/LookinConnectionResponseAttachment.m, - DerivedSource/LookinCore/LookinCustomAttrModification.m, - DerivedSource/LookinCore/LookinCustomDisplayItemInfo.m, - DerivedSource/LookinCore/LookinDashboardBlueprint.m, - DerivedSource/LookinCore/LookinDisplayItem.m, - DerivedSource/LookinCore/LookinDisplayItemDetail.m, - DerivedSource/LookinCore/LookinEventHandler.m, - DerivedSource/LookinCore/LookinHierarchyFile.m, - DerivedSource/LookinCore/LookinHierarchyInfo.m, - DerivedSource/LookinCore/LookinObject.m, - DerivedSource/LookinCore/LookinStaticAsyncUpdateTask.m, - DerivedSource/LookinCore/LookinTuple.m, - DerivedSource/LookinCore/LookinWeakContainer.m, - DerivedSource/LookinCore/Peertalk/Lookin_PTChannel.m, - DerivedSource/LookinCore/Peertalk/Lookin_PTProtocol.m, - DerivedSource/LookinCore/Peertalk/Lookin_PTUSBHub.m, - DerivedSource/LookinServerBase/LookinIvarTrace.m, - DerivedSource/LookinServerBase/LookInsideServerLogger.m, - Export/LKExportAccessoryView.m, - Export/LKExportManager.m, - Hierarchy/LKDanceUIAttrMaker.m, - Hierarchy/LKHierarchyController.m, - Hierarchy/LKHierarchyDataSource.m, - "Hierarchy/LKHierarchyDataSource+KeyDown.m", - Hierarchy/LKHierarchyHandlersPopoverController.m, - Hierarchy/LKHierarchyHandlersPopoverItemView.m, - Hierarchy/LKHierarchyRowView.m, - Hierarchy/LKHierarchyView.m, + "Connection/LKConnectionManager+License.swift", + Connection/LKAppsManager.swift, + Connection/LKChannel.swift, + Connection/LKConnectionErrors.swift, + Connection/LKConnectionManager.swift, + Connection/LKConnectionRequest.swift, + Connection/LKInspectableApp.swift, + Connection/LKServerUpgradeHint.swift, + Connection/LookinLiveDocument.swift, + Connection/LookinLiveDocumentController.swift, + Console/LKConsoleDataSource.swift, + Console/LKConsoleRowViews.swift, + Console/LKConsoleSelectPopoverController.swift, + Console/LKConsoleViewController.swift, + DanceScript/DanceScriptManager.swift, + Dashboard/AttributeView/Constraint/LKConstraintPopoverController.swift, + Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.swift, + Dashboard/AttributeView/JSON/LKJSONAttributeItem.swift, + Dashboard/AttributeView/JSON/LKJSONAttributeViews.swift, + Dashboard/AttributeView/LKDashboardAttributeColorView.swift, + Dashboard/AttributeView/LKDashboardAttributeEnumsView.swift, + Dashboard/AttributeView/LKDashboardAttributeGeometryViews.swift, + Dashboard/AttributeView/LKDashboardAttributeNumberInputView.swift, + Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.swift, + Dashboard/AttributeView/LKDashboardAttributeReadOnlyViews.swift, + Dashboard/AttributeView/LKDashboardAttributeStringArrayView.swift, + Dashboard/AttributeView/LKDashboardAttributeSwitchView.swift, + Dashboard/AttributeView/LKDashboardAttributeTextView.swift, + Dashboard/AttributeView/LKDashboardAttributeView.swift, + Dashboard/LKDashboardAccessoryWindowController.swift, + Dashboard/LKDashboardCardView.swift, + Dashboard/LKDashboardLayout.swift, + Dashboard/LKDashboardModification.swift, + Dashboard/LKDashboardSectionView.swift, + Dashboard/LKDashboardViewController.swift, + Dashboard/LKEnumListRegistry.swift, + Dashboard/Search/LKDashboardHeaderView.swift, + Dashboard/Search/LKDashboardSearchViews.swift, + DebugE2E/LKDebugE2EDump.swift, + DebugE2E/LKDebugE2EDumpWriter.swift, + DebugE2E/LKDebugUISnapshots.swift, + DebugE2E/LKDebugWindowImage.swift, + DebugE2E/LookinSnapshotNormalizer.swift, + Export/LKExportAccessoryView.swift, + Export/LKExportManager.swift, + Hierarchy/LKDanceUIAttrMaker.swift, + Hierarchy/LKHierarchyController.swift, + "Hierarchy/LKHierarchyDataSource+KeyDown.swift", + Hierarchy/LKHierarchyDataSource.swift, + Hierarchy/LKHierarchyFrameLayout.swift, + Hierarchy/LKHierarchyHandlersPopoverController.swift, + Hierarchy/LKHierarchyRowView.swift, + Hierarchy/LKHierarchyView.swift, + Hierarchy/LKSyncSignal.swift, + GestureDebug/LKGestureCaptureModels.swift, + GestureDebug/LKGestureDebugSession.swift, + GestureDebug/LKGestureDebugView.swift, + GestureDebug/LKGestureDebugWindowController.swift, + GestureDebug/LKGestureNodeDetailView.swift, + GestureDebug/LKNativeInteractionDetailView.swift, + GestureDebug/LKSuggestions.swift, + GestureDebug/LKSuggestionsView.swift, InfoPlist.xcstrings, Injection/Device/LKAttachedDeviceMonitor.swift, Injection/Device/LKDeviceControlClient.swift, @@ -204,163 +167,52 @@ Injection/LKInjectionService.swift, Injection/LKInjectionStartGate.swift, Injection/LKInjectionTargetPicker.swift, - Launch/LKLaunchAppView.m, - Launch/LKLaunchViewController.m, - Launch/LKLaunchWindowController.m, + Launch/LKLaunchAppView.swift, + Launch/LKLaunchRules.swift, + Launch/LKLaunchViewController.swift, + Launch/LKLaunchWindowController.swift, Localizable.xcstrings, LookinArchiveIcon.icns, - main.m, - Manager/LKAppMenuManager.m, - Manager/LKHelper.m, - Manager/LKNavigationManager.m, - Manager/LKPreferenceManager.m, - MCPBridge/LKMCPBridgeAttributeEncoder.swift, - GestureDebug/LKGestureCaptureModels.swift, - GestureDebug/LKGestureDebugSession.swift, - GestureDebug/LKGestureDebugView.swift, - GestureDebug/LKGestureDebugWindowController.swift, - GestureDebug/LKGestureNodeDetailView.swift, - GestureDebug/LKNativeInteractionDetailView.swift, - GestureDebug/LKSuggestions.swift, - GestureDebug/LKSuggestionsView.swift, - MCPBridge/LKMCPBridgeAttributeValueDecoder.swift, - MCPBridge/LKMCPBridgeConnection.swift, - MCPBridge/LKMCPBridgeDetailsService.swift, - MCPBridge/LKMCPBridgeEventPublisher.swift, - MCPBridge/LKMCPBridgeFrame.swift, - MCPBridge/LKMCPBridgeInspectionModels.swift, - MCPBridge/LKMCPBridgeInspectionService.swift, - MCPBridge/LKMCPBridgeInvocationService.swift, - MCPBridge/LKMCPBridgeListenSocket.swift, - MCPBridge/LKMCPBridgeLiveDocumentLookup.swift, - MCPBridge/LKMCPBridgeModificationService.swift, - MCPBridge/LKMCPBridgeObjectIdentifierList.swift, - MCPBridge/LKMCPBridgeRACBridge.swift, - MCPBridge/LKMCPBridgeRefreshService.swift, - MCPBridge/LKMCPBridgeScreenshotService.swift, - MCPBridge/LKMCPBridgeSearchQuery.swift, - MCPBridge/LKMCPBridgeSearchService.swift, - MCPBridge/LKMCPBridgeSecureContentDetector.swift, - MCPBridge/LKMCPBridgeSelectorService.swift, - MCPBridge/LKMCPBridgeServer.swift, - Measure/LKMeasureController.m, - Measure/LKMeasureResultLineData.m, - Measure/LKMeasureResultView.m, - Other/LKVersionComparer.m, + Manager/LKAppMenuManager.swift, + Manager/LKHelper.swift, + Manager/LKNavigationManager.swift, + Manager/LKPreferenceManager.swift, + Measure/LKMeasureController.swift, + Measure/LKMeasureResultView.swift, + Other/LKVersionComparer.swift, Preference/LKPreferenceView.swift, - Preference/LKPreferenceViewController.m, - Preference/LKPreferenceWindowController.m, + Preference/LKPreferenceWindowController.swift, PrivateDiscriminator/LKPrivateDiscriminatorSettingsWindowController.swift, PrivateDiscriminator/LKPrivateDiscriminatorStore.swift, - ReactiveObjC/extobjc/RACEXTRuntimeExtensions.m, - "ReactiveObjC/NSArray+RACSequenceAdditions.m", - "ReactiveObjC/NSControl+RACCommandSupport.m", - "ReactiveObjC/NSControl+RACTextSignalSupport.m", - "ReactiveObjC/NSData+RACSupport.m", - "ReactiveObjC/NSDictionary+RACSequenceAdditions.m", - "ReactiveObjC/NSEnumerator+RACSequenceAdditions.m", - "ReactiveObjC/NSFileHandle+RACSupport.m", - "ReactiveObjC/NSIndexSet+RACSequenceAdditions.m", - "ReactiveObjC/NSInvocation+RACTypeParsing.m", - "ReactiveObjC/NSNotificationCenter+RACSupport.m", - "ReactiveObjC/NSObject+RACAppKitBindings.m", - "ReactiveObjC/NSObject+RACDeallocating.m", - "ReactiveObjC/NSObject+RACDescription.m", - "ReactiveObjC/NSObject+RACKVOWrapper.m", - "ReactiveObjC/NSObject+RACLifting.m", - "ReactiveObjC/NSObject+RACPropertySubscribing.m", - "ReactiveObjC/NSObject+RACSelectorSignal.m", - "ReactiveObjC/NSOrderedSet+RACSequenceAdditions.m", - "ReactiveObjC/NSSet+RACSequenceAdditions.m", - "ReactiveObjC/NSString+RACKeyPathUtilities.m", - "ReactiveObjC/NSString+RACSequenceAdditions.m", - "ReactiveObjC/NSString+RACSupport.m", - "ReactiveObjC/NSText+RACSignalSupport.m", - "ReactiveObjC/NSURLConnection+RACSupport.m", - "ReactiveObjC/NSUserDefaults+RACSupport.m", - ReactiveObjC/RACArraySequence.m, - ReactiveObjC/RACBehaviorSubject.m, - ReactiveObjC/RACBlockTrampoline.m, - ReactiveObjC/RACChannel.m, - ReactiveObjC/RACCommand.m, - ReactiveObjC/RACCompoundDisposable.m, - ReactiveObjC/RACCompoundDisposableProvider.d, - ReactiveObjC/RACDelegateProxy.m, - ReactiveObjC/RACDisposable.m, - ReactiveObjC/RACDynamicSequence.m, - ReactiveObjC/RACDynamicSignal.m, - ReactiveObjC/RACEagerSequence.m, - ReactiveObjC/RACEmptySequence.m, - ReactiveObjC/RACEmptySignal.m, - ReactiveObjC/RACErrorSignal.m, - ReactiveObjC/RACEvent.m, - ReactiveObjC/RACGroupedSignal.m, - ReactiveObjC/RACImmediateScheduler.m, - ReactiveObjC/RACIndexSetSequence.m, - ReactiveObjC/RACKVOChannel.m, - ReactiveObjC/RACKVOProxy.m, - ReactiveObjC/RACKVOTrampoline.m, - ReactiveObjC/RACMulticastConnection.m, - ReactiveObjC/RACPassthroughSubscriber.m, - ReactiveObjC/RACQueueScheduler.m, - ReactiveObjC/RACReplaySubject.m, - ReactiveObjC/RACReturnSignal.m, - ReactiveObjC/RACScheduler.m, - ReactiveObjC/RACScopedDisposable.m, - ReactiveObjC/RACSequence.m, - ReactiveObjC/RACSerialDisposable.m, - ReactiveObjC/RACSignal.m, - "ReactiveObjC/RACSignal+Operations.m", - ReactiveObjC/RACSignalProvider.d, - ReactiveObjC/RACSignalSequence.m, - ReactiveObjC/RACStream.m, - ReactiveObjC/RACStringSequence.m, - ReactiveObjC/RACSubject.m, - ReactiveObjC/RACSubscriber.m, - ReactiveObjC/RACSubscriptingAssignmentTrampoline.m, - ReactiveObjC/RACSubscriptionScheduler.m, - ReactiveObjC/RACTargetQueueScheduler.m, - ReactiveObjC/RACTestScheduler.m, - ReactiveObjC/RACTuple.m, - ReactiveObjC/RACTupleSequence.m, - ReactiveObjC/RACUnarySequence.m, - ReactiveObjC/RACUnit.m, - ReactiveObjC/RACValueTransformer.m, - Read/LKReadHierarchyController.m, - Read/LKReadHierarchyDataSource.m, - Read/LKReadViewController.m, - Read/LKReadWindowController.m, - Read/LookinArchiveDocument.m, - "ShortCocoa/ShortCocoa+Layout.m", - "ShortCocoa/ShortCocoa+Others.m", - "ShortCocoa/ShortCocoa+Private.m", - "ShortCocoa/ShortCocoa+String.m", - "ShortCocoa/ShortCocoa+UIToolkit.m", - ShortCocoa/ShortCocoaCore.m, - Static/LKMessageManager.m, - Static/LKReloadItemAndChildrenUpdateTaskMaker.m, - Static/LKReloadSingleItemUpdateTaskMaker.m, - Static/LKStaticAsyncUpdateManager.m, - Static/LKStaticHierarchyController.m, - Static/LKStaticHierarchyDataSource.m, - Static/LKStaticViewController.m, - Static/LKStaticWindowController.m, - Static/Preview/LKDisplayItemNode.m, - Static/Preview/LKPreviewController.m, - Static/Preview/LKPreviewPanGestureRecognizer.m, - Static/Preview/LKPreviewStageView.m, - Static/Preview/LKPreviewView.m, - SwiftUISupport/LKSwiftUIHierarchyDisplayMode.m, - SwiftUISupport/LKSwiftUISupportAuthServerPathPolicy.swift, + Read/LKReadHierarchyController.swift, + Read/LKReadHierarchyDataSource.swift, + Read/LKReadScreenshotLookup.swift, + Read/LKReadViewController.swift, + Read/LKReadWindowController.swift, + Read/LookinArchiveCoding.swift, + Read/LookinArchiveDocument.swift, + Static/LKMessageManager.swift, + Static/LKStaticAsyncUpdateManager.swift, + Static/LKStaticConstants.swift, + Static/LKStaticHierarchyController.swift, + Static/LKStaticHierarchyDataSource.swift, + Static/LKStaticLayout.swift, + Static/LKStaticViewController.swift, + Static/LKStaticWindowController.swift, + Static/Preview/LKDisplayItemNode.swift, + Static/Preview/LKPreviewController.swift, + Static/Preview/LKPreviewStageView.swift, + Static/Preview/LKPreviewView.swift, + SwiftUISupport/LKSwiftUIHierarchyDisplayMode.swift, SwiftUISupport/LKSwiftUISupportGatekeeper.swift, - SwiftUISupport/LKSwiftUISupportInstaller.swift, - SwiftUISupport/LKSwiftUISupportLocalLicenseProbe.swift, SwiftUISupport/LKSwiftUISupportLogger.swift, - Toolbar/LKMenuPopoverAppsListController.m, - Toolbar/LKMenuPopoverSettingController.m, - Toolbar/LKWindowToolbarAppButton.m, - Toolbar/LKWindowToolbarHelper.m, - Toolbar/LKWindowToolbarScaleView.m, + Toolbar/LKMenuPopoverAppsListController.swift, + Toolbar/LKMenuPopoverSettingController.swift, + Toolbar/LKToolbarRules.swift, + Toolbar/LKViewFrameLayout.swift, + Toolbar/LKWindowToolbarAppButton.swift, + Toolbar/LKWindowToolbarHelper.swift, + Toolbar/LKWindowToolbarScaleView.swift, XcodeViewHierarchy/LKXcodeViewHierarchyAttributeCatalog.swift, XcodeViewHierarchy/LKXcodeViewHierarchyAttributes.swift, XcodeViewHierarchy/LKXcodeViewHierarchyBundleReader.swift, @@ -382,302 +234,20 @@ isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet; buildPhase = 50EADFB42F71C00C00EBE063 /* Sources */; membershipExceptions = ( - About/LKAboutViewController.h, - About/LKAboutWindowController.h, - AppDelegate.h, - "Base/Category/LookinAttributesGroup+LookinClient.h", - "Base/Category/LookinAttributesSection+LookinClient.h", - "Base/Category/LookinAutoLayoutConstraint+LookinClient.h", - "Base/Category/LookinCustomDisplayItemInfo+LookinClient.h", - "Base/Category/LookinDisplayItem+LookinClient.h", - "Base/Category/LookinObject+LookinClient.h", - "Base/Category/NSAppearance+LookinClient.h", - "Base/Category/NSArray+LookinClient.h", - "Base/Category/NSButton+LookinClient.h", - "Base/Category/NSColor+LookinClient.h", - "Base/Category/NSControl+LookinClient.h", - "Base/Category/NSPointerArray+LookinClient.h", - "Base/Category/NSString+LookinClient.h", - "Base/Category/NSString+Score.h", - "Base/Category/NSView+LookinClient.h", - "Base/Category/ShortCocoa+LookinClient.h", - Base/InputSearch/LKInputSearchSuggestionItem.h, - Base/InputSearch/LKInputSearchSuggestionsContentView.h, - Base/InputSearch/LKInputSearchSuggestionsRowView.h, - Base/InputSearch/LKInputSearchSuggestionWindowController.h, - Base/InputSearch/LKInputSearchView.h, - Base/LKBaseControl.h, - Base/LKBaseView.h, - Base/LKBaseViewController.h, - Base/LKColorIndicatorLayer.h, - Base/LKImageControl.h, - Base/LKImageTextView.h, - Base/LKLabel.h, - Base/LKNumberInputView.h, - Base/LKOutlineItem.h, - Base/LKOutlineRowView.h, - Base/LKOutlineView.h, - Base/LKPanelContentView.h, - Base/LKPerformanceReporter.h, - Base/LKPopPanel.h, - Base/LKProgressIndicatorView.h, - Base/LKSplitView.h, - Base/LKTableRowView.h, - Base/LKTableView.h, - Base/LKTableViewHorizontalScrollWidthManager.h, - Base/LKTextControl.h, - Base/LKTextFieldView.h, - Base/LKTextsMenuView.h, - Base/LKTipsView.h, - Base/LKTwoColors.h, - Base/LKUserActionManager.h, - Base/LKWindow.h, - Base/LKWindowController.h, "Base/LookinClient-Bridging-Header.h", - Base/Message/LookinMsgAttribute.h, - Base/Message/LookinMsgTargetAction.h, - Connection/LKAppsManager.h, - Connection/LKConnectionManager.h, - Connection/LKConnectionRequest.h, - Connection/LKInspectableApp.h, - Connection/LookinLiveDocument.h, - Connection/LookinLiveDocumentController.h, - Console/LKConsoleDataSource.h, - Console/LKConsoleDataSourceRowItem.h, - Console/LKConsoleInputRowView.h, - Console/LKConsoleReturnRowView.h, - Console/LKConsoleSelectPopoverController.h, - Console/LKConsoleSelectPopoverItemControl.h, - Console/LKConsoleSubmitRowView.h, - Console/LKConsoleViewController.h, - DanceScript/DanceScriptManager.h, - Dashboard/AttributeView/Constraint/LKConstraintPopoverController.h, - Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.h, - Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.h, - Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.h, - Dashboard/AttributeView/JSON/LKJSONAttributeContentView.h, - Dashboard/AttributeView/JSON/LKJSONAttributeItem.h, - Dashboard/AttributeView/JSON/LKJSONAttributeViewController.h, - Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.h, - Dashboard/AttributeView/LKDashboardAttributeClassView.h, - Dashboard/AttributeView/LKDashboardAttributeColorView.h, - Dashboard/AttributeView/LKDashboardAttributeEnumsView.h, - Dashboard/AttributeView/LKDashboardAttributeInsetsView.h, - Dashboard/AttributeView/LKDashboardAttributeNumberInputView.h, - Dashboard/AttributeView/LKDashboardAttributeOpenImageView.h, - Dashboard/AttributeView/LKDashboardAttributePointView.h, - Dashboard/AttributeView/LKDashboardAttributeRectView.h, - Dashboard/AttributeView/LKDashboardAttributeRelationView.h, - Dashboard/AttributeView/LKDashboardAttributeRowsCountView.h, - Dashboard/AttributeView/LKDashboardAttributeShadowView.h, - Dashboard/AttributeView/LKDashboardAttributeSizeView.h, - Dashboard/AttributeView/LKDashboardAttributeStringArrayView.h, - Dashboard/AttributeView/LKDashboardAttributeSwitchView.h, - Dashboard/AttributeView/LKDashboardAttributeTextView.h, - Dashboard/AttributeView/LKDashboardAttributeView.h, - Dashboard/AttributeView/LKDashboardTextControlEditingFlag.h, - Dashboard/LKDashboardAccessoryWindowController.h, - Dashboard/LKDashboardCardTitleControl.h, - Dashboard/LKDashboardCardView.h, - Dashboard/LKDashboardSectionView.h, - Dashboard/LKDashboardSectionViewPool.h, - Dashboard/LKDashboardViewController.h, - Dashboard/LKEnumListRegistry.h, - Dashboard/Search/LKDashboardHeaderView.h, - Dashboard/Search/LKDashboardSearchCardView.h, - Dashboard/Search/LKDashboardSearchMethodsDataSource.h, - Dashboard/Search/LKDashboardSearchMethodsView.h, - Dashboard/Search/LKDashboardSearchPropView.h, - "DerivedSource/LookinCore/Category/CALayer+Lookin.h", - "DerivedSource/LookinCore/Category/Color+Lookin.h", - "DerivedSource/LookinCore/Category/Image+Lookin.h", - "DerivedSource/LookinCore/Category/NSArray+Lookin.h", - "DerivedSource/LookinCore/Category/NSObject+Lookin.h", - "DerivedSource/LookinCore/Category/NSSet+Lookin.h", - "DerivedSource/LookinCore/Category/NSString+Lookin.h", - "DerivedSource/LookinCore/Category/NSValue+Lookin.h", - DerivedSource/LookinCore/include/LookinCore.h, - DerivedSource/LookinCore/LKS_MultiplatformAdapter.h, - DerivedSource/LookinCore/LookinAppInfo.h, - DerivedSource/LookinCore/LookinAttribute.h, - DerivedSource/LookinCore/LookinAttributeModification.h, - DerivedSource/LookinCore/LookinAttributesGroup.h, - DerivedSource/LookinCore/LookinAttributesSection.h, - DerivedSource/LookinCore/LookinAttrIdentifiers.h, - DerivedSource/LookinCore/LookinAttrType.h, - DerivedSource/LookinCore/LookinAutoLayoutConstraint.h, - DerivedSource/LookinCore/LookinCodingValueType.h, - DerivedSource/LookinCore/LookinConnectionAttachment.h, - DerivedSource/LookinCore/LookinConnectionResponseAttachment.h, - DerivedSource/LookinCore/LookinCustomAttrModification.h, - DerivedSource/LookinCore/LookinCustomDisplayItemInfo.h, - DerivedSource/LookinCore/LookinDashboardBlueprint.h, - DerivedSource/LookinCore/LookinDefines.h, - DerivedSource/LookinCore/LookinDisplayItem.h, - DerivedSource/LookinCore/LookinDisplayItemDetail.h, - DerivedSource/LookinCore/LookinEventHandler.h, - DerivedSource/LookinCore/LookinHierarchyFile.h, - DerivedSource/LookinCore/LookinHierarchyInfo.h, - DerivedSource/LookinCore/LookinObject.h, - DerivedSource/LookinCore/LookinStaticAsyncUpdateTask.h, - DerivedSource/LookinCore/LookinTuple.h, - DerivedSource/LookinCore/LookinWeakContainer.h, - DerivedSource/LookinCore/Peertalk/Lookin_PTChannel.h, - DerivedSource/LookinCore/Peertalk/Lookin_PTPrivate.h, - DerivedSource/LookinCore/Peertalk/Lookin_PTProtocol.h, - DerivedSource/LookinCore/Peertalk/Lookin_PTUSBHub.h, - DerivedSource/LookinCore/Peertalk/Peertalk.h, - DerivedSource/LookinServerBase/LookinIvarTrace.h, - DerivedSource/LookinServerBase/LookInsideServerLogger.h, - Export/LKExportAccessoryView.h, - Export/LKExportManager.h, - Hierarchy/LKDanceUIAttrMaker.h, - Hierarchy/LKHierarchyController.h, - Hierarchy/LKHierarchyDataSource.h, - "Hierarchy/LKHierarchyDataSource+KeyDown.h", - Hierarchy/LKHierarchyHandlersPopoverController.h, - Hierarchy/LKHierarchyHandlersPopoverItemView.h, - Hierarchy/LKHierarchyRowView.h, - Hierarchy/LKHierarchyView.h, - Launch/LKLaunchAppView.h, - Launch/LKLaunchViewController.h, - Launch/LKLaunchWindowController.h, - Manager/LKAppMenuManager.h, - Manager/LKHelper.h, - Manager/LKNavigationManager.h, - Manager/LKPreferenceManager.h, - Measure/LKMeasureController.h, - Measure/LKMeasureResultLineData.h, - Measure/LKMeasureResultView.h, - Other/LKVersionComparer.h, - Preference/LKPreferenceViewController.h, - Preference/LKPreferenceWindowController.h, - ReactiveObjC/extobjc/RACEXTKeyPathCoding.h, - ReactiveObjC/extobjc/RACEXTRuntimeExtensions.h, - ReactiveObjC/extobjc/RACEXTScope.h, - ReactiveObjC/extobjc/RACmetamacros.h, - "ReactiveObjC/NSArray+RACSequenceAdditions.h", - "ReactiveObjC/NSControl+RACCommandSupport.h", - "ReactiveObjC/NSControl+RACTextSignalSupport.h", - "ReactiveObjC/NSData+RACSupport.h", - "ReactiveObjC/NSDictionary+RACSequenceAdditions.h", - "ReactiveObjC/NSEnumerator+RACSequenceAdditions.h", - "ReactiveObjC/NSFileHandle+RACSupport.h", - "ReactiveObjC/NSIndexSet+RACSequenceAdditions.h", - "ReactiveObjC/NSInvocation+RACTypeParsing.h", - "ReactiveObjC/NSNotificationCenter+RACSupport.h", - "ReactiveObjC/NSObject+RACAppKitBindings.h", - "ReactiveObjC/NSObject+RACDeallocating.h", - "ReactiveObjC/NSObject+RACDescription.h", - "ReactiveObjC/NSObject+RACKVOWrapper.h", - "ReactiveObjC/NSObject+RACLifting.h", - "ReactiveObjC/NSObject+RACPropertySubscribing.h", - "ReactiveObjC/NSObject+RACSelectorSignal.h", - "ReactiveObjC/NSOrderedSet+RACSequenceAdditions.h", - "ReactiveObjC/NSSet+RACSequenceAdditions.h", - "ReactiveObjC/NSString+RACKeyPathUtilities.h", - "ReactiveObjC/NSString+RACSequenceAdditions.h", - "ReactiveObjC/NSString+RACSupport.h", - "ReactiveObjC/NSText+RACSignalSupport.h", - "ReactiveObjC/NSURLConnection+RACSupport.h", - "ReactiveObjC/NSUserDefaults+RACSupport.h", - ReactiveObjC/RACAnnotations.h, - ReactiveObjC/RACArraySequence.h, - ReactiveObjC/RACBehaviorSubject.h, - ReactiveObjC/RACBlockTrampoline.h, - ReactiveObjC/RACChannel.h, - ReactiveObjC/RACCommand.h, - ReactiveObjC/RACCompoundDisposable.h, - ReactiveObjC/RACCompoundDisposableProvider.h, - ReactiveObjC/RACDelegateProxy.h, - ReactiveObjC/RACDisposable.h, - ReactiveObjC/RACDynamicSequence.h, - ReactiveObjC/RACDynamicSignal.h, - ReactiveObjC/RACEagerSequence.h, - ReactiveObjC/RACEmptySequence.h, - ReactiveObjC/RACEmptySignal.h, - ReactiveObjC/RACErrorSignal.h, - ReactiveObjC/RACEvent.h, - ReactiveObjC/RACEXTKeyPathCoding.h, - ReactiveObjC/RACEXTRuntimeExtensions.h, - ReactiveObjC/RACEXTScope.h, - ReactiveObjC/RACGroupedSignal.h, - ReactiveObjC/RACImmediateScheduler.h, - ReactiveObjC/RACIndexSetSequence.h, - ReactiveObjC/RACKVOChannel.h, - ReactiveObjC/RACKVOProxy.h, - ReactiveObjC/RACKVOTrampoline.h, - ReactiveObjC/RACmetamacros.h, - ReactiveObjC/RACMulticastConnection.h, - "ReactiveObjC/RACMulticastConnection+Private.h", - ReactiveObjC/RACPassthroughSubscriber.h, - ReactiveObjC/RACQueueScheduler.h, - "ReactiveObjC/RACQueueScheduler+Subclass.h", - ReactiveObjC/RACReplaySubject.h, - ReactiveObjC/RACReturnSignal.h, - ReactiveObjC/RACScheduler.h, - "ReactiveObjC/RACScheduler+Private.h", - "ReactiveObjC/RACScheduler+Subclass.h", - ReactiveObjC/RACScopedDisposable.h, - ReactiveObjC/RACSequence.h, - ReactiveObjC/RACSerialDisposable.h, - ReactiveObjC/RACSignal.h, - "ReactiveObjC/RACSignal+Operations.h", - ReactiveObjC/RACSignalProvider.h, - ReactiveObjC/RACSignalSequence.h, - ReactiveObjC/RACStream.h, - "ReactiveObjC/RACStream+Private.h", - ReactiveObjC/RACStringSequence.h, - ReactiveObjC/RACSubject.h, - ReactiveObjC/RACSubscriber.h, - "ReactiveObjC/RACSubscriber+Private.h", - ReactiveObjC/RACSubscriptingAssignmentTrampoline.h, - ReactiveObjC/RACSubscriptionScheduler.h, - ReactiveObjC/RACTargetQueueScheduler.h, - ReactiveObjC/RACTestScheduler.h, - ReactiveObjC/RACTuple.h, - ReactiveObjC/RACTupleSequence.h, - ReactiveObjC/RACUnarySequence.h, - ReactiveObjC/RACUnit.h, - ReactiveObjC/RACValueTransformer.h, - ReactiveObjC/ReactiveObjC.h, - Read/LKReadHierarchyController.h, - Read/LKReadHierarchyDataSource.h, - Read/LKReadViewController.h, - Read/LKReadWindowController.h, - Read/LookinArchiveDocument.h, - ShortCocoa/ShortCocoa.h, - "ShortCocoa/ShortCocoa+Layout.h", - "ShortCocoa/ShortCocoa+Others.h", - "ShortCocoa/ShortCocoa+Private.h", - "ShortCocoa/ShortCocoa+String.h", - "ShortCocoa/ShortCocoa+UIToolkit.h", - ShortCocoa/ShortCocoaCore.h, - ShortCocoa/ShortCocoaDefines.h, - Static/LKMessageManager.h, - Static/LKReloadItemAndChildrenUpdateTaskMaker.h, - Static/LKReloadSingleItemUpdateTaskMaker.h, - Static/LKStaticAsyncUpdateManager.h, - Static/LKStaticHierarchyController.h, - Static/LKStaticHierarchyDataSource.h, - Static/LKStaticViewController.h, - Static/LKStaticWindowController.h, - Static/Preview/LKDisplayItemNode.h, - Static/Preview/LKPreviewController.h, - Static/Preview/LKPreviewPanGestureRecognizer.h, - Static/Preview/LKPreviewStageView.h, - Static/Preview/LKPreviewView.h, - SwiftUISupport/LKSwiftUIHierarchyDisplayMode.h, - Toolbar/LKMenuPopoverAppsListController.h, - Toolbar/LKMenuPopoverSettingController.h, - Toolbar/LKWindowToolbarAppButton.h, - Toolbar/LKWindowToolbarHelper.h, - Toolbar/LKWindowToolbarScaleView.h, ); }; /* End PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + C0DE5A302FA0000100A1B2C3 /* DerivedSource */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + C0DE5A312FA0000100A1B2C3 /* Exceptions for "DerivedSource" folder in "LookInside" target */, + ); + path = LookInside/DerivedSource; + sourceTree = ""; + }; 5011BBAE2F9754F0002DA475 /* Configuration */ = { isa = PBXFileSystemSynchronizedRootGroup; path = Configuration; @@ -710,6 +280,9 @@ E972100B2FC2E5C0003AAEA6 /* HelperCommunication in Frameworks */, E972100D2FC2E5C0003AAEA6 /* InjectionServiceInterface in Frameworks */, E9FE990E3026C657008692AF /* UIFoundationToolbox in Frameworks */, + 15FFB4D96CB7375162952FFE /* LookInsideActivation in Frameworks */, + 79CC3F736CA547232F1827F4 /* LookInsideActivationUI in Frameworks */, + C0DE5A1E2FA0000100A1B2C3 /* LookInsideHostCore in Frameworks */, ); }; /* End PBXFrameworksBuildPhase section */ @@ -730,6 +303,7 @@ children = ( 5011BBAE2F9754F0002DA475 /* Configuration */, 50EADFB32F71BFF200EBE063 /* LookInside */, + C0DE5A302FA0000100A1B2C3 /* DerivedSource */, AA48F29C21CC0CA50032EC2C /* Products */, 0A9FB6E904476A1F9D0BB82E /* Frameworks */, ); @@ -753,12 +327,13 @@ 50EADFB42F71C00C00EBE063 /* Sources */, AA48F29821CC0CA50032EC2C /* Frameworks */, AA48F29921CC0CA50032EC2C /* Resources */, - 5005F8FD2FA505DF00C0BB07 /* Build Authenticator at DEBUG */, 5005F8FE2FA505DF00C0BB07 /* Embed Injector Daemon */, - 5005F8FF2FA505DF00C0BB07 /* Embed MCP CLI */, ); buildRules = ( ); + fileSystemSynchronizedGroups = ( + C0DE5A302FA0000100A1B2C3 /* DerivedSource */, + ); name = LookInside; packageProductDependencies = ( E37B8D0C2F97B1B100600001 /* Demangling */, @@ -770,6 +345,9 @@ E972100C2FC2E5C0003AAEA6 /* InjectionServiceInterface */, E9FE990B3026C657008692AF /* UIFoundation */, E9FE990D3026C657008692AF /* UIFoundationToolbox */, + 4CF60A9D44E04A6BE91035EF /* LookInsideActivation */, + F05DCA7C93CF923C02603F72 /* LookInsideActivationUI */, + C0DE5A1F2FA0000100A1B2C3 /* LookInsideHostCore */, ); productName = Lookin; productReference = AA48F29B21CC0CA50032EC2C /* LookInside.app */; @@ -816,6 +394,8 @@ E97210042FC2D5C0003AAEA6 /* XCRemoteSwiftPackageReference "RunningApplicationKit" */, E97210072FC2E3FC003AAEA6 /* XCRemoteSwiftPackageReference "swift-helper-service" */, E9FE990A3026C657008692AF /* XCRemoteSwiftPackageReference "UIFoundation" */, + E81BCA16A46AEA38803B27B7 /* XCLocalSwiftPackageReference "Packages/LookInsideActivation" */, + C0DE5A1D2FA0000100A1B2C3 /* XCLocalSwiftPackageReference "Packages/LookInsideHostCore" */, ); preferredProjectObjectVersion = 50; productRefGroup = AA48F29C21CC0CA50032EC2C /* Products */; @@ -836,27 +416,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 5005F8FD2FA505DF00C0BB07 /* Build Authenticator at DEBUG */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - name = "Build Authenticator at DEBUG"; - shellPath = /bin/sh; - shellScript = ( - "if [ \"$CONFIGURATION\" != \"Debug\" ]; then", - " echo \"Skipping Build Authenticator (configuration: $CONFIGURATION)\"", - " exit 0", - "fi", - "if [ ! -d \"$SRCROOT/../LookInside-Auth\" ]; then", - " echo \"Skipping Build Authenticator: LookInside-Auth not present (not in monorepo checkout)\"", - " exit 0", - "fi", - "PREPARE_SCRIPT=\"$SRCROOT/Scripts/prepare-debug-auth-server.sh\"", - "# Run in a fresh login zsh so xcodebuild env from the host build does not leak", - "# into the nested LookInside-Auth build (causes XCBBuildService to crash).", - "env -i HOME=\"$HOME\" USER=\"$USER\" TERM=\"${TERM:-dumb}\" /bin/zsh -lc \"$PREPARE_SCRIPT\"", - "", - ); - }; 5005F8FE2FA505DF00C0BB07 /* Embed Injector Daemon */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -885,34 +444,6 @@ "", ); }; - 5005F8FF2FA505DF00C0BB07 /* Embed MCP CLI */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - name = "Embed MCP CLI"; - shellPath = /bin/sh; - shellScript = ( - "if [ ! -d \"$SRCROOT/../LookInside-MCP\" ]; then", - " if [ \"$CONFIGURATION\" = \"Release\" ]; then", - " echo \"error: LookInside-MCP not present; Release builds must embed the MCP server binary\"", - " exit 1", - " fi", - " echo \"Skipping Embed MCP CLI: LookInside-MCP not present (not in monorepo checkout)\"", - " exit 0", - "fi", - "PREPARE_SCRIPT=\"$SRCROOT/Scripts/prepare-mcp-cli.sh\"", - "# Run in a fresh login zsh so xcodebuild env from the host build does not leak", - "# into the nested SwiftPM build (would crash XCBBuildService).", - "# Re-export the host's BUILT_PRODUCTS_DIR / CONTENTS_FOLDER_PATH / CONFIGURATION", - "# so the nested zsh can locate the LookInside.app bundle to embed into.", - "env -i HOME=\"$HOME\" USER=\"$USER\" TERM=\"${TERM:-dumb}\" \\", - " GITHUB_TOKEN=\"${GITHUB_TOKEN:-}\" \\", - " CONFIGURATION=\"$CONFIGURATION\" \\", - " BUILT_PRODUCTS_DIR=\"$BUILT_PRODUCTS_DIR\" \\", - " CONTENTS_FOLDER_PATH=\"$CONTENTS_FOLDER_PATH\" \\", - " /bin/zsh -lc \"$PREPARE_SCRIPT\"", - "", - ); - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1098,22 +629,17 @@ "$(inherited)", "$(PROJECT_DIR)/LookInside", ); - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "$(SRCROOT)/LookInside/PrefixHeaders.pch"; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "SHOULD_COMPILE_LOOKIN_SERVER=1", ); + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SHOULD_COMPILE_LOOKIN_SERVER"; GCC_WARN_SHADOW = NO; HEADER_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/LookInside", - "$(SRCROOT)/LookInside/DerivedSource/LookinCore", "$(SRCROOT)/LookInside/DerivedSource/LookinCore/include", - "$(SRCROOT)/LookInside/DerivedSource/LookinCore/Category", - "$(SRCROOT)/LookInside/DerivedSource/LookinCore/Peertalk", "$(SRCROOT)/LookInside/DerivedSource/LookinServerBase", - "$(SRCROOT)/LookInside/ReactiveObjC", ); INFOPLIST_FILE = LookInside/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = LookInside; @@ -1173,22 +699,17 @@ "$(inherited)", "$(PROJECT_DIR)/LookInside", ); - GCC_PRECOMPILE_PREFIX_HEADER = YES; - GCC_PREFIX_HEADER = "$(SRCROOT)/LookInside/PrefixHeaders.pch"; GCC_PREPROCESSOR_DEFINITIONS = ( "$(inherited)", "SHOULD_COMPILE_LOOKIN_SERVER=1", ); + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) SHOULD_COMPILE_LOOKIN_SERVER"; GCC_WARN_SHADOW = NO; HEADER_SEARCH_PATHS = ( "$(inherited)", "$(SRCROOT)/LookInside", - "$(SRCROOT)/LookInside/DerivedSource/LookinCore", "$(SRCROOT)/LookInside/DerivedSource/LookinCore/include", - "$(SRCROOT)/LookInside/DerivedSource/LookinCore/Category", - "$(SRCROOT)/LookInside/DerivedSource/LookinCore/Peertalk", "$(SRCROOT)/LookInside/DerivedSource/LookinServerBase", - "$(SRCROOT)/LookInside/ReactiveObjC", ); INFOPLIST_FILE = LookInside/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = LookInside; @@ -1240,6 +761,17 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + E81BCA16A46AEA38803B27B7 /* XCLocalSwiftPackageReference "Packages/LookInsideActivation" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/LookInsideActivation; + }; + C0DE5A1D2FA0000100A1B2C3 /* XCLocalSwiftPackageReference "Packages/LookInsideHostCore" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/LookInsideHostCore; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ E37B8D0B2F97B1B100600001 /* XCRemoteSwiftPackageReference "swift-demangling" */ = { isa = XCRemoteSwiftPackageReference; @@ -1340,6 +872,21 @@ package = E9FE990A3026C657008692AF /* XCRemoteSwiftPackageReference "UIFoundation" */; productName = UIFoundationToolbox; }; + 4CF60A9D44E04A6BE91035EF /* LookInsideActivation */ = { + isa = XCSwiftPackageProductDependency; + package = E81BCA16A46AEA38803B27B7 /* XCLocalSwiftPackageReference "Packages/LookInsideActivation" */; + productName = LookInsideActivation; + }; + F05DCA7C93CF923C02603F72 /* LookInsideActivationUI */ = { + isa = XCSwiftPackageProductDependency; + package = E81BCA16A46AEA38803B27B7 /* XCLocalSwiftPackageReference "Packages/LookInsideActivation" */; + productName = LookInsideActivationUI; + }; + C0DE5A1F2FA0000100A1B2C3 /* LookInsideHostCore */ = { + isa = XCSwiftPackageProductDependency; + package = C0DE5A1D2FA0000100A1B2C3 /* XCLocalSwiftPackageReference "Packages/LookInsideHostCore" */; + productName = LookInsideHostCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = AA48F29321CC0CA50032EC2C /* Project object */; diff --git a/LookInside.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LookInside.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index efe3d4aa..7fbd9082 100644 --- a/LookInside.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LookInside.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "150141d3e14fde766abf505a6b83f67fe544f2c1a265db716c5eb94553d002cb", + "originHash" : "83b40af5dbc1363bd059a3cac334f7d14332936a5740d4465a68a93cbd0efbdb", "pins" : [ { "identity" : "associatedobject", @@ -10,6 +10,24 @@ "version" : "0.15.0" } }, + { + "identity" : "confettiswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simibac/ConfettiSwiftUI.git", + "state" : { + "revision" : "79666d42b882bf7921b1941fa7c636d681d77980", + "version" : "2.0.3" + } + }, + { + "identity" : "displaylink", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/DisplayLink.git", + "state" : { + "revision" : "41bea2661e04bebcb1c4f15fc0b6197850100fdd", + "version" : "3.0.1" + } + }, { "identity" : "frameworktoolbox", "kind" : "remoteSourceControl", @@ -55,6 +73,15 @@ "version" : "2.9.1" } }, + { + "identity" : "springinterpolation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/SpringInterpolation.git", + "state" : { + "revision" : "cdb556516daa9b43c16aae9436dd39e19ff930fd", + "version" : "1.4.0" + } + }, { "identity" : "swift-demangling", "kind" : "remoteSourceControl", @@ -117,6 +144,15 @@ "revision" : "33ebdc39d890c26e3773cb5eba2817fe77e1ffee", "version" : "0.15.2" } + }, + { + "identity" : "windowanimation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/WindowAnimation.git", + "state" : { + "revision" : "c402e7787fa9877b814b93ff231b47ba7185c19a", + "version" : "1.1.1" + } } ], "version" : 3 diff --git a/LookInside.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LookInside.xcworkspace/xcshareddata/swiftpm/Package.resolved index 037f7dc0..6675a841 100644 --- a/LookInside.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LookInside.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2da647f17029fa490bf2ced42c321e7d7316677fdee694c3d4cdb42a16cbd8e6", + "originHash" : "a148ac4ce6b5097fb7542ced14483b445d89fd4968deb332ab931f42bc988672", "pins" : [ { "identity" : "associatedobject", @@ -10,6 +10,24 @@ "version" : "0.15.0" } }, + { + "identity" : "confettiswiftui", + "kind" : "remoteSourceControl", + "location" : "https://github.com/simibac/ConfettiSwiftUI.git", + "state" : { + "revision" : "79666d42b882bf7921b1941fa7c636d681d77980", + "version" : "2.0.3" + } + }, + { + "identity" : "displaylink", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/DisplayLink.git", + "state" : { + "revision" : "41bea2661e04bebcb1c4f15fc0b6197850100fdd", + "version" : "3.0.1" + } + }, { "identity" : "frameworktoolbox", "kind" : "remoteSourceControl", @@ -55,6 +73,15 @@ "version" : "2.9.1" } }, + { + "identity" : "springinterpolation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/SpringInterpolation.git", + "state" : { + "revision" : "cdb556516daa9b43c16aae9436dd39e19ff930fd", + "version" : "1.4.0" + } + }, { "identity" : "swift-demangling", "kind" : "remoteSourceControl", @@ -117,6 +144,15 @@ "revision" : "33ebdc39d890c26e3773cb5eba2817fe77e1ffee", "version" : "0.15.2" } + }, + { + "identity" : "windowanimation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/WindowAnimation.git", + "state" : { + "revision" : "c402e7787fa9877b814b93ff231b47ba7185c19a", + "version" : "1.1.1" + } } ], "version" : 3 diff --git a/LookInside/About/LKAboutViewController.h b/LookInside/About/LKAboutViewController.h deleted file mode 100644 index 8f44b296..00000000 --- a/LookInside/About/LKAboutViewController.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKAboutViewController.h -// LookinClient -// -// Created by 李凯 on 2019/10/30. -// Copyright © 2019 hughkli. All rights reserved. -// - -#import "LKBaseViewController.h" - -@interface LKAboutViewController : LKBaseViewController - -@end diff --git a/LookInside/About/LKAboutViewController.m b/LookInside/About/LKAboutViewController.m deleted file mode 100644 index 530dfa03..00000000 --- a/LookInside/About/LKAboutViewController.m +++ /dev/null @@ -1,152 +0,0 @@ -// -// LKAboutViewController.m -// LookinClient -// -// Created by 李凯 on 2019/10/30. -// Copyright © 2019 hughkli. All rights reserved. -// - -#import "LKAboutViewController.h" - -static NSString * const kLookInsideUpstreamURL = @"https://github.com/QMUI/LookinServer"; -static NSString * const kLookInsideHomeURL = @"https://lookinside-app.com"; - -@interface LKAboutViewController () - -@property(nonatomic, strong) LKBaseView *backgroundImageView; -@property(nonatomic, strong) LKBaseView *paperOverlayView; -@property(nonatomic, strong) NSImageView *iconImageView; -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKLabel *versionLabel; -@property(nonatomic, strong) LKLabel *taglineLabel; -@property(nonatomic, strong) NSStackView *contentStackView; -@property(nonatomic, strong) NSTextView *legalTextView; - -@end - -@implementation LKAboutViewController - -- (NSView *)makeContainerView { - LKBaseView *containerView = [LKBaseView new]; - containerView.backgroundColors = LKColorsCombine( - [NSColor colorWithCalibratedRed:0.99 green:0.97 blue:0.93 alpha:1], - [NSColor colorWithCalibratedWhite:0.10 alpha:1]); - - self.backgroundImageView = [LKBaseView new]; - self.backgroundImageView.wantsLayer = YES; - self.backgroundImageView.layer.contents = NSImageMake(@"aboutHeroBg"); - self.backgroundImageView.layer.contentsGravity = kCAGravityResizeAspectFill; - self.backgroundImageView.layer.masksToBounds = YES; - self.backgroundImageView.layer.opacity = 0.25; - [containerView addSubview:self.backgroundImageView]; - - self.paperOverlayView = [LKBaseView new]; - self.paperOverlayView.backgroundColors = LKColorsCombine( - [NSColor colorWithCalibratedRed:0.99 green:0.97 blue:0.93 alpha:0.55], - [NSColor colorWithCalibratedWhite:0.10 alpha:0.55]); - [containerView addSubview:self.paperOverlayView]; - - self.iconImageView = [NSImageView new]; - self.iconImageView.image = [NSApp applicationIconImage]; - self.iconImageView.imageScaling = NSImageScaleProportionallyUpOrDown; - [self.iconImageView.widthAnchor constraintEqualToConstant:96].active = YES; - [self.iconImageView.heightAnchor constraintEqualToConstant:96].active = YES; - - NSFontDescriptor *serifDescriptor = [[NSFontDescriptor fontDescriptorWithFontAttributes:@{}] - fontDescriptorWithDesign:NSFontDescriptorSystemDesignSerif]; - NSFont *titleFont = [NSFont fontWithDescriptor:serifDescriptor size:30] ?: [NSFont systemFontOfSize:30 weight:NSFontWeightRegular]; - NSFont *footnoteFont = [NSFont preferredFontForTextStyle:NSFontTextStyleFootnote options:@{}]; - - self.titleLabel = [LKLabel new]; - self.titleLabel.stringValue = @"LookInside"; - self.titleLabel.textColors = LKColorsCombine([NSColor colorWithCalibratedWhite:0.12 alpha:1], - [NSColor colorWithCalibratedWhite:0.96 alpha:1]); - self.titleLabel.font = titleFont; - - NSString *dotVersion = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleShortVersionString"]; - NSString *numberVersion = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleVersion"]; - self.versionLabel = [LKLabel new]; - self.versionLabel.stringValue = [NSString stringWithFormat:@"Version %@ (%@)", dotVersion, numberVersion]; - self.versionLabel.textColors = LKColorsCombine([NSColor colorWithCalibratedWhite:0.35 alpha:1], - [NSColor colorWithCalibratedWhite:0.78 alpha:1]); - self.versionLabel.font = footnoteFont; - - self.taglineLabel = [LKLabel new]; - self.taglineLabel.stringValue = @"A SwiftUI- and UIKit-aware view debugger.\nWalk every layer. Read every modifier."; - self.taglineLabel.textColors = LKColorsCombine([NSColor colorWithCalibratedWhite:0.30 alpha:1], - [NSColor colorWithCalibratedWhite:0.82 alpha:1]); - self.taglineLabel.font = footnoteFont; - self.taglineLabel.alignment = NSTextAlignmentCenter; - self.taglineLabel.maximumNumberOfLines = 2; - - self.contentStackView = [NSStackView stackViewWithViews:@[ - self.iconImageView, self.titleLabel, self.versionLabel, self.taglineLabel - ]]; - self.contentStackView.orientation = NSUserInterfaceLayoutOrientationVertical; - self.contentStackView.alignment = NSLayoutAttributeCenterX; - self.contentStackView.spacing = 16; - [containerView addSubview:self.contentStackView]; - - self.legalTextView = [[NSTextView alloc] initWithFrame:NSZeroRect]; - self.legalTextView.editable = NO; - self.legalTextView.selectable = YES; - self.legalTextView.drawsBackground = NO; - self.legalTextView.textContainerInset = NSZeroSize; - self.legalTextView.textContainer.lineFragmentPadding = 0; - self.legalTextView.linkTextAttributes = @{ - NSForegroundColorAttributeName: [NSColor colorWithCalibratedRed:0.40 green:0.32 blue:0.62 alpha:1], - NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle), - NSCursorAttributeName: [NSCursor pointingHandCursor] - }; - - NSMutableParagraphStyle *legalParagraph = [[NSMutableParagraphStyle alloc] init]; - legalParagraph.alignment = NSTextAlignmentCenter; - legalParagraph.lineSpacing = 2; - - NSColor *legalColor = [NSColor colorWithCalibratedWhite:0.40 alpha:1]; - NSDictionary *legalAttrs = @{ - NSFontAttributeName: footnoteFont, - NSForegroundColorAttributeName: legalColor, - NSParagraphStyleAttributeName: legalParagraph - }; - NSCalendar *calendar = [NSCalendar currentCalendar]; - NSInteger year = [calendar component:NSCalendarUnitYear fromDate:[NSDate date]]; - - NSMutableAttributedString *legal = [[NSMutableAttributedString alloc] init]; - [legal appendAttributedString:[[NSAttributedString alloc] initWithString: - [NSString stringWithFormat:@"© %ld LookInside-App. Released under GPL-3.0.\n", (long)year] - attributes:legalAttrs]]; - [legal appendAttributedString:[[NSAttributedString alloc] initWithString:@"Based on " attributes:legalAttrs]]; - [legal appendAttributedString:[[NSAttributedString alloc] initWithString:@"Lookin" attributes:legalAttrs]]; - [legal addAttribute:NSLinkAttributeName value:kLookInsideUpstreamURL - range:NSMakeRange(legal.length - 6, 6)]; - [legal appendAttributedString:[[NSAttributedString alloc] initWithString:@" by QMUI · " attributes:legalAttrs]]; - [legal appendAttributedString:[[NSAttributedString alloc] initWithString:@"lookinside-app.com" attributes:legalAttrs]]; - [legal addAttribute:NSLinkAttributeName value:kLookInsideHomeURL - range:NSMakeRange(legal.length - 18, 18)]; - - [self.legalTextView.textStorage setAttributedString:legal]; - [containerView addSubview:self.legalTextView]; - - return containerView; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - static const CGFloat kPad = 16; - $(self.backgroundImageView, self.paperOverlayView).fullFrame; - - NSSize legalSize = [self.legalTextView.attributedString - boundingRectWithSize:NSMakeSize(self.view.$width - kPad * 2, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin].size; - CGFloat legalHeight = ceil(legalSize.height) + 4; - CGFloat legalY = self.view.$height - kPad - legalHeight; - $(self.legalTextView).width(self.view.$width - kPad * 2).height(legalHeight).horAlign.y(legalY); - - NSSize stackSize = [self.contentStackView fittingSize]; - CGFloat available = legalY - kPad - kPad; - CGFloat stackY = kPad + MAX(0, (available - stackSize.height) / 2.0); - $(self.contentStackView).width(stackSize.width).height(stackSize.height).horAlign.y(stackY); -} - -@end diff --git a/LookInside/About/LKAboutWindowController.h b/LookInside/About/LKAboutWindowController.h deleted file mode 100644 index fda5967a..00000000 --- a/LookInside/About/LKAboutWindowController.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKAboutWindowController.h -// LookinClient -// -// Created by 李凯 on 2019/10/30. -// Copyright © 2019 hughkli. All rights reserved. -// - -#import "LKWindowController.h" - -@interface LKAboutWindowController : LKWindowController - -@end diff --git a/LookInside/About/LKAboutWindowController.m b/LookInside/About/LKAboutWindowController.m deleted file mode 100644 index 05b6d731..00000000 --- a/LookInside/About/LKAboutWindowController.m +++ /dev/null @@ -1,41 +0,0 @@ -// -// LKAboutWindowController.m -// LookinClient -// -// Created by 李凯 on 2019/10/30. -// Copyright © 2019 hughkli. All rights reserved. -// - -#import "LKAboutWindowController.h" -#import "LKAboutViewController.h" -#import "LKWindow.h" - -@interface LKAboutWindowController () - -@end - -@implementation LKAboutWindowController - -- (instancetype)init { - CGFloat width = 480; - CGFloat height = 460; - - LKWindow *window = [[LKWindow alloc] initWithContentRect:NSMakeRect(0, 0, width, height) styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskFullSizeContentView backing:NSBackingStoreBuffered defer:YES]; - window.movableByWindowBackground = YES; - window.titlebarAppearsTransparent = YES; - window.titleVisibility = NSWindowTitleHidden; - [window standardWindowButton:NSWindowMiniaturizeButton].hidden = YES; - [window standardWindowButton:NSWindowZoomButton].hidden = YES; - [window center]; - - if (self = [self initWithWindow:window]) { - LKAboutViewController *vc = [LKAboutViewController new]; - window.contentView = vc.view; - self.contentViewController = vc; - } - return self; -} - - - -@end diff --git a/LookInside/About/LKAboutWindowController.swift b/LookInside/About/LKAboutWindowController.swift new file mode 100644 index 00000000..c99d5721 --- /dev/null +++ b/LookInside/About/LKAboutWindowController.swift @@ -0,0 +1,193 @@ +// +// LKAboutWindowController.swift +// LookInside +// +// The About window: app icon, name, version, tagline and the license line +// with links to upstream Lookin and the LookInside website. +// + +import AppKit + +@objc(LKAboutWindowController) +final class LKAboutWindowController: LKWindowController { + @objc init() { + let window = LKWindow(contentRect: NSRect(x: 0, y: 0, width: 480, height: 460), + styleMask: [.titled, .closable, .fullSizeContentView], + backing: .buffered, defer: true) + window.isMovableByWindowBackground = true + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + window.center() + + super.init(window: window) + let viewController = LKAboutViewController() + window.contentView = viewController.view + contentViewController = viewController + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } +} + +@objc(LKAboutViewController) +final class LKAboutViewController: LKBaseViewController { + private static let upstreamURL = "https://github.com/QMUI/LookinServer" + private static let homeURL = "https://lookinside-app.com" + private static let padding: CGFloat = 16 + + private var backgroundImageView: LKBaseView? + private var paperOverlayView: LKBaseView? + private var contentStackView: NSStackView? + private var legalTextView: NSTextView? + + init() { + super.init(containerView: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func makeContainerView() -> NSView { + let containerView = LKBaseView() + containerView.backgroundColors = LKTwoColors( + colorInLightMode: NSColor(calibratedRed: 0.99, green: 0.97, blue: 0.93, alpha: 1), + colorInDarkMode: NSColor(calibratedWhite: 0.10, alpha: 1) + ) + + let backgroundImageView = LKBaseView() + backgroundImageView.wantsLayer = true + backgroundImageView.layer?.contents = NSImage(named: "aboutHeroBg") + backgroundImageView.layer?.contentsGravity = .resizeAspectFill + backgroundImageView.layer?.masksToBounds = true + backgroundImageView.layer?.opacity = 0.25 + containerView.addSubview(backgroundImageView) + self.backgroundImageView = backgroundImageView + + let paperOverlayView = LKBaseView() + paperOverlayView.backgroundColors = LKTwoColors( + colorInLightMode: NSColor(calibratedRed: 0.99, green: 0.97, blue: 0.93, alpha: 0.55), + colorInDarkMode: NSColor(calibratedWhite: 0.10, alpha: 0.55) + ) + containerView.addSubview(paperOverlayView) + self.paperOverlayView = paperOverlayView + + let iconImageView = NSImageView() + iconImageView.image = NSApp.applicationIconImage + iconImageView.imageScaling = .scaleProportionallyUpOrDown + iconImageView.widthAnchor.constraint(equalToConstant: 96).isActive = true + iconImageView.heightAnchor.constraint(equalToConstant: 96).isActive = true + + let serifDescriptor = NSFontDescriptor(fontAttributes: [:]).withDesign(.serif) + let titleFont = serifDescriptor.flatMap { NSFont(descriptor: $0, size: 30) } + ?? NSFont.systemFont(ofSize: 30, weight: .regular) + let footnoteFont = NSFont.preferredFont(forTextStyle: .footnote, options: [:]) + + let titleLabel = LKLabel() + titleLabel.stringValue = "LookInside" + titleLabel.textColors = LKTwoColors(colorInLightMode: NSColor(calibratedWhite: 0.12, alpha: 1), + colorInDarkMode: NSColor(calibratedWhite: 0.96, alpha: 1)) + titleLabel.font = titleFont + + let info = Bundle.main.infoDictionary + let dotVersion = info?["CFBundleShortVersionString"] as? String ?? "(null)" + let numberVersion = info?["CFBundleVersion"] as? String ?? "(null)" + let versionLabel = LKLabel() + versionLabel.stringValue = "Version \(dotVersion) (\(numberVersion))" + versionLabel.textColors = LKTwoColors(colorInLightMode: NSColor(calibratedWhite: 0.35, alpha: 1), + colorInDarkMode: NSColor(calibratedWhite: 0.78, alpha: 1)) + versionLabel.font = footnoteFont + + let taglineLabel = LKLabel() + taglineLabel.stringValue = "A SwiftUI- and UIKit-aware view debugger.\nWalk every layer. Read every modifier." + taglineLabel.textColors = LKTwoColors(colorInLightMode: NSColor(calibratedWhite: 0.30, alpha: 1), + colorInDarkMode: NSColor(calibratedWhite: 0.82, alpha: 1)) + taglineLabel.font = footnoteFont + taglineLabel.alignment = .center + taglineLabel.maximumNumberOfLines = 2 + + let contentStackView = NSStackView(views: [iconImageView, titleLabel, versionLabel, taglineLabel]) + contentStackView.orientation = .vertical + contentStackView.alignment = .centerX + contentStackView.spacing = 16 + containerView.addSubview(contentStackView) + self.contentStackView = contentStackView + + let legalTextView = NSTextView(frame: .zero) + legalTextView.isEditable = false + legalTextView.isSelectable = true + legalTextView.drawsBackground = false + legalTextView.textContainerInset = .zero + legalTextView.textContainer?.lineFragmentPadding = 0 + legalTextView.linkTextAttributes = [ + .foregroundColor: NSColor(calibratedRed: 0.40, green: 0.32, blue: 0.62, alpha: 1), + .underlineStyle: NSUnderlineStyle.single.rawValue, + .cursor: NSCursor.pointingHand, + ] + legalTextView.textStorage?.setAttributedString(Self.legalText(font: footnoteFont)) + containerView.addSubview(legalTextView) + self.legalTextView = legalTextView + + return containerView + } + + private static func legalText(font: NSFont) -> NSAttributedString { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + paragraph.lineSpacing = 2 + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor(calibratedWhite: 0.40, alpha: 1), + .paragraphStyle: paragraph, + ] + let year = Calendar.current.component(.year, from: Date()) + + let legal = NSMutableAttributedString() + func append(_ string: String, link: String? = nil) { + let start = legal.length + legal.append(NSAttributedString(string: string, attributes: attributes)) + if let link { + legal.addAttribute(.link, value: link, range: NSRange(location: start, length: legal.length - start)) + } + } + append("© \(year) LookInside-App. Released under GPL-3.0.\n") + append("Based on ") + append("Lookin", link: upstreamURL) + append(" by QMUI · ") + append("lookinside-app.com", link: homeURL) + return legal + } + + override func viewDidLayout() { + super.viewDidLayout() + let padding = Self.padding + backgroundImageView?.lkpFullFrame() + paperOverlayView?.lkpFullFrame() + + let viewSize = view.bounds.size + guard let legalTextView, let contentStackView else { return } + let legalSize = legalTextView.attributedString().boundingRect( + with: NSSize(width: viewSize.width - padding * 2, height: .greatestFiniteMagnitude), + options: .usesLineFragmentOrigin + ).size + let legalHeight = ceil(legalSize.height) + 4 + let legalY = viewSize.height - padding - legalHeight + legalTextView.lkpSetWidth(viewSize.width - padding * 2) + legalTextView.lkpSetHeight(legalHeight) + legalTextView.lkpHorAlign() + legalTextView.lkpSetY(legalY) + + let stackSize = contentStackView.fittingSize + let available = legalY - padding - padding + let stackY = padding + max(0, (available - stackSize.height) / 2) + contentStackView.lkpSetWidth(stackSize.width) + contentStackView.lkpSetHeight(stackSize.height) + contentStackView.lkpHorAlign() + contentStackView.lkpSetY(stackY) + } +} diff --git a/LookInside/AppDelegate.h b/LookInside/AppDelegate.h deleted file mode 100644 index d434bfe3..00000000 --- a/LookInside/AppDelegate.h +++ /dev/null @@ -1,14 +0,0 @@ -// -// AppDelegate.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import - -@interface AppDelegate : NSObject - -@end - diff --git a/LookInside/AppDelegate.m b/LookInside/AppDelegate.m deleted file mode 100644 index 732cff9a..00000000 --- a/LookInside/AppDelegate.m +++ /dev/null @@ -1,219 +0,0 @@ -// -// AppDelegate.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "AppDelegate.h" -#import "LKNavigationManager.h" -#import "LKConnectionManager.h" -#import "LKPreferenceManager.h" -#import "LKAppMenuManager.h" -#import "LKLaunchWindowController.h" -#import "LKWindowController.h" -#import "LookinArchiveDocument.h" -#import "LookInside-Swift.h" -#import "NSString+Score.h" -#import "LookinDashboardBlueprint.h" -#import "LKPreferenceManager.h" - -@interface AppDelegate () - -@end - -@implementation AppDelegate - -- (void)applicationWillFinishLaunching:(NSNotification *)notification { - [[LKAppMenuManager sharedInstance] setup]; - - [RACObserve([LKPreferenceManager mainManager], appearanceType) subscribeNext:^(NSNumber *number) { - LookinPreferredAppeanranceType type = [number integerValue]; - switch (type) { - case LookinPreferredAppeanranceTypeDark: - NSApp.appearance = [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; - break; - case LookinPreferredAppeanranceTypeLight: - NSApp.appearance = [NSAppearance appearanceNamed:NSAppearanceNameAqua]; - break; - default: - NSApp.appearance = nil; - break; - } - }]; -} - -- (void)applicationDidFinishLaunching:(NSNotification *)aNotification { - [LKConnectionManager sharedInstance]; - [[LKMCPBridgeServer sharedInstance] start]; - // Phase F: any documents opened during launch (Finder double-click, - // Open With…) are already registered with NSDocumentController by the - // time we reach here, so the simple "no docs ⇒ show Launch" check - // covers both cold-start cases without needing a separate flag. - NSDocumentController *documentController = [NSDocumentController sharedDocumentController]; - if (documentController.documents.count == 0) { - [[LKNavigationManager sharedInstance] showLaunchAllowingAutoEnter:YES]; - } - - [self _lk_installActivationStateObserverExample]; - - // Phase E: when the last document closes, bring Launch back (Q2=A) so the - // app never sits without any visible UI. - [NSNotificationCenter.defaultCenter - addObserver:self - selector:@selector(_lk_handleWindowWillClose:) - name:NSWindowWillCloseNotification - object:nil]; - -#ifdef DEBUG - [self _runTests]; -#endif -} - -- (void)_lk_handleWindowWillClose:(NSNotification *)note { - NSWindow *closingWindow = note.object; - if (![closingWindow.windowController isKindOfClass:[LKWindowController class]]) { - return; - } - // Closing the Launch window itself shouldn't re-spawn Launch; only Doc - // (Live / Archive) windows trigger the "no docs left" reopen. - LKLaunchWindowController *launchWC = [LKNavigationManager sharedInstance].launchWindowController; - if (closingWindow == launchWC.window) { - return; - } - // Defer one tick so NSDocumentController has finished removing the doc. - dispatch_async(dispatch_get_main_queue(), ^{ - [self _lk_showLaunchIfNoDocuments]; - }); -} - -- (void)_lk_showLaunchIfNoDocuments { - if ([NSDocumentController sharedDocumentController].documents.count > 0) { - return; - } - LKLaunchWindowController *launchWC = [LKNavigationManager sharedInstance].launchWindowController; - if (launchWC.window.isVisible) { - return; - } - [[LKNavigationManager sharedInstance] showLaunch]; -} - -- (void)_lk_installActivationStateObserverExample { - LKSwiftUISupportGatekeeper *gatekeeper = [LKSwiftUISupportGatekeeper sharedInstance]; - NSLog(@"[LK-Activation] initial state=%ld (polling every 5s; waits for auth server)", - (long)gatekeeper.activationState); - - [NSNotificationCenter.defaultCenter - addObserver:self - selector:@selector(_lk_activationStateDidChange:) - name:LKSwiftUISupportGatekeeper.activationStateDidChangeNotificationName - object:nil]; -} - -- (void)_lk_activationStateDidChange:(NSNotification *)note { - NSNumber *state = note.userInfo[@"activationState"]; - NSString *label; - switch ((LKSwiftUISupportActivationState)state.integerValue) { - case LKSwiftUISupportActivationStateUnknown: label = @"unknown"; break; - case LKSwiftUISupportActivationStateNotActivated: label = @"notActivated"; break; - case LKSwiftUISupportActivationStateActivated: label = @"activated"; break; - } - NSLog(@"[LK-Activation] state changed -> %@ (raw=%@)", label, state); -} - -- (BOOL)applicationShouldTerminateAfterLastWindowClosed:(NSApplication *)sender { - // Phase E (Q2=A): keep the app alive after the last window closes so we can - // bring the Launch window back instead of quitting. Quit goes through the - // standard Cmd-Q path. - return NO; -} - -- (BOOL)applicationShouldOpenUntitledFile:(NSApplication *)sender { - // Phase E: there is no meaningful "untitled" document in this app. New - // Inspection / Open are explicit user actions; suppress the macOS default - // behavior that would create one on activation. - return NO; -} - -- (BOOL)applicationOpenUntitledFile:(NSApplication *)sender { - return NO; -} - -- (BOOL)applicationShouldHandleReopen:(NSApplication *)sender hasVisibleWindows:(BOOL)flag { - // Dock-icon click after closing all windows: bring Launch back. - if (!flag) { - [self _lk_showLaunchIfNoDocuments]; - } - return YES; -} - -- (void)application:(NSApplication *)application openURLs:(NSArray *)urls { - // Phase C: replaces the legacy -application:openFile:. Routing flows through - // NSDocumentController so .lookin archives integrate with Recent Documents, - // proxy icon dragging, Save As, and de-duplication of already-open files. - NSDocumentController *documentController = [NSDocumentController sharedDocumentController]; - for (NSURL *url in urls) { - [documentController openDocumentWithContentsOfURL:url - display:YES - completionHandler:^(NSDocument *_Nullable document, - BOOL alreadyOpen, - NSError *_Nullable error) { - if (!document && error) { - [NSApp presentError:error]; - } - }]; - } -} - -- (NSApplicationTerminateReply)applicationShouldTerminate:(NSApplication *)sender { - [[LKSwiftUISupportGatekeeper sharedInstance] shutdownRuntime]; - [[LKMCPBridgeServer sharedInstance] stop]; - - // 清理打开 UIImageView 的图片时创建的临时文件 - NSArray *tempImageFilesToDelete = [LKHelper sharedInstance].tempImageFiles; - if (tempImageFilesToDelete.count == 0) { - return NSTerminateNow; - } - [tempImageFilesToDelete enumerateObjectsUsingBlock:^(NSString * _Nonnull path, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL isSucc = [[NSFileManager defaultManager] removeItemAtPath:path error:nil]; - if (!isSucc) { - NSAssert(NO, @""); - } - }]; - return NSTerminateNow; -} - -/// 一些单元测试 -- (void)_runTests { - // 确保 LookinAttrGroupIdentifier 的 value 没有重复 - NSArray *allGroupIDs = [LookinDashboardBlueprint groupIDs]; - NSSet *allGroupIDs_unique = [NSSet setWithArray:allGroupIDs]; - if (allGroupIDs.count != allGroupIDs_unique.count) { - NSAssert(NO, @""); - } - - // 确保 LookinAttrSectionIdentifier 的 value 没有重复 - NSMutableArray *allSecIDs = [NSMutableArray array]; - [allGroupIDs enumerateObjectsUsingBlock:^(LookinAttrGroupIdentifier _Nonnull groupID, NSUInteger idx, BOOL * _Nonnull stop) { - NSArray *secIDs = [LookinDashboardBlueprint sectionIDsForGroupID:groupID]; - [allSecIDs addObjectsFromArray:secIDs]; - }]; - NSSet *allSecIDs_unique = [NSSet setWithArray:allSecIDs]; - if (allSecIDs.count != allSecIDs_unique.count) { - NSAssert(NO, @""); - } - - // 确保 LookinAttrIdentifier 的 value 没有重复 - NSMutableArray *allAttrIDs = [NSMutableArray array]; - [allSecIDs enumerateObjectsUsingBlock:^(LookinAttrSectionIdentifier _Nonnull secID, NSUInteger idx, BOOL * _Nonnull stop) { - NSArray *attrIDs = [LookinDashboardBlueprint attrIDsForSectionID:secID]; - [allAttrIDs addObjectsFromArray:attrIDs]; - }]; - NSSet *allAttrIDs_unique = [NSSet setWithArray:allAttrIDs]; - if (allAttrIDs.count != allAttrIDs_unique.count) { - NSAssert(NO, @""); - } -} - -@end diff --git a/LookInside/AppDelegate.swift b/LookInside/AppDelegate.swift new file mode 100644 index 00000000..4b565973 --- /dev/null +++ b/LookInside/AppDelegate.swift @@ -0,0 +1,205 @@ +// +// AppDelegate.swift +// LookInside +// +// Created by Li Kai on 2018/8/4. +// https://lookin.work +// + +import AppKit + +@main +@objc(AppDelegate) +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private var appearanceObservation: NSKeyValueObservation? + + /// The app's entry point. There is no main nib: the delegate is set + /// here, and kept alive for the run since NSApplication holds it weakly. + /// (A main.swift instead makes WMO Release builds put each file's code + /// in the next file's object, which the release-clean check reads as + /// DEBUG-only code shipping.) + static func main() { + #if DEBUG + // Before the delegate is set, so the dump observes the end of + // launching ahead of it, as its Objective-C +load did. + LKDebugE2EDump.installIfRequested() + #endif + let delegate = AppDelegate() + NSApplication.shared.delegate = delegate + withExtendedLifetime(delegate) { + NSApplication.shared.run() + } + } + + func applicationWillFinishLaunching(_: Notification) { + LKAppMenuManager.shared.setup() + + appearanceObservation = LKPreferenceManager.shared.observe(\.appearanceType, options: [.initial, .new]) { manager, _ in + MainActor.assumeIsolated { + Self.applyAppearance(manager.appearanceType) + } + } + } + + private static func applyAppearance(_ type: LookinPreferredAppeanranceType) { + switch type { + case .dark: + NSApp.appearance = NSAppearance(named: .darkAqua) + case .light: + NSApp.appearance = NSAppearance(named: .aqua) + default: + NSApp.appearance = nil + } + } + + func applicationDidFinishLaunching(_: Notification) { + _ = LKConnectionManager.shared + // Documents opened during launch (Finder double-click, Open With…) + // are registered with NSDocumentController by now, so "no documents + // ⇒ show Launch" covers both cold-start cases. + if NSDocumentController.shared.documents.isEmpty { + var allowAutoEnter = true + #if DEBUG + // The end-to-end dump (LKDebugE2EDump.swift) opens the app itself; an + // activated Host's auto-enter would fetch the same hierarchy + // alongside it. + allowAutoEnter = (ProcessInfo.processInfo.environment["LOOKINSIDE_DEBUG_E2E_DUMP_DIR"] ?? "").isEmpty + #endif + LKNavigationManager.shared.showLaunch(allowingAutoEnter: allowAutoEnter) + } + + installActivationStateObserver() + + // When the last document closes, bring Launch back so the app never + // sits without any visible UI. + NotificationCenter.default.addObserver( + self, + selector: #selector(handleWindowWillClose(_:)), + name: NSWindow.willCloseNotification, + object: nil + ) + + #if DEBUG + Self.checkDashboardBlueprintIdentifiers() + #endif + } + + @objc private func handleWindowWillClose(_ notification: Notification) { + guard let closingWindow = notification.object as? NSWindow, + closingWindow.windowController is LKWindowController + else { + return + } + // Closing the Launch window itself shouldn't re-spawn Launch; only + // document (live / archive) windows trigger the "no docs left" reopen. + if closingWindow == LKNavigationManager.shared.launchWindowController?.window { + return + } + // Defer one tick so NSDocumentController has finished removing the doc. + DispatchQueue.main.async { + self.showLaunchIfNoDocuments() + } + } + + private func showLaunchIfNoDocuments() { + guard NSDocumentController.shared.documents.isEmpty else { + return + } + if LKNavigationManager.shared.launchWindowController?.window?.isVisible == true { + return + } + LKNavigationManager.shared.showLaunch() + } + + private func installActivationStateObserver() { + let gatekeeper = LKSwiftUISupportGatekeeper.sharedInstance() + NSLog("[LK-Activation] initial state=%ld (re-evaluated every 60s in process)", gatekeeper.activationState.rawValue) + + NotificationCenter.default.addObserver( + self, + selector: #selector(activationStateDidChange(_:)), + name: NSNotification.Name(LKSwiftUISupportGatekeeper.activationStateDidChangeNotificationName as String), + object: nil + ) + } + + @objc private func activationStateDidChange(_ notification: Notification) { + let state = notification.userInfo?["activationState"] as? NSNumber + let label: String + switch state.flatMap({ LKSwiftUISupportActivationState(rawValue: $0.intValue) }) { + case .unknown: label = "unknown" + case .notActivated: label = "notActivated" + case .activated: label = "activated" + default: label = "(null)" + } + NSLog("[LK-Activation] state changed -> %@ (raw=%@)", label, state?.description ?? "(null)") + } + + func applicationShouldTerminateAfterLastWindowClosed(_: NSApplication) -> Bool { + // Keep the app alive after the last window closes so the Launch + // window can come back instead of quitting. Quit goes through Cmd-Q. + false + } + + func applicationShouldOpenUntitledFile(_: NSApplication) -> Bool { + // There is no meaningful "untitled" document in this app. New + // Inspection / Open are explicit user actions; suppress the macOS + // default that would create one on activation. + false + } + + func applicationOpenUntitledFile(_: NSApplication) -> Bool { + false + } + + func applicationShouldHandleReopen(_: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + // Dock-icon click after closing all windows: bring Launch back. + if !flag { + showLaunchIfNoDocuments() + } + return true + } + + func application(_: NSApplication, open urls: [URL]) { + // Routed through NSDocumentController so .lookin archives get Recent + // Documents, proxy icon dragging, Save As, and the reuse of an + // already open file. + for url in urls { + NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { document, _, error in + if document == nil, let error { + NSApp.presentError(error) + } + } + } + } + + func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { + LKSwiftUISupportGatekeeper.sharedInstance().shutdownRuntime() + + // 清理打开 UIImageView 的图片时创建的临时文件 + for path in LKHelper.sharedInstance().tempImageFiles { + do { + try FileManager.default.removeItem(atPath: path) + } catch { + assertionFailure("could not remove the temporary image file \(path): \(error)") + } + } + return .terminateNow + } + + #if DEBUG + /// The dashboard blueprint's group, section and attribute identifiers + /// must each be unique. + private static func checkDashboardBlueprintIdentifiers() { + let groupIDs = LookinDashboardBlueprint.groupIDs().map { $0 as String } + assert(Set(groupIDs).count == groupIDs.count, "duplicate LookinAttrGroupIdentifier") + + let sectionIDs = groupIDs.flatMap { LookinDashboardBlueprint.sectionIDs(forGroupID: $0).map { $0 as String } } + assert(Set(sectionIDs).count == sectionIDs.count, "duplicate LookinAttrSectionIdentifier") + + let attrIDs = sectionIDs.flatMap { LookinDashboardBlueprint.attrIDs(forSectionID: $0).map { $0 as String } } + assert(Set(attrIDs).count == attrIDs.count, "duplicate LookinAttrIdentifier") + } + #endif +} diff --git a/LookInside/Base/Category/LookinAttributesGroup+LookinClient.h b/LookInside/Base/Category/LookinAttributesGroup+LookinClient.h deleted file mode 100644 index a30f811e..00000000 --- a/LookInside/Base/Category/LookinAttributesGroup+LookinClient.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LookinAttributesGroup+LookinClient.h -// LookinClient -// -// Created by LikaiMacStudioWork on 2023/10/31. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LookinAttributesGroup.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LookinAttributesGroup (LookinClient) - -/// `isMacTarget` describes the app being inspected, not the host. It only affects the -/// ViewLayer group, whose title names the view class (`NSView` vs `UIView`). -- (NSString *)queryDisplayTitleForMacTarget:(BOOL)isMacTarget; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Base/Category/LookinAttributesGroup+LookinClient.m b/LookInside/Base/Category/LookinAttributesGroup+LookinClient.m deleted file mode 100644 index 076cb0c5..00000000 --- a/LookInside/Base/Category/LookinAttributesGroup+LookinClient.m +++ /dev/null @@ -1,33 +0,0 @@ -// -// LookinAttributesGroup+LookinClient.m -// LookinClient -// -// Created by LikaiMacStudioWork on 2023/10/31. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LookinAttributesGroup+LookinClient.h" -#import "LookinDashboardBlueprint.h" -#import "LookinAttrIdentifiers.h" -#import "LKHelper.h" - -@implementation LookinAttributesGroup (LookinClient) - -- (NSString *)queryDisplayTitleForMacTarget:(BOOL)isMacTarget { - if (self.userCustomTitle.length > 0) { - return self.userCustomTitle; - } - if ([self.identifier isEqualToString:LookinAttrGroup_ViewLayer]) { - // +groupTitleWithGroupID: picks this title with TARGET_OS_IPHONE, which is - // always 0 in the host, so it would say "NSView" even for an iOS target. - return [NSString stringWithFormat:@"CALayer / %@", [LKHelper viewClassNameForMacTarget:isMacTarget]]; - } - if ([self.identifier isEqualToString:LookinAttrGroup_LayoutGuide]) { - // The group ID is platform-neutral; the title names the inspected - // platform's actual class. - return isMacTarget ? @"NSLayoutGuide" : @"UILayoutGuide"; - } - return [LookinDashboardBlueprint groupTitleWithGroupID:self.identifier]; -} - -@end diff --git a/LookInside/Base/Category/LookinAttributesGroup+LookinClient.swift b/LookInside/Base/Category/LookinAttributesGroup+LookinClient.swift new file mode 100644 index 00000000..9c7c2dba --- /dev/null +++ b/LookInside/Base/Category/LookinAttributesGroup+LookinClient.swift @@ -0,0 +1,30 @@ +// +// LookinAttributesGroup+LookinClient.swift +// LookInside +// + +import Foundation + +extension LookinAttributesGroup { + /// The group's card title. `isMacTarget` describes the inspected app, + /// not the Host; it only changes the ViewLayer group, whose title names + /// the view class (`NSView` vs `UIView`), and the layout guide group. + @objc(queryDisplayTitleForMacTarget:) + func queryDisplayTitle(forMacTarget isMacTarget: Bool) -> String { + if let userCustomTitle, !userCustomTitle.isEmpty { + return userCustomTitle + } + if identifier == LookinAttrGroup_ViewLayer { + // +groupTitleWithGroupID: picks this title with TARGET_OS_IPHONE, + // which is always 0 in the Host, so it would say "NSView" even + // for an iOS target. + return "CALayer / \(LKHelper.viewClassName(forMacTarget: isMacTarget))" + } + if identifier == LookinAttrGroup_LayoutGuide { + // The group ID is platform-neutral; the title names the inspected + // platform's actual class. + return isMacTarget ? "NSLayoutGuide" : "UILayoutGuide" + } + return LookinDashboardBlueprint.groupTitle(withGroupID: identifier) ?? "" + } +} diff --git a/LookInside/Base/Category/LookinAttributesSection+LookinClient.h b/LookInside/Base/Category/LookinAttributesSection+LookinClient.h deleted file mode 100644 index c66ff076..00000000 --- a/LookInside/Base/Category/LookinAttributesSection+LookinClient.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LookinAttributesSection+LookinClient.h -// LookinClient -// -// Created by LikaiMacStudioWork on 2023/10/31. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LookinAttributesSection.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LookinAttributesSection (LookinClient) - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Base/Category/LookinAttributesSection+LookinClient.m b/LookInside/Base/Category/LookinAttributesSection+LookinClient.m deleted file mode 100644 index a99e4185..00000000 --- a/LookInside/Base/Category/LookinAttributesSection+LookinClient.m +++ /dev/null @@ -1,13 +0,0 @@ -// -// LookinAttributesSection+LookinClient.m -// LookinClient -// -// Created by LikaiMacStudioWork on 2023/10/31. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LookinAttributesSection+LookinClient.h" - -@implementation LookinAttributesSection (LookinClient) - -@end diff --git a/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.h b/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.h deleted file mode 100644 index 474c8dd7..00000000 --- a/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// LookinAutoLayoutConstraint+LookinClient.h -// LookinClient -// -// Created by likai.123 on 2024/1/14. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import "LookinAutoLayoutConstraint.h" - -@interface LookinAutoLayoutConstraint (LookinClient) - -+ (NSString *)descriptionWithItemObject:(LookinObject *)object type:(LookinConstraintItemType)type detailed:(BOOL)detailed; -+ (NSString *)descriptionWithAttributeInt:(NSInteger)attribute; -+ (NSString *)symbolWithRelation:(NSLayoutRelation)relation; -+ (NSString *)descriptionWithRelation:(NSLayoutRelation)relation; - -@end diff --git a/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.m b/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.m deleted file mode 100644 index c977ed24..00000000 --- a/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.m +++ /dev/null @@ -1,129 +0,0 @@ -// -// LookinObject+LookinAutoLayoutConstraint.m -// LookinClient -// -// Created by likai.123 on 2024/1/14. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import "LookinAutoLayoutConstraint+LookinClient.h" - -@implementation LookinAutoLayoutConstraint (LookinAutoLayoutConstraint) - - -+ (NSString *)descriptionWithItemObject:(LookinObject *)object type:(LookinConstraintItemType)type detailed:(BOOL)detailed { - switch (type) { - case LookinConstraintItemTypeNil: - return detailed ? @"Nil" : @"nil"; - - case LookinConstraintItemTypeSelf: - return detailed ? @"Self" : @"self"; - - case LookinConstraintItemTypeSuper: - return detailed ? @"Superview" : @"super"; - - case LookinConstraintItemTypeView: - case LookinConstraintItemTypeLayoutGuide: - return detailed ? [NSString stringWithFormat:@"<%@: %@>", object.rawClassName, object.memoryAddress] : [NSString stringWithFormat:@"(%@*)", object.lk_simpleDemangledClassName]; - - default: - NSAssert(NO, @""); - return detailed ? [NSString stringWithFormat:@"<%@: %@>", object.rawClassName, object.memoryAddress] : [NSString stringWithFormat:@"(%@*)", object.rawClassName]; - } -} - -// 注意 iOS 和 macOS 虽然都有 NSLayoutAttribute 但是 value 非常不同,这里应该去 iOS 平台查看 NSLayoutAttribute 的对应 -+ (NSString *)descriptionWithAttributeInt:(NSInteger)attribute { - switch (attribute) { - case 0 : - // 在某些业务里确实会出现这种情况,在 Reveal 和 UI Debugger 里也是这么显示的 - return @"notAnAttribute"; - case 1: - return @"left"; - case 2: - return @"right"; - case 3: - return @"top"; - case 4: - return @"bottom"; - case 5: - return @"leading"; - case 6: - return @"trailing"; - case 7: - return @"width"; - case 8: - return @"height"; - case 9: - return @"centerX"; - case 10: - return @"centerY"; - case 11: - return @"lastBaseline"; - case 12: - return @"firstBaseline"; - case 13: - return @"leftMargin"; - case 14: - return @"rightMargin"; - case 15: - return @"topMargin"; - case 16: - return @"bottomMargin"; - case 17: - return @"leadingMargin"; - case 18: - return @"trailingMargin"; - case 19: - return @"centerXWithinMargins"; - case 20: - return @"centerYWithinMargins"; - - // 以下都是和 AutoResizingMask 有关的,这里的定义是从系统 UI Debugger 里抄过来的,暂时没在官方文档里发现它们的公开定义 - case 32: - return @"minX"; - case 33: - return @"minY"; - case 34: - return @"midX"; - case 35: - return @"midY"; - case 36: - return @"maxX"; - case 37: - return @"maxY"; - default: - NSAssert(NO, @""); - return [NSString stringWithFormat:@"unknownAttr(%@)", @(attribute)]; - } -} - -+ (NSString *)symbolWithRelation:(NSLayoutRelation)relation { - switch (relation) { - case -1: - return @"<="; - case 0: - return @"="; - case 1: - return @">="; - default: - NSAssert(NO, @""); - return @"?"; - } -} - -+ (NSString *)descriptionWithRelation:(NSLayoutRelation)relation { - switch (relation) { - case -1: - return @"LessThanOrEqual"; - case 0: - return @"Equal"; - case 1: - return @"GreaterThanOrEqual"; - default: - NSAssert(NO, @""); - return @"?"; - } -} - -@end diff --git a/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.swift b/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.swift new file mode 100644 index 00000000..d66f8a5f --- /dev/null +++ b/LookInside/Base/Category/LookinAutoLayoutConstraint+LookinClient.swift @@ -0,0 +1,61 @@ +// +// LookinAutoLayoutConstraint+LookinClient.swift +// LookInside +// +// How constraints are written in the Dashboard's constraint rows and +// popover. Attributes use the iOS NSLayoutAttribute numbering; see +// ClientDisplayText.layoutAttributeName(_:). +// + +import AppKit +import LookInsideHostCore + +extension LookinAutoLayoutConstraint { + @objc(descriptionWithItemObject:type:detailed:) + static func description(withItemObject object: LookinObject?, type: LookinConstraintItemType, detailed: Bool) -> String { + switch type { + case .`nil`: + return detailed ? "Nil" : "nil" + case .`self`: + return detailed ? "Self" : "self" + case .`super`: + return detailed ? "Superview" : "super" + case .view, .layoutGuide: + return detailed + ? "<\(object?.rawClassName() ?? "(null)"): \(object?.memoryAddress ?? "(null)")>" + : "(\(object?.lk_simpleDemangledClassName() ?? "(null)")*)" + default: + assertionFailure("unknown constraint item type \(type.rawValue)") + return detailed + ? "<\(object?.rawClassName() ?? "(null)"): \(object?.memoryAddress ?? "(null)")>" + : "(\(object?.rawClassName() ?? "(null)")*)" + } + } + + @objc(descriptionWithAttributeInt:) + static func description(withAttributeInt attribute: Int) -> String { + if let name = ClientDisplayText.layoutAttributeName(attribute) { + return name + } + assertionFailure("unknown layout attribute \(attribute)") + return "unknownAttr(\(attribute))" + } + + @objc(symbolWithRelation:) + static func symbol(with relation: NSLayoutConstraint.Relation) -> String { + if let symbol = ClientDisplayText.layoutRelationSymbol(relation.rawValue) { + return symbol + } + assertionFailure("unknown layout relation \(relation.rawValue)") + return "?" + } + + @objc(descriptionWithRelation:) + static func description(with relation: NSLayoutConstraint.Relation) -> String { + if let name = ClientDisplayText.layoutRelationName(relation.rawValue) { + return name + } + assertionFailure("unknown layout relation \(relation.rawValue)") + return "?" + } +} diff --git a/LookInside/Base/Category/LookinCustomDisplayItemInfo+LookinClient.h b/LookInside/Base/Category/LookinCustomDisplayItemInfo+LookinClient.h deleted file mode 100644 index b07a684e..00000000 --- a/LookInside/Base/Category/LookinCustomDisplayItemInfo+LookinClient.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LookinCustomDisplayItemInfo+LookinClient.h -// LookinClient -// -// Created by likai.123 on 2023/11/1. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LookinCustomDisplayItemInfo.h" - -NS_ASSUME_NONNULL_BEGIN - -// hasValidFrame moved into LookinCore's LookinCustomDisplayItemInfo so it can -// be unit-tested and shared with the server side. The category stays (empty) -// only because removing the files would require project-file surgery; a -// category re-declaring the method would override the class implementation. -@interface LookinCustomDisplayItemInfo (LookinClient) - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Base/Category/LookinCustomDisplayItemInfo+LookinClient.m b/LookInside/Base/Category/LookinCustomDisplayItemInfo+LookinClient.m deleted file mode 100644 index 717b32b4..00000000 --- a/LookInside/Base/Category/LookinCustomDisplayItemInfo+LookinClient.m +++ /dev/null @@ -1,13 +0,0 @@ -// -// LookinCustomDisplayItemInfo+LookinClient.m -// LookinClient -// -// Created by likai.123 on 2023/11/1. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LookinCustomDisplayItemInfo+LookinClient.h" - -@implementation LookinCustomDisplayItemInfo (LookinClient) - -@end diff --git a/LookInside/Base/Category/LookinDisplayItem+LookinClient.h b/LookInside/Base/Category/LookinDisplayItem+LookinClient.h deleted file mode 100644 index 211745e4..00000000 --- a/LookInside/Base/Category/LookinDisplayItem+LookinClient.h +++ /dev/null @@ -1,81 +0,0 @@ -// -// LookinDisplayItem+LookinClient.h -// LookinClient -// -// Created by likaimacbookhome on 2023/11/1. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LookinDisplayItem.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LookinDisplayItem (LookinClient) - -/// 该 item 在左侧 hierarchy 中显示的字符串,通常是类名 -- (NSString *)title; - -- (NSString *)subtitle; - -/// 返回 soloScreenshot 或 groupScreenshot -- (NSImage *)appropriateScreenshot; - -- (unsigned long)bestObjectOidPreferView:(BOOL)preferView; - -- (NSArray *)availableObjectOidsPreferView:(BOOL)preferView; - -/// Whether a separate BackingLayer child node claimed this view node's layer -/// oid (backing-layer-toggle proposal, toggle ON). Requests for THIS node -/// must then route by the view oid — the layer oid now resolves to the child -/// node on both ends. Keyed off the tree itself rather than the preference, -/// so it stays NO against servers that ignored the toggle. -- (BOOL)lk_ownsSeparateBackingLayerNode; - -/// 当前 item 是否在预览里用 groupScreenshot 代替了 soloScreenshot。 -- (BOOL)usesGroupScreenshotFallbackInPreview; - -/// 任意上级 item 是否在预览里用 groupScreenshot 代替了 soloScreenshot。 -- (BOOL)hasAncestorUsingGroupScreenshotFallbackInPreview; - -/// className 以 “UI”、”CA” 等开头时认为是系统类,该属性将返回 YES -@property(nonatomic, assign, readonly) BOOL representedForSystemClass; - -- (BOOL)isUserCustom; - -/// 是否有能力显示图层框 -- (BOOL)hasPreviewBoxAbility; - -// hasValidFrameToRoot and calculateFrameToRoot moved into LookinCore's -// LookinDisplayItem so they can be unit-tested and shared with the server -// side; calculateFrameToRoot now degrades to CGRectZero on unusable frames -// instead of composing NaN values. - -/// 在 string 这个搜索词下,如果该 displayItem 应该被搜索到,则该方法返回 YES。 -/// string 字段不能为 nil 或空字符串 -- (BOOL)isMatchedWithSearchString:(NSString *)string; - -/// 遍历自身和所有上级元素 -- (void)enumerateSelfAndAncestors:(void (^)(LookinDisplayItem *item, BOOL *stop))block; - -- (void)enumerateAncestors:(void (^)(LookinDisplayItem *item, BOOL *stop))block; - -/// 遍历自身后所有下级元素 -- (void)enumerateSelfAndChildren:(void (^)(LookinDisplayItem *item))block; - -- (BOOL)itemIsKindOfClassWithName:(NSString *)className; -- (BOOL)itemIsKindOfClassesWithNames:(NSSet *)classNames; - -- (BOOL)lk_isSwiftUISupportRelated; - -- (NSArray *)lk_swiftUIBackingLayerMemoryAddresses; -- (NSArray *)lk_swiftUIBackingDisplayListIDs; -- (nullable NSNumber *)lk_swiftUILayerDisplayListID; -- (NSArray *)lk_swiftUILayerSourceTypeNames; -- (NSArray *)lk_swiftUITypeNames; - -+ (NSArray *)lk_validSwiftUIDisplayListIDsInString:(NSString *)string; -+ (nullable NSString *)lk_memoryAddressInObjectDescription:(NSString *)description; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Base/Category/LookinDisplayItem+LookinClient.m b/LookInside/Base/Category/LookinDisplayItem+LookinClient.m deleted file mode 100644 index 800fb83f..00000000 --- a/LookInside/Base/Category/LookinDisplayItem+LookinClient.m +++ /dev/null @@ -1,623 +0,0 @@ -// -// LookinDisplayItem+LookinClient.m -// LookinClient -// -// Created by likaimacbookhome on 2023/11/1. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LookinDisplayItem+LookinClient.h" -#import "LookinIvarTrace.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LookinAttribute.h" -#import "LookInside-Swift.h" -#include - -enum { - LKVisiblePixelSampleWidth = 16, - LKVisiblePixelSampleHeight = 16, - LKVisiblePixelBytesPerPixel = 4, -}; - -static CGFloat LKApproximateVisiblePixelRatio(NSImage *image) { - if (!image || image.size.width <= 0 || image.size.height <= 0) { - return 0; - } - - CGRect proposedRect = CGRectMake(0, 0, image.size.width, image.size.height); - CGImageRef cgImage = [image CGImageForProposedRect:&proposedRect context:nil hints:nil]; - if (!cgImage) { - return 0; - } - - const size_t bytesPerRow = LKVisiblePixelSampleWidth * LKVisiblePixelBytesPerPixel; - uint8_t pixels[LKVisiblePixelSampleWidth * LKVisiblePixelSampleHeight * LKVisiblePixelBytesPerPixel]; - memset(pixels, 0, sizeof(pixels)); - - CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB(); - if (!colorSpace) { - return 0; - } - - CGContextRef context = CGBitmapContextCreate(pixels, - LKVisiblePixelSampleWidth, - LKVisiblePixelSampleHeight, - 8, - bytesPerRow, - colorSpace, - (CGBitmapInfo)kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big); - CGColorSpaceRelease(colorSpace); - if (!context) { - return 0; - } - - CGContextDrawImage(context, CGRectMake(0, 0, LKVisiblePixelSampleWidth, LKVisiblePixelSampleHeight), cgImage); - CGContextRelease(context); - - NSUInteger visiblePixelCount = 0; - const NSUInteger totalPixelCount = LKVisiblePixelSampleWidth * LKVisiblePixelSampleHeight; - for (NSUInteger idx = 0; idx < totalPixelCount; idx++) { - const uint8_t alpha = pixels[idx * LKVisiblePixelBytesPerPixel + 3]; - if (alpha > 12) { - visiblePixelCount++; - } - } - - return (CGFloat)visiblePixelCount / (CGFloat)totalPixelCount; -} - -static BOOL LKShouldFallbackToGroupScreenshot(NSImage *soloScreenshot, NSImage *groupScreenshot) { - if (!groupScreenshot) { - return NO; - } - if (!soloScreenshot) { - return YES; - } - - CGFloat soloVisibleRatio = LKApproximateVisiblePixelRatio(soloScreenshot); - if (soloVisibleRatio >= 0.02) { - return NO; - } - - CGFloat groupVisibleRatio = LKApproximateVisiblePixelRatio(groupScreenshot); - return groupVisibleRatio > MAX(0.05, soloVisibleRatio * 4.0); -} - -static BOOL LKClassNameLooksLikeSwiftUISupport(NSString *className) { - if (className.length == 0) { - return NO; - } - - static NSArray *markers; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - markers = @[ - @"SwiftUI", - @"HostingView", - @"UIHosting", - @"NSHosting", - @"ViewRendererHost", - @"PlatformViewHost", - @"_UIHosting", - @"_NSHosting", - ]; - }); - - for (NSString *marker in markers) { - if ([className containsString:marker]) { - return YES; - } - } - return NO; -} - -static BOOL LKObjectLooksLikeSwiftUISupport(LookinObject *object) { - if (!object) { - return NO; - } - - if (LKClassNameLooksLikeSwiftUISupport(object.rawClassName)) { - return YES; - } - - for (NSString *className in object.classChainList) { - if (LKClassNameLooksLikeSwiftUISupport(className)) { - return YES; - } - } - return NO; -} - -static NSArray *LKAllAttributeGroupsForItem(LookinDisplayItem *item) { - NSMutableArray *groups = [NSMutableArray array]; - if (item.attributesGroupList.count) { - [groups addObjectsFromArray:item.attributesGroupList]; - } - if (item.customAttrGroupList.count) { - [groups addObjectsFromArray:item.customAttrGroupList]; - } - return groups; -} - -static void LKEnumerateAttributesInGroup(LookinAttributesGroup *group, void (^block)(LookinAttribute *attribute)) { - if (!block) { - return; - } - [group.attrSections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop) { - [section.attributes enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attribute, NSUInteger idx, BOOL * _Nonnull stop) { - block(attribute); - }]; - }]; -} - -static void LKAddUniqueObject(NSMutableArray *array, id object) { - if (object && ![array containsObject:object]) { - [array addObject:object]; - } -} - -@implementation LookinDisplayItem (LookinClient) - -- (BOOL)_lk_usesAbsoluteRootCoordinates { - LookinDisplayItem *rootItem = self; - while (rootItem.superItem) { - rootItem = rootItem.superItem; - } - - LookinObject *rootObject = rootItem.displayingObject; - for (NSString *className in rootObject.classChainList ?: @[]) { - if ([className hasPrefix:@"NSWindow"]) { - return YES; - } - } - return NO; -} - -- (LookinDisplayItem *)_lk_rootItem { - LookinDisplayItem *rootItem = self; - while (rootItem.superItem) { - rootItem = rootItem.superItem; - } - return rootItem; -} - -- (unsigned long)bestObjectOidPreferView:(BOOL)preferView { - switch (self.resolvedNodeKind) { - case LookinDisplayItemNodeKindWindow: - case LookinDisplayItemNodeKindWindowScene: - return self.windowObject.oid; - case LookinDisplayItemNodeKindLayoutGuide: - case LookinDisplayItemNodeKindCell: - return self.kindObject.oid; - case LookinDisplayItemNodeKindCustom: - // Custom nodes carry no oid — a known limitation, out of scope - // for the nodeKind mechanism itself. - return 0; - case LookinDisplayItemNodeKindView: - case LookinDisplayItemNodeKindLayer: - // A view's outer layer rides layerObject like any other layer node. - case LookinDisplayItemNodeKindViewOuterLayer: - // So does a backing layer node (backing-layer-toggle proposal). - case LookinDisplayItemNodeKindBackingLayer: - case LookinDisplayItemNodeKindUnspecified: - // With the backing-layer toggle ON the layer oid belongs to the - // separate BackingLayer child node — requests keyed by it would - // land on that node on both ends (the host's oid map and the - // server's detail handler agree on this). The view node itself - // must route by its view oid. - if (self.viewObject.oid && [self lk_ownsSeparateBackingLayerNode]) { - return self.viewObject.oid; - } - // 始终优先使用 layerObject.oid,以匹配上游行为。 - // 上游服务端 detail handler 通过 CALayer OID 解析并截图, - // 这是经过验证的可靠路径。 - if (self.layerObject.oid) { - return self.layerObject.oid; - } - if (self.viewObject.oid) { - return self.viewObject.oid; - } - return self.windowObject.oid; - } - return 0; -} - -- (BOOL)lk_ownsSeparateBackingLayerNode { - if (!self.layerObject.oid || !self.viewObject.oid) { - return NO; - } - for (LookinDisplayItem *subitem in self.subitems) { - if (subitem.resolvedNodeKind == LookinDisplayItemNodeKindBackingLayer) { - return subitem.layerObject.oid == self.layerObject.oid; - } - // On iOS the BackingLayer node nests inside the wrapper node - // (view → outer layer → backing layer, Xcode's arrangement). - if (subitem.resolvedNodeKind == LookinDisplayItemNodeKindViewOuterLayer) { - for (LookinDisplayItem *wrapperChild in subitem.subitems) { - if (wrapperChild.resolvedNodeKind == LookinDisplayItemNodeKindBackingLayer) { - return wrapperChild.layerObject.oid == self.layerObject.oid; - } - } - } - } - return NO; -} - -- (NSArray *)availableObjectOidsPreferView:(BOOL)preferView { - NSMutableOrderedSet *oids = [NSMutableOrderedSet orderedSet]; - unsigned long preferredOid = [self bestObjectOidPreferView:preferView]; - if (preferredOid) { - [oids addObject:@(preferredOid)]; - } - if (self.viewObject.oid) { - [oids addObject:@(self.viewObject.oid)]; - } - if (self.layerObject.oid) { - [oids addObject:@(self.layerObject.oid)]; - } - if (self.kindObject.oid) { - [oids addObject:@(self.kindObject.oid)]; - } - return oids.array; -} - -- (NSString *)title { - NSString *baseTitle = nil; - if (self.customInfo) { - baseTitle = self.customInfo.title; - } else if (self.customDisplayTitle.length > 0) { - baseTitle = self.customDisplayTitle; - } else { - baseTitle = self.displayingObject.lk_simpleDemangledClassName; - } - return [[LKPrivateDiscriminatorStore shared] displayTitleForDisplayItem:self fallback:baseTitle]; -} - -- (NSString *)subtitle { - if (self.customInfo) { - return self.customInfo.subtitle; - } - - NSString *windowControllerName = self.hostWindowControllerObject.lk_simpleDemangledClassName; - if (windowControllerName.length) { - return [NSString stringWithFormat:@"%@.window", windowControllerName]; - } - NSString *text = self.hostViewControllerObject.lk_simpleDemangledClassName; - if (text.length) { - return [NSString stringWithFormat:@"%@.view", text]; - } - - LookinObject *representedObject = self.displayingObject; - if (representedObject.specialTrace.length) { - return representedObject.specialTrace; - - } - if (representedObject.ivarTraces.count) { - NSArray *ivarNameList = [representedObject.ivarTraces lookin_map:^id(NSUInteger idx, LookinIvarTrace *value) { - return value.ivarName; - }]; - return [[[NSSet setWithArray:ivarNameList] allObjects] componentsJoinedByString:@" "]; - } - - return nil; -} - -- (BOOL)representedForSystemClass { - return [self.title hasPrefix:@"UI"] || [self.title hasPrefix:@"CA"] || [self.title hasPrefix:@"_"] || [self.title hasPrefix:@"NS"]; -} - -- (NSImage *)appropriateScreenshot { - // Only children that actually draw something justify switching to the solo - // screenshot. A node whose children are all pixelless (layout guides, - // cells, a view's outer layer) has nothing to exclude, and a leaf view has - // no solo screenshot at all — asking for one there blanks it out. - if (self.isExpandable && self.isExpanded && self.hasPixelBearingSubitems) { - return self.soloScreenshot; - } - return self.groupScreenshot; -} - -- (BOOL)usesGroupScreenshotFallbackInPreview { - if (!(self.isExpandable && self.isExpanded)) { - return NO; - } - // Window 根节点的 soloScreenshot 始终是近乎透明的(只有窗口边框)。 - // 这是预期行为——子视图负责渲染实际内容。不要对这些节点回退到 - // groupScreenshot,否则祖先抑制逻辑会导致所有子视图变为空白。 - LookinDisplayItemNodeKind nodeKind = self.resolvedNodeKind; - if (nodeKind == LookinDisplayItemNodeKindWindow || nodeKind == LookinDisplayItemNodeKindWindowScene) { - return NO; - } - return LKShouldFallbackToGroupScreenshot(self.soloScreenshot, self.groupScreenshot); -} - -- (BOOL)hasAncestorUsingGroupScreenshotFallbackInPreview { - __block BOOL found = NO; - [self enumerateAncestors:^(LookinDisplayItem *item, BOOL *stop) { - if ([item usesGroupScreenshotFallbackInPreview]) { - found = YES; - *stop = YES; - } - }]; - return found; -} - -- (BOOL)isUserCustom { - return self.customInfo != nil; -} - -- (BOOL)hasPreviewBoxAbility { - if (!self.customInfo) { - return YES; - } - if ([self.customInfo hasValidFrame]) { - return YES; - } - return NO; -} - -- (BOOL)isMatchedWithSearchString:(NSString *)string { - if (string.length == 0) { - NSAssert(NO, @""); - return NO; - } - NSString *searchString = string.lowercaseString; - if ([self.title.lowercaseString containsString:searchString]) { - return YES; - } - if ([self.subtitle.lowercaseString containsString:searchString]) { - return YES; - } - if ([self.viewObject.memoryAddress containsString:searchString]) { - return YES; - } - if ([self.layerObject.memoryAddress containsString:searchString]) { - return YES; - } - if ([self.windowObject.memoryAddress containsString:searchString]) { - return YES; - } - if ([self.kindObject.memoryAddress containsString:searchString]) { - return YES; - } - return NO; -} - -- (void)enumerateSelfAndAncestors:(void (^)(LookinDisplayItem *, BOOL *))block { - if (!block) { - return; - } - LookinDisplayItem *item = self; - while (item) { - BOOL shouldStop = NO; - block(item, &shouldStop); - if (shouldStop) { - break; - } - item = item.superItem; - } -} - -- (void)enumerateAncestors:(void (^)(LookinDisplayItem *, BOOL *))block { - [self.superItem enumerateSelfAndAncestors:block]; -} - -- (void)enumerateSelfAndChildren:(void (^)(LookinDisplayItem *item))block { - if (!block) { - return; - } - - block(self); - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull subitem, NSUInteger idx, BOOL * _Nonnull stop) { - [subitem enumerateSelfAndChildren:block]; - }]; -} - -- (BOOL)itemIsKindOfClassWithName:(NSString *)className { - if (!className) { - NSAssert(NO, @""); - return NO; - } - return [self itemIsKindOfClassesWithNames:[NSSet setWithObject:className]]; -} - -- (BOOL)itemIsKindOfClassesWithNames:(NSSet *)targetClassNames { - if (!targetClassNames.count) { - return NO; - } - LookinObject *selfObj = self.displayingObject; - if (!selfObj) { - return NO; - } - - __block BOOL boolValue = NO; - [targetClassNames enumerateObjectsUsingBlock:^(NSString * _Nonnull targetClassName, BOOL * _Nonnull stop_outer) { - [selfObj.classChainList enumerateObjectsUsingBlock:^(NSString * _Nonnull selfClass, NSUInteger idx, BOOL * _Nonnull stop_inner) { - NSString *nonPrefixSelfClass = [selfClass componentsSeparatedByString:@"."].lastObject; - if ([nonPrefixSelfClass isEqualToString:targetClassName]) { - boolValue = YES; - *stop_inner = YES; - } - }]; - if (boolValue) { - *stop_outer = YES; - } - }]; - - return boolValue; -} - -- (BOOL)lk_isSwiftUISupportRelated { - if (LKObjectLooksLikeSwiftUISupport(self.viewObject) || - LKObjectLooksLikeSwiftUISupport(self.layerObject) || - LKObjectLooksLikeSwiftUISupport(self.windowObject) || - LKObjectLooksLikeSwiftUISupport(self.kindObject)) { - return YES; - } - - if (LKClassNameLooksLikeSwiftUISupport(self.title) || - LKClassNameLooksLikeSwiftUISupport(self.subtitle)) { - return YES; - } - return NO; -} - -- (NSArray *)lk_swiftUIBackingLayerMemoryAddresses { - NSMutableArray *addresses = [NSMutableArray array]; - for (LookinAttributesGroup *group in LKAllAttributeGroupsForItem(self)) { - if (![group.userCustomTitle isEqualToString:@"SwiftUI Layers"]) { - continue; - } - LKEnumerateAttributesInGroup(group, ^(LookinAttribute *attribute) { - if (![attribute.displayTitle hasSuffix:@"Backed By"] || ![attribute.value isKindOfClass:NSString.class]) { - return; - } - NSString *address = [LookinDisplayItem lk_memoryAddressInObjectDescription:attribute.value]; - LKAddUniqueObject(addresses, address); - }); - } - return addresses; -} - -- (NSArray *)lk_swiftUIBackingDisplayListIDs { - NSMutableArray *displayListIDs = [NSMutableArray array]; - for (LookinAttributesGroup *group in LKAllAttributeGroupsForItem(self)) { - BOOL isLayersGroup = [group.userCustomTitle isEqualToString:@"SwiftUI Layers"]; - BOOL isDisplayListGroup = [group.userCustomTitle isEqualToString:@"SwiftUI Display List"]; - if (!isLayersGroup && !isDisplayListGroup) { - continue; - } - LKEnumerateAttributesInGroup(group, ^(LookinAttribute *attribute) { - if (![attribute.value isKindOfClass:NSString.class]) { - return; - } - BOOL isLayerDisplayListID = isLayersGroup && [attribute.displayTitle hasSuffix:@"Display List ID"]; - BOOL isIdentityIDs = isDisplayListGroup && [attribute.displayTitle isEqualToString:@"Identity IDs"]; - if (!isLayerDisplayListID && !isIdentityIDs) { - return; - } - for (NSNumber *displayListID in [LookinDisplayItem lk_validSwiftUIDisplayListIDsInString:attribute.value]) { - LKAddUniqueObject(displayListIDs, displayListID); - } - }); - } - return displayListIDs; -} - -- (NSNumber *)lk_swiftUILayerDisplayListID { - if (!self.layerObject) { - return nil; - } - for (LookinAttributesGroup *group in LKAllAttributeGroupsForItem(self)) { - if (![group.userCustomTitle isEqualToString:@"SwiftUI"]) { - continue; - } - __block NSNumber *result = nil; - LKEnumerateAttributesInGroup(group, ^(LookinAttribute *attribute) { - if (result || ![attribute.displayTitle isEqualToString:@"Display List ID"] || ![attribute.value isKindOfClass:NSString.class]) { - return; - } - result = [LookinDisplayItem lk_validSwiftUIDisplayListIDsInString:attribute.value].firstObject; - }); - if (result) { - return result; - } - } - return nil; -} - -- (NSArray *)lk_swiftUILayerSourceTypeNames { - NSMutableArray *sourceNames = [NSMutableArray array]; - for (LookinAttributesGroup *group in LKAllAttributeGroupsForItem(self)) { - if (![group.userCustomTitle isEqualToString:@"SwiftUI"]) { - continue; - } - LKEnumerateAttributesInGroup(group, ^(LookinAttribute *attribute) { - if (([attribute.displayTitle isEqualToString:@"Source"] || [attribute.displayTitle isEqualToString:@"Full Source"]) && - [attribute.value isKindOfClass:NSString.class]) { - LKAddUniqueObject(sourceNames, attribute.value); - } - }); - } - return sourceNames; -} - -- (NSArray *)lk_swiftUITypeNames { - NSMutableArray *typeNames = [NSMutableArray array]; - LKAddUniqueObject(typeNames, self.customInfo.title); - for (LookinAttributesGroup *group in LKAllAttributeGroupsForItem(self)) { - if (![group.userCustomTitle isEqualToString:@"SwiftUI Type"]) { - continue; - } - LKEnumerateAttributesInGroup(group, ^(LookinAttribute *attribute) { - if (([attribute.displayTitle isEqualToString:@"Type"] || [attribute.displayTitle isEqualToString:@"Full Type"]) && - [attribute.value isKindOfClass:NSString.class]) { - LKAddUniqueObject(typeNames, attribute.value); - } - }); - } - return typeNames; -} - -+ (NSArray *)lk_validSwiftUIDisplayListIDsInString:(NSString *)string { - if (![string isKindOfClass:NSString.class] || string.length == 0) { - return @[]; - } - NSMutableArray *result = [NSMutableArray array]; - NSCharacterSet *separatorSet = [[NSCharacterSet alphanumericCharacterSet] invertedSet]; - NSArray *tokens = [string componentsSeparatedByCharactersInSet:separatorSet]; - for (NSString *rawToken in tokens) { - if (rawToken.length == 0) { - continue; - } - - NSString *token = rawToken.lowercaseString; - unsigned long long value = 0; - NSScanner *scanner = nil; - if ([token hasPrefix:@"0x"]) { - scanner = [NSScanner scannerWithString:[token substringFromIndex:2]]; - if (![scanner scanHexLongLong:&value] || !scanner.atEnd) { - continue; - } - } else { - scanner = [NSScanner scannerWithString:token]; - if (![scanner scanUnsignedLongLong:&value] || !scanner.atEnd) { - continue; - } - } - - if (value == 0 || value == UINT32_MAX || value > UINT32_MAX) { - continue; - } - LKAddUniqueObject(result, @(value)); - } - return result; -} - -+ (NSString *)lk_memoryAddressInObjectDescription:(NSString *)description { - if (![description isKindOfClass:NSString.class] || description.length == 0) { - return nil; - } - NSRange prefixRange = [description rangeOfString:@"0x" options:NSCaseInsensitiveSearch]; - if (prefixRange.location == NSNotFound) { - return nil; - } - - NSUInteger start = prefixRange.location; - NSUInteger idx = NSMaxRange(prefixRange); - NSCharacterSet *hexSet = [NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdefABCDEF"]; - while (idx < description.length) { - unichar ch = [description characterAtIndex:idx]; - if (![hexSet characterIsMember:ch]) { - break; - } - idx++; - } - if (idx <= NSMaxRange(prefixRange)) { - return nil; - } - return [[description substringWithRange:NSMakeRange(start, idx - start)] lowercaseString]; -} - -@end diff --git a/LookInside/Base/Category/LookinDisplayItem+LookinClient.swift b/LookInside/Base/Category/LookinDisplayItem+LookinClient.swift new file mode 100644 index 00000000..c8d03f31 --- /dev/null +++ b/LookInside/Base/Category/LookinDisplayItem+LookinClient.swift @@ -0,0 +1,402 @@ +// +// LookinDisplayItem+LookinClient.swift +// LookInside +// +// Host-side display helpers on the LookinCore display item: the row title +// and subtitle, which object id a request should use, preview screenshot +// choice, ancestor and subtree walks, class-chain predicates and the +// SwiftUI identifiers read out of attribute groups. Selectors keep their +// Objective-C names for the callers that still use them. +// + +import AppKit +import LookInsideHostCore + +extension LookinDisplayItem { + /// The row text in the hierarchy, usually the class name without module. + @objc func title() -> String { + let baseTitle: String? + if let customInfo { + baseTitle = customInfo.title + } else if let customDisplayTitle, !customDisplayTitle.isEmpty { + baseTitle = customDisplayTitle + } else { + baseTitle = displayingObject()?.lk_simpleDemangledClassName() + } + return LKPrivateDiscriminatorStore.shared.displayTitle(for: self, fallback: baseTitle) ?? "" + } + + /// The owning window or view controller, a special trace, or the ivar + /// names that point at the object; nil when there is none. + @objc func subtitle() -> String? { + if let customInfo { + return customInfo.subtitle + } + if let windowControllerName = hostWindowControllerObject?.lk_simpleDemangledClassName(), !windowControllerName.isEmpty { + return "\(windowControllerName).window" + } + if let viewControllerName = hostViewControllerObject?.lk_simpleDemangledClassName(), !viewControllerName.isEmpty { + return "\(viewControllerName).view" + } + let representedObject = displayingObject() + if let specialTrace = representedObject?.specialTrace, !specialTrace.isEmpty { + return specialTrace + } + if let ivarTraces = representedObject?.ivarTraces, !ivarTraces.isEmpty { + let names: [String] = ivarTraces.compactMap(\.ivarName) + return (NSSet(array: names).allObjects as NSArray).componentsJoined(by: " ") + } + return nil + } + + /// The solo screenshot once children that draw something are shown on + /// their own; the group screenshot otherwise. + @objc func appropriateScreenshot() -> NSImage? { + // A node whose children are all pixelless (layout guides, cells, a + // view's outer layer) has nothing to exclude, and a leaf view has no + // solo screenshot at all — asking for one there blanks it out. + if isExpandable, isExpanded, hasPixelBearingSubitems() { + return soloScreenshot + } + return groupScreenshot + } + + @objc(bestObjectOidPreferView:) + func bestObjectOidPreferView(_: Bool) -> UInt { + switch resolvedNodeKind() { + case .window, .windowScene: + return windowObject?.oid ?? 0 + case .layoutGuide, .cell: + return kindObject?.oid ?? 0 + case .custom: + // Custom nodes carry no oid — a known limitation, out of scope + // for the nodeKind mechanism itself. + return 0 + case .view, .layer, .viewOuterLayer, .backingLayer, .unspecified: + // A view's outer layer and a backing layer node ride layerObject + // like any other layer node. With the backing-layer toggle ON the + // layer oid belongs to the separate BackingLayer child node — + // requests keyed by it would land on that node on both ends. The + // view node itself must route by its view oid. + if let viewOid = viewObject?.oid, viewOid != 0, lk_ownsSeparateBackingLayerNode() { + return viewOid + } + // Prefer the layer oid, as upstream does: the server's detail + // handler resolves and captures through the CALayer oid. + if let layerOid = layerObject?.oid, layerOid != 0 { + return layerOid + } + if let viewOid = viewObject?.oid, viewOid != 0 { + return viewOid + } + return windowObject?.oid ?? 0 + @unknown default: + return 0 + } + } + + /// Whether a separate BackingLayer child node claimed this view node's + /// layer oid (backing-layer toggle ON); requests for this node must then + /// route by the view oid. Read off the tree rather than the preference, + /// so it stays false against servers that ignored the toggle. + @objc func lk_ownsSeparateBackingLayerNode() -> Bool { + guard let layerOid = layerObject?.oid, layerOid != 0, + let viewOid = viewObject?.oid, viewOid != 0 + else { return false } + for subitem in subitems ?? [] { + if subitem.resolvedNodeKind() == .backingLayer { + return subitem.layerObject?.oid == layerOid + } + // On iOS the BackingLayer node nests inside the wrapper node + // (view → outer layer → backing layer, Xcode's arrangement). + if subitem.resolvedNodeKind() == .viewOuterLayer { + for wrapperChild in subitem.subitems ?? [] where wrapperChild.resolvedNodeKind() == .backingLayer { + return wrapperChild.layerObject?.oid == layerOid + } + } + } + return false + } + + /// The preferred oid first, then every other oid this node carries. + @objc(availableObjectOidsPreferView:) + func availableObjectOidsPreferView(_ preferView: Bool) -> [NSNumber] { + var oids: [UInt] = [] + func add(_ oid: UInt?) { + if let oid, oid != 0, !oids.contains(oid) { + oids.append(oid) + } + } + add(bestObjectOidPreferView(preferView)) + add(viewObject?.oid) + add(layerObject?.oid) + add(kindObject?.oid) + return oids.map { NSNumber(value: $0) } + } + + /// Whether the title starts like a system class ("UI", "CA", "NS", "_"). + @objc var representedForSystemClass: Bool { + let title = title() + return title.hasPrefix("UI") || title.hasPrefix("CA") || title.hasPrefix("_") || title.hasPrefix("NS") + } + + @objc func isUserCustom() -> Bool { + customInfo != nil + } + + /// Whether the preview can draw a box for this node. + @objc func hasPreviewBoxAbility() -> Bool { + guard let customInfo else { return true } + return customInfo.hasValidFrame() + } + + /// Whether an expanded node shows its group screenshot in the preview + /// because its solo screenshot is (nearly) empty. + @objc func usesGroupScreenshotFallbackInPreview() -> Bool { + guard isExpandable, isExpanded else { return false } + // A window root's solo screenshot is always nearly transparent (just + // the frame) — its children draw the content. Falling back here + // would make the ancestor suppression blank every child. + let nodeKind = resolvedNodeKind() + if nodeKind == .window || nodeKind == .windowScene { + return false + } + let solo = soloScreenshot + let group = groupScreenshot + return ClientDisplayText.prefersGroupScreenshot( + soloVisibleRatio: { solo.map(Self.approximateVisiblePixelRatio) }, + groupVisibleRatio: { group.map(Self.approximateVisiblePixelRatio) }, + hasGroupScreenshot: group != nil + ) + } + + @objc func hasAncestorUsingGroupScreenshotFallbackInPreview() -> Bool { + var found = false + enumerateAncestors { item, stop in + if item.usesGroupScreenshotFallbackInPreview() { + found = true + stop.pointee = true + } + } + return found + } + + /// Whether the node matches a non-empty hierarchy filter: its title, + /// subtitle or one of its objects' addresses contains the text. + @objc(isMatchedWithSearchString:) + func isMatched(withSearch string: String) -> Bool { + if string.isEmpty { + assertionFailure("an empty search string matches nothing") + return false + } + // NSString comparisons, as the inspector always matched; addresses + // are compared as they are, without lowercasing. + let searchString = (string as NSString).lowercased + if (title() as NSString).lowercased.contains(searchString) { + return true + } + if let subtitle = subtitle(), (subtitle as NSString).lowercased.contains(searchString) { + return true + } + for object in [viewObject, layerObject, windowObject, kindObject] { + if let address = object?.memoryAddress, (address as NSString).contains(searchString) { + return true + } + } + return false + } + + /// Visits this item, then each ancestor up to the root, until stopped. + @objc(enumerateSelfAndAncestors:) + func enumerateSelfAndAncestors(_ block: (LookinDisplayItem, UnsafeMutablePointer) -> Void) { + var item: LookinDisplayItem? = self + while let current = item { + var shouldStop: ObjCBool = false + block(current, &shouldStop) + if shouldStop.boolValue { + break + } + item = current.`super` + } + } + + @objc(enumerateAncestors:) + func enumerateAncestors(_ block: (LookinDisplayItem, UnsafeMutablePointer) -> Void) { + self.`super`?.enumerateSelfAndAncestors(block) + } + + /// Visits this item and then its whole subtree, depth first. + @objc(enumerateSelfAndChildren:) + func enumerateSelfAndChildren(_ block: (LookinDisplayItem) -> Void) { + block(self) + for subitem in subitems ?? [] { + subitem.enumerateSelfAndChildren(block) + } + } + + @objc(itemIsKindOfClassWithName:) + func itemIsKindOfClass(withName className: String) -> Bool { + itemIsKindOfClasses(withNames: [className]) + } + + /// Whether any class in the displayed object's class chain, without its + /// module prefix, is one of `classNames`. + @objc(itemIsKindOfClassesWithNames:) + func itemIsKindOfClasses(withNames classNames: Set) -> Bool { + guard !classNames.isEmpty, let object = displayingObject() else { return false } + let chain = object.classChainList ?? [] + return classNames.contains { target in + chain.contains { ClientDisplayText.classChainEntry($0, matches: target) } + } + } + + // MARK: - SwiftUI + + @objc func lk_isSwiftUISupportRelated() -> Bool { + func looksLikeSwiftUISupport(_ object: LookinObject?) -> Bool { + guard let object else { return false } + if ClientDisplayText.looksLikeSwiftUISupport(object.rawClassName()) { + return true + } + return (object.classChainList ?? []).contains { ClientDisplayText.looksLikeSwiftUISupport($0) } + } + if looksLikeSwiftUISupport(viewObject) || looksLikeSwiftUISupport(layerObject) + || looksLikeSwiftUISupport(windowObject) || looksLikeSwiftUISupport(kindObject) + { + return true + } + return ClientDisplayText.looksLikeSwiftUISupport(title()) || ClientDisplayText.looksLikeSwiftUISupport(subtitle()) + } + + /// Addresses of the layers named by "… Backed By" rows in the + /// "SwiftUI Layers" group. + @objc func lk_swiftUIBackingLayerMemoryAddresses() -> [String] { + var addresses: [String] = [] + for group in allAttributeGroups where group.userCustomTitle == "SwiftUI Layers" { + for attribute in group.lk_allAttributes { + guard let title = attribute.displayTitle, title.hasSuffix("Backed By"), + let value = attribute.value as? String, + let address = Self.lk_memoryAddress(inObjectDescription: value), + !addresses.contains(address) + else { continue } + addresses.append(address) + } + } + return addresses + } + + /// Display-list IDs from "… Display List ID" rows of "SwiftUI Layers" + /// and "Identity IDs" rows of "SwiftUI Display List". + @objc func lk_swiftUIBackingDisplayListIDs() -> [NSNumber] { + var displayListIDs: [UInt64] = [] + for group in allAttributeGroups { + let isLayersGroup = group.userCustomTitle == "SwiftUI Layers" + let isDisplayListGroup = group.userCustomTitle == "SwiftUI Display List" + guard isLayersGroup || isDisplayListGroup else { continue } + for attribute in group.lk_allAttributes { + guard let value = attribute.value as? String else { continue } + let title = attribute.displayTitle ?? "" + let isLayerDisplayListID = isLayersGroup && title.hasSuffix("Display List ID") + let isIdentityIDs = isDisplayListGroup && title == "Identity IDs" + guard isLayerDisplayListID || isIdentityIDs else { continue } + for displayListID in ClientDisplayText.swiftUIDisplayListIDs(in: value) where !displayListIDs.contains(displayListID) { + displayListIDs.append(displayListID) + } + } + } + return displayListIDs.map { NSNumber(value: $0) } + } + + /// The first "Display List ID" of the "SwiftUI" group, for layer nodes. + @objc func lk_swiftUILayerDisplayListID() -> NSNumber? { + guard layerObject != nil else { return nil } + for group in allAttributeGroups where group.userCustomTitle == "SwiftUI" { + for attribute in group.lk_allAttributes { + guard attribute.displayTitle == "Display List ID", let value = attribute.value as? String else { continue } + if let first = ClientDisplayText.swiftUIDisplayListIDs(in: value).first { + return NSNumber(value: first) + } + } + } + return nil + } + + /// "Source" and "Full Source" values of the "SwiftUI" group. + @objc func lk_swiftUILayerSourceTypeNames() -> [String] { + var sourceNames: [String] = [] + for group in allAttributeGroups where group.userCustomTitle == "SwiftUI" { + for attribute in group.lk_allAttributes { + guard attribute.displayTitle == "Source" || attribute.displayTitle == "Full Source", + let value = attribute.value as? String, !sourceNames.contains(value) + else { continue } + sourceNames.append(value) + } + } + return sourceNames + } + + /// The custom node's title, then "Type" and "Full Type" values of the + /// "SwiftUI Type" group. + @objc func lk_swiftUITypeNames() -> [String] { + var typeNames: [String] = [] + if let customTitle = customInfo?.title { + typeNames.append(customTitle) + } + for group in allAttributeGroups where group.userCustomTitle == "SwiftUI Type" { + for attribute in group.lk_allAttributes { + guard attribute.displayTitle == "Type" || attribute.displayTitle == "Full Type", + let value = attribute.value as? String, !typeNames.contains(value) + else { continue } + typeNames.append(value) + } + } + return typeNames + } + + @objc(lk_validSwiftUIDisplayListIDsInString:) + static func lk_validSwiftUIDisplayListIDs(in string: String) -> [NSNumber] { + ClientDisplayText.swiftUIDisplayListIDs(in: string).map { NSNumber(value: $0) } + } + + @objc(lk_memoryAddressInObjectDescription:) + static func lk_memoryAddress(inObjectDescription description: String) -> String? { + ClientDisplayText.memoryAddress(inObjectDescription: description) + } + + // MARK: - Private + + private var allAttributeGroups: [LookinAttributesGroup] { + (attributesGroupList ?? []) + (customAttrGroupList ?? []) + } + + /// The share of visible pixels in a 16×16 downsample of `image`. + private static func approximateVisiblePixelRatio(_ image: NSImage) -> Double { + guard image.size.width > 0, image.size.height > 0 else { return 0 } + var proposedRect = CGRect(origin: .zero, size: image.size) + guard let cgImage = image.cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else { return 0 } + let side = 16 + var pixels = [UInt8](repeating: 0, count: side * side * 4) + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, + width: side, + height: side, + bitsPerComponent: 8, + bytesPerRow: side * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue + ) else { return false } + context.draw(cgImage, in: CGRect(x: 0, y: 0, width: side, height: side)) + return true + } + guard drawn else { return 0 } + return ClientDisplayText.visiblePixelRatio(rgbaPixels: pixels) + } +} + +private extension LookinAttributesGroup { + /// Every attribute of every section, in order. + var lk_allAttributes: [LookinAttribute] { + (attrSections ?? []).flatMap { $0.attributes ?? [] } + } +} diff --git a/LookInside/Base/Category/LookinObject+LookinClient.h b/LookInside/Base/Category/LookinObject+LookinClient.h deleted file mode 100644 index 70c76015..00000000 --- a/LookInside/Base/Category/LookinObject+LookinClient.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LookinObject+LookinClient.h -// LookinClient -// -// Created by likai.123 on 2024/1/14. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import "LookinObject.h" - -@interface LookinObject (LookinClient) - -/// 这里返回的类名已经被 demangle 过,但是【有 module 前缀】 -- (NSString *)lk_completedDemangledClassName; - -/// 这里返回的类名已经被 demangle 过,并且【没有 module 前缀】 -- (NSString *)lk_simpleDemangledClassName; - -@end diff --git a/LookInside/Base/Category/LookinObject+LookinClient.m b/LookInside/Base/Category/LookinObject+LookinClient.m deleted file mode 100644 index 0a6e4906..00000000 --- a/LookInside/Base/Category/LookinObject+LookinClient.m +++ /dev/null @@ -1,31 +0,0 @@ -// -// LookinObject+LookinClient.m -// LookinClient -// -// Created by likai.123 on 2024/1/14. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import "LookinObject+LookinClient.h" -#import "LookInside-Swift.h" - -@implementation LookinObject (LookinClient) - -- (NSString *)lk_completedDemangledClassName { - return [LKSwiftDemangler completedParseWithInput:self.rawClassName]; -} - -- (NSString *)lk_simpleDemangledClassName { - NSString *name = [LKSwiftDemangler simpleParseWithInput:self.rawClassName]; - // 去掉模块前缀(例如 "AppKit._NSCoreHostingView" 中的 "AppKit."), - // 但只考虑泛型 '<' 括号之前的点号,避免误拆泛型类型参数内部的点号。 - NSUInteger angleBracketIndex = [name rangeOfString:@"<"].location; - NSString *prefixPortion = (angleBracketIndex != NSNotFound) ? [name substringToIndex:angleBracketIndex] : name; - NSRange lastDotRange = [prefixPortion rangeOfString:@"." options:NSBackwardsSearch]; - if (lastDotRange.location != NSNotFound) { - return [name substringFromIndex:lastDotRange.location + 1]; - } - return name; -} - -@end diff --git a/LookInside/Base/Category/LookinObject+LookinClient.swift b/LookInside/Base/Category/LookinObject+LookinClient.swift new file mode 100644 index 00000000..0b7af5b4 --- /dev/null +++ b/LookInside/Base/Category/LookinObject+LookinClient.swift @@ -0,0 +1,20 @@ +// +// LookinObject+LookinClient.swift +// LookInside +// + +import Foundation +import LookInsideHostCore + +extension LookinObject { + /// The demangled class name, module prefix included. + @objc func lk_completedDemangledClassName() -> String { + LKSwiftDemangler.completedParse(input: rawClassName() ?? "") + } + + /// The demangled class name without its module prefix; generic + /// arguments keep theirs. + @objc func lk_simpleDemangledClassName() -> String { + ClientDisplayText.removingModulePrefix(LKSwiftDemangler.simpleParse(input: rawClassName() ?? "")) + } +} diff --git a/LookInside/Base/Category/NSAppearance+LookinClient.h b/LookInside/Base/Category/NSAppearance+LookinClient.h deleted file mode 100644 index 29ef0ea7..00000000 --- a/LookInside/Base/Category/NSAppearance+LookinClient.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// NSAppearance+macOS.h -// Lookin -// -// Created by Li Kai on 2019/3/4. -// https://lookin.work -// - -#import - -@interface NSAppearance (macOS) - -@property(nonatomic, assign, readonly) BOOL lk_isDarkMode; - -@end diff --git a/LookInside/Base/Category/NSAppearance+LookinClient.m b/LookInside/Base/Category/NSAppearance+LookinClient.m deleted file mode 100644 index e8431f62..00000000 --- a/LookInside/Base/Category/NSAppearance+LookinClient.m +++ /dev/null @@ -1,27 +0,0 @@ -// -// NSAppearance+macOS.m -// Lookin -// -// Created by Li Kai on 2019/3/4. -// https://lookin.work -// - -#import "NSAppearance+LookinClient.h" - -@implementation NSAppearance (macOS) - -- (BOOL)lk_isDarkMode { - if (__builtin_available(macOS 10.14, *)) { - if ([self.name isEqualToString:NSAppearanceNameDarkAqua] - || [self.name isEqualToString:NSAppearanceNameVibrantDark] - || [self.name isEqualToString:NSAppearanceNameAccessibilityHighContrastDarkAqua] - || [self.name isEqualToString:NSAppearanceNameAccessibilityHighContrastVibrantDark]) { - return YES; - } - return NO; - } else { - return NO; - } -} - -@end diff --git a/LookInside/Base/Category/NSAppearance+LookinClient.swift b/LookInside/Base/Category/NSAppearance+LookinClient.swift new file mode 100644 index 00000000..c1963bd5 --- /dev/null +++ b/LookInside/Base/Category/NSAppearance+LookinClient.swift @@ -0,0 +1,14 @@ +// +// NSAppearance+LookinClient.swift +// LookInside +// + +import AppKit + +extension NSAppearance { + /// Whether this is one of the dark appearances, including the vibrant + /// and high-contrast variants. + @objc var lk_isDarkMode: Bool { + [.darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua, .accessibilityHighContrastVibrantDark].contains(name) + } +} diff --git a/LookInside/Base/Category/NSArray+LookinClient.h b/LookInside/Base/Category/NSArray+LookinClient.h deleted file mode 100644 index 2f26f007..00000000 --- a/LookInside/Base/Category/NSArray+LookinClient.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// NSArray+LookinClient.h -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - -#import -#import - -@interface NSArray<__covariant ValueType> (LookinClient) - -- (NSArray *)lk_visibleViews; - -@end diff --git a/LookInside/Base/Category/NSArray+LookinClient.m b/LookInside/Base/Category/NSArray+LookinClient.m deleted file mode 100644 index 6bb005f4..00000000 --- a/LookInside/Base/Category/NSArray+LookinClient.m +++ /dev/null @@ -1,26 +0,0 @@ -// -// NSArray+LookinClient.m -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - -#import "NSArray+LookinClient.h" -#import - -@implementation NSArray (LookinClient) - -- (NSArray *)lk_visibleViews { - NSArray *newArray = [self lookin_filter:^BOOL(id obj) { - if ([obj isKindOfClass:[NSView class]]) { - return ![((NSView *)obj) isHidden]; - } else { - NSAssert(NO, @""); - return NO; - } - }]; - return newArray; -} - -@end diff --git a/LookInside/Base/Category/NSArray+LookinClient.swift b/LookInside/Base/Category/NSArray+LookinClient.swift new file mode 100644 index 00000000..d5173c0b --- /dev/null +++ b/LookInside/Base/Category/NSArray+LookinClient.swift @@ -0,0 +1,19 @@ +// +// NSArray+LookinClient.swift +// LookInside +// + +import AppKit + +extension NSArray { + /// The views that are not hidden. Every element must be an NSView. + @objc func lk_visibleViews() -> [Any] { + filter { object in + guard let view = object as? NSView else { + assertionFailure("lk_visibleViews expects views only") + return false + } + return !view.isHidden + } + } +} diff --git a/LookInside/Base/Category/NSButton+LookinClient.h b/LookInside/Base/Category/NSButton+LookinClient.h deleted file mode 100644 index 9b4e69a3..00000000 --- a/LookInside/Base/Category/NSButton+LookinClient.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// NSButton+LookinClient.h -// Lookin -// -// Created by Li Kai on 2019/5/25. -// https://lookin.work -// - -@interface NSButton (LookinClient) - -/// size 已经被设置好 -+ (instancetype)lk_normalButtonWithTitle:(NSString *)title target:(id)target action:(SEL)action; - -/// 只有图片的按钮,没有 border。可以手动设置高度与宽度。 -+ (instancetype)lk_buttonWithImage:(NSImage *)image target:(id)target action:(SEL)action; - -@end diff --git a/LookInside/Base/Category/NSButton+LookinClient.m b/LookInside/Base/Category/NSButton+LookinClient.m deleted file mode 100644 index 6dcdb63d..00000000 --- a/LookInside/Base/Category/NSButton+LookinClient.m +++ /dev/null @@ -1,34 +0,0 @@ -// -// NSButton+LookinClient.m -// Lookin -// -// Created by Li Kai on 2019/5/25. -// https://lookin.work -// - -#import "NSButton+LookinClient.h" - -@implementation NSButton (LookinClient) - -+ (instancetype)lk_normalButtonWithTitle:(NSString *)title target:(id)target action:(SEL)action { - NSButton *button = [NSButton new]; - button.bezelStyle = NSBezelStyleRounded; - button.title = title; - button.font = [NSFont systemFontOfSize:13]; - button.target = target; - button.action = action; - button.frame = NSMakeRect(0, 0, 84, 40); - return button; -} - -+ (instancetype)lk_buttonWithImage:(NSImage *)image target:(id)target action:(SEL)action { - NSButton *button = [NSButton new]; - button.image = image; - button.bezelStyle = NSBezelStyleRoundRect; - button.bordered = NO; - button.target = target; - button.action = action; - return button; -} - -@end diff --git a/LookInside/Base/Category/NSButton+LookinClient.swift b/LookInside/Base/Category/NSButton+LookinClient.swift new file mode 100644 index 00000000..c46eeb00 --- /dev/null +++ b/LookInside/Base/Category/NSButton+LookinClient.swift @@ -0,0 +1,33 @@ +// +// NSButton+LookinClient.swift +// LookInside +// + +import AppKit + +extension NSButton { + /// A rounded push button, already sized 84×40. + @objc(lk_normalButtonWithTitle:target:action:) + static func lk_normalButton(withTitle title: String, target: AnyObject?, action: Selector?) -> NSButton { + let button = NSButton() + button.bezelStyle = .rounded + button.title = title + button.font = NSFont.systemFont(ofSize: 13) + button.target = target + button.action = action + button.frame = NSRect(x: 0, y: 0, width: 84, height: 40) + return button + } + + /// A borderless image-only button; size it yourself. + @objc(lk_buttonWithImage:target:action:) + static func lk_button(with image: NSImage?, target: AnyObject?, action: Selector?) -> NSButton { + let button = NSButton() + button.image = image + button.bezelStyle = .roundRect + button.isBordered = false + button.target = target + button.action = action + return button + } +} diff --git a/LookInside/Base/Category/NSColor+LookinClient.h b/LookInside/Base/Category/NSColor+LookinClient.h deleted file mode 100644 index 56468ced..00000000 --- a/LookInside/Base/Category/NSColor+LookinClient.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// NSColor+LookinClient.h -// Lookin -// -// Created by Li Kai on 2019/5/17. -// https://lookin.work -// - -#import - -@interface NSColor (LookinClient) - -/** - 如果 alpha 为 1 则返回诸如 (15, 17, 19) 这样的格式 - 如果 alpha 小于 1 则返回诸如 (15, 17, 19, 0.5) 这样的格式 - */ -- (NSString *)rgbaString; - -- (NSString *)hexString; - -- (NSArray *)lk_rgbaComponents; - -+ (instancetype)lk_colorFromRGBAComponents:(NSArray *)components; - -@end diff --git a/LookInside/Base/Category/NSColor+LookinClient.m b/LookInside/Base/Category/NSColor+LookinClient.m deleted file mode 100644 index 8f80ffc0..00000000 --- a/LookInside/Base/Category/NSColor+LookinClient.m +++ /dev/null @@ -1,123 +0,0 @@ -// -// NSColor+LookinClient.m -// Lookin -// -// Created by Li Kai on 2019/5/17. -// https://lookin.work -// - -#import "NSColor+LookinClient.h" - -@implementation NSColor (LookinClient) - -- (NSArray *)lk_rgbaComponents { - NSColor *rgbColor = [self colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; - CGFloat r, g, b, a; - [rgbColor getRed:&r green:&g blue:&b alpha:&a]; - NSArray *rgba = @[@(r), @(g), @(b), @(a)]; - return rgba; -} - -+ (instancetype)lk_colorFromRGBAComponents:(NSArray *)components { - if (!components) { - return nil; - } - if (![components isKindOfClass:[NSArray class]]) { - NSAssert(NO, @""); - return nil; - } - if (components.count != 4) { - NSAssert(NO, @""); - return nil; - } - NSColor *color = [NSColor colorWithRed:components[0].doubleValue green:components[1].doubleValue blue:components[2].doubleValue alpha:components[3].doubleValue]; - return color; -} - -- (NSString *)rgbaString { - NSColor *rgbColor = [self colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; - - CGFloat r, g, b, a; - [rgbColor getRed:&r green:&g blue:&b alpha:&a]; - - if (a >= 1) { - return [NSString stringWithFormat:@"(%.0f, %.0f, %.0f)", r * 255, g * 255, b * 255]; - } else { - return [NSString stringWithFormat:@"(%.0f, %.0f, %.0f, %.2f)", r * 255, g * 255, b * 255, a]; - } -} - -- (NSString *)hexString { - NSColor *rgbColor = [self colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; - - CGFloat r, g, b, a; - [rgbColor getRed:&r green:&g blue:&b alpha:&a]; - - NSInteger red = r * 255; - NSInteger green = g * 255; - NSInteger blue = b * 255; - NSInteger alpha = a * 255; - - NSString *rString = [NSColor _alignColorHexStringLength:[NSColor _hexStringWithInteger:red]]; - NSString *gString = [NSColor _alignColorHexStringLength:[NSColor _hexStringWithInteger:green]]; - NSString *bString = [NSColor _alignColorHexStringLength:[NSColor _hexStringWithInteger:blue]]; - NSString *aString = [NSColor _alignColorHexStringLength:[NSColor _hexStringWithInteger:alpha]]; - - if (a >= 1) { - return [[NSString stringWithFormat:@"#%@%@%@", rString, gString, bString] lowercaseString]; - } - return [[NSString stringWithFormat:@"#%@%@%@%@", rString, gString, bString, aString] lowercaseString]; -} - -// 对于色值只有单位数的,在前面补一个0,例如“F”会补齐为“0F” -+ (NSString *)_alignColorHexStringLength:(NSString *)hexString { - return hexString.length < 2 ? [@"0" stringByAppendingString:hexString] : hexString; -} - -+ (NSString *)_hexStringWithInteger:(NSInteger)integer { - NSString *hexString = @""; - NSInteger remainder = 0; - for (NSInteger i = 0; i < 9; i++) { - remainder = integer % 16; - integer = integer / 16; - NSString *letter = [self _hexLetterStringWithInteger:remainder]; - hexString = [letter stringByAppendingString:hexString]; - if (integer == 0) { - break; - } - - } - return hexString; -} - -+ (NSString *)_hexLetterStringWithInteger:(NSInteger)integer { - NSAssert(integer < 16, @"要转换的数必须是16进制里的个位数,也即小于16,但你传给我是%@", @(integer)); - - NSString *letter = nil; - switch (integer) { - case 10: - letter = @"A"; - break; - case 11: - letter = @"B"; - break; - case 12: - letter = @"C"; - break; - case 13: - letter = @"D"; - break; - case 14: - letter = @"E"; - break; - case 15: - letter = @"F"; - break; - default: - letter = [[NSString alloc]initWithFormat:@"%@", @(integer)]; - break; - } - return letter; -} - -@end diff --git a/LookInside/Base/Category/NSColor+LookinClient.swift b/LookInside/Base/Category/NSColor+LookinClient.swift new file mode 100644 index 00000000..e531b885 --- /dev/null +++ b/LookInside/Base/Category/NSColor+LookinClient.swift @@ -0,0 +1,58 @@ +// +// NSColor+LookinClient.swift +// LookInside +// + +import AppKit +import LookInsideHostCore + +extension NSColor { + /// sRGB components in 0...1; zeros when the colour has no sRGB form. + private var lk_sRGBComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + usingColorSpace(.sRGB)?.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return (red, green, blue, alpha) + } + + /// "(15, 17, 19)" when opaque, "(15, 17, 19, 0.50)" otherwise. + @objc func rgbaString() -> String { + let rgba = lk_sRGBComponents + return ClientDisplayText.rgbaString(red: rgba.red, green: rgba.green, blue: rgba.blue, alpha: rgba.alpha) + } + + /// "#0f1113" when opaque, "#0f111380" otherwise. + @objc func hexString() -> String { + let rgba = lk_sRGBComponents + return ClientDisplayText.hexString(red: rgba.red, green: rgba.green, blue: rgba.blue, alpha: rgba.alpha) + } + + /// [r, g, b, a] in sRGB. + @objc func lk_rgbaComponents() -> [NSNumber] { + let rgba = lk_sRGBComponents + return [rgba.red, rgba.green, rgba.blue, rgba.alpha].map { NSNumber(value: Double($0)) } + } + + /// The colour for [r, g, b, a]; nil for anything but four numbers. + @objc(lk_colorFromRGBAComponents:) + static func lk_color(fromRGBAComponents components: [NSNumber]?) -> NSColor? { + guard let components else { return nil } + guard components.count == 4 else { + assertionFailure("expected four RGBA components") + return nil + } + return NSColor( + red: components[0].doubleValue, + green: components[1].doubleValue, + blue: components[2].doubleValue, + alpha: components[3].doubleValue + ) + } +} + +extension NSColor { + /// `LookinColorMake` / `LookinColorRGBAMake` for Swift: 0...255 + /// channels and a 0...1 alpha. + static func lkBaseRGB(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = 1) -> NSColor { + NSColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) + } +} diff --git a/LookInside/Base/Category/NSControl+LookinClient.h b/LookInside/Base/Category/NSControl+LookinClient.h deleted file mode 100644 index e002b9b5..00000000 --- a/LookInside/Base/Category/NSControl+LookinClient.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// NSControl+LookinClient.h -// Lookin -// -// Created by Li Kai on 2019/8/13. -// https://lookin.work -// - -#import -#import - -@interface NSControl (LookinClient) - -- (CGFloat)heightForWidth:(CGFloat)width; - -- (CGFloat)bestHeight; - -- (CGFloat)bestWidth; - -- (NSSize)bestSize; - -@end diff --git a/LookInside/Base/Category/NSControl+LookinClient.m b/LookInside/Base/Category/NSControl+LookinClient.m deleted file mode 100644 index 0d7b647b..00000000 --- a/LookInside/Base/Category/NSControl+LookinClient.m +++ /dev/null @@ -1,30 +0,0 @@ -// -// NSControl+LookinClient.m -// Lookin -// -// Created by Li Kai on 2019/8/13. -// https://lookin.work -// - -#import "NSControl+LookinClient.h" -#import - -@implementation NSControl (LookinClient) - -- (CGFloat)heightForWidth:(CGFloat)width { - return [self sizeThatFits:NSMakeSize(width, CGFLOAT_MAX)].height; -} - -- (CGFloat)bestHeight { - return [self sizeThatFits:NSSizeMax].height; -} - -- (CGFloat)bestWidth { - return [self sizeThatFits:NSSizeMax].width; -} - -- (NSSize)bestSize { - return [self sizeThatFits:NSSizeMax]; -} - -@end diff --git a/LookInside/Base/Category/NSControl+LookinClient.swift b/LookInside/Base/Category/NSControl+LookinClient.swift new file mode 100644 index 00000000..583d9fdc --- /dev/null +++ b/LookInside/Base/Category/NSControl+LookinClient.swift @@ -0,0 +1,27 @@ +// +// NSControl+LookinClient.swift +// LookInside +// + +import AppKit + +extension NSControl { + private static let lk_unlimitedSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + + @objc(heightForWidth:) + func height(forWidth width: CGFloat) -> CGFloat { + sizeThatFits(NSSize(width: width, height: .greatestFiniteMagnitude)).height + } + + @objc func bestHeight() -> CGFloat { + sizeThatFits(Self.lk_unlimitedSize).height + } + + @objc func bestWidth() -> CGFloat { + sizeThatFits(Self.lk_unlimitedSize).width + } + + @objc func bestSize() -> NSSize { + sizeThatFits(Self.lk_unlimitedSize) + } +} diff --git a/LookInside/Base/Category/NSPointerArray+LookinClient.h b/LookInside/Base/Category/NSPointerArray+LookinClient.h deleted file mode 100644 index c3b5903c..00000000 --- a/LookInside/Base/Category/NSPointerArray+LookinClient.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// NSPointerArray+LookinClient.h -// Lookin -// -// Created by Li Kai on 2019/5/9. -// https://lookin.work -// - -#import - -@interface NSPointerArray (LookinClient) - -- (NSUInteger)lk_indexOfPointer:(void *)pointer; - -- (BOOL)lk_containsPointer:(void *)pointer; - -@end diff --git a/LookInside/Base/Category/NSPointerArray+LookinClient.m b/LookInside/Base/Category/NSPointerArray+LookinClient.m deleted file mode 100644 index 917ec988..00000000 --- a/LookInside/Base/Category/NSPointerArray+LookinClient.m +++ /dev/null @@ -1,37 +0,0 @@ -// -// NSPointerArray+LookinClient.m -// Lookin -// -// Created by Li Kai on 2019/5/9. -// https://lookin.work -// - -#import "NSPointerArray+LookinClient.h" - -@implementation NSPointerArray (LookinClient) - -- (NSUInteger)lk_indexOfPointer:(nullable void *)pointer { - if (!pointer) { - return NSNotFound; - } - - NSPointerArray *array = [self copy]; - for (NSUInteger i = 0; i < array.count; i++) { - if ([array pointerAtIndex:i] == ((void *)pointer)) { - return i; - } - } - return NSNotFound; -} - -- (BOOL)lk_containsPointer:(void *)pointer { - if (!pointer) { - return NO; - } - if ([self lk_indexOfPointer:pointer] != NSNotFound) { - return YES; - } - return NO; -} - -@end diff --git a/LookInside/Base/Category/NSString+LookinClient.h b/LookInside/Base/Category/NSString+LookinClient.h deleted file mode 100644 index baa6d1ee..00000000 --- a/LookInside/Base/Category/NSString+LookinClient.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// NSString+LookinClient.h -// Lookin -// -// Created by Li Kai on 2019/9/29. -// https://lookin.work -// - -#import -#import - -@interface NSString (LookinClient) - -/// 返回首字母大写的字符串 -- (NSString *)lk_capitalizedString; - -@end diff --git a/LookInside/Base/Category/NSString+LookinClient.m b/LookInside/Base/Category/NSString+LookinClient.m deleted file mode 100644 index 19306b8a..00000000 --- a/LookInside/Base/Category/NSString+LookinClient.m +++ /dev/null @@ -1,22 +0,0 @@ -// -// NSString+LookinClient.m -// Lookin -// -// Created by Li Kai on 2019/9/29. -// https://lookin.work -// - -#import "NSString+LookinClient.h" -#import - - -@implementation NSString (LookinClient) - -- (NSString *)lk_capitalizedString { - if (self.length) { - return [NSString stringWithFormat:@"%@%@", [self substringToIndex:1].uppercaseString, [self substringFromIndex:1]]; - } - return nil; -} - -@end diff --git a/LookInside/Base/Category/NSString+LookinClient.swift b/LookInside/Base/Category/NSString+LookinClient.swift new file mode 100644 index 00000000..c9f874a3 --- /dev/null +++ b/LookInside/Base/Category/NSString+LookinClient.swift @@ -0,0 +1,32 @@ +// +// NSString+LookinClient.swift +// LookInside +// + +import Foundation +import LookInsideHostCore + +extension NSString { + /// The string with its first UTF-16 unit uppercased; nil when empty. + @objc func lk_capitalizedString() -> String? { + guard length > 0 else { return nil } + return (substring(to: 1) as NSString).uppercased + substring(from: 1) + } + + @objc(scoreAgainst:) + func score(against otherString: String) -> CGFloat { + score(against: otherString, fuzziness: nil) + } + + @objc(scoreAgainst:fuzziness:) + func score(against otherString: String, fuzziness: NSNumber?) -> CGFloat { + score(against: otherString, fuzziness: fuzziness, options: StringScore.Options.none.rawValue) + } + + /// Fuzzy match score of `otherString` (an abbreviation) against this + /// string; `options` takes the NSStringScoreOption bits. + @objc(scoreAgainst:fuzziness:options:) + func score(against otherString: String, fuzziness: NSNumber?, options: UInt) -> CGFloat { + StringScore.score(self as String, against: otherString, fuzziness: fuzziness?.doubleValue, options: StringScore.Options(rawValue: options)) + } +} diff --git a/LookInside/Base/Category/NSString+Score.h b/LookInside/Base/Category/NSString+Score.h deleted file mode 100755 index e7b57097..00000000 --- a/LookInside/Base/Category/NSString+Score.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// NSString+Score.h -// -// Created by Nicholas Bruning on 5/12/11. -// Copyright (c) 2011 Involved Pty Ltd. All rights reserved. -// - -#import - -enum{ - NSStringScoreOptionNone = 1 << 0, - NSStringScoreOptionFavorSmallerWords = 1 << 1, - NSStringScoreOptionReducedLongStringPenalty = 1 << 2 -}; - -typedef NSUInteger NSStringScoreOption; - -@interface NSString (Score) - -- (CGFloat) scoreAgainst:(NSString *)otherString; -- (CGFloat) scoreAgainst:(NSString *)otherString fuzziness:(NSNumber *)fuzziness; -- (CGFloat) scoreAgainst:(NSString *)otherString fuzziness:(NSNumber *)fuzziness options:(NSStringScoreOption)options; - -- (CGFloat) scoreAgainst:(NSString *)anotherString fuzziness:(NSNumber *)fuzziness options:(NSStringScoreOption)options - invalidCharacterSet:(NSCharacterSet *)invalidCharacterSet decomposedString:(NSString *)string; - -- (NSString *)decomposedStringWithInvalidCharacterSet:(NSCharacterSet *)invalidCharacterSet; -- (NSCharacterSet *)invalidCharacterSet; - -@end diff --git a/LookInside/Base/Category/NSString+Score.m b/LookInside/Base/Category/NSString+Score.m deleted file mode 100755 index fdc00049..00000000 --- a/LookInside/Base/Category/NSString+Score.m +++ /dev/null @@ -1,164 +0,0 @@ -// -// NSString+Score.m -// -// Created by Nicholas Bruning on 5/12/11. -// Copyright (c) 2011 Involved Pty Ltd. All rights reserved. -// - -//score reference: http://jsfiddle.net/JrLVD/ - -#import "NSString+Score.h" - -@implementation NSString (Score) - -- (CGFloat) scoreAgainst:(NSString *)otherString{ - return [self scoreAgainst:otherString fuzziness:nil]; -} - -- (CGFloat) scoreAgainst:(NSString *)otherString fuzziness:(NSNumber *)fuzziness{ - return [self scoreAgainst:otherString fuzziness:fuzziness options:NSStringScoreOptionNone]; -} - -- (CGFloat) scoreAgainst:(NSString *)anotherString fuzziness:(NSNumber *)fuzziness options:(NSStringScoreOption)options { - NSCharacterSet *invalidCharacterSet = [self invalidCharacterSet]; - NSString *string = [self decomposedStringWithInvalidCharacterSet:invalidCharacterSet]; - return [self scoreAgainst:anotherString fuzziness:fuzziness options:options invalidCharacterSet:invalidCharacterSet decomposedString:string]; -} - -- (NSCharacterSet *)invalidCharacterSet { - NSMutableCharacterSet *workingInvalidCharacterSet = [NSCharacterSet lowercaseLetterCharacterSet].mutableCopy; - [workingInvalidCharacterSet formUnionWithCharacterSet:[NSCharacterSet uppercaseLetterCharacterSet]]; - [workingInvalidCharacterSet addCharactersInString:@" "]; - NSCharacterSet *invalidCharacterSet = [workingInvalidCharacterSet invertedSet]; - return invalidCharacterSet; -} - -- (NSString *)decomposedStringWithInvalidCharacterSet:(NSCharacterSet *)invalidCharacterSet { - NSString *string = [[[self decomposedStringWithCanonicalMapping] componentsSeparatedByCharactersInSet:invalidCharacterSet] componentsJoinedByString:@""]; - return string; -} - -- (CGFloat) scoreAgainst:(NSString *)anotherString fuzziness:(NSNumber *)fuzziness options:(NSStringScoreOption)options - invalidCharacterSet:(NSCharacterSet *)invalidCharacterSet decomposedString:(NSString *)string { - NSString *otherString = [[[anotherString decomposedStringWithCanonicalMapping] componentsSeparatedByCharactersInSet:invalidCharacterSet] componentsJoinedByString:@""]; - - // If the string is equal to the abbreviation, perfect match. - if([string isEqualToString:otherString]) return (CGFloat) 1.0f; - - //if it's not a perfect match and is empty return 0 - if([otherString length] == 0) return (CGFloat) 0.0f; - - CGFloat totalCharacterScore = 0; - NSUInteger otherStringLength = [otherString length]; - NSUInteger stringLength = [string length]; - BOOL startOfStringBonus = NO; - CGFloat otherStringScore; - CGFloat fuzzies = 1; - CGFloat finalScore; - - NSString *otherUpper = [otherString uppercaseString]; - NSString *otherLower = [otherString lowercaseString]; - - CGFloat fuzzinessFloat = fuzziness.floatValue; - - unichar space = [@" " characterAtIndex:0]; - - // Walk through abbreviation and add up scores. - for(uint index = 0; index < otherStringLength; index++){ - CGFloat characterScore = 0.1; - NSInteger indexInString = NSNotFound; - NSRange rangeChrLowercase; - NSRange rangeChrUppercase; - - NSRange r =NSMakeRange(index, 1); - - unichar chr = [otherString characterAtIndex:index]; - - NSString *upperChr = [otherUpper substringWithRange:r]; - NSString *lowerChr = [otherLower substringWithRange:r]; - - //make these next few lines leverage NSNotfound, methinks. - rangeChrLowercase = [string rangeOfString:lowerChr]; - rangeChrUppercase = [string rangeOfString:upperChr]; - - if(rangeChrLowercase.location == NSNotFound && rangeChrUppercase.location == NSNotFound){ - if(fuzziness != nil){ - fuzzies += 1 - fuzzinessFloat; - } else { - return 0; // this is an error! - } - - } else if (rangeChrLowercase.location != NSNotFound && rangeChrUppercase.location != NSNotFound){ - indexInString = MIN(rangeChrLowercase.location, rangeChrUppercase.location); - - } else if(rangeChrLowercase.location != NSNotFound || rangeChrUppercase.location != NSNotFound){ - indexInString = rangeChrLowercase.location != NSNotFound ? rangeChrLowercase.location : rangeChrUppercase.location; - - } else { - indexInString = MIN(rangeChrLowercase.location, rangeChrUppercase.location); - - } - - // Set base score for matching chr - - // Same case bonus. - if(indexInString != NSNotFound && [string characterAtIndex:indexInString] == chr){ - characterScore += 0.1; - } - - // Consecutive letter & start-of-string bonus - if(indexInString == 0){ - // Increase the score when matching first character of the remainder of the string - characterScore += 0.6; - if(index == 0){ - // If match is the first character of the string - // & the first character of abbreviation, add a - // start-of-string match bonus. - startOfStringBonus = YES; - } - } else if(indexInString != NSNotFound) { - // Acronym Bonus - // Weighing Logic: Typing the first character of an acronym is as if you - // preceded it with two perfect character matches. - - if([string characterAtIndex:indexInString - 1] == space) { - characterScore += 0.8; - } - } - - // Left trim the already matched part of the string - // (forces sequential matching). - if(indexInString != NSNotFound){ - string = [string substringFromIndex:indexInString + 1]; - } - - totalCharacterScore += characterScore; - } - - if(NSStringScoreOptionFavorSmallerWords == (options & NSStringScoreOptionFavorSmallerWords)){ - // Weigh smaller words higher - return totalCharacterScore / stringLength; - } - - otherStringScore = totalCharacterScore / otherStringLength; - - if(NSStringScoreOptionReducedLongStringPenalty == (options & NSStringScoreOptionReducedLongStringPenalty)){ - // Reduce the penalty for longer words - CGFloat percentageOfMatchedString = otherStringLength / stringLength; - CGFloat wordScore = otherStringScore * percentageOfMatchedString; - finalScore = (wordScore + otherStringScore) / 2; - - } else { - finalScore = ((otherStringScore * ((CGFloat)(otherStringLength) / (CGFloat)(stringLength))) + otherStringScore) / 2; - } - - finalScore = finalScore / fuzzies; - - if(startOfStringBonus && finalScore + 0.15 < 1){ - finalScore += 0.15; - } - - return finalScore; -} - -@end diff --git a/LookInside/Base/Category/NSView+LookinClient.h b/LookInside/Base/Category/NSView+LookinClient.h deleted file mode 100644 index 079144b0..00000000 --- a/LookInside/Base/Category/NSView+LookinClient.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// NSView+Lookin.h -// Lookin -// -// Created by Li Kai on 2018/11/24. -// https://lookin.work -// - -#import - -@interface NSView (LookinClient) - -@property(nonatomic, assign, readonly) BOOL isVisible; - -@property(nonatomic, copy) NSString *backgroundColorName; - -/// 将一个 view 作为自己的 subview 并且放到最底部 -- (void)lk_insertSubviewAtBottom:(NSView *)view; - -- (void)showDebugBorder; - -@end diff --git a/LookInside/Base/Category/NSView+LookinClient.m b/LookInside/Base/Category/NSView+LookinClient.m deleted file mode 100644 index e861e2c6..00000000 --- a/LookInside/Base/Category/NSView+LookinClient.m +++ /dev/null @@ -1,43 +0,0 @@ -// -// NSView+Lookin.m -// Lookin -// -// Created by Li Kai on 2018/11/24. -// https://lookin.work -// - -#import "NSView+LookinClient.h" - -@implementation NSView (LookinClient) - -- (BOOL)isVisible { - return !self.hidden && self.alphaValue > 0; -} - -- (NSString *)backgroundColorName { - return [self lookin_getBindObjectForKey:@"lk_backgroundColorName"]; -} - -- (void)setBackgroundColorName:(NSString *)backgroundColorName { - [self lookin_bindObject:backgroundColorName forKey:@"lk_backgroundColorName"]; - if (!backgroundColorName) { - self.layer.backgroundColor = nil; - } else { - self.layer.backgroundColor = [NSColor colorNamed:backgroundColorName].CGColor; - } -} - -- (void)showDebugBorder { - self.layer.borderWidth = 1; - self.layer.borderColor = [NSColor whiteColor].CGColor; -} - -- (void)lk_insertSubviewAtBottom:(NSView *)view { - if (self.subviews.count) { - [self addSubview:view positioned:NSWindowBelow relativeTo:self.subviews.firstObject]; - } else { - [self addSubview:view]; - } -} - -@end diff --git a/LookInside/Base/Category/NSView+LookinClient.swift b/LookInside/Base/Category/NSView+LookinClient.swift new file mode 100644 index 00000000..6a99026d --- /dev/null +++ b/LookInside/Base/Category/NSView+LookinClient.swift @@ -0,0 +1,46 @@ +// +// NSView+LookinClient.swift +// LookInside +// + +import AppKit + +private var lk_backgroundColorNameKey: UInt8 = 0 + +extension NSView { + /// Not hidden and not fully transparent. + @objc var isVisible: Bool { + !isHidden && alphaValue > 0 + } + + /// An asset catalog colour name painted onto the layer's background; nil + /// clears it. + @objc var backgroundColorName: String? { + get { + objc_getAssociatedObject(self, &lk_backgroundColorNameKey) as? String + } + set { + objc_setAssociatedObject(self, &lk_backgroundColorNameKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) + if let newValue { + layer?.backgroundColor = NSColor(named: newValue)?.cgColor + } else { + layer?.backgroundColor = nil + } + } + } + + /// Adds `view` below every existing subview. + @objc(lk_insertSubviewAtBottom:) + func lk_insertSubviewAtBottom(_ view: NSView) { + if let first = subviews.first { + addSubview(view, positioned: .below, relativeTo: first) + } else { + addSubview(view) + } + } + + @objc func showDebugBorder() { + layer?.borderWidth = 1 + layer?.borderColor = NSColor.white.cgColor + } +} diff --git a/LookInside/Base/Category/ShortCocoa+LookinClient.h b/LookInside/Base/Category/ShortCocoa+LookinClient.h deleted file mode 100644 index d08cc54c..00000000 --- a/LookInside/Base/Category/ShortCocoa+LookinClient.h +++ /dev/null @@ -1,14 +0,0 @@ -// -// ShortCocoa+LookinClient.h -// Lookin -// -// Created by Li Kai on 2018/12/8. -// https://lookin.work -// - -@interface ShortCocoa (Lookin) - -- (ShortCocoa * (^)(CGFloat))lk_maxWidth; -- (ShortCocoa * (^)(CGFloat))lk_minWidth; - -@end diff --git a/LookInside/Base/Category/ShortCocoa+LookinClient.m b/LookInside/Base/Category/ShortCocoa+LookinClient.m deleted file mode 100644 index 5d4d4b7c..00000000 --- a/LookInside/Base/Category/ShortCocoa+LookinClient.m +++ /dev/null @@ -1,117 +0,0 @@ -// -// ShortCocoa+LookinClient.m -// Lookin -// -// Created by Li Kai on 2018/12/8. -// https://lookin.work -// - -#import "ShortCocoa+LookinClient.h" -#import -#import "ShortCocoa+Private.h" -#import "LKBaseView.h" - -@implementation ShortCocoa (Lookin) - -+ (void)load { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - Method oriMethod = class_getInstanceMethod([self class], @selector(sizeToFit)); - Method newMethod = class_getInstanceMethod([self class], @selector(lookin_sizeToFit)); - method_exchangeImplementations(oriMethod, newMethod); - - Method oriMethod1 = class_getInstanceMethod([self class], @selector(heightToFit)); - Method newMethod1 = class_getInstanceMethod([self class], @selector(lookin_heightToFit)); - method_exchangeImplementations(oriMethod1, newMethod1); - }); -} - -- (ShortCocoa *)lookin_sizeToFit { - [self unpackClassA:[LKBaseView class] doA:^(LKBaseView * _Nonnull view, BOOL * _Nonnull stop) { - NSSize size = [view sizeThatFits:NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX)]; - NSRect rect = view.frame; - rect.size = size; - view.frame = rect; - } classB:[NSControl class] doB:^(NSControl * _Nonnull control, BOOL * _Nonnull stop) { - NSSize size = [control sizeThatFits:NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX)]; - if (isnan(size.width)) { - size.width = 0; - } - if (isnan(size.height)) { - size.height = 0; - } - NSRect rect = control.frame; - rect.size = size; - control.frame = rect; - }]; - return self; -} - -- (ShortCocoa *)lookin_heightToFit { - [self unpackClassA:[LKBaseView class] doA:^(LKBaseView * _Nonnull view, BOOL * _Nonnull stop) { - CGFloat limitedWidth = CGRectGetWidth(view.bounds); - CGFloat height = [view sizeThatFits:NSMakeSize(limitedWidth, CGFLOAT_MAX)].height; - - CGRect rect = view.frame; - rect.size.height = height; - view.frame = rect; - } classB:[NSControl class] doB:^(NSControl * _Nonnull control, BOOL * _Nonnull stop) { - CGFloat limitedWidth = CGRectGetWidth(control.bounds); - CGFloat height = [control sizeThatFits:NSMakeSize(limitedWidth, CGFLOAT_MAX)].height; - - CGRect rect = control.frame; - rect.size.height = height; - control.frame = rect; - }]; - return self; -} - -- (ShortCocoa * (^)(CGFloat))lk_maxWidth { - return ^(CGFloat maxWidth) { - if (isnan(maxWidth)) { - NSAssert(NO, @"传入了 NaN"); - return self; - } - - [self unpackClassA:[NSView class] doA:^(NSView *view, BOOL *stop) { - CGRect rect = view.frame; - if (rect.size.width > maxWidth) { - rect.size.width = maxWidth; - view.frame = rect; - } - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGRect rect = layer.frame; - if (rect.size.width > maxWidth) { - rect.size.width = maxWidth; - layer.frame = rect; - } - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))lk_minWidth { - return ^(CGFloat minWidth) { - if (isnan(minWidth)) { - NSAssert(NO, @"传入了 NaN"); - return self; - } - - [self unpackClassA:[NSView class] doA:^(NSView *view, BOOL *stop) { - CGRect rect = view.frame; - if (rect.size.width < minWidth) { - rect.size.width = minWidth; - view.frame = rect; - } - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGRect rect = layer.frame; - if (rect.size.width < minWidth) { - rect.size.width = minWidth; - layer.frame = rect; - } - }]; - return self; - }; -} - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchSuggestionItem.h b/LookInside/Base/InputSearch/LKInputSearchSuggestionItem.h deleted file mode 100644 index 3840dd28..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchSuggestionItem.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// LKInputSearchSuggestionItem.h -// Lookin -// -// Created by Li Kai on 2019/6/3. -// https://lookin.work -// - -#import - -@interface LKInputSearchSuggestionItem : NSObject - -@property(nonatomic, strong) NSImage *image; -@property(nonatomic, copy) NSString *text; - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchSuggestionItem.m b/LookInside/Base/InputSearch/LKInputSearchSuggestionItem.m deleted file mode 100644 index a804d74e..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchSuggestionItem.m +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKInputSearchSuggestionItem.m -// Lookin -// -// Created by Li Kai on 2019/6/3. -// https://lookin.work -// - -#import "LKInputSearchSuggestionItem.h" - -@implementation LKInputSearchSuggestionItem - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchSuggestionWindowController.h b/LookInside/Base/InputSearch/LKInputSearchSuggestionWindowController.h deleted file mode 100644 index 9a68bb95..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchSuggestionWindowController.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKInputSearchSuggestionWindowController.h -// Lookin -// -// Created by Li Kai on 2019/6/2. -// https://lookin.work -// - -#import "LKWindowController.h" - -@class LKInputSearchSuggestionsContentView; - -@interface LKInputSearchSuggestionWindowController : LKWindowController - -@property(nonatomic, strong) LKInputSearchSuggestionsContentView *suggestionsView; - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchSuggestionWindowController.m b/LookInside/Base/InputSearch/LKInputSearchSuggestionWindowController.m deleted file mode 100644 index eaf033d2..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchSuggestionWindowController.m +++ /dev/null @@ -1,29 +0,0 @@ -// -// LKInputSearchSuggestionWindowController.m -// Lookin -// -// Created by Li Kai on 2019/6/2. -// https://lookin.work -// - -#import "LKInputSearchSuggestionWindowController.h" -#import "LKInputSearchSuggestionsContentView.h" - -@implementation LKInputSearchSuggestionWindowController - -- (instancetype)init { - LKInputSearchSuggestionsContentView *view = [LKInputSearchSuggestionsContentView new]; - - NSPanel *suggestionPanel = [[NSPanel alloc] initWithContentRect:NSMakeRect(0, 0, 400, 100) styleMask:NSWindowStyleMaskUtilityWindow backing:NSBackingStoreBuffered defer:YES]; - suggestionPanel.contentView = view; - suggestionPanel.floatingPanel = YES; - suggestionPanel.becomesKeyOnlyIfNeeded = YES; - suggestionPanel.backgroundColor = [NSColor clearColor]; - - if (self = [self initWithWindow:suggestionPanel]) { - self.suggestionsView = view; - } - return self; -} - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchSuggestionsContentView.h b/LookInside/Base/InputSearch/LKInputSearchSuggestionsContentView.h deleted file mode 100644 index ed129f36..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchSuggestionsContentView.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// LKInputSearchSuggestionsContentView.h -// Lookin -// -// Created by Li Kai on 2019/6/3. -// https://lookin.work -// - -#import "LKBaseView.h" - -@class LKInputSearchSuggestionItem; - -@interface LKInputSearchSuggestionsContentView : LKBaseView - -@property(nonatomic, copy) NSArray *items; - -@property(nonatomic, strong, readonly) NSTableView *tableView; - -- (LKInputSearchSuggestionItem *)currentSelectedItem; - -- (NSSize)bestSize; - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchSuggestionsContentView.m b/LookInside/Base/InputSearch/LKInputSearchSuggestionsContentView.m deleted file mode 100644 index bb49fc76..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchSuggestionsContentView.m +++ /dev/null @@ -1,110 +0,0 @@ -// -// LKInputSearchSuggestionsContentView.m -// Lookin -// -// Created by Li Kai on 2019/6/3. -// https://lookin.work -// - -#import "LKInputSearchSuggestionsContentView.h" -#import "LKInputSearchSuggestionsRowView.h" -#import "LKInputSearchSuggestionItem.h" - -static CGFloat const kInputSearchSuggestionsItemViewHeight = 28; - -@interface LKInputSearchSuggestionsContentView () - -@property(nonatomic, strong) LKVisualEffectView *backgroundEffectView; - -@end - -@implementation LKInputSearchSuggestionsContentView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.layer.cornerRadius = 4; - - self.backgroundEffectView = [LKVisualEffectView new]; - self.backgroundEffectView.blendingMode = NSVisualEffectBlendingModeBehindWindow; - self.backgroundEffectView.state = NSVisualEffectStateActive; - [self addSubview:self.backgroundEffectView]; - - _tableView = [[NSTableView alloc] init]; - self.tableView.delegate = self; - self.tableView.dataSource = self; - self.tableView.wantsLayer = YES; - self.tableView.headerView = nil; -// self.tableView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleNone; -// self.tableView.focusRingType = NSFocusRingTypeNone; - self.tableView.intercellSpacing = NSMakeSize(0, 0); - NSTableColumn *column = [[NSTableColumn alloc] initWithIdentifier:@"column"]; - column.editable = NO; - [self.tableView addTableColumn:column]; - [self addSubview:self.tableView]; - - @weakify(self); - [RACObserve(self, items) subscribeNext:^(id _Nullable x) { - @strongify(self); - [self.tableView reloadData]; - }]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.backgroundEffectView, self.tableView).fullFrame; -} - -- (LKInputSearchSuggestionItem *)currentSelectedItem { - NSInteger selectedRow = self.tableView.selectedRow; - return [self.items lookin_safeObjectAtIndex:selectedRow]; -} - -- (NSSize)bestSize { - LKInputSearchSuggestionsRowView *rowView = [self lookin_getBindObjectForKey:@"calculatingRowView"]; - if (!rowView) { - rowView = [LKInputSearchSuggestionsRowView new]; - [self lookin_bindObject:rowView forKey:@"calculatingRowView"]; - } - CGFloat width = [self.items lookin_reduceCGFloat:^CGFloat(CGFloat accumulator, NSUInteger idx, LKInputSearchSuggestionItem *obj) { - rowView.imageView.image = obj.image; - rowView.titleLabel.stringValue = obj.text; - return MAX(rowView.bestWidth, accumulator); - } initialAccumlator:0]; - - CGFloat height = kInputSearchSuggestionsItemViewHeight * self.items.count; - return NSMakeSize(width, height); -} - -#pragma mark - Table View - -- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { - return self.items.count; -} - -- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row { - return kInputSearchSuggestionsItemViewHeight; -} - -- (NSTableRowView *)tableView:(NSTableView *)tableView rowViewForRow:(NSInteger)row { - LKInputSearchSuggestionItem *item = [self.items lookin_safeObjectAtIndex:row]; - if (!item) { - return [LKInputSearchSuggestionsRowView new]; - } - LKInputSearchSuggestionsRowView *view = (LKInputSearchSuggestionsRowView *)[tableView makeViewWithIdentifier:@"cell" owner:self]; - if (!view) { - view = [LKInputSearchSuggestionsRowView new]; - view.identifier = @"cell"; - } - view.titleLabel.stringValue = item.text; - view.imageView.image = item.image; - [view setNeedsLayout:YES]; - return view; -} - -- (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { - return nil; -} - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchSuggestionsRowView.h b/LookInside/Base/InputSearch/LKInputSearchSuggestionsRowView.h deleted file mode 100644 index 0bf24504..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchSuggestionsRowView.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// LKInputSearchSuggestionsRowView.h -// Lookin -// -// Created by Li Kai on 2019/6/3. -// https://lookin.work -// - -#import - -@interface LKInputSearchSuggestionsRowView : NSTableRowView - -@property(nonatomic, strong, readonly) LKLabel *titleLabel; -@property(nonatomic, strong, readonly) NSImageView *imageView; - -- (CGFloat)bestWidth; - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchSuggestionsRowView.m b/LookInside/Base/InputSearch/LKInputSearchSuggestionsRowView.m deleted file mode 100644 index 2edf02a5..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchSuggestionsRowView.m +++ /dev/null @@ -1,52 +0,0 @@ -// -// LKInputSearchSuggestionsRowView.m -// Lookin -// -// Created by Li Kai on 2019/6/3. -// https://lookin.work -// - -#import "LKInputSearchSuggestionsRowView.h" - -@implementation LKInputSearchSuggestionsRowView { - CGFloat _horInset; - CGFloat _titleLeft; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _horInset = 10; - _titleLeft = 5; - - _imageView = [NSImageView new]; - [self addSubview:self.imageView]; - - _titleLabel = [LKLabel new]; - self.titleLabel.maximumNumberOfLines = 1; - self.titleLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - self.titleLabel.font = NSFontMake(14); - [self addSubview:self.titleLabel]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.imageView).sizeToFit.x(_horInset).verAlign; - $(self.titleLabel).x(self.imageView.$maxX + _titleLeft).toRight(_horInset).heightToFit.verAlign.offsetY(-1); -} - -- (CGFloat)bestWidth { - return _horInset * 2 + self.imageView.image.size.width + _titleLeft + [self.titleLabel sizeThatFits:NSSizeMax].width; -} - -- (void)drawSelectionInRect:(NSRect)dirtyRect { - if (self.selectionHighlightStyle != NSTableViewSelectionHighlightStyleNone) { - NSColor *color = self.effectiveAppearance.lk_isDarkMode ? [LKHelper accentColor] : LookinColorRGBAMake(0, 0, 0, .24); - [color setFill]; - NSBezierPath *selectionPath = [NSBezierPath bezierPathWithRect:self.bounds]; - [selectionPath fill]; - } -} - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchView.h b/LookInside/Base/InputSearch/LKInputSearchView.h deleted file mode 100644 index a4f85987..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchView.h +++ /dev/null @@ -1,33 +0,0 @@ -// -// LKInputSearchView.h -// Lookin -// -// Created by Li Kai on 2019/6/2. -// https://lookin.work -// - -#import "LKBaseView.h" - -@class LKInputSearchSuggestionItem, LKInputSearchView; - -@protocol LKInputSearchViewDelegate - -- (NSArray *)inputSearchView:(LKInputSearchView *)view suggestionsForString:(NSString *)string; - -- (void)inputSearchView:(LKInputSearchView *)view submitText:(NSString *)text; - -@end - -@interface LKInputSearchView : LKBaseView - -- (instancetype)initWithThrottleTime:(CGFloat)throttleTime; - -@property(nonatomic, assign) CGFloat horizontalInset; - -@property(nonatomic, strong, readonly) NSTextField *textField; - -- (void)clearContentAndSuggestions; - -@property(nonatomic, weak) id delegate; - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchView.m b/LookInside/Base/InputSearch/LKInputSearchView.m deleted file mode 100644 index f6a45f0c..00000000 --- a/LookInside/Base/InputSearch/LKInputSearchView.m +++ /dev/null @@ -1,155 +0,0 @@ -// -// LKInputSearchView.m -// Lookin -// -// Created by Li Kai on 2019/6/2. -// https://lookin.work -// - -#import "LKInputSearchView.h" -#import "LKTextFieldView.h" -#import "LKInputSearchSuggestionWindowController.h" -#import "LKInputSearchSuggestionsContentView.h" -#import "LKInputSearchSuggestionItem.h" - -@interface LKInputSearchView () - -@property(nonatomic, strong) LKTextFieldView *textFieldView; -@property(nonatomic, strong) LKInputSearchSuggestionWindowController *suggestionWc; - -@property(nonatomic, copy) NSString *previousInput; - -@end - -@implementation LKInputSearchView - -- (instancetype)initWithThrottleTime:(CGFloat)throttleTime { - if (self = [self initWithFrame:NSZeroRect]) { - self.textFieldView = [LKTextFieldView new]; - self.textFieldView.textField.delegate = self; - self.textFieldView.insets = NSEdgeInsetsMake(0, 0, 0, 0); - self.textFieldView.textField.editable = YES; - self.textFieldView.textField.bordered = NO; - self.textFieldView.textField.bezeled = NO; - self.textFieldView.textField.usesSingleLineMode = YES; - self.textFieldView.textField.backgroundColor = [NSColor clearColor]; - self.textFieldView.textField.lineBreakMode = NSLineBreakByTruncatingTail; - self.textFieldView.textField.font = NSFontMake(13); - [self addSubview:self.textFieldView]; - - self.suggestionWc = [LKInputSearchSuggestionWindowController new]; - self.suggestionWc.suggestionsView.tableView.target = self; - self.suggestionWc.suggestionsView.tableView.action = @selector(_handleClickTableView); - - @weakify(self); - [[self.textFieldView.textField.rac_textSignal throttle:throttleTime] subscribeNext:^(NSString * _Nullable x) { - @strongify(self); - NSArray *items; - if ([self.delegate respondsToSelector:@selector(inputSearchView:suggestionsForString:)]) { - items = [self.delegate inputSearchView:self suggestionsForString:x]; - } else { - items = nil; - } - - self.suggestionWc.suggestionsView.items = items; - if (items.count && [self.textField.stringValue isEqualToString:x]) { - [self.window addChildWindow:self.suggestionWc.window ordered:NSWindowAbove]; - [self setNeedsLayout:YES]; - } else { - [self.suggestionWc close]; - } - }]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.textFieldView).x(self.horizontalInset).toRight(self.horizontalInset).fullHeight; - - if (self.superview && self.window) { - CGPoint selfOrigin = [self.window.contentView convertPoint:self.frame.origin fromView:self.superview]; - CGRect mainWindowFrame = self.window.frame; - NSSize panelSize = self.suggestionWc.suggestionsView.bestSize; - CGFloat panelHeight = panelSize.height; - CGFloat panelWidth = MIN(panelSize.width, 500); - - CGFloat y = mainWindowFrame.origin.y + self.window.contentView.frame.size.height - selfOrigin.y - panelHeight - self.frame.size.height; - if (y < 200) { - // 底部空间不够,放到上面 - y += panelHeight + self.frame.size.height; - } - [self.suggestionWc.window setFrame:CGRectMake(mainWindowFrame.origin.x + selfOrigin.x, y, panelWidth, panelHeight) display:YES]; - } -} - -- (NSTextField *)textField { - return self.textFieldView.textField; -} - -- (void)_handleClickTableView { - LKInputSearchSuggestionItem *item = [self.suggestionWc.suggestionsView currentSelectedItem]; - if (item.text) { - self.textField.stringValue = item.text; - [self.suggestionWc close]; - } -} - -- (void)clearContentAndSuggestions { - if (self.textField.stringValue.length) { - self.previousInput = self.textField.stringValue; - } - - self.textField.stringValue = @""; - [self.suggestionWc close]; -} - -#pragma mark - - -- (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector { - if (commandSelector == @selector(moveUp:)) { - if (!self.suggestionWc.window.visible || !self.suggestionWc.suggestionsView.currentSelectedItem) { - if (self.previousInput) { - self.textField.stringValue = self.previousInput; - return YES; - } - } - } - - if (commandSelector == @selector(moveDown:) || commandSelector == @selector(moveUp:)) { - if (self.suggestionWc.window.visible) { - [self.suggestionWc.suggestionsView.tableView keyDown:[NSApp currentEvent]]; - return YES; - } - } - - if (commandSelector == @selector(insertNewline:)) { - if (self.suggestionWc.window.visible) { - LKInputSearchSuggestionItem *item = [self.suggestionWc.suggestionsView currentSelectedItem]; - if (item.text) { - self.textField.stringValue = item.text; - [self.suggestionWc close]; - return YES; - } - } - - NSString *input = [self.textField.stringValue stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; - if (input.length) { - if ([self.delegate respondsToSelector:@selector(inputSearchView:submitText:)]) { - [self.delegate inputSearchView:self submitText:input]; - } - return YES; - } - - // 防止 textField失去焦点 - return YES; - } - return NO; -} - -- (BOOL)control:(NSControl *)control textShouldEndEditing:(NSText *)fieldEditor { - [self.suggestionWc close]; - return YES; -} - -@end diff --git a/LookInside/Base/InputSearch/LKInputSearchView.swift b/LookInside/Base/InputSearch/LKInputSearchView.swift new file mode 100644 index 00000000..67074702 --- /dev/null +++ b/LookInside/Base/InputSearch/LKInputSearchView.swift @@ -0,0 +1,341 @@ +// +// LKInputSearchView.swift +// LookInside +// +// A single-line text field with a floating suggestion list, used by the +// Console input row. Typing asks the delegate for suggestions once the text +// has been still for the throttle time; Return submits the trimmed text, the +// arrow keys move through the suggestions, and Up on an empty selection +// restores the previous input. +// + +import AppKit + +extension NSAppearance { + /// The dark appearances, matched by name like `-[NSAppearance lk_isDarkMode]`. + var lkpIsDarkMode: Bool { + [.darkAqua, .vibrantDark, .accessibilityHighContrastDarkAqua, .accessibilityHighContrastVibrantDark] + .contains(name) + } +} + +final class LKInputSearchSuggestionItem: NSObject { + var image: NSImage? + var text: String? +} + +protocol LKInputSearchViewDelegate: AnyObject { + func inputSearchView(_ view: LKInputSearchView, suggestionsFor string: String) -> [LKInputSearchSuggestionItem]? + func inputSearchView(_ view: LKInputSearchView, submitText text: String) +} + +final class LKInputSearchView: LKBaseView, NSTextFieldDelegate { + weak var delegate: LKInputSearchViewDelegate? + + var horizontalInset: CGFloat = 0 + + var textField: NSTextField { + textFieldView.textField + } + + private let throttleTime: TimeInterval + private let textFieldView = LKTextFieldView() + private let suggestionWindowController = LKInputSearchSuggestionWindowController() + private var previousInput: String? + private var pendingSuggestionUpdate: DispatchWorkItem? + + init(throttleTime: CGFloat) { + self.throttleTime = TimeInterval(throttleTime) + super.init(frame: .zero) + + let textField: NSTextField = textFieldView.textField + textField.delegate = self + textFieldView.insets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + textField.isEditable = true + textField.isBordered = false + textField.isBezeled = false + textField.usesSingleLineMode = true + textField.backgroundColor = .clear + textField.lineBreakMode = .byTruncatingTail + textField.font = .systemFont(ofSize: 13) + addSubview(textFieldView) + + let tableView = suggestionWindowController.suggestionsView.tableView + tableView.target = self + tableView.action = #selector(handleClickTableView) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + textFieldView.lkpSetX(horizontalInset) + textFieldView.lkpToRight(horizontalInset) + textFieldView.lkpFullHeight() + + guard superview != nil, let window, let contentView = window.contentView, + let suggestionWindow = suggestionWindowController.window + else { return } + let selfOrigin = contentView.convert(frame.origin, from: superview) + let mainWindowFrame = window.frame + let panelSize = suggestionWindowController.suggestionsView.bestSize() + let panelHeight = panelSize.height + let panelWidth = min(panelSize.width, 500) + + var y = mainWindowFrame.origin.y + contentView.frame.height - selfOrigin.y - panelHeight - frame.height + if y < 200 { + // Not enough room below: show the list above the field. + y += panelHeight + frame.height + } + suggestionWindow.setFrame( + NSRect(x: mainWindowFrame.origin.x + selfOrigin.x, y: y, width: panelWidth, height: panelHeight), + display: true + ) + } + + func clearContentAndSuggestions() { + if !textField.stringValue.isEmpty { + previousInput = textField.stringValue + } + textField.stringValue = "" + suggestionWindowController.close() + } + + @objc private func handleClickTableView() { + if let text = suggestionWindowController.suggestionsView.currentSelectedItem()?.text { + textField.stringValue = text + suggestionWindowController.close() + } + } + + /// Runs `updateSuggestions(for:)` once the text has not changed for `throttleTime`. + private func scheduleSuggestionUpdate(for text: String) { + pendingSuggestionUpdate?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.updateSuggestions(for: text) + } + pendingSuggestionUpdate = work + DispatchQueue.main.asyncAfter(deadline: .now() + throttleTime, execute: work) + } + + private func updateSuggestions(for text: String) { + let items = delegate?.inputSearchView(self, suggestionsFor: text) ?? [] + suggestionWindowController.suggestionsView.items = items + if !items.isEmpty, textField.stringValue == text, let suggestionWindow = suggestionWindowController.window { + window?.addChildWindow(suggestionWindow, ordered: .above) + needsLayout = true + } else { + suggestionWindowController.close() + } + } + + // MARK: - NSTextFieldDelegate + + func controlTextDidChange(_ obj: Notification) { + let editorText = (obj.userInfo?["NSFieldEditor"] as? NSText)?.string ?? textField.stringValue + scheduleSuggestionUpdate(for: editorText) + } + + func control(_: NSControl, textView _: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + let suggestionsView = suggestionWindowController.suggestionsView + let isShowingSuggestions = suggestionWindowController.window?.isVisible ?? false + + if commandSelector == #selector(NSResponder.moveUp(_:)) { + if !isShowingSuggestions || suggestionsView.currentSelectedItem() == nil, let previousInput { + textField.stringValue = previousInput + return true + } + } + + if commandSelector == #selector(NSResponder.moveDown(_:)) || commandSelector == #selector(NSResponder.moveUp(_:)) { + if isShowingSuggestions, let event = NSApp.currentEvent { + suggestionsView.tableView.keyDown(with: event) + return true + } + } + + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + if isShowingSuggestions, let text = suggestionsView.currentSelectedItem()?.text { + textField.stringValue = text + suggestionWindowController.close() + return true + } + let input = textField.stringValue.trimmingCharacters(in: .whitespaces) + if !input.isEmpty { + delegate?.inputSearchView(self, submitText: input) + } + // Keep the focus in the field either way. + return true + } + return false + } + + func control(_: NSControl, textShouldEndEditing _: NSText) -> Bool { + suggestionWindowController.close() + return true + } +} + +/// The floating panel that shows the suggestion list. +final class LKInputSearchSuggestionWindowController: LKWindowController { + let suggestionsView: LKInputSearchSuggestionsContentView + + init() { + let view = LKInputSearchSuggestionsContentView() + suggestionsView = view + + let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 400, height: 100), + styleMask: .utilityWindow, backing: .buffered, defer: true) + panel.contentView = view + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.backgroundColor = .clear + super.init(window: panel) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } +} + +final class LKInputSearchSuggestionsContentView: LKBaseView, NSTableViewDelegate, NSTableViewDataSource { + private static let rowHeight: CGFloat = 28 + + var items: [LKInputSearchSuggestionItem] = [] { + didSet { tableView.reloadData() } + } + + let tableView = NSTableView() + + private let backgroundEffectView = LKVisualEffectView() + private lazy var measuringRowView = LKInputSearchSuggestionsRowView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + layer?.cornerRadius = 4 + + backgroundEffectView.blendingMode = .behindWindow + backgroundEffectView.state = .active + addSubview(backgroundEffectView) + + tableView.delegate = self + tableView.dataSource = self + tableView.wantsLayer = true + tableView.headerView = nil + tableView.intercellSpacing = .zero + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("column")) + column.isEditable = false + tableView.addTableColumn(column) + addSubview(tableView) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + backgroundEffectView.lkpFullFrame() + tableView.lkpFullFrame() + } + + func currentSelectedItem() -> LKInputSearchSuggestionItem? { + let row = tableView.selectedRow + return items.indices.contains(row) ? items[row] : nil + } + + func bestSize() -> NSSize { + let rowView = measuringRowView + let width = items.reduce(CGFloat(0)) { widest, item in + rowView.imageView.image = item.image + rowView.titleLabel.stringValue = item.text ?? "" + return max(rowView.bestWidth(), widest) + } + return NSSize(width: width, height: Self.rowHeight * CGFloat(items.count)) + } + + // MARK: - NSTableViewDataSource, NSTableViewDelegate + + func numberOfRows(in _: NSTableView) -> Int { + items.count + } + + func tableView(_: NSTableView, heightOfRow _: Int) -> CGFloat { + Self.rowHeight + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + guard items.indices.contains(row) else { + return LKInputSearchSuggestionsRowView() + } + let item = items[row] + let identifier = NSUserInterfaceItemIdentifier("cell") + let view = tableView.makeView(withIdentifier: identifier, owner: self) as? LKInputSearchSuggestionsRowView + ?? { + let view = LKInputSearchSuggestionsRowView() + view.identifier = identifier + return view + }() + view.titleLabel.stringValue = item.text ?? "" + view.imageView.image = item.image + view.needsLayout = true + return view + } + + func tableView(_: NSTableView, viewFor _: NSTableColumn?, row _: Int) -> NSView? { + nil + } +} + +final class LKInputSearchSuggestionsRowView: NSTableRowView { + private static let horizontalInset: CGFloat = 10 + private static let titleLeft: CGFloat = 5 + + let titleLabel = LKLabel() + let imageView = NSImageView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + addSubview(imageView) + + titleLabel.maximumNumberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingMiddle + titleLabel.font = .systemFont(ofSize: 14) + addSubview(titleLabel) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + imageView.lkpSizeToFit() + imageView.lkpSetX(Self.horizontalInset) + imageView.lkpVerAlign() + + titleLabel.lkpSetX(imageView.frame.maxX + Self.titleLeft) + titleLabel.lkpToRight(Self.horizontalInset) + titleLabel.lkpHeightToFit() + titleLabel.lkpVerAlign() + titleLabel.lkpOffsetY(-1) + } + + func bestWidth() -> CGFloat { + Self.horizontalInset * 2 + (imageView.image?.size.width ?? 0) + Self.titleLeft + + titleLabel.sizeThatFits(lkpMaxSize).width + } + + override func drawSelection(in _: NSRect) { + guard selectionHighlightStyle != .none else { return } + let color = effectiveAppearance.lkpIsDarkMode ? LKHelper.accentColor() : NSColor(red: 0, green: 0, blue: 0, alpha: 0.24) + color.setFill() + NSBezierPath(rect: bounds).fill() + } +} diff --git a/LookInside/Base/InputSearch/NSView+LKPeripheralLayout.swift b/LookInside/Base/InputSearch/NSView+LKPeripheralLayout.swift new file mode 100644 index 00000000..0e69f802 --- /dev/null +++ b/LookInside/Base/InputSearch/NSView+LKPeripheralLayout.swift @@ -0,0 +1,196 @@ +// +// NSView+LKPeripheralLayout.swift +// LookInside +// +// Frame helpers for the Swift peripheral modules (About, Console, Export, +// Measure, Preference, InputSearch). They keep the arithmetic of the +// ShortCocoa calls they replace: setters snap to the main screen's pixel +// grid, `lkpSizeToFit` / `lkpHeightToFit` only size LKBaseView and NSControl +// instances, and edges are measured against the superview's bounds. +// + +import AppKit + +/// Rounds up to the main screen's pixel grid, like ShortCocoa's `CGFloatSnapToPixel`. +func lkpSnapToPixel(_ value: CGFloat) -> CGFloat { + if value == .leastNormalMagnitude || value == .greatestFiniteMagnitude { + return value + } + let scale = NSScreen.main?.backingScaleFactor ?? 1 + return (value * scale).rounded(.up) / scale +} + +/// `NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX)`. +let lkpMaxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + +extension NSView { + var lkpSuperSize: NSSize { + superview?.bounds.size ?? .zero + } + + /// ShortCocoa's `visibles` test: in a superview, not hidden, not transparent. + var lkpIsShown: Bool { + superview != nil && !isHidden && alphaValue >= 0.01 + } + + func lkpSetX(_ value: CGFloat) { + frame.origin.x = lkpSnapToPixel(value) + } + + func lkpSetY(_ value: CGFloat) { + frame.origin.y = lkpSnapToPixel(value) + } + + func lkpSetWidth(_ value: CGFloat) { + frame.size.width = lkpSnapToPixel(value) + } + + func lkpSetHeight(_ value: CGFloat) { + frame.size.height = lkpSnapToPixel(value) + } + + func lkpSetSize(_ size: NSSize) { + lkpSetWidth(size.width) + lkpSetHeight(size.height) + } + + func lkpSetMidX(_ value: CGFloat) { + lkpSetX(value - bounds.width / 2) + } + + func lkpSetMidY(_ value: CGFloat) { + lkpSetY(value - bounds.height / 2) + } + + func lkpSetMaxX(_ value: CGFloat) { + lkpSetX(value - bounds.width) + } + + func lkpSetMaxY(_ value: CGFloat) { + lkpSetY(value - bounds.height) + } + + func lkpOffsetY(_ delta: CGFloat) { + frame.origin.y = lkpSnapToPixel(frame.minY + delta) + } + + /// Stretches the width so the right edge sits `inset` from the superview's right edge. + func lkpToRight(_ inset: CGFloat) { + let width = lkpSnapToPixel(lkpSuperSize.width - frame.minX - inset) + frame.size.width = max(width, 0) + } + + /// Places the right edge `inset` from the superview's right edge. + func lkpRight(_ inset: CGFloat) { + lkpSetMaxX(lkpSuperSize.width - inset) + } + + /// Places the view `inset` from the superview's bottom edge. + func lkpBottom(_ inset: CGFloat) { + guard let superview else { return } + if superview.isFlipped { + lkpSetMaxY(superview.bounds.height - inset) + } else { + lkpSetY(inset) + } + } + + func lkpHorAlign() { + lkpSetMidX(lkpSuperSize.width / 2) + } + + func lkpVerAlign() { + lkpSetMidY(lkpSuperSize.height / 2) + } + + func lkpCenterAlign() { + lkpHorAlign() + lkpVerAlign() + } + + func lkpFullWidth() { + lkpSetX(0) + lkpToRight(0) + } + + func lkpFullHeight() { + lkpSetHeight(lkpSuperSize.height) + lkpSetY(0) + } + + func lkpFullFrame() { + lkpFullWidth() + lkpFullHeight() + } + + func lkpMaxWidth(_ value: CGFloat) { + if frame.width > value { + frame.size.width = value + } + } + + /// Sizes LKBaseView and NSControl instances to their fitting size; other views stay as they are. + func lkpSizeToFit() { + if let view = self as? LKBaseView { + view.setFrameSize(view.sizeThatFits(lkpMaxSize)) + } else if let control = self as? NSControl { + var size = control.sizeThatFits(lkpMaxSize) + if size.width.isNaN { + size.width = 0 + } + if size.height.isNaN { + size.height = 0 + } + control.setFrameSize(size) + } + } + + /// Sets the fitting height for the current width (LKBaseView and NSControl only). + func lkpHeightToFit() { + let limit = NSSize(width: bounds.width, height: .greatestFiniteMagnitude) + if let view = self as? LKBaseView { + frame.size.height = view.sizeThatFits(limit).height + } else if let control = self as? NSControl { + frame.size.height = control.sizeThatFits(limit).height + } + } + + /// Sets the fitting width for the current height (NSControl only). + func lkpWidthToFit() { + guard let control = self as? NSControl else { return } + let width = control.sizeThatFits(NSSize(width: .greatestFiniteMagnitude, height: bounds.height)).width + lkpSetWidth(width) + } +} + +extension CALayer { + func lkpSetFrame(x: CGFloat? = nil, y: CGFloat? = nil, width: CGFloat? = nil, height: CGFloat? = nil) { + var rect = frame + if let width { + rect.size.width = lkpSnapToPixel(width) + } + if let height { + rect.size.height = lkpSnapToPixel(height) + } + if let x { + rect.origin.x = lkpSnapToPixel(x) + } + if let y { + rect.origin.y = lkpSnapToPixel(y) + } + frame = rect + } + + /// Stretches the width so the right edge sits `inset` from the superlayer's right edge. + func lkpToRight(_ inset: CGFloat) { + let superWidth = superlayer?.bounds.width ?? 0 + var rect = frame + rect.size.width = max(lkpSnapToPixel(superWidth - rect.minX - inset), 0) + frame = rect + } + + func lkpFullWidth() { + lkpSetFrame(x: 0) + lkpToRight(0) + } +} diff --git a/LookInside/Base/LKBaseControl.h b/LookInside/Base/LKBaseControl.h deleted file mode 100644 index 8b6b4b78..00000000 --- a/LookInside/Base/LKBaseControl.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// LKBaseControl.h -// Lookin -// -// Created by Li Kai on 2018/8/28. -// https://lookin.work -// - -#import - -@interface LKBaseControl : NSControl - -@property(nonatomic, assign) SEL clickAction; - -- (void)triggerClickAction; - -- (void)addTarget:(id)target clickAction:(SEL)action; - -@property(nonatomic, assign) BOOL adjustAlphaWhenClick; - -@property(nonatomic, copy) void (^didChangeAppearance)(LKBaseControl *control, BOOL isDarkMode); - -@end - -@interface LKBaseControl (NSSubclassingHooks) - -/// 如果子类返回 YES,则 mouseEntered: 和 mouseExited: 会被调用。默认为 NO -- (BOOL)shouldTrackMouseEnteredAndExited; - -@end diff --git a/LookInside/Base/LKBaseControl.m b/LookInside/Base/LKBaseControl.m deleted file mode 100644 index f0ac5e14..00000000 --- a/LookInside/Base/LKBaseControl.m +++ /dev/null @@ -1,91 +0,0 @@ -// -// LKBaseControl.m -// Lookin -// -// Created by Li Kai on 2018/8/28. -// https://lookin.work -// - -#import "LKBaseControl.h" - -@interface LKBaseControl () - -@end - -@implementation LKBaseControl - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.wantsLayer = YES; - } - return self; -} - -- (BOOL)isFlipped { - return YES; -} - -- (void)mouseDown:(NSEvent *)event { - [super mouseDown:event]; - if (self.adjustAlphaWhenClick) { - self.alphaValue = .8; - } -} - -- (void)mouseUp:(NSEvent *)event { - [super mouseUp:event]; - [self triggerClickAction]; - if (self.adjustAlphaWhenClick) { - self.alphaValue = 1; - } -} - -- (void)addTarget:(id)target clickAction:(SEL)action { - self.target = target; - self.clickAction = action; -} - -- (void)triggerClickAction { - if (self.clickAction && self.target) { - [self sendAction:self.clickAction to:self.target]; - } -} - -- (void)viewDidChangeEffectiveAppearance { - [self _triggerDidChangeAppearanceBlock]; -} - -- (void)setDidChangeAppearance:(void (^)(LKBaseControl *, BOOL))didChangeAppearance { - _didChangeAppearance = didChangeAppearance; - [self _triggerDidChangeAppearanceBlock]; -} - -- (void)_triggerDidChangeAppearanceBlock { - if (self.didChangeAppearance) { - BOOL isDarkMode = [self.effectiveAppearance lk_isDarkMode]; - self.didChangeAppearance(self, isDarkMode); - } -} - -- (void)sizeToFit { - $(self).size([self bestSize]); -} - -- (BOOL)shouldTrackMouseEnteredAndExited { - return NO; -} - -- (void)updateTrackingAreas { - [super updateTrackingAreas]; - if (![self shouldTrackMouseEnteredAndExited]) { - return; - } - [self.trackingAreas enumerateObjectsUsingBlock:^(NSTrackingArea * _Nonnull oldArea, NSUInteger idx, BOOL * _Nonnull stop) { - [self removeTrackingArea:oldArea]; - }]; - - NSTrackingArea *newArea = [[NSTrackingArea alloc] initWithRect:self.bounds options:NSTrackingMouseEnteredAndExited|NSTrackingActiveInKeyWindow|NSTrackingInVisibleRect owner:self userInfo:nil]; - [self addTrackingArea:newArea]; -} - -@end diff --git a/LookInside/Base/LKBaseControl.swift b/LookInside/Base/LKBaseControl.swift new file mode 100644 index 00000000..06123f69 --- /dev/null +++ b/LookInside/Base/LKBaseControl.swift @@ -0,0 +1,94 @@ +// +// LKBaseControl.swift +// Lookin +// +// Created by Li Kai on 2018/8/28. +// https://lookin.work +// + +import AppKit + +@objc(LKBaseControl) +class LKBaseControl: NSControl { + @objc var clickAction: Selector? + + @objc var adjustAlphaWhenClick = false + + @objc var didChangeAppearance: ((LKBaseControl?, Bool) -> Void)? { + didSet { + triggerDidChangeAppearanceBlock() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + override var isFlipped: Bool { + true + } + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + if adjustAlphaWhenClick { + alphaValue = 0.8 + } + } + + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + triggerClickAction() + if adjustAlphaWhenClick { + alphaValue = 1 + } + } + + @objc(addTarget:clickAction:) + func addTarget(_ target: AnyObject?, clickAction action: Selector?) { + self.target = target + clickAction = action + } + + @objc func triggerClickAction() { + if let clickAction, target != nil { + sendAction(clickAction, to: target) + } + } + + override func viewDidChangeEffectiveAppearance() { + triggerDidChangeAppearanceBlock() + } + + private func triggerDidChangeAppearanceBlock() { + if let didChangeAppearance { + didChangeAppearance(self, effectiveAppearance.lk_isDarkMode) + } + } + + override func sizeToFit() { + lkLayout.size(bestSize()) + } + + /// 如果子类返回 true,则 mouseEntered: 和 mouseExited: 会被调用。默认为 false + @objc func shouldTrackMouseEnteredAndExited() -> Bool { + false + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + guard shouldTrackMouseEnteredAndExited() else { + return + } + for oldArea in trackingAreas { + removeTrackingArea(oldArea) + } + + let newArea = NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], owner: self, userInfo: nil) + addTrackingArea(newArea) + } +} diff --git a/LookInside/Base/LKBaseView.h b/LookInside/Base/LKBaseView.h deleted file mode 100644 index 128f6bb0..00000000 --- a/LookInside/Base/LKBaseView.h +++ /dev/null @@ -1,66 +0,0 @@ -// -// LKBaseView.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import - -@class LKTwoColors; - -typedef NS_ENUM(NSInteger, LKViewBorderPosition) { - LKViewBorderPositionNone, - LKViewBorderPositionTop, - LKViewBorderPositionLeft, - LKViewBorderPositionBottom, - LKViewBorderPositionRight -}; - -@interface LKBaseView : NSView - -@property(nonatomic, copy) NSString *tooltipString; - -@property(nonatomic, strong) NSColor *backgroundColor; - -@property(nonatomic, strong) LKTwoColors *backgroundColors; - -/// 可以单独设置某一条边有 border,颜色由 borderColors 属性决定,注意通过这个属性设置了单边 border 后,就不要再使用系统的 layer.border 接口来设置四边 border 了 -/// 默认为 LKViewBorderPositionNone -@property(nonatomic, assign) LKViewBorderPosition borderPosition; - -/// 通过系统的 layer.border 接口和上面的 borderPosition 接口设置的 border 均可被该属性影响到 -/// 默认为 SeparatorLightModeColor / SeparatorDarkModeColor -@property(nonatomic, strong) LKTwoColors *borderColors; - -@property(nonatomic, assign, readonly) BOOL isVisible; - -- (CGFloat)heightForWidth:(CGFloat)width; - -/// 当设置该 block 之后,该 block 会立即被调用一次 -@property(nonatomic, copy) void (^didChangeAppearanceBlock)(LKBaseView *view, BOOL isDarkMode); - -- (BOOL)isDarkMode; - -/// 用户切换主题时,该方法会被调用 -- (void)updateColors NS_REQUIRES_SUPER; - -@property(nonatomic, copy) void (^didLayout)(void); - -/// 是否磨砂背景 -@property(nonatomic, assign) BOOL hasEffectedBackground; - -@end - -@interface LKBaseView (SubslassingHooks) - -- (NSSize)sizeThatFits:(NSSize)limitedSize; - -- (void)sizeToFit; - -@end - -@interface LKVisualEffectView : NSVisualEffectView - -@end diff --git a/LookInside/Base/LKBaseView.m b/LookInside/Base/LKBaseView.m deleted file mode 100644 index 046a0f12..00000000 --- a/LookInside/Base/LKBaseView.m +++ /dev/null @@ -1,226 +0,0 @@ -// -// LKBaseView.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKBaseView.h" - -static void *LKBaseViewEffectiveAppearanceContext = &LKBaseViewEffectiveAppearanceContext; - -@interface LKBaseView () - -@property(nonatomic, strong) CALayer *customBorderLayer; -@property(nonatomic, strong) LKVisualEffectView *backgroundEffectView; -@property(nonatomic, assign) BOOL isObservingEffectiveAppearance; - -@end - -@implementation LKBaseView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.wantsLayer = YES; - - self.borderColors = LKColorsCombine(SeparatorLightModeColor, SeparatorDarkModeColor); - [self _beginObservingAppearanceIfNeeded]; - -// self.layer.borderWidth = 1; -// self.layer.borderColor = [NSColor redColor].CGColor; - } - return self; -} - -- (void)dealloc { - if (self.isObservingEffectiveAppearance) { - [self removeObserver:self forKeyPath:@"effectiveAppearance" context:LKBaseViewEffectiveAppearanceContext]; - } -} - -- (BOOL)isVisible { - BOOL isVisible = self.superview && !self.hidden && self.alphaValue >= 0.01; - return isVisible; -} - -- (void)setBackgroundColor:(NSColor *)backgroundColor { - _backgroundColor = backgroundColor; - self.layer.backgroundColor = backgroundColor.CGColor; -} - -- (void)setBackgroundColors:(LKTwoColors *)backgroundColors { - _backgroundColors = backgroundColors; - [self updateColors]; -} - -- (void)setBorderColors:(LKTwoColors *)borderColors { - _borderColors = borderColors; - [self updateColors]; -} - -- (void)setBorderPosition:(LKViewBorderPosition)borderPosition { - _borderPosition = borderPosition; - if (borderPosition == LKViewBorderPositionNone) { - [self.customBorderLayer removeFromSuperlayer]; - return; - } - if (!self.customBorderLayer) { - self.customBorderLayer = [CALayer layer]; - [self.customBorderLayer lookin_removeImplicitAnimations]; - [self updateColors]; - [self.layer addSublayer:self.customBorderLayer]; - } - [self setNeedsLayout:YES]; -} - -- (BOOL)isFlipped { - return YES; -} - -- (NSView *)hitTest:(NSPoint)point { - if (self.hidden || self.alphaValue <= 0) { - return nil; - } - return [super hitTest:point]; -} - -- (void)layout { - [super layout]; - - if (self.backgroundEffectView) { - $(self.backgroundEffectView).fullFrame; - } - - if (self.tooltipString) { - [self addToolTipRect:self.bounds owner:self userData:nil]; - } else { - [self removeAllToolTips]; - } - - switch (self.borderPosition) { - case LKViewBorderPositionNone: - break; - case LKViewBorderPositionTop: - $(self.customBorderLayer).fullWidth.height(1).y(0); - break; - case LKViewBorderPositionLeft: - $(self.customBorderLayer).fullHeight.width(1).x(0); - break; - case LKViewBorderPositionBottom: - $(self.customBorderLayer).fullFrame.height(1).bottom(0); - break; - case LKViewBorderPositionRight: - $(self.customBorderLayer).fullHeight.width(1).right(0); - break; - } - - if (self.didLayout) { - self.didLayout(); - } -} - -- (NSString *)view:(NSView *)view stringForToolTip:(NSToolTipTag)tag point:(NSPoint)point userData:(void *)data { - return self.tooltipString; -} - -- (void)setTooltipString:(NSString *)tooltipString { - _tooltipString = tooltipString; - if (tooltipString) { - [self addToolTipRect:self.bounds owner:self userData:nil]; - } else { - [self removeAllToolTips]; - } -} - -- (CGFloat)heightForWidth:(CGFloat)width { - CGFloat height = [self sizeThatFits:NSMakeSize(width, CGFLOAT_MAX)].height; - return height; -} - -- (void)updateLayer { - [super updateLayer]; - if (self.backgroundColorName) { - self.layer.backgroundColor = [NSColor colorNamed:self.backgroundColorName].CGColor; - } -} - -- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { - if (context == LKBaseViewEffectiveAppearanceContext) { - [self _triggerDidChangeAppearanceBlock]; - return; - } - [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; -} - -- (void)setDidChangeAppearanceBlock:(void (^)(LKBaseView *, BOOL))didChangeAppearance { - _didChangeAppearanceBlock = didChangeAppearance; - [self _triggerDidChangeAppearanceBlock]; -} - -- (void)_beginObservingAppearanceIfNeeded { - if (self.isObservingEffectiveAppearance) { - return; - } - [self addObserver:self forKeyPath:@"effectiveAppearance" options:NSKeyValueObservingOptionInitial | NSKeyValueObservingOptionNew context:LKBaseViewEffectiveAppearanceContext]; - self.isObservingEffectiveAppearance = YES; -} - -- (void)_triggerDidChangeAppearanceBlock { - if (self.didChangeAppearanceBlock) { - self.didChangeAppearanceBlock(self, [self isDarkMode]); - } - [self updateColors]; -} - -- (void)updateColors { - if (self.backgroundColors) { - self.layer.backgroundColor = self.backgroundColors.color.CGColor; - } - if (self.borderColors) { - self.layer.borderColor = self.borderColors.color.CGColor; - self.customBorderLayer.backgroundColor = self.borderColors.color.CGColor; - } -} - -- (BOOL)isDarkMode { - return [self.effectiveAppearance lk_isDarkMode]; -} - -- (void)setHasEffectedBackground:(BOOL)hasEffectedBackground { - _hasEffectedBackground = hasEffectedBackground; - if (hasEffectedBackground) { - if (self.backgroundEffectView) { - return; - } - self.backgroundEffectView = [LKVisualEffectView new]; - self.backgroundEffectView.blendingMode = NSVisualEffectBlendingModeWithinWindow; - self.backgroundEffectView.state = NSVisualEffectStateActive; - [self lk_insertSubviewAtBottom:self.backgroundEffectView]; - [self setNeedsLayout:YES]; - } else { - [self.backgroundEffectView removeFromSuperview]; - self.backgroundEffectView = nil; - } -} - -@end - -@implementation LKBaseView (SubslassingHooks) - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - return NSZeroSize; -} - -- (void)sizeToFit { -} - -@end - -@implementation LKVisualEffectView - -- (BOOL)isFlipped { - return YES; -} - -@end diff --git a/LookInside/Base/LKBaseView.swift b/LookInside/Base/LKBaseView.swift new file mode 100644 index 00000000..a01c6184 --- /dev/null +++ b/LookInside/Base/LKBaseView.swift @@ -0,0 +1,235 @@ +// +// LKBaseView.swift +// Lookin +// +// Created by Li Kai on 2018/8/4. +// https://lookin.work +// + +import AppKit + +private let effectiveAppearanceContext = UnsafeMutableRawPointer.allocate(byteCount: 1, alignment: 1) + +@objc enum LKViewBorderPosition: Int { + case none + case top + case left + case bottom + case right +} + +@objc(LKBaseView) +@objcMembers +class LKBaseView: NSView, NSViewToolTipOwner { + var tooltipString: String? { + didSet { + if tooltipString != nil { + addToolTip(bounds, owner: self, userData: nil) + } else { + removeAllToolTips() + } + } + } + + var backgroundColor: NSColor? { + didSet { + layer?.backgroundColor = backgroundColor?.cgColor + } + } + + var backgroundColors: LKTwoColors? { + didSet { + updateColors() + } + } + + /// 可以单独设置某一条边有 border,颜色由 borderColors 属性决定,注意通过这个属性设置了单边 border 后,就不要再使用系统的 layer.border 接口来设置四边 border 了 + /// 默认为 .none + var borderPosition: LKViewBorderPosition = .none { + didSet { + if borderPosition == .none { + customBorderLayer?.removeFromSuperlayer() + return + } + if customBorderLayer == nil { + let borderLayer = CALayer() + borderLayer.lookin_removeImplicitAnimations() + customBorderLayer = borderLayer + updateColors() + layer?.addSublayer(borderLayer) + } + needsLayout = true + } + } + + /// 通过系统的 layer.border 接口和上面的 borderPosition 接口设置的 border 均可被该属性影响到 + /// 默认为 SeparatorLightModeColor / SeparatorDarkModeColor + var borderColors: LKTwoColors? { + didSet { + updateColors() + } + } + + /// 当设置该 block 之后,该 block 会立即被调用一次 + var didChangeAppearanceBlock: ((LKBaseView?, Bool) -> Void)? { + didSet { + triggerDidChangeAppearanceBlock() + } + } + + var didLayout: (() -> Void)? + + /// 是否磨砂背景 + var hasEffectedBackground: Bool = false { + didSet { + if hasEffectedBackground { + if backgroundEffectView != nil { + return + } + let effectView = LKVisualEffectView() + effectView.blendingMode = .withinWindow + effectView.state = .active + backgroundEffectView = effectView + lk_insertSubviewAtBottom(effectView) + needsLayout = true + } else { + backgroundEffectView?.removeFromSuperview() + backgroundEffectView = nil + } + } + } + + private var customBorderLayer: CALayer? + private var backgroundEffectView: LKVisualEffectView? + private var isObservingEffectiveAppearance = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + + borderColors = LKTwoColors(colorInLightMode: .lkBaseRGB(215, 215, 215), colorInDarkMode: .lkBaseRGB(67, 67, 69)) + beginObservingAppearanceIfNeeded() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + deinit { + if isObservingEffectiveAppearance { + removeObserver(self, forKeyPath: "effectiveAppearance", context: effectiveAppearanceContext) + } + } + + /// Overrides the NSView extension (Base/Category/NSView+LookinClient.swift): + /// a base view also needs a superview to count as visible. + override var isVisible: Bool { + superview != nil && !isHidden && alphaValue >= 0.01 + } + + override var isFlipped: Bool { + true + } + + override func hitTest(_ point: NSPoint) -> NSView? { + if isHidden || alphaValue <= 0 { + return nil + } + return super.hitTest(point) + } + + override func layout() { + super.layout() + + if let backgroundEffectView { + backgroundEffectView.lkLayout.fullFrame() + } + + if tooltipString != nil { + addToolTip(bounds, owner: self, userData: nil) + } else { + removeAllToolTips() + } + + switch borderPosition { + case .top: + customBorderLayer?.lkLayout.fullWidth().height(1).y(0) + case .left: + customBorderLayer?.lkLayout.fullHeight().width(1).x(0) + case .bottom: + customBorderLayer?.lkLayout.fullFrame().height(1).bottom(0) + case .right: + customBorderLayer?.lkLayout.fullHeight().width(1).right(0) + default: + break + } + + didLayout?() + } + + func view(_: NSView, stringForToolTip _: NSView.ToolTipTag, point _: NSPoint, userData _: UnsafeMutableRawPointer?) -> String { + tooltipString ?? "" + } + + func height(forWidth width: CGFloat) -> CGFloat { + sizeThatFits(NSSize(width: width, height: .greatestFiniteMagnitude)).height + } + + override func updateLayer() { + super.updateLayer() + if let backgroundColorName { + layer?.backgroundColor = NSColor(named: backgroundColorName)?.cgColor + } + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + if context == effectiveAppearanceContext { + triggerDidChangeAppearanceBlock() + return + } + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + } + + private func beginObservingAppearanceIfNeeded() { + if isObservingEffectiveAppearance { + return + } + addObserver(self, forKeyPath: "effectiveAppearance", options: [.initial, .new], context: effectiveAppearanceContext) + isObservingEffectiveAppearance = true + } + + private func triggerDidChangeAppearanceBlock() { + didChangeAppearanceBlock?(self, isDarkMode()) + updateColors() + } + + /// 用户切换主题时,该方法会被调用;子类重写时要调用 super。 + func updateColors() { + if let backgroundColors { + layer?.backgroundColor = backgroundColors.color?.cgColor + } + if let borderColors { + layer?.borderColor = borderColors.color?.cgColor + customBorderLayer?.backgroundColor = borderColors.color?.cgColor + } + } + + func isDarkMode() -> Bool { + effectiveAppearance.lk_isDarkMode + } + + // MARK: - Subclassing hooks + + func sizeThatFits(_: NSSize) -> NSSize { + .zero + } + + func sizeToFit() {} +} + +@objc(LKVisualEffectView) +class LKVisualEffectView: NSVisualEffectView { + override var isFlipped: Bool { + true + } +} diff --git a/LookInside/Base/LKBaseViewController.h b/LookInside/Base/LKBaseViewController.h deleted file mode 100644 index d6de4958..00000000 --- a/LookInside/Base/LKBaseViewController.h +++ /dev/null @@ -1,34 +0,0 @@ -// -// LKBaseViewController.h -// Lookin -// -// Created by Li Kai on 2018/8/28. -// https://lookin.work -// - -#import - -@class LKRedTipsView; - -@interface LKBaseViewController : NSViewController - -/** - 如果使用该初始化方法,则 controller.view 会被赋值为传入的 view。 - 如果使用普通的 init 方法,或该方法传入 nil,则 controller 会自动创建一个 view,即 [self containerView] - */ -- (instancetype)initWithContainerView:(NSView *)view NS_DESIGNATED_INITIALIZER; - -@property(nonatomic, assign, readonly) BOOL isViewAppeared; - -@property(nonatomic, strong, readonly) LKRedTipsView *connectionTipsView; - -@end - -@interface LKBaseViewController (NSSubclassingHooks) - -- (NSView *)makeContainerView; - -/// 是否在连接断开时显示 tips,默认为 NO -- (BOOL)shouldShowConnectionTips; - -@end diff --git a/LookInside/Base/LKBaseViewController.m b/LookInside/Base/LKBaseViewController.m deleted file mode 100644 index 59e3b176..00000000 --- a/LookInside/Base/LKBaseViewController.m +++ /dev/null @@ -1,139 +0,0 @@ -// -// LKBaseViewController.m -// Lookin -// -// Created by Li Kai on 2018/8/28. -// https://lookin.work -// - -#import "LKBaseViewController.h" -#import "LKWindowController.h" -#import "LKTipsView.h" -#import "LKInspectableApp.h" -#import "LKNavigationManager.h" -#import "LKStaticWindowController.h" -#import "LookinLiveDocument.h" - -@interface LKBaseViewController () - -@end - -@implementation LKBaseViewController - -- (instancetype)initWithContainerView:(NSView *)view { - if (self = [super initWithNibName:nil bundle:nil]) { - if (!view) { - view = [self makeContainerView]; - } - self.view = view; - } - return self; -} - -- (instancetype)initWithNibName:(NSNibName)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil { - return [self initWithContainerView:nil]; -} - -- (instancetype)initWithCoder:(NSCoder *)coder { - return [self initWithContainerView:nil]; -} - -- (void)viewDidAppear { - [super viewDidAppear]; - _isViewAppeared = YES; -} - -- (void)setView:(NSView *)view { - [super setView:view]; - if (self.shouldShowConnectionTips) { - _connectionTipsView = [LKRedTipsView new]; - self.connectionTipsView.hidden = YES; - self.connectionTipsView.title = NSLocalizedString(@"Reconnecting…", nil); - self.connectionTipsView.buttonText = NSLocalizedString(@"Change App", nil); - self.connectionTipsView.target = self; - self.connectionTipsView.clickAction = @selector(_handleClickReconnectTips); - } -} - -- (void)viewWillAppear { - [super viewWillAppear]; - if (!self.shouldShowConnectionTips || self.connectionTipsView == nil) { - return; - } - // Phase F: drive the per-window banner from the host Live Doc's - // `connectionLossBannerMessage` (set when the channel dies, cleared - // when Phase D auto-reconnect succeeds). The lookup happens once - // here because the window↔doc binding is fixed for this controller's - // lifetime once the doc has called `-makeWindowControllers`. - LookinLiveDocument *doc = [LookinLiveDocument documentInWindow:self.view.window]; - if (!doc) { - return; - } - LKInspectableApp *initialApp = doc.inspectableApp; - if (initialApp) { - [self.connectionTipsView setImageByAppInfo:initialApp.appInfo]; - } - @weakify(self); - [[RACObserve(doc, connectionLossBannerMessage) takeUntil:self.rac_willDeallocSignal] - subscribeNext:^(NSString *message) { - @strongify(self); - NSView *containerView = self.view; - if (message.length == 0) { - [self.connectionTipsView endAnimation]; - self.connectionTipsView.hidden = YES; - } else { - if (!self.connectionTipsView.superview) { - [containerView addSubview:self.connectionTipsView]; - } - self.connectionTipsView.hidden = NO; - [self.connectionTipsView startAnimation]; - [containerView setNeedsLayout:YES]; - } - }]; - // Track inspectableApp swaps (Phase D auto-reconnect) so the banner - // image stays in sync with the currently-bound app. - [[RACObserve(doc, inspectableApp) takeUntil:self.rac_willDeallocSignal] - subscribeNext:^(LKInspectableApp *app) { - @strongify(self); - if (app) { - [self.connectionTipsView setImageByAppInfo:app.appInfo]; - } - }]; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - if (self.connectionTipsView.isVisible) { - CGFloat windowTitleHeight = [LKNavigationManager sharedInstance].windowTitleBarHeight; - $(self.connectionTipsView).sizeToFit.horAlign.y(windowTitleHeight + 10); - } -} - -- (void)_handleClickReconnectTips { - // Phase F: each Live Doc owns its own banner now, so clicking the - // "Change App" pill on this window's banner pops up the picker on - // *this* window (rather than routing through a global Static - // workspace singleton). - NSWindowController *wc = self.view.window.windowController; - if ([wc isKindOfClass:[LKStaticWindowController class]]) { - [(LKStaticWindowController *)wc popupAllInspectableAppsWithSource:MenuPopoverAppsListControllerEventSourceNoConnectionTips]; - } -} - -- (void)dealloc { - NSLog(@"%@ dealloc", self.class); -} - -@end - -@implementation LKBaseViewController (NSSubclassingHooks) - -- (NSView *)makeContainerView { - return [[LKBaseView alloc] init]; -} - -- (BOOL)shouldShowConnectionTips { - return NO; -} - -@end diff --git a/LookInside/Base/LKBaseViewController.swift b/LookInside/Base/LKBaseViewController.swift new file mode 100644 index 00000000..6a7b7efd --- /dev/null +++ b/LookInside/Base/LKBaseViewController.swift @@ -0,0 +1,108 @@ +// +// LKBaseViewController.swift +// Lookin +// +// Created by Li Kai on 2018/8/28. +// https://lookin.work +// + +import AppKit + +@objc(LKBaseViewController) +@objcMembers +class LKBaseViewController: NSViewController { + private(set) var isViewAppeared = false + + private(set) var connectionTipsView: LKRedTipsView? + + private var connectionTipsBinding: LKConnectionTipsBinding? + + /// 如果使用该初始化方法,则 controller.view 会被赋值为传入的 view。 + /// 如果使用普通的 init 方法,或该方法传入 nil,则 controller 会自动创建一个 view,即 makeContainerView() + init(containerView view: NSView?) { + super.init(nibName: nil, bundle: nil) + self.view = view ?? makeContainerView() + } + + override convenience init(nibName _: NSNib.Name?, bundle _: Bundle?) { + self.init(containerView: nil) + } + + required convenience init?(coder _: NSCoder) { + self.init(containerView: nil) + } + + deinit { + NSLog("%@ dealloc", NSStringFromClass(type(of: self))) + } + + override func viewDidAppear() { + super.viewDidAppear() + isViewAppeared = true + } + + override var view: NSView { + get { + super.view + } + set { + super.view = newValue + if shouldShowConnectionTips() { + let tipsView = LKRedTipsView() + tipsView.isHidden = true + tipsView.title = NSLocalizedString("Reconnecting…", comment: "") + tipsView.buttonText = NSLocalizedString("Change App", comment: "") + tipsView.target = self + tipsView.clickAction = #selector(handleClickReconnectTips) + connectionTipsView = tipsView + } + } + } + + override func viewWillAppear() { + super.viewWillAppear() + guard shouldShowConnectionTips(), let connectionTipsView else { + return + } + // Phase F: drive the per-window banner from the host Live Doc's + // `connectionLossBannerMessage` (set when the channel dies, cleared + // when Phase D auto-reconnect succeeds). The lookup happens once + // here because the window↔doc binding is fixed for this controller's + // lifetime once the doc has called `-makeWindowControllers`. + guard let document = LookinLiveDocument.document(in: view.window) else { + return + } + // The binding also follows inspectableApp swaps (Phase D auto-reconnect) + // so the banner image stays in sync with the currently-bound app. + connectionTipsBinding = LKConnectionTipsBinding(document: document, tipsView: connectionTipsView, viewController: self) + } + + override func viewDidLayout() { + super.viewDidLayout() + if let connectionTipsView, connectionTipsView.isVisible { + let windowTitleHeight = LKNavigationManager.shared.windowTitleBarHeight + connectionTipsView.lkLayout.sizeToFit().horAlign().y(windowTitleHeight + 10) + } + } + + @objc private func handleClickReconnectTips() { + // Phase F: each Live Doc owns its own banner now, so clicking the + // "Change App" pill on this window's banner pops up the picker on + // *this* window (rather than routing through a global Static + // workspace singleton). + if let windowController = view.window?.windowController as? LKStaticWindowController { + windowController.popupAllInspectableApps(with: .noConnectionTips) + } + } + + // MARK: - Subclassing hooks + + func makeContainerView() -> NSView { + LKBaseView() + } + + /// 是否在连接断开时显示 tips,默认为 NO + func shouldShowConnectionTips() -> Bool { + false + } +} diff --git a/LookInside/Base/LKColorIndicatorLayer.h b/LookInside/Base/LKColorIndicatorLayer.h deleted file mode 100644 index 68f066bf..00000000 --- a/LookInside/Base/LKColorIndicatorLayer.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LKColorIndicatorLayer.h -// Lookin -// -// Created by Li Kai on 2019/1/19. -// https://lookin.work -// - -#import - -/// 当 backgroundColor -@interface LKColorIndicatorLayer : CALayer - -/// 默认为 (0, 0, 0) -@property(nonatomic, strong) NSColor *color; - -+ (NSImage *)imageWithColor:(NSColor *)color shapeSize:(NSSize)shapeSize insets:(NSEdgeInsets)insets; - -@end diff --git a/LookInside/Base/LKColorIndicatorLayer.m b/LookInside/Base/LKColorIndicatorLayer.m deleted file mode 100644 index 252b9707..00000000 --- a/LookInside/Base/LKColorIndicatorLayer.m +++ /dev/null @@ -1,108 +0,0 @@ -// -// LKColorIndicatorLayer.m -// Lookin -// -// Created by Li Kai on 2019/1/19. -// https://lookin.work -// - -#import "LKColorIndicatorLayer.h" - -@interface LKColorIndicatorLayer () - -@property(nonatomic, strong) CALayer *imageLayer; - -@property(nonatomic, strong) CALayer *colorLayer; - -@end - -@implementation LKColorIndicatorLayer - -- (instancetype)init { - if (self = [super init]) { - [self lookin_removeImplicitAnimations]; - - self.borderWidth = 1; - - _color = LookinColorMake(0, 0, 0); - self.colorLayer = [CALayer layer]; - self.colorLayer.backgroundColor = self.color.CGColor; - [self.colorLayer lookin_removeImplicitAnimations]; - [self addSublayer:self.colorLayer]; - - self.masksToBounds = YES; - } - return self; -} - -- (void)setColor:(NSColor *)color { - _color = color; - - if (color) { - if ([color alphaComponent] < 1) { - [self _createImageLayerIfNeeded]; - self.imageLayer.hidden = NO; - self.imageLayer.contents = NSImageMake(@"Transparent_Background"); - } else { - self.imageLayer.hidden = YES; - } - - } else { - [self _createImageLayerIfNeeded]; - self.imageLayer.hidden = NO; - self.imageLayer.contents = NSImageMake(@"Nil_Color_Image"); - } - self.colorLayer.backgroundColor = color.CGColor; - - self.borderColor = [self _contrastColorForColor:color].CGColor; -} - -- (NSColor *)_contrastColorForColor:(NSColor *)color { - if (!color) { - return LookinColorMake(191, 191, 191); - } - - CGFloat hue, saturation, brightness, alpha; - [color getHue:&hue saturation:&saturation brightness:&brightness alpha:&alpha]; - - CGFloat newBrightness = (brightness > .5) ? (brightness - .2) : (brightness + .2); - CGFloat newAlpha = MIN(1, alpha + .3); - - NSColor *resultColor = [NSColor colorWithHue:hue saturation:saturation brightness:newBrightness alpha:newAlpha]; - return resultColor; -} - -- (void)_createImageLayerIfNeeded { - if (self.imageLayer) { - return; - } - self.imageLayer = [CALayer layer]; - [self.imageLayer lookin_removeImplicitAnimations]; - [self insertSublayer:self.imageLayer atIndex:0]; - [self setNeedsLayout]; -} - -- (void)layoutSublayers { - [super layoutSublayers]; - - $(self.imageLayer, self.colorLayer).visibles.fullFrame; - self.cornerRadius = MIN(self.$width, self.$height) / 2.0; -} - -+ (NSImage *)imageWithColor:(NSColor *)color shapeSize:(NSSize)shapeSize insets:(NSEdgeInsets)insets { - static LKColorIndicatorLayer *layer = nil; - if (!layer) { - layer = [LKColorIndicatorLayer new]; - } - - NSImage *image = [[NSImage alloc] initWithSize:NSMakeSize(shapeSize.width + insets.left + insets.right, shapeSize.height + insets.top + insets.bottom)]; - [image lockFocus]; - layer.frame = NSMakeRect(0, 0, shapeSize.width, shapeSize.height); - layer.color = color; - CGContextTranslateCTM([NSGraphicsContext currentContext].CGContext, insets.left, insets.top); - [layer renderInContext:[NSGraphicsContext currentContext].CGContext]; - [image unlockFocus]; - return image; -} - -@end diff --git a/LookInside/Base/LKColorIndicatorLayer.swift b/LookInside/Base/LKColorIndicatorLayer.swift new file mode 100644 index 00000000..43837884 --- /dev/null +++ b/LookInside/Base/LKColorIndicatorLayer.swift @@ -0,0 +1,100 @@ +// +// LKColorIndicatorLayer.swift +// LookInside +// + +import AppKit + +/// A round colour swatch: a checkerboard behind translucent colours, a +/// "no colour" image for nil, and a contrasting border. +@objc(LKColorIndicatorLayer) +class LKColorIndicatorLayer: CALayer { + private var colorLayer: CALayer? + private var imageLayer: CALayer? + + /// Defaults to black. + @objc var color: NSColor? = .lkBaseRGB(0, 0, 0) { + didSet { updateForColor() } + } + + override init() { + super.init() + lookin_removeImplicitAnimations() + borderWidth = 1 + let colorLayer = CALayer() + colorLayer.backgroundColor = color?.cgColor + colorLayer.lookin_removeImplicitAnimations() + addSublayer(colorLayer) + self.colorLayer = colorLayer + masksToBounds = true + } + + /// Presentation copies carry no sublayer references. + override init(layer: Any) { + super.init(layer: layer) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + private func updateForColor() { + if let color { + if color.alphaComponent < 1 { + createImageLayerIfNeeded() + imageLayer?.isHidden = false + imageLayer?.contents = NSImage(named: "Transparent_Background") + } else { + imageLayer?.isHidden = true + } + } else { + createImageLayerIfNeeded() + imageLayer?.isHidden = false + imageLayer?.contents = NSImage(named: "Nil_Color_Image") + } + colorLayer?.backgroundColor = color?.cgColor + borderColor = contrastColor(for: color).cgColor + } + + private func contrastColor(for color: NSColor?) -> NSColor { + guard let color else { return .lkBaseRGB(191, 191, 191) } + var hue: CGFloat = 0, saturation: CGFloat = 0, brightness: CGFloat = 0, alpha: CGFloat = 0 + color.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha) + let newBrightness = brightness > 0.5 ? brightness - 0.2 : brightness + 0.2 + let newAlpha = min(1, alpha + 0.3) + return NSColor(hue: hue, saturation: saturation, brightness: newBrightness, alpha: newAlpha) + } + + private func createImageLayerIfNeeded() { + guard imageLayer == nil else { return } + let imageLayer = CALayer() + imageLayer.lookin_removeImplicitAnimations() + insertSublayer(imageLayer, at: 0) + self.imageLayer = imageLayer + setNeedsLayout() + } + + override func layoutSublayers() { + super.layoutSublayers() + LKFrameLayout([imageLayer, colorLayer]).visibles().fullFrame() + cornerRadius = min(frame.width, frame.height) / 2 + } + + private static let renderingLayer = LKColorIndicatorLayer() + + /// The swatch drawn into an image of `shapeSize` plus `insets`. + @objc(imageWithColor:shapeSize:insets:) + static func image(with color: NSColor?, shapeSize: NSSize, insets: NSEdgeInsets) -> NSImage { + let layer = renderingLayer + let image = NSImage(size: NSSize(width: shapeSize.width + insets.left + insets.right, height: shapeSize.height + insets.top + insets.bottom)) + image.lockFocus() + layer.frame = NSRect(origin: .zero, size: shapeSize) + layer.color = color + if let context = NSGraphicsContext.current?.cgContext { + context.translateBy(x: insets.left, y: insets.top) + layer.render(in: context) + } + image.unlockFocus() + return image + } +} diff --git a/LookInside/Base/LKConnectionTipsBinding.swift b/LookInside/Base/LKConnectionTipsBinding.swift new file mode 100644 index 00000000..79628a5d --- /dev/null +++ b/LookInside/Base/LKConnectionTipsBinding.swift @@ -0,0 +1,46 @@ +// +// LKConnectionTipsBinding.swift +// LookInside +// +// Drives a view controller's "Reconnecting…" banner from its live +// document: the banner shows (and pulses) while the document has a +// connection-loss message and follows the document's current app icon. +// Used by LKBaseViewController, which stays Objective-C while ObjC view +// controllers still subclass it. +// + +import AppKit + +@objc(LKConnectionTipsBinding) +final class LKConnectionTipsBinding: NSObject { + private var observations: [NSKeyValueObservation] = [] + + /// Starts observing right away and applies the current state. Holds the + /// tips view and view controller weakly; observation stops when the + /// binding is released. + @objc(initWithDocument:tipsView:viewController:) + init(document: LookinLiveDocument, tipsView: LKRedTipsView, viewController: NSViewController) { + super.init() + observations = [ + document.observe(\.connectionLossBannerMessage, options: [.initial, .new]) { [weak tipsView, weak viewController] document, _ in + guard let tipsView, let viewController else { return } + let containerView = viewController.view + if (document.connectionLossBannerMessage ?? "").isEmpty { + tipsView.endAnimation() + tipsView.isHidden = true + } else { + if tipsView.superview == nil { + containerView.addSubview(tipsView) + } + tipsView.isHidden = false + tipsView.startAnimation() + containerView.needsLayout = true + } + }, + // Auto-reconnect swaps the app; keep the banner's icon in step. + document.observe(\.inspectableApp, options: [.initial, .new]) { [weak tipsView] document, _ in + tipsView?.setImage(byAppInfo: document.inspectableApp.appInfo) + }, + ] + } +} diff --git a/LookInside/Base/LKDeviceIconProvider.swift b/LookInside/Base/LKDeviceIconProvider.swift index 8f7c4352..85f126d7 100644 --- a/LookInside/Base/LKDeviceIconProvider.swift +++ b/LookInside/Base/LKDeviceIconProvider.swift @@ -15,7 +15,6 @@ import UIFoundationToolbox /// a model this macOS does not declare (hardware newer than the host OS, or a VM). @objc(LKDeviceIconProvider) final class LKDeviceIconProvider: NSObject { - /// Height of the device icon in the window toolbar, matching the legacy /// `icon_*_small` assets (32 pixels at 2x). @objc static let toolbarPointSize: CGFloat = 16 @@ -41,7 +40,8 @@ final class LKDeviceIconProvider: NSObject { static func deviceIcon(forAppInfo appInfo: LookinAppInfo?, pointSize: CGFloat) -> NSImage? { guard let appInfo, let modelIdentifier = appInfo.deviceModelIdentifier, - modelIdentifier.isEmpty == false else { + modelIdentifier.isEmpty == false + else { return nil } if appInfo.deviceType == .simulator { @@ -53,7 +53,8 @@ final class LKDeviceIconProvider: NSObject { /// The full-colour CoreTypes icon for a physical device. private static func hardwareIcon(forModelIdentifier modelIdentifier: String, pointSize: CGFloat) -> NSImage? { guard let icon = NSWorkspace.shared.box.declaredDeviceIcon(forModelIdentifier: modelIdentifier), - let resizedIcon = icon.copy() as? NSImage else { + let resizedIcon = icon.copy() as? NSImage + else { return nil } // Resize a copy: NSWorkspace vends a shared multi-representation icns, and setting @@ -73,7 +74,8 @@ final class LKDeviceIconProvider: NSObject { } let symbolConfiguration = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .regular) guard let configuredIcon = symbolIcon.withSymbolConfiguration(symbolConfiguration), - configuredIcon.size.width > 0, configuredIcon.size.height > 0 else { + configuredIcon.size.width > 0, configuredIcon.size.height > 0 + else { return nil } // Draw the symbol into a square box of exactly `pointSize`, filling the same diff --git a/LookInside/Base/LKFrameLayout.swift b/LookInside/Base/LKFrameLayout.swift new file mode 100644 index 00000000..fb0d6760 --- /dev/null +++ b/LookInside/Base/LKFrameLayout.swift @@ -0,0 +1,479 @@ +// +// LKFrameLayout.swift +// LookInside +// +// Frame arithmetic for views and layers laid out by hand. It replaces +// ShortCocoa's `$(view)` chains with the same results: every setter snaps +// to the main screen's pixel grid, `sizeToFit` / `heightToFit` behave as +// ShortCocoa+LookinClient's replacements did, and views and layers can be +// mixed in one chain. +// + +import AppKit + +@objc(LKFrameLayout) +final class LKFrameLayout: NSObject { + private enum Target { + case view(NSView) + case layer(CALayer) + + var frame: CGRect { + get { + switch self { + case let .view(view): view.frame + case let .layer(layer): layer.frame + } + } + nonmutating set { + switch self { + case let .view(view): view.frame = newValue + case let .layer(layer): layer.frame = newValue + } + } + } + + var bounds: CGRect { + switch self { + case let .view(view): view.bounds + case let .layer(layer): layer.bounds + } + } + + /// The superview's or superlayer's bounds; nil without one. + var parentBounds: CGRect? { + switch self { + case let .view(view): view.superview?.bounds + case let .layer(layer): layer.superlayer?.bounds + } + } + + var parentIsFlipped: Bool { + switch self { + case let .view(view): view.superview?.isFlipped ?? false + case let .layer(layer): layer.superlayer?.contentsAreFlipped() ?? false + } + } + + var parent: AnyObject? { + switch self { + case let .view(view): view.superview + case let .layer(layer): layer.superlayer + } + } + + var isVisible: Bool { + switch self { + case let .view(view): + !view.isHidden && view.superview != nil && view.alphaValue >= 0.01 + case let .layer(layer): + !layer.isHidden && layer.superlayer != nil && layer.opacity >= 0.01 + } + } + } + + private var targets: [Target] + + /// Wraps views and layers; anything else (and nil) is ignored. + init(_ objects: [AnyObject?]) { + targets = objects.compactMap { object in + if let view = object as? NSView { + return .view(view) + } + if let layer = object as? CALayer { + return .layer(layer) + } + return nil + } + } + + private init(targets: [Target]) { + self.targets = targets + } + + /// ObjC entry point: `[LKFrameLayout of:view]`. + @objc(of:) + static func of(_ object: AnyObject?) -> LKFrameLayout { + LKFrameLayout([object]) + } + + /// ObjC entry point for several views or layers at once. + @objc(ofAll:) + static func ofAll(_ objects: [AnyObject]) -> LKFrameLayout { + LKFrameLayout(objects) + } + + /// Rounds up to the main screen's pixel grid. `CGFLOAT_MIN` and + /// `CGFLOAT_MAX` pass through because callers use them as markers. + @objc + static func snapToPixel(_ value: CGFloat) -> CGFloat { + if value == .leastNormalMagnitude || value == .greatestFiniteMagnitude { + return value + } + let scale = NSScreen.main?.backingScaleFactor ?? 0 + return ceil(value * scale) / scale + } + + private static func checked(_ value: CGFloat) -> CGFloat { + if value.isNaN { + assertionFailure("NaN passed to a layout setter") + return 0 + } + return value + } + + private func each(_ body: (Target) -> Void) { + targets.forEach(body) + } + + // MARK: - Sizing + + /// Sizes an LKBaseView or NSControl to `sizeThatFits:` of an unlimited + /// size; other views and layers are left alone. + @objc @discardableResult + func sizeToFit() -> LKFrameLayout { + each { target in + guard case let .view(view) = target else { return } + let unlimited = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + if let baseView = view as? LKBaseView { + var rect = baseView.frame + rect.size = baseView.sizeThatFits(unlimited) + baseView.frame = rect + } else if let control = view as? NSControl { + var size = control.sizeThatFits(unlimited) + if size.width.isNaN { + size.width = 0 + } + if size.height.isNaN { + size.height = 0 + } + var rect = control.frame + rect.size = size + control.frame = rect + } + } + return self + } + + /// Sets an LKBaseView's or NSControl's height to what it needs at its + /// current width. + @objc @discardableResult + func heightToFit() -> LKFrameLayout { + each { target in + guard case let .view(view) = target else { return } + let height: CGFloat + if let baseView = view as? LKBaseView { + height = baseView.sizeThatFits(NSSize(width: baseView.bounds.width, height: .greatestFiniteMagnitude)).height + } else if let control = view as? NSControl { + height = control.sizeThatFits(NSSize(width: control.bounds.width, height: .greatestFiniteMagnitude)).height + } else { + return + } + var rect = view.frame + rect.size.height = height + view.frame = rect + } + return self + } + + @objc(width:) @discardableResult + func width(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { $0.frame.size.width = Self.snapToPixel(value) } + return self + } + + @objc(height:) @discardableResult + func height(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { $0.frame.size.height = Self.snapToPixel(value) } + return self + } + + @objc(size:) @discardableResult + func size(_ value: NSSize) -> LKFrameLayout { + width(value.width).height(value.height) + } + + /// Grows the width to `value` when it is narrower; never snaps. + @objc(minWidth:) @discardableResult + func minWidth(_ value: CGFloat) -> LKFrameLayout { + if value.isNaN { + assertionFailure("NaN passed to minWidth") + return self + } + each { target in + if target.frame.width < value { + target.frame.size.width = value + } + } + return self + } + + // MARK: - Position + + @objc(x:) @discardableResult + func x(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { $0.frame.origin.x = Self.snapToPixel(value) } + return self + } + + @objc(y:) @discardableResult + func y(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { $0.frame.origin.y = Self.snapToPixel(value) } + return self + } + + @objc(offsetX:) @discardableResult + func offsetX(_ value: CGFloat) -> LKFrameLayout { + offset(x: value, y: 0) + } + + @objc(offsetY:) @discardableResult + func offsetY(_ value: CGFloat) -> LKFrameLayout { + offset(x: 0, y: value) + } + + private func offset(x: CGFloat, y: CGFloat) -> LKFrameLayout { + let x = Self.checked(x) + let y = Self.checked(y) + each { target in + var rect = target.frame + rect.origin.x = Self.snapToPixel(target.frame.minX + x) + rect.origin.y = Self.snapToPixel(target.frame.minY + y) + target.frame = rect + } + return self + } + + @objc(midX:) @discardableResult + func midX(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { LKFrameLayout(targets: [$0]).x(value - $0.bounds.width / 2) } + return self + } + + @objc(maxX:) @discardableResult + func maxX(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { LKFrameLayout(targets: [$0]).x(value - $0.bounds.width) } + return self + } + + @objc(midY:) @discardableResult + func midY(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { LKFrameLayout(targets: [$0]).y(value - $0.bounds.height / 2) } + return self + } + + @objc(maxY:) @discardableResult + func maxY(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { LKFrameLayout(targets: [$0]).y(value - $0.bounds.height) } + return self + } + + /// Puts the right edge `value` points from the parent's right edge. + @objc(right:) @discardableResult + func right(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { target in + guard let parentBounds = target.parentBounds else { + assertionFailure("right: needs a superview or superlayer") + return + } + LKFrameLayout(targets: [target]).maxX(parentBounds.width - value) + } + return self + } + + /// Puts the bottom edge `value` points from the parent's bottom edge, + /// in the parent's own (possibly unflipped) coordinates. + @objc(bottom:) @discardableResult + func bottom(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { target in + guard let parentBounds = target.parentBounds else { + assertionFailure("bottom: needs a superview or superlayer") + return + } + if target.parentIsFlipped { + LKFrameLayout(targets: [target]).maxY(parentBounds.height - value) + } else { + LKFrameLayout(targets: [target]).y(value) + } + } + return self + } + + @objc @discardableResult + func horAlign() -> LKFrameLayout { + each { target in + guard let parentBounds = target.parentBounds else { + assertionFailure("horAlign needs a superview or superlayer") + return + } + LKFrameLayout(targets: [target]).midX(parentBounds.width / 2) + } + return self + } + + @objc @discardableResult + func verAlign() -> LKFrameLayout { + each { target in + guard let parentBounds = target.parentBounds else { + assertionFailure("verAlign needs a superview or superlayer") + return + } + LKFrameLayout(targets: [target]).midY(parentBounds.height / 2) + } + return self + } + + // MARK: - Filling the parent + + @objc @discardableResult + func fullWidth() -> LKFrameLayout { + x(0).toRight(0) + } + + @objc @discardableResult + func fullHeight() -> LKFrameLayout { + each { target in + guard let parentBounds = target.parentBounds else { + assertionFailure("fullHeight needs a superview or superlayer") + return + } + LKFrameLayout(targets: [target]).height(parentBounds.height).y(0) + } + return self + } + + @objc @discardableResult + func fullFrame() -> LKFrameLayout { + fullWidth().fullHeight() + } + + /// Stretches the width so the right edge sits `value` points from the + /// parent's right edge; never negative. + @objc(toRight:) @discardableResult + func toRight(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { target in + let parentWidth = target.parentBounds?.width ?? 0 + var rect = target.frame + rect.size.width = max(0, Self.snapToPixel(parentWidth - rect.minX - value)) + target.frame = rect + } + return self + } + + /// Stretches the width so the right edge sits at `value`, never past the + /// left edge. + @objc(toMaxX:) @discardableResult + func toMaxX(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { target in + var rect = target.frame + let safeValue = max(value, rect.minX) + rect.size.width = Self.snapToPixel(safeValue - rect.minX) + target.frame = rect + } + return self + } + + /// Stretches the height so the bottom edge sits at `value`, never above + /// the top edge. + @objc(toMaxY:) @discardableResult + func toMaxY(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { target in + var rect = target.frame + let safeValue = max(value, rect.minY) + rect.size.height = Self.snapToPixel(safeValue - rect.minY) + target.frame = rect + } + return self + } + + /// Moves the top edge to `value` keeping the bottom edge in place. + @objc(toY:) @discardableResult + func toY(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { target in + var rect = target.frame + let safeValue = min(value, rect.maxY) + rect.size.height = Self.snapToPixel(rect.maxY - safeValue) + rect.origin.y = Self.snapToPixel(safeValue) + target.frame = rect + } + return self + } + + /// Stretches the height so the bottom edge sits `value` points from the + /// parent's bottom edge. + @objc(toBottom:) @discardableResult + func toBottom(_ value: CGFloat) -> LKFrameLayout { + let value = Self.checked(value) + each { target in + guard let parentBounds = target.parentBounds else { + assertionFailure("toBottom: needs a superview or superlayer") + return + } + if target.parentIsFlipped { + LKFrameLayout(targets: [target]).toMaxY(parentBounds.height - value) + } else { + LKFrameLayout(targets: [target]).toY(value) + } + } + return self + } + + // MARK: - Groups + + /// Keeps only the visible views and layers: not hidden, attached, and at + /// least 1% opaque. + @objc @discardableResult + func visibles() -> LKFrameLayout { + targets = targets.filter(\.isVisible) + return self + } + + /// Centres the group's union horizontally in the shared parent, moving + /// every member by the same amount. Does nothing when the members do + /// not share a parent. + @objc @discardableResult + func groupHorAlign() -> LKFrameLayout { + guard let first = targets.first, let parent = first.parent else { + if !targets.isEmpty { + assertionFailure("groupHorAlign needs a shared superview or superlayer") + } + return self + } + guard targets.allSatisfy({ $0.parent === parent }) else { + assertionFailure("groupHorAlign needs a shared superview or superlayer") + return self + } + let parentWidth = first.parentBounds?.width ?? 0 + let minX = targets.map(\.frame.minX).min() ?? 0 + let maxX = targets.map(\.frame.maxX).max() ?? 0 + let groupMidX = minX + (maxX - minX) / 2 + return offset(x: parentWidth / 2 - groupMidX, y: 0) + } +} + +extension NSView { + /// Frame layout for this view, replacing ShortCocoa's `$(view)`. + var lkLayout: LKFrameLayout { + LKFrameLayout([self]) + } +} + +extension CALayer { + /// Frame layout for this layer, replacing ShortCocoa's `$(layer)`. + var lkLayout: LKFrameLayout { + LKFrameLayout([self]) + } +} diff --git a/LookInside/Base/LKImageControl.h b/LookInside/Base/LKImageControl.h deleted file mode 100644 index cb87a033..00000000 --- a/LookInside/Base/LKImageControl.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// LKImageButton.h -// Lookin -// -// Created by Li Kai on 2018/9/1. -// https://lookin.work -// - -#import "LKBaseControl.h" - -@interface LKImageControl : LKBaseControl { - @protected - NSImageView *_imageView; -} - -@property(nonatomic, strong) NSImage *image; - -@end diff --git a/LookInside/Base/LKImageControl.m b/LookInside/Base/LKImageControl.m deleted file mode 100644 index 8c4e2eed..00000000 --- a/LookInside/Base/LKImageControl.m +++ /dev/null @@ -1,50 +0,0 @@ -// -// LKImageButton.m -// Lookin -// -// Created by Li Kai on 2018/9/1. -// https://lookin.work -// - -#import "LKImageControl.h" - -@interface LKImageControl () - -@end - -@implementation LKImageControl - -+ (instancetype)buttonWithImage:(NSImage *)image { - LKImageControl *button = [[LKImageControl alloc] init]; - button.image = image; - return button; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _imageView = [NSImageView new]; - _imageView.wantsLayer = YES; - [self addSubview:_imageView]; - } - return self; -} - -- (void)setImage:(NSImage *)image { - _image = image; - _imageView.image = image; - - [self setNeedsLayout:YES]; -} - -- (void)layout { - [super layout]; - _imageView.frame = self.bounds; - _imageView.layer.anchorPoint = NSMakePoint(.5, .5); - _imageView.layer.frame = _imageView.frame; -} - -- (NSSize)sizeThatFits:(NSSize)size { - return _imageView.image.size; -} - -@end diff --git a/LookInside/Base/LKImageTextView.h b/LookInside/Base/LKImageTextView.h deleted file mode 100644 index 54843c31..00000000 --- a/LookInside/Base/LKImageTextView.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// LKImageTextView.h -// Lookin -// -// Created by Li Kai on 2019/6/19. -// https://lookin.work -// - -#import "LKBaseView.h" - -@interface LKImageTextView : LKBaseView - -@property(nonatomic, strong, readonly) NSImageView *imageView; -@property(nonatomic, strong, readonly) LKLabel *label; - -@property(nonatomic, assign) HorizontalMargins imageMargins; - -@end diff --git a/LookInside/Base/LKImageTextView.m b/LookInside/Base/LKImageTextView.m deleted file mode 100644 index 3c1c89e6..00000000 --- a/LookInside/Base/LKImageTextView.m +++ /dev/null @@ -1,37 +0,0 @@ -// -// LKImageTextView.m -// Lookin -// -// Created by Li Kai on 2019/6/19. -// https://lookin.work -// - -#import "LKImageTextView.h" - -@implementation LKImageTextView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _imageView = [NSImageView new]; - [self addSubview:self.imageView]; - - _label = [LKLabel new]; - [self addSubview:self.label]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.imageView).sizeToFit.x(self.imageMargins.left).verAlign; - $(self.label).x(self.imageView.$maxX + self.imageMargins.right).toRight(0).heightToFit.verAlign; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - NSSize labelSize = [self.label sizeThatFits:NSSizeMax]; - limitedSize.width = self.imageMargins.left + self.imageView.image.size.width + self.imageMargins.right + labelSize.width; - limitedSize.height = MAX(self.imageView.image.size.height, labelSize.height); - return limitedSize; -} - -@end diff --git a/LookInside/Base/LKImageTextView.swift b/LookInside/Base/LKImageTextView.swift new file mode 100644 index 00000000..5d3571b2 --- /dev/null +++ b/LookInside/Base/LKImageTextView.swift @@ -0,0 +1,42 @@ +// +// LKImageTextView.swift +// LookInside +// + +import AppKit + +/// An image followed by a one-line label, centred vertically. +@objc(LKImageTextView) +class LKImageTextView: LKBaseView { + @objc let imageView = NSImageView() + @objc let label = LKLabel() + + var imageMargins = HorizontalMargins(left: 0, right: 0) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + addSubview(imageView) + addSubview(label) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + addSubview(imageView) + addSubview(label) + } + + override func layout() { + super.layout() + imageView.lkLayout.sizeToFit().x(imageMargins.left).verAlign() + label.lkLayout.x(imageView.frame.maxX + imageMargins.right).toRight(0).heightToFit().verAlign() + } + + override func sizeThatFits(_: NSSize) -> NSSize { + let labelSize = label.sizeThatFits(NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude)) + let imageSize = imageView.image?.size ?? .zero + return NSSize( + width: imageMargins.left + imageSize.width + imageMargins.right + labelSize.width, + height: max(imageSize.height, labelSize.height) + ) + } +} diff --git a/LookInside/Base/LKLabel.h b/LookInside/Base/LKLabel.h deleted file mode 100644 index 5ad01be5..00000000 --- a/LookInside/Base/LKLabel.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LKLabel.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import - -@class LKTwoColors; - -@interface LKLabel : NSTextField - -/// 默认为 nil -@property(nonatomic, strong) LKTwoColors *textColors; -/// 默认为 nil -@property(nonatomic, strong) LKTwoColors *backgroundColors; - - -@end diff --git a/LookInside/Base/LKLabel.m b/LookInside/Base/LKLabel.m deleted file mode 100644 index 2fe800a1..00000000 --- a/LookInside/Base/LKLabel.m +++ /dev/null @@ -1,61 +0,0 @@ -// -// LKLabel.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKLabel.h" - -@interface LKLabel () - -@end - -@implementation LKLabel - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.wantsLayer = YES; - self.backgroundColor = [NSColor clearColor]; - [self setBezeled:NO]; - [self setDrawsBackground:YES]; - [self setEditable:NO]; - [self setSelectable:NO]; - - [self _updateColors]; - } - return self; -} - -- (void)setStringValue:(NSString *)stringValue { - // 不做保护的话,传入 nil 会 crash - stringValue = stringValue ? : @""; - [super setStringValue:stringValue]; -} - -- (void)viewDidChangeEffectiveAppearance { - [super viewDidChangeEffectiveAppearance]; - [self _updateColors]; -} - -- (void)setTextColors:(LKTwoColors *)textColors { - _textColors = textColors; - [self _updateColors]; -} - -- (void)setBackgroundColors:(LKTwoColors *)backgroundColors { - _backgroundColors = backgroundColors; - [self _updateColors]; -} - -- (void)_updateColors { - if (self.textColors) { - self.textColor = self.textColors.color; - } - if (self.backgroundColors) { - self.backgroundColor = self.backgroundColors.color; - } -} - -@end diff --git a/LookInside/Base/LKLabel.swift b/LookInside/Base/LKLabel.swift new file mode 100644 index 00000000..d4635106 --- /dev/null +++ b/LookInside/Base/LKLabel.swift @@ -0,0 +1,67 @@ +// +// LKLabel.swift +// Lookin +// +// Created by Li Kai on 2018/8/4. +// https://lookin.work +// + +import AppKit + +@objc(LKLabel) +class LKLabel: NSTextField { + /// 默认为 nil + @objc var textColors: LKTwoColors? { + didSet { + updateColors() + } + } + + /// 默认为 nil + @objc var backgroundColors: LKTwoColors? { + didSet { + updateColors() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + backgroundColor = .clear + isBezeled = false + drawsBackground = true + isEditable = false + isSelectable = false + + updateColors() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + /// 不做保护的话,传入 nil 会 crash:an Objective-C nil reaches this + /// setter as an empty string. + override var stringValue: String { + get { + super.stringValue + } + set { + super.stringValue = newValue + } + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateColors() + } + + private func updateColors() { + if let textColors { + textColor = textColors.color + } + if let backgroundColors { + backgroundColor = backgroundColors.color + } + } +} diff --git a/LookInside/Base/LKNumberInputView.h b/LookInside/Base/LKNumberInputView.h deleted file mode 100644 index e1cdc319..00000000 --- a/LookInside/Base/LKNumberInputView.h +++ /dev/null @@ -1,33 +0,0 @@ -// -// LKNumberInputView.h -// Lookin -// -// Created by Li Kai on 2019/2/22. -// https://lookin.work -// - -#import "LKBaseView.h" -#import "LookinAttrType.h" - -typedef NS_ENUM(NSUInteger, LKNumberInputViewStyle) { - LKNumberInputViewStyleHorizontal, // titleLabel 在输入框内部的右侧 - LKNumberInputViewStyleVertical // titleLabel 在输入框的下面 -}; - -@class LKTextFieldView; - -extern const CGFloat LKNumberInputHorizontalHeight; -extern const CGFloat LKNumberInputVerticalHeight; - -@interface LKNumberInputView : LKBaseView - -@property(nonatomic, assign) LKNumberInputViewStyle viewStyle; - -@property(nonatomic, copy) NSString *title; - -@property(nonatomic, strong, readonly) LKTextFieldView *textFieldView; - -/// 将当前 string 转换成 attrType 格式的对象并返回,如果返回 nil 则说明转换失败 -+ (id)parsedValueWithString:(NSString *)string attrType:(LookinAttrType)attrType; - -@end diff --git a/LookInside/Base/LKNumberInputView.m b/LookInside/Base/LKNumberInputView.m deleted file mode 100644 index 78e6d9b8..00000000 --- a/LookInside/Base/LKNumberInputView.m +++ /dev/null @@ -1,161 +0,0 @@ -// -// LKNumberInputView.m -// Lookin -// -// Created by Li Kai on 2019/2/22. -// https://lookin.work -// - -#import "LKNumberInputView.h" -#import "LKTextFieldView.h" - -const CGFloat LKNumberInputHorizontalHeight = 21; -const CGFloat LKNumberInputVerticalHeight = 38; - -@interface LKNumberInputView () - -@property(nonatomic, strong) LKLabel *titleLabel; - -@end - -@implementation LKNumberInputView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { -// self.layer.borderWidth = 1; -// self.layer.borderColor = [NSColor greenColor].CGColor; - - _textFieldView = [LKTextFieldView new]; - self.textFieldView.backgroundColorName = @"DashboardCardValueBGColor"; - self.textFieldView.layer.cornerRadius = DashboardCardControlCornerRadius; - self.textFieldView.textField.cell = [NSTextFieldCell new]; - self.textFieldView.textField.cell.focusRingType = NSFocusRingTypeNone; - self.textFieldView.textField.cell.usesSingleLineMode = YES; - self.textFieldView.textField.cell.lineBreakMode = NSLineBreakByTruncatingTail; - self.textFieldView.textField.cell.scrollable = YES; - self.textFieldView.textField.cell.editable = YES; - self.textFieldView.textField.cell.selectable = YES; - self.textFieldView.textField.drawsBackground = NO; - self.textFieldView.textField.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - self.textFieldView.textField.font = NSFontMake(12); -// self.textFieldView.layer.borderColor = DashboardCardControlBorderColor.CGColor; -// self.textFieldView.layer.borderWidth = 1; - [self addSubview:self.textFieldView]; - - self.titleLabel = [LKLabel new]; - self.titleLabel.alignment = NSTextAlignmentCenter; - // The vertical style lays the title out under the field, inside a - // column that can be narrower than the title. Truncate rather than - // overflow — an unclamped title used to spill onto its neighbours. - self.titleLabel.lineBreakMode = NSLineBreakByTruncatingTail; - self.titleLabel.usesSingleLineMode = YES; - self.titleLabel.cell.truncatesLastVisibleLine = YES; -// self.titleLabel.layer.borderWidth = 1; -// self.titleLabel.layer.borderColor = [NSColor purpleColor].CGColor; - [self addSubview:self.titleLabel]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.textFieldView).fullWidth.height(LKNumberInputHorizontalHeight); - if (self.viewStyle == LKNumberInputViewStyleHorizontal) { - $(self.titleLabel).sizeToFit.lk_minWidth(15).verAlign.right(2); - self.textFieldView.insets = ({ - CGFloat right = self.$width - self.titleLabel.$x + 2; - NSEdgeInsets insets = self.textFieldView.insets; - insets.right = right; - insets; - }); - } else if (self.viewStyle == LKNumberInputViewStyleVertical) { - // Clamped to the column width. Previously this was sizeToFit + horAlign, - // so a title wider than its column kept its natural width, centred, and - // overlapped whatever sat to the left and right of it. - $(self.titleLabel).width(self.$width).heightToFit.x(0).y(self.textFieldView.$maxY + 3); - } else { - NSAssert(NO, @""); - } -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - if (self.viewStyle == LKNumberInputViewStyleHorizontal) { - limitedSize.height = LKNumberInputHorizontalHeight; - } else if (self.viewStyle == LKNumberInputViewStyleVertical) { - limitedSize.height = LKNumberInputVerticalHeight; - } else { - NSAssert(NO, @""); - } - return limitedSize; -} - -- (void)setTitle:(NSString *)title { - _title = title; - self.titleLabel.stringValue = title ?: @""; - // The title can be truncated or abbreviated to a single letter, so keep the - // full name reachable on hover. - self.titleLabel.toolTip = title.length > 0 ? title : nil; - [self _updateTitleLabelFontAndColor]; - [self setNeedsLayout:YES]; -} - -- (void)setViewStyle:(LKNumberInputViewStyle)viewStyle { - _viewStyle = viewStyle; - if (viewStyle == LKNumberInputViewStyleHorizontal) { - self.textFieldView.textField.alignment = NSTextAlignmentLeft; - self.textFieldView.insets = NSEdgeInsetsMake(3, 3, 3, 3); - - } else if (viewStyle == LKNumberInputViewStyleVertical) { - self.textFieldView.textField.alignment = NSTextAlignmentCenter; - self.textFieldView.insets = NSEdgeInsetsMake(2, 0, 2, 0); - } - [self _updateTitleLabelFontAndColor]; - [self setNeedsLayout:YES]; -} - -+ (id)parsedValueWithString:(NSString *)string attrType:(LookinAttrType)attrType { - switch (attrType) { - case LookinAttrTypeInt: - case LookinAttrTypeLong: - case LookinAttrTypeLongLong: { - NSScanner *scanner = [NSScanner scannerWithString:string]; - long long newValue; - BOOL isSucc = [scanner scanLongLong:&newValue]; - if (isSucc) { - return @(newValue); - } - return nil; - } - case LookinAttrTypeFloat: - case LookinAttrTypeDouble: { - NSScanner *scanner = [NSScanner scannerWithString:string]; - double newValue; - BOOL isSucc = [scanner scanDouble:&newValue]; - if (isSucc) { - return @(newValue); - } - return nil; - } - default: - NSAssert(NO, @"不支持该 AttrType"); - return nil; - } -} - -- (void)_updateTitleLabelFontAndColor { - if (self.viewStyle == LKNumberInputViewStyleHorizontal) { - if (self.title.length > 1) { - self.titleLabel.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - self.titleLabel.font = NSFontMake(12); - } else { - self.titleLabel.textColor = [NSColor colorNamed:@"DashboardInputAccessoryColor"]; - self.titleLabel.font = NSFontMake(10); - } - } else { - self.titleLabel.font = NSFontMake(11); - self.titleLabel.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - } - [self setNeedsLayout:YES]; -} - -@end diff --git a/LookInside/Base/LKNumberInputView.swift b/LookInside/Base/LKNumberInputView.swift new file mode 100644 index 00000000..2051cff9 --- /dev/null +++ b/LookInside/Base/LKNumberInputView.swift @@ -0,0 +1,145 @@ +// +// LKNumberInputView.swift +// LookInside +// + +import AppKit + +let LKNumberInputHorizontalHeight: CGFloat = 21 +let LKNumberInputVerticalHeight: CGFloat = 38 + +@objc enum LKNumberInputViewStyle: UInt { + /// The title sits inside the field, on its right. + case horizontal + /// The title sits under the field. + case vertical +} + +/// A Dashboard number field with a short title. +@objc(LKNumberInputView) +class LKNumberInputView: LKBaseView { + @objc let textFieldView = LKTextFieldView() + private let titleLabel = LKLabel() + + @objc var viewStyle: LKNumberInputViewStyle = .horizontal { + didSet { applyViewStyle() } + } + + @objc var title: String? { + didSet { + titleLabel.stringValue = title ?? "" + // The title can be truncated or abbreviated to a single letter, + // so keep the full name reachable on hover. + titleLabel.toolTip = title?.isEmpty == false ? title : nil + updateTitleLabelFontAndColor() + needsLayout = true + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + private func setUp() { + textFieldView.backgroundColorName = "DashboardCardValueBGColor" + textFieldView.layer?.cornerRadius = DashboardCardControlCornerRadius + let textField = textFieldView.textField + textField.cell = NSTextFieldCell() + textField.cell?.focusRingType = .none + textField.cell?.usesSingleLineMode = true + textField.cell?.lineBreakMode = .byTruncatingTail + textField.cell?.isScrollable = true + textField.cell?.isEditable = true + textField.cell?.isSelectable = true + textField.drawsBackground = false + textField.textColor = NSColor(named: "DashboardCardValueColor") + textField.font = NSFont.systemFont(ofSize: 12) + addSubview(textFieldView) + + titleLabel.alignment = .center + // The vertical style lays the title out under the field, inside a + // column that can be narrower than the title. Truncate rather than + // overflow — an unclamped title used to spill onto its neighbours. + titleLabel.lineBreakMode = .byTruncatingTail + titleLabel.usesSingleLineMode = true + titleLabel.cell?.truncatesLastVisibleLine = true + addSubview(titleLabel) + } + + override func layout() { + super.layout() + textFieldView.lkLayout.fullWidth().height(LKNumberInputHorizontalHeight) + switch viewStyle { + case .horizontal: + titleLabel.lkLayout.sizeToFit().minWidth(15).verAlign().right(2) + var insets = textFieldView.insets + insets.right = frame.width - titleLabel.frame.minX + 2 + textFieldView.insets = insets + case .vertical: + // Clamped to the column width, so a title wider than its column + // no longer overlaps whatever sits to its left and right. + titleLabel.lkLayout.width(frame.width).heightToFit().x(0).y(textFieldView.frame.maxY + 3) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + switch viewStyle { + case .horizontal: + size.height = LKNumberInputHorizontalHeight + case .vertical: + size.height = LKNumberInputVerticalHeight + } + return size + } + + private func applyViewStyle() { + switch viewStyle { + case .horizontal: + textFieldView.textField.alignment = .left + textFieldView.insets = NSEdgeInsets(top: 3, left: 3, bottom: 3, right: 3) + case .vertical: + textFieldView.textField.alignment = .center + textFieldView.insets = NSEdgeInsets(top: 2, left: 0, bottom: 2, right: 0) + } + updateTitleLabelFontAndColor() + needsLayout = true + } + + /// The value `string` parses to for `attrType` (an integer or a floating + /// point type), or nil when it does not parse. + @objc(parsedValueWithString:attrType:) + static func parsedValue(with string: String, attrType: LookinAttrType) -> Any? { + switch attrType { + case .int, .long, .longLong: + return Scanner(string: string).scanInt64().map { NSNumber(value: $0) } + case .float, .double: + return Scanner(string: string).scanDouble().map { NSNumber(value: $0) } + default: + assertionFailure("unsupported attribute type \(attrType.rawValue)") + return nil + } + } + + private func updateTitleLabelFontAndColor() { + if viewStyle == .horizontal { + if ((title ?? "") as NSString).length > 1 { + titleLabel.textColor = NSColor(named: "DashboardCardValueColor") + titleLabel.font = NSFont.systemFont(ofSize: 12) + } else { + titleLabel.textColor = NSColor(named: "DashboardInputAccessoryColor") + titleLabel.font = NSFont.systemFont(ofSize: 10) + } + } else { + titleLabel.font = NSFont.systemFont(ofSize: 11) + titleLabel.textColor = NSColor(named: "DashboardCardValueColor") + } + needsLayout = true + } +} diff --git a/LookInside/Base/LKOutlineItem.h b/LookInside/Base/LKOutlineItem.h deleted file mode 100644 index 23dcb08a..00000000 --- a/LookInside/Base/LKOutlineItem.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// LKOutlineItem.h -// Lookin -// -// Created by Li Kai on 2019/5/28. -// https://lookin.work -// - -#import - -typedef NS_ENUM(NSUInteger, LKOutlineItemStatus) { - LKOutlineItemStatusNotExpandable, - LKOutlineItemStatusExpanded, - LKOutlineItemStatusCollapsed -}; - -@interface LKOutlineItem : NSObject - -@property(nonatomic, strong) NSArray *subItems; - -@property(nonatomic, assign) LKOutlineItemStatus status; - -@property(nonatomic, copy) NSString *titleText; - -@property(nonatomic, strong) NSImage *image; - -@property(nonatomic, assign, readonly) NSUInteger indentation; - -+ (NSArray *)flatItemsFromRootItems:(NSArray *)items; - -@end diff --git a/LookInside/Base/LKOutlineItem.m b/LookInside/Base/LKOutlineItem.m deleted file mode 100644 index 887845b1..00000000 --- a/LookInside/Base/LKOutlineItem.m +++ /dev/null @@ -1,51 +0,0 @@ -// -// LKOutlineItem.m -// Lookin -// -// Created by Li Kai on 2019/5/28. -// https://lookin.work -// - -#import "LKOutlineItem.h" - -@interface LKOutlineItem () - -@property(nonatomic, assign, readwrite) NSUInteger indentation; - -@end - -@implementation LKOutlineItem - -- (void)setSubItems:(NSArray *)subItems { - _subItems = subItems.copy; - if (subItems) { - self.status = LKOutlineItemStatusCollapsed; - } else { - self.status = LKOutlineItemStatusNotExpandable; - } -} - -- (NSArray *)flatItems { - NSMutableArray *array = [NSMutableArray array]; - - [array addObject:self]; - - if (self.status == LKOutlineItemStatusExpanded) { - [self.subItems enumerateObjectsUsingBlock:^(LKOutlineItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.indentation = self.indentation + 1; - [array addObjectsFromArray:[obj flatItems]]; - }]; - } - - return array.copy; -} - -+ (NSArray *)flatItemsFromRootItems:(NSArray *)items { - NSMutableArray *resultItems = [NSMutableArray array]; - [items enumerateObjectsUsingBlock:^(LKOutlineItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [resultItems addObjectsFromArray:[obj flatItems]]; - }]; - return resultItems.copy; -} - -@end diff --git a/LookInside/Base/LKOutlineRowView.h b/LookInside/Base/LKOutlineRowView.h deleted file mode 100644 index f2141c99..00000000 --- a/LookInside/Base/LKOutlineRowView.h +++ /dev/null @@ -1,44 +0,0 @@ -// -// LKOutlineRowView.h -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - -#import "LKTableRowView.h" - -typedef NS_ENUM(NSUInteger, LKOutlineRowViewStatus) { - LKOutlineRowViewStatusNotExpandable, - LKOutlineRowViewStatusExpanded, - LKOutlineRowViewStatusCollapsed -}; - -@interface LKOutlineRowView : LKTableRowView { - @protected - CGFloat _imageLeft; - CGFloat _imageRight; - CGFloat _titleLeft; - CGFloat _subtitleLeft; -} - -- (instancetype)initWithCompactUI:(BOOL)compact; - -@property(nonatomic, strong, readonly) NSButton *disclosureButton; - -@property(nonatomic, strong) NSImage *image; -@property(nonatomic, strong, readonly) NSImageView *imageView; - -@property(nonatomic, assign) LKOutlineRowViewStatus status; - -@property(nonatomic, assign) NSUInteger indentLevel; - -+ (CGFloat)dislosureMidXWithIndentLevel:(NSUInteger)level; - -@end - -@interface LKOutlineRowView (NSSubclassingHooks) - -+ (CGFloat)insetLeft; - -@end diff --git a/LookInside/Base/LKOutlineRowView.m b/LookInside/Base/LKOutlineRowView.m deleted file mode 100644 index 03facaae..00000000 --- a/LookInside/Base/LKOutlineRowView.m +++ /dev/null @@ -1,131 +0,0 @@ -// -// LKOutlineRowView.m -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - -#import "LKOutlineRowView.h" -#import "LKTableViewHorizontalScrollWidthManager.h" - -static CGFloat const kIndentUnitWidth = 14; -static CGFloat const kDisclosureWidth = 16; - -@interface LKOutlineRowView () - -@property(nonatomic, assign) BOOL useCompactUI; - -@end - -@implementation LKOutlineRowView - -- (instancetype)initWithCompactUI:(BOOL)compact { - if (self = [super initWithFrame:NSZeroRect]) { - self.useCompactUI = compact; - - if (compact) { - _imageLeft = 5; - _imageRight = 2; - _titleLeft = 0; - _subtitleLeft = 2; - } else { - _imageLeft = 5; - _imageRight = 2; - _titleLeft = 2; - _subtitleLeft = 10; - } - - _disclosureButton = [NSButton new]; - self.disclosureButton.bordered = NO; - [self.disclosureButton setButtonType:NSButtonTypeMomentaryChange]; - [self addSubview:self.disclosureButton]; - - _imageView = [NSImageView new]; - self.imageView.hidden = YES; - [self addSubview:self.imageView]; - } - return self; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - return [self initWithCompactUI:NO]; -} - -- (void)layout { - [super layout]; - - $(self.disclosureButton).width(kDisclosureWidth).fullHeight.midX([self.class dislosureMidXWithIndentLevel:self.indentLevel]).verAlign; - - CGFloat x = self.disclosureButton.$maxX; - if (self.imageView.isVisible) { - $(self.imageView).sizeToFit.verAlign.x(x + _imageLeft); - x = self.imageView.$maxX + _imageRight; - } - - $(self.titleLabel).sizeToFit.x(x + _titleLeft).verAlign; - CGFloat maxX = self.titleLabel.$maxX; - - if (self.subtitleLabel.isVisible) { - $(self.subtitleLabel).sizeToFit.x(self.titleLabel.$maxX + _subtitleLeft).verAlign; - maxX = self.subtitleLabel.$maxX; - } - $(self.disclosureButton, self.titleLabel, self.subtitleLabel).visibles.offsetY(-1); - - if (self.horizontalScrollWidthManager) { - [self.horizontalScrollWidthManager rowDidLayoutWithWidth:maxX]; - } -} - -- (void)setIndentLevel:(NSUInteger)indentLevel { - _indentLevel = indentLevel; - [self setNeedsLayout:YES]; -} - -- (void)setImage:(NSImage *)image { - _image = image; - self.imageView.image = image; - self.imageView.hidden = !image; - [self setNeedsLayout:YES]; -} - -- (void)setStatus:(LKOutlineRowViewStatus)status { - _status = status; - [self _updateDisclosureButton]; -} - -- (void)setIsSelected:(BOOL)isSelected { - [super setIsSelected:isSelected]; - [self _updateDisclosureButton]; -} - -- (void)_updateDisclosureButton { - if (self.status == LKOutlineRowViewStatusNotExpandable) { - self.disclosureButton.hidden = YES; - - } else if (self.status == LKOutlineRowViewStatusExpanded) { - self.disclosureButton.hidden = NO; - if (self.isSelected) { - self.disclosureButton.image = NSImageMake(@"icon_arrow_down_selected"); - } else { - self.disclosureButton.image = NSImageMake(@"icon_arrow_down"); - } - } else { - self.disclosureButton.hidden = NO; - if (self.isSelected) { - self.disclosureButton.image = NSImageMake(@"icon_arrow_right_selected"); - } else { - self.disclosureButton.image = NSImageMake(@"icon_arrow_right"); - } - } -} - -+ (CGFloat)dislosureMidXWithIndentLevel:(NSUInteger)level { - return [self insetLeft] + level * kIndentUnitWidth + kDisclosureWidth / 2.0; -} - -+ (CGFloat)insetLeft { - return 6; -} - -@end diff --git a/LookInside/Base/LKOutlineRowView.swift b/LookInside/Base/LKOutlineRowView.swift new file mode 100644 index 00000000..17362c1d --- /dev/null +++ b/LookInside/Base/LKOutlineRowView.swift @@ -0,0 +1,133 @@ +// +// LKOutlineRowView.swift +// LookInside +// +// Created by Li Kai on 2019/4/20. +// https://lookin.work +// + +import AppKit + +private let indentUnitWidth: CGFloat = 14 +private let disclosureWidth: CGFloat = 16 + +@objc enum LKOutlineRowViewStatus: UInt { + case notExpandable + case expanded + case collapsed +} + +@objc(LKOutlineRowView) +@objcMembers +class LKOutlineRowView: LKTableRowView { + let disclosureButton: NSButton! + let imageView: NSImageView! + + var image: NSImage? { + didSet { + imageView.image = image + imageView.isHidden = (image == nil) + needsLayout = true + } + } + + var status: LKOutlineRowViewStatus = .notExpandable { + didSet { + updateDisclosureButton() + } + } + + var indentLevel: UInt = 0 { + didSet { + needsLayout = true + } + } + + /// The gap between the title and the subtitle; set by init(compactUI:). + private(set) var subtitleLeft: CGFloat + + private let imageLeft: CGFloat = 5 + private let imageRight: CGFloat = 2 + private let titleLeft: CGFloat + + init(compactUI compact: Bool) { + titleLeft = compact ? 0 : 2 + subtitleLeft = compact ? 2 : 10 + disclosureButton = NSButton() + imageView = NSImageView() + super.init(frame: .zero) + + disclosureButton.isBordered = false + disclosureButton.setButtonType(.momentaryChange) + addSubview(disclosureButton) + + imageView.isHidden = true + addSubview(imageView) + } + + override convenience init(frame _: NSRect) { + self.init(compactUI: false) + } + + required init?(coder _: NSCoder) { + fatalError("LKOutlineRowView is not loaded from archives") + } + + override func layout() { + super.layout() + + LKHierarchyFrameLayout(disclosureButton) + .width(disclosureWidth) + .fullHeight() + .midX(Self.dislosureMidX(withIndentLevel: indentLevel)) + .verAlign() + + var x = disclosureButton.frame.maxX + if !imageView.isHidden, imageView.alphaValue > 0 { + LKHierarchyFrameLayout(imageView).sizeToFit().verAlign().x(x + imageLeft) + x = imageView.frame.maxX + imageRight + } + + LKHierarchyFrameLayout(titleLabel).sizeToFit().x(x + titleLeft).verAlign() + var maxX = titleLabel.frame.maxX + + if !subtitleLabel.isHidden, subtitleLabel.alphaValue > 0 { + LKHierarchyFrameLayout(subtitleLabel).sizeToFit().x(titleLabel.frame.maxX + subtitleLeft).verAlign() + maxX = subtitleLabel.frame.maxX + } + for view: NSView in [disclosureButton!, titleLabel!, subtitleLabel!] where view.lkHierarchyIsLaidOutVisible { + LKHierarchyFrameLayout(view).offsetY(-1) + } + + horizontalScrollWidthManager?.rowDidLayout(withWidth: maxX) + } + + override var isRowSelected: Bool { + didSet { + updateDisclosureButton() + } + } + + class func dislosureMidX(withIndentLevel level: UInt) -> CGFloat { + insetLeft() + CGFloat(level) * indentUnitWidth + disclosureWidth / 2 + } + + private func updateDisclosureButton() { + switch status { + case .notExpandable: + disclosureButton.isHidden = true + case .expanded: + disclosureButton.isHidden = false + disclosureButton.image = NSImage(named: isSelected ? "icon_arrow_down_selected" : "icon_arrow_down") + default: + disclosureButton.isHidden = false + disclosureButton.image = NSImage(named: isSelected ? "icon_arrow_right_selected" : "icon_arrow_right") + } + } + + // MARK: - Subclassing hooks + + class func insetLeft() -> CGFloat { + 6 + } +} diff --git a/LookInside/Base/LKOutlineView.h b/LookInside/Base/LKOutlineView.h deleted file mode 100644 index 49d428b5..00000000 --- a/LookInside/Base/LKOutlineView.h +++ /dev/null @@ -1,35 +0,0 @@ -// -// LKOutlineView.h -// Lookin -// -// Created by Li Kai on 2019/5/28. -// https://lookin.work -// - -#import "LKBaseView.h" - -@class LKOutlineView, LKOutlineItem, LKOutlineRowView, LKTableView; - -@protocol LKOutlineViewDelegate - -- (void)outlineView:(LKOutlineView *)view configureRowView:(LKOutlineRowView *)rowView withItem:(LKOutlineItem *)item; - -@end - -@interface LKOutlineView : LKBaseView - -/// aClass 必须是 LKOutlineRowView 或 LKOutlineRowView 的 subclass -- (instancetype)initWithRowViewClass:(Class)aClass NS_DESIGNATED_INITIALIZER; - -@property(nonatomic, strong) NSArray *items; - -@property(nonatomic, weak) id delegate; - -/// 默认为 24 -@property(nonatomic, assign) CGFloat itemHeight; - -@property(nonatomic, strong, readonly) LKTableView *tableView; - -@property(nonatomic, copy, readonly) NSArray *displayingItems; - -@end diff --git a/LookInside/Base/LKOutlineView.m b/LookInside/Base/LKOutlineView.m deleted file mode 100644 index a5c7023f..00000000 --- a/LookInside/Base/LKOutlineView.m +++ /dev/null @@ -1,128 +0,0 @@ -// -// LKOutlineView.m -// Lookin -// -// Created by Li Kai on 2019/5/28. -// https://lookin.work -// - -#import "LKOutlineView.h" -#import "LKTableView.h" -#import "LKOutlineItem.h" -#import "LKOutlineRowView.h" - -@interface LKOutlineView () - -@property(nonatomic, copy, readwrite) NSArray *displayingItems; -@property(nonatomic, assign) Class rowViewClass; - -@end - -@implementation LKOutlineView - -- (instancetype)initWithRowViewClass:(Class)aClass { - if (self = [super initWithFrame:NSZeroRect]) { - _itemHeight = 24; - - self.rowViewClass = aClass; - NSAssert([aClass isSubclassOfClass:[LKOutlineRowView class]], @""); - - self.displayingItems = [NSArray array]; - - _tableView = [LKTableView new]; - self.tableView.delegate = self; - self.tableView.dataSource = self; - self.tableView.adjustsSelectionAutomatically = NO; - [self addSubview:self.tableView]; - } - return self; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - return [self initWithRowViewClass:[LKOutlineRowView class]]; -} - -- (instancetype)initWithCoder:(NSCoder *)decoder { - return [self initWithRowViewClass:[LKOutlineRowView class]]; -} - -- (void)layout { - [super layout]; - $(self.tableView).fullFrame; -} - -- (void)setItems:(NSArray *)items { - _items = items.copy; - [self _updateDisplayingItems]; -} - -#pragma mark - LKTableView - -- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { - return self.displayingItems.count; -} - -- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row { - return self.itemHeight; -} - -- (LKOutlineRowView *)tableView:(NSTableView *)tableView rowViewForRow:(NSInteger)row { - LKOutlineItem *item = [self.displayingItems lookin_safeObjectAtIndex:row]; - if (!item) { - return [self.rowViewClass new]; - } - - LKOutlineRowView *view = (LKOutlineRowView *)[tableView makeViewWithIdentifier:@"cell" owner:self]; - if (!view) { - view = [self.rowViewClass new]; - view.identifier = @"cell"; - } - view.disclosureButton.tag = row; - view.disclosureButton.target = self; - view.disclosureButton.action = @selector(_handleDisclosureButton:); - - if (item.status == LKOutlineItemStatusNotExpandable) { - view.status = LKOutlineRowViewStatusNotExpandable; - } else if (item.status == LKOutlineItemStatusExpanded) { - view.status = LKOutlineRowViewStatusExpanded; - } else { - view.status = LKOutlineRowViewStatusCollapsed; - } - - view.indentLevel = item.indentation; - view.titleLabel.stringValue = item.titleText; - view.image = item.image; - - if ([self.delegate respondsToSelector:@selector(outlineView:configureRowView:withItem:)]) { - [self.delegate outlineView:self configureRowView:view withItem:item]; - } - - [view setNeedsLayout:YES]; - - return view; -} - -#pragma mark - Event Handler - -- (void)_handleDisclosureButton:(NSButton *)button { - NSInteger row = button.tag; - LKOutlineItem *item = [self.displayingItems lookin_safeObjectAtIndex:row]; - if (!item || item.status == LKOutlineItemStatusNotExpandable) { - return; - } - if (item.status == LKOutlineItemStatusExpanded) { - item.status = LKOutlineItemStatusCollapsed; - } else { - item.status = LKOutlineItemStatusExpanded; - } - [self _updateDisplayingItems]; -} - -#pragma mark - Others - -- (void)_updateDisplayingItems { - self.displayingItems = [LKOutlineItem flatItemsFromRootItems:self.items]; - [self.tableView reloadData]; -} - -@end diff --git a/LookInside/Base/LKPanelContentView.h b/LookInside/Base/LKPanelContentView.h deleted file mode 100644 index fd91cd59..00000000 --- a/LookInside/Base/LKPanelContentView.h +++ /dev/null @@ -1,29 +0,0 @@ -// -// LKPanelContentView.h -// Lookin -// -// Created by Li Kai on 2019/5/24. -// https://lookin.work -// - -#import "LKBaseView.h" - -@interface LKPanelContentView : LKBaseView - -@property(nonatomic, strong, readonly) NSButton *submitButton; - -@property(nonatomic, strong) NSImage *titleImage; - -@property(nonatomic, copy) NSString *titleText; - -@property(nonatomic, strong, readonly) LKBaseView *contentView; - -@property(nonatomic, copy) void (^needExit)(void); - -@end - -@interface LKPanelContentView (NSSubclassingHooks) - -- (void)didClickSubmitButton; - -@end diff --git a/LookInside/Base/LKPanelContentView.m b/LookInside/Base/LKPanelContentView.m deleted file mode 100644 index 19856c90..00000000 --- a/LookInside/Base/LKPanelContentView.m +++ /dev/null @@ -1,95 +0,0 @@ -// -// LKPanelContentView.m -// Lookin -// -// Created by Li Kai on 2019/5/24. -// https://lookin.work -// - -#import "LKPanelContentView.h" - -@interface LKPanelContentView () - -@property(nonatomic, strong) NSImageView *titleImageView; -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKBaseView *titleContainerView; - -@property(nonatomic, strong) NSButton *cancelButton; - -@end - -@implementation LKPanelContentView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.backgroundColorName = @"DashboardBackgroundColor"; - - self.titleContainerView = [LKBaseView new]; - self.titleContainerView.backgroundColorName = @"PanelTitleBackgroundColor"; - [self addSubview:self.titleContainerView]; - - self.titleImageView = [NSImageView new]; - [self.titleContainerView addSubview:self.titleImageView]; - - self.titleLabel = [LKLabel new]; - self.titleLabel.font = NSFontMake(13); - [self.titleContainerView addSubview:self.titleLabel]; - - self.cancelButton = [NSButton lk_normalButtonWithTitle:NSLocalizedString(@"Cancel", nil) target:self action:@selector(_handleCancelButton)]; - // esc - self.cancelButton.keyEquivalent = [NSString stringWithFormat:@"%C", 0x1b]; - [self addSubview:self.cancelButton]; - - _submitButton = [NSButton lk_normalButtonWithTitle:NSLocalizedString(@"Done", nil) target:self action:@selector(_handleSubmitButton)]; - self.submitButton.keyEquivalent = @"\r"; - [self addSubview:self.submitButton]; - - _contentView = [LKBaseView new]; - self.contentView.layer.masksToBounds = NO; - [self addSubview:self.contentView]; - } - return self; -} - -- (void)layout { - [super layout]; - - NSEdgeInsets insets = NSEdgeInsetsMake(0, 14, 6, 14); - - $(self.titleContainerView).fullWidth.height(40).y(0); - $(self.titleImageView).sizeToFit.x(insets.left).verAlign; - $(self.titleLabel).sizeToFit.x(self.titleImageView.$maxX + 6).verAlign; - - $(self.submitButton).right(insets.right).bottom(insets.bottom); - $(self.cancelButton).maxX(self.submitButton.$x - 5).bottom(insets.bottom); - - $(self.contentView).x(insets.left).toRight(insets.right).y(self.titleContainerView.$maxY + 10).toMaxY(self.submitButton.$y - 10); -} - -- (void)setTitleImage:(NSImage *)titleImage { - _titleImage = titleImage; - self.titleImageView.image = titleImage; - [self setNeedsLayout:YES]; -} - -- (void)setTitleText:(NSString *)titleText { - _titleText = titleText.copy; - self.titleLabel.stringValue = titleText; - [self setNeedsLayout:YES]; -} - -- (void)_handleCancelButton { - if (self.needExit) { - self.needExit(); - } -} - -- (void)_handleSubmitButton { - [self didClickSubmitButton]; -} - -- (void)didClickSubmitButton { - NSAssert(NO, @"should implement by subclass"); -} - -@end diff --git a/LookInside/Base/LKPerformanceReporter.h b/LookInside/Base/LKPerformanceReporter.h deleted file mode 100644 index d68bb6af..00000000 --- a/LookInside/Base/LKPerformanceReporter.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// LKPerformanceReporter.h -// LookinClient -// -// Created by 李凯 on 2022/5/3. -// Copyright © 2022 hughkli. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface LKPerformanceReporter : NSObject - -+ (instancetype)sharedInstance; - -- (void)willStartReload; - -- (void)didFetchHierarchy; - -- (void)didComplete; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Base/LKPerformanceReporter.m b/LookInside/Base/LKPerformanceReporter.m deleted file mode 100644 index 7eb63e18..00000000 --- a/LookInside/Base/LKPerformanceReporter.m +++ /dev/null @@ -1,73 +0,0 @@ -// -// LKPerformanceReporter.m -// LookinClient -// -// Created by 李凯 on 2022/5/3. -// Copyright © 2022 hughkli. All rights reserved. -// - -#import "LKPerformanceReporter.h" - - -@interface LKPerformanceReporter () - -@property(nonatomic, assign) CFTimeInterval reloadStartTime; -@property(nonatomic, assign) CFTimeInterval hierarchyFetchedTime; - -@end - -@implementation LKPerformanceReporter - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKPerformanceReporter *instance = nil; - dispatch_once(&onceToken, ^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone { - return [self sharedInstance]; -} - -- (void)willStartReload { - self.reloadStartTime = CACurrentMediaTime(); -} - -- (void)didFetchHierarchy { - self.hierarchyFetchedTime = CACurrentMediaTime(); -} - -- (void)didComplete { -} - -- (NSString *)resolveDurationDescription:(CFTimeInterval)duration { - if (duration < 0.1) { - return @"< 0.1s"; - } - if (duration < 0.5) { - return @"0.1s ~ 0.5s"; - } - if (duration < 1.0) { - return @"0.5s ~ 1.0s"; - } - if (duration < 3.0) { - return @"1s ~ 3s"; - } - if (duration < 6.0) { - return @"3s ~ 6s"; - } - if (duration < 10.0) { - return @"6s ~ 10s"; - } - if (duration < 20.0) { - return @"10s ~ 20s"; - } - if (duration < 30.0) { - return @"20s ~ 30s"; - } - return @"> 30s"; -} - -@end diff --git a/LookInside/Base/LKPerformanceReporter.swift b/LookInside/Base/LKPerformanceReporter.swift new file mode 100644 index 00000000..72223260 --- /dev/null +++ b/LookInside/Base/LKPerformanceReporter.swift @@ -0,0 +1,30 @@ +// +// LKPerformanceReporter.swift +// LookInside +// + +import QuartzCore + +/// Timestamps of the current hierarchy reload. Nothing reads them yet; the +/// hooks stay so reload timing can be reported again. +@objc(LKPerformanceReporter) +final class LKPerformanceReporter: NSObject { + private static let shared = LKPerformanceReporter() + + @objc static func sharedInstance() -> LKPerformanceReporter { + shared + } + + private var reloadStartTime: CFTimeInterval = 0 + private var hierarchyFetchedTime: CFTimeInterval = 0 + + @objc func willStartReload() { + reloadStartTime = CACurrentMediaTime() + } + + @objc func didFetchHierarchy() { + hierarchyFetchedTime = CACurrentMediaTime() + } + + @objc func didComplete() {} +} diff --git a/LookInside/Base/LKPopPanel.h b/LookInside/Base/LKPopPanel.h deleted file mode 100644 index 4e29a813..00000000 --- a/LookInside/Base/LKPopPanel.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// LKPopPanel.h -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - -#import - -@interface LKPopPanel : NSPanel - -- (instancetype)initWithSize:(NSSize)size; - -@end diff --git a/LookInside/Base/LKPopPanel.m b/LookInside/Base/LKPopPanel.m deleted file mode 100644 index 4f18fa08..00000000 --- a/LookInside/Base/LKPopPanel.m +++ /dev/null @@ -1,34 +0,0 @@ -// -// LKPopPanel.m -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - -#import "LKPopPanel.h" - -@implementation LKPopPanel - -- (instancetype)initWithSize:(NSSize)size { - // 如果不是 NSWindowStyleMaskNonactivatingPanel 的话,当显示该 window 时点击别的 app,整个 Lookin 窗口都会被隐藏,不知道为什么 - if (self = [super initWithContentRect:NSMakeRect(0, 0, size.width, size.height) styleMask:NSWindowStyleMaskNonactivatingPanel backing:NSBackingStoreBuffered defer:YES]) { - LKBaseView *contentView = [LKBaseView new]; - contentView.layer.cornerRadius = 6; - contentView.layer.borderWidth = 1; - contentView.didChangeAppearanceBlock = ^(LKBaseView *view, BOOL isDarkMode) { - view.backgroundColor = isDarkMode ? LookinColorMake(44, 44, 44) : LookinColorMake(236, 236, 236); - view.layer.borderColor = isDarkMode ? SeparatorDarkModeColor.CGColor : SeparatorLightModeColor.CGColor; - }; - self.contentView = contentView; - self.backgroundColor = [NSColor clearColor]; - } - return self; -} - -// 如果没有这一句,window 里的输入框将无法触发编辑 -- (BOOL)canBecomeKeyWindow { - return YES; -} - -@end diff --git a/LookInside/Base/LKPopPanel.swift b/LookInside/Base/LKPopPanel.swift new file mode 100644 index 00000000..84e4978c --- /dev/null +++ b/LookInside/Base/LKPopPanel.swift @@ -0,0 +1,31 @@ +// +// LKPopPanel.swift +// LookInside +// + +import AppKit + +/// A borderless rounded panel for small editors that pop up over a window. +@objc(LKPopPanel) +class LKPopPanel: NSPanel { + @objc(initWithSize:) + init(size: NSSize) { + // Without NSWindowStyleMaskNonactivatingPanel, clicking another app + // while the panel is shown hides every LookInside window. + super.init(contentRect: NSRect(origin: .zero, size: size), styleMask: .nonactivatingPanel, backing: .buffered, defer: true) + let contentView = LKBaseView() + contentView.layer?.cornerRadius = 6 + contentView.layer?.borderWidth = 1 + contentView.didChangeAppearanceBlock = { view, isDarkMode in + view?.backgroundColor = isDarkMode ? .lkBaseRGB(44, 44, 44) : .lkBaseRGB(236, 236, 236) + view?.layer?.borderColor = (isDarkMode ? NSColor.lkBaseRGB(67, 67, 69) : NSColor.lkBaseRGB(215, 215, 215)).cgColor + } + self.contentView = contentView + backgroundColor = .clear + } + + /// Without this, text fields in the panel cannot start editing. + override var canBecomeKey: Bool { + true + } +} diff --git a/LookInside/Base/LKProgressIndicatorView.h b/LookInside/Base/LKProgressIndicatorView.h deleted file mode 100644 index 9c7e63a4..00000000 --- a/LookInside/Base/LKProgressIndicatorView.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// LKMenuProgressIndicatorView.h -// Lookin -// -// Created by Li Kai on 2018/10/7. -// https://lookin.work -// - -#import "LKBaseView.h" - -extern const CGFloat InitialIndicatorProgressWhenFetchHierarchy; - -@interface LKProgressIndicatorView : LKBaseView - -@property(nonatomic, assign, readonly) CGFloat progress; - -- (void)resetToZero; - -/// 默认 duration 为 0.3 -- (void)animateToProgress:(CGFloat)progress; - -- (void)animateToProgress:(CGFloat)progress duration:(NSTimeInterval)duration; - -- (void)finishWithCompletion:(void (^)(void))completionBlock; - -@end diff --git a/LookInside/Base/LKProgressIndicatorView.m b/LookInside/Base/LKProgressIndicatorView.m deleted file mode 100644 index e7a6345f..00000000 --- a/LookInside/Base/LKProgressIndicatorView.m +++ /dev/null @@ -1,82 +0,0 @@ -// -// LKMenuProgressIndicatorView.m -// Lookin -// -// Created by Li Kai on 2018/10/7. -// https://lookin.work -// - -#import "LKProgressIndicatorView.h" - -const CGFloat InitialIndicatorProgressWhenFetchHierarchy = .7; - -@interface LKProgressIndicatorView () - -@property(nonatomic, assign, readwrite) CGFloat progress; -@property(nonatomic, strong) CALayer *fillLayer; - -@end - -@implementation LKProgressIndicatorView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.fillLayer = [CALayer new]; - self.fillLayer.backgroundColor = [LKHelper accentColor].CGColor; - [self.layer addSublayer:self.fillLayer]; - [self.fillLayer lookin_removeImplicitAnimations]; - - self.progress = 0; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.fillLayer).x(0).width(self.$width * self.progress).height(self.$height).y(0); -} - -- (id)animationForKey:(NSAnimatablePropertyKey)key { - if ([key isEqualToString:@"progress"]) { - return [CABasicAnimation animation]; - } - return [super animationForKey:key]; -} - -- (void)setProgress:(CGFloat)progress { - _progress = progress; - $(self.fillLayer).x(0).width(self.$width * progress).height(self.$height).y(0); -} - -- (void)resetToZero { - self.progress = 0; -} - -- (void)animateToProgress:(CGFloat)progress { - [self animateToProgress:progress duration:.5]; -} - -- (void)animateToProgress:(CGFloat)progress duration:(NSTimeInterval)duration { - [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) { - context.duration = duration; - self.animator.progress = progress; - } completionHandler:nil]; -} - -- (void)finishWithCompletion:(void (^)(void))completionBlock { - [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) { - context.duration = .1; - self.animator.progress = 1; - } completionHandler:^{ - if (completionBlock) { - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - completionBlock(); - }); - } - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - self.progress = 0; - }); - }]; -} - -@end diff --git a/LookInside/Base/LKProgressIndicatorView.swift b/LookInside/Base/LKProgressIndicatorView.swift new file mode 100644 index 00000000..472df691 --- /dev/null +++ b/LookInside/Base/LKProgressIndicatorView.swift @@ -0,0 +1,89 @@ +// +// LKProgressIndicatorView.swift +// LookInside +// + +import AppKit + +/// The progress shown once the hierarchy itself has arrived and only +/// screenshots are left to fetch. +let InitialIndicatorProgressWhenFetchHierarchy: CGFloat = 0.7 + +/// A thin accent-coloured bar that fills from the left. +@objc(LKProgressIndicatorView) +class LKProgressIndicatorView: LKBaseView { + private let fillLayer = CALayer() + + /// 0...1. Animatable through `animator()`. + @objc dynamic var progress: CGFloat = 0 { + didSet { layoutFillLayer() } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + private func setUp() { + fillLayer.backgroundColor = LKHelper.accentColor().cgColor + layer?.addSublayer(fillLayer) + fillLayer.lookin_removeImplicitAnimations() + progress = 0 + } + + override func layout() { + super.layout() + layoutFillLayer() + } + + private func layoutFillLayer() { + fillLayer.lkLayout.x(0).width(frame.width * progress).height(frame.height).y(0) + } + + override func animation(forKey key: NSAnimatablePropertyKey) -> Any? { + if key == "progress" { + return CABasicAnimation() + } + return super.animation(forKey: key) + } + + @objc func resetToZero() { + progress = 0 + } + + /// Animates over 0.5 seconds. + @objc(animateToProgress:) + func animate(toProgress progress: CGFloat) { + animate(toProgress: progress, duration: 0.5) + } + + @objc(animateToProgress:duration:) + func animate(toProgress progress: CGFloat, duration: TimeInterval) { + NSAnimationContext.runAnimationGroup { context in + context.duration = duration + self.animator().progress = progress + } + } + + /// Fills the bar, calls `completion`, then empties the bar 0.2 seconds + /// later. + @objc(finishWithCompletion:) + func finish(completion: (() -> Void)?) { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + self.animator().progress = 1 + } completionHandler: { + if let completion { + DispatchQueue.main.async(execute: completion) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self.progress = 0 + } + } + } +} diff --git a/LookInside/Base/LKSplitView.h b/LookInside/Base/LKSplitView.h deleted file mode 100644 index 810eec7b..00000000 --- a/LookInside/Base/LKSplitView.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// LKSplitView.h -// Lookin -// -// Created by Li Kai on 2018/11/4. -// https://lookin.work -// - -#import - -@interface LKSplitView : NSSplitView - -@property(nonatomic, copy) void (^didFinishFirstLayout)(LKSplitView *view); - -@end diff --git a/LookInside/Base/LKSplitView.m b/LookInside/Base/LKSplitView.m deleted file mode 100644 index 398fc01c..00000000 --- a/LookInside/Base/LKSplitView.m +++ /dev/null @@ -1,39 +0,0 @@ -// -// LKSplitView.m -// Lookin -// -// Created by Li Kai on 2018/11/4. -// https://lookin.work -// - -#import "LKSplitView.h" - -@implementation LKSplitView { - BOOL _hasLayouted; -} - -- (CGFloat)dividerThickness { - return 0; -} - -//- (NSColor *)dividerColor { -// BOOL isDarkMode = self.effectiveAppearance.lk_isDarkMode; -// if (isDarkMode) { -// return [NSColor blackColor]; -// } else { -// return [NSColor colorWithWhite:0 alpha:.1]; -// } -//} - -- (void)layout { - [super layout]; - - if (!_hasLayouted) { - if (self.didFinishFirstLayout) { - self.didFinishFirstLayout(self); - } - _hasLayouted = YES; - } -} - -@end diff --git a/LookInside/Base/LKSplitView.swift b/LookInside/Base/LKSplitView.swift new file mode 100644 index 00000000..74c6e23d --- /dev/null +++ b/LookInside/Base/LKSplitView.swift @@ -0,0 +1,27 @@ +// +// LKSplitView.swift +// LookInside +// + +import AppKit + +/// A split view without visible dividers that reports its first layout. +@objc(LKSplitView) +class LKSplitView: NSSplitView { + /// Called once, after the first `layout`. + @objc var didFinishFirstLayout: ((LKSplitView) -> Void)? + + private var hasLaidOut = false + + override var dividerThickness: CGFloat { + 0 + } + + override func layout() { + super.layout() + if !hasLaidOut { + didFinishFirstLayout?(self) + hasLaidOut = true + } + } +} diff --git a/LookInside/Base/LKTableRowView.h b/LookInside/Base/LKTableRowView.h deleted file mode 100644 index ca233d9d..00000000 --- a/LookInside/Base/LKTableRowView.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// LKTableRowView.h -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - -#import - -@class LKTableViewHorizontalScrollWidthManager; - -@interface LKTableRowView : NSTableRowView - -@property(nonatomic, strong, readonly) LKLabel *titleLabel; -@property(nonatomic, strong, readonly) LKLabel *subtitleLabel; - -@property(nonatomic, assign) BOOL isSelected; -@property(nonatomic, assign) BOOL isHovered; - -@property(nonatomic, assign, readonly) BOOL isDarkMode; -/// 子类可在该方法里更新 UI -- (void)setIsDarkMode:(BOOL)isDarkMode NS_REQUIRES_SUPER; - -@property(nonatomic, weak) LKTableViewHorizontalScrollWidthManager* horizontalScrollWidthManager; - -@end - -@interface LKTableBlankRowView : LKTableRowView - -@end diff --git a/LookInside/Base/LKTableRowView.m b/LookInside/Base/LKTableRowView.m deleted file mode 100644 index 12583253..00000000 --- a/LookInside/Base/LKTableRowView.m +++ /dev/null @@ -1,95 +0,0 @@ -// -// LKTableRowView.m -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - -#import "LKTableRowView.h" -#import "LookinDefines.h" - -@interface LKTableRowView () - -@property(nonatomic, strong) CALayer *backgroundColorLayer; - -@property(nonatomic, assign, readwrite) BOOL isDarkMode; - -@end - -@implementation LKTableRowView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.wantsLayer = YES; - - self.backgroundColorLayer = [CALayer layer]; - [self.backgroundColorLayer lookin_removeImplicitAnimations]; - [self.layer addSublayer:self.backgroundColorLayer]; - - _titleLabel = [LKLabel new]; - self.titleLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - [self addSubview:self.titleLabel]; - - _subtitleLabel = [LKLabel new]; - [self addSubview:self.subtitleLabel]; - - self.isDarkMode = self.effectiveAppearance.lk_isDarkMode; - - [self _updateBackgroundLayerColor]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.backgroundColorLayer).fullFrame; -} - -- (BOOL)isFlipped { - return YES; -} - -- (void)viewDidChangeEffectiveAppearance { - [super viewDidChangeEffectiveAppearance]; - self.isDarkMode = self.effectiveAppearance.lk_isDarkMode; - [self _updateBackgroundLayerColor]; -} - -- (void)setIsSelected:(BOOL)isSelected { - if (_isSelected == isSelected) { - return; - } - _isSelected = isSelected; - [self _updateBackgroundLayerColor]; -} - -- (void)setIsHovered:(BOOL)isHovered { - if (_isHovered == isHovered) { - return; - } - _isHovered = isHovered; - [self _updateBackgroundLayerColor]; -} - -- (void)_updateBackgroundLayerColor { - if (self.isSelected) { - self.backgroundColorLayer.backgroundColor = [LKHelper accentColor].CGColor; - } else if (self.isHovered) { - self.backgroundColorLayer.backgroundColor = self.isDarkMode ? LookinColorRGBAMake(255, 255, 255, .15).CGColor : LookinColorRGBAMake(0, 0, 0, .1).CGColor; - } else { - self.backgroundColorLayer.backgroundColor = [NSColor clearColor].CGColor; - } -} - -@end - -@implementation LKTableBlankRowView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - } - return self; -} - -@end diff --git a/LookInside/Base/LKTableRowView.swift b/LookInside/Base/LKTableRowView.swift new file mode 100644 index 00000000..974e08e0 --- /dev/null +++ b/LookInside/Base/LKTableRowView.swift @@ -0,0 +1,123 @@ +// +// LKTableRowView.swift +// Lookin +// +// Created by Li Kai on 2019/4/20. +// https://lookin.work +// + +import AppKit + +@objc(LKTableRowView) +@objcMembers +class LKTableRowView: NSTableRowView { + let titleLabel: LKLabel! + let subtitleLabel: LKLabel! + + /// The selection LKTableView draws. NSTableView's own row selection + /// (setSelected:) does not change it, but isSelected reports this value. + var isRowSelected: Bool { + get { + selectedState + } + set { + if selectedState == newValue { + return + } + selectedState = newValue + updateBackgroundLayerColor() + } + } + + /// The getter reports isRowSelected; the setter (-setSelected:, which + /// NSTableView calls) keeps AppKit's behaviour and changes nothing here. + override var isSelected: Bool { + get { + isRowSelected + } + set { + super.isSelected = newValue + } + } + + var isHovered: Bool { + get { + hoveredState + } + set { + if hoveredState == newValue { + return + } + hoveredState = newValue + updateBackgroundLayerColor() + } + } + + var isDarkMode: Bool { + darkModeState + } + + /// 子类可在该方法里更新 UI;子类重写时要调用 super。 + func setIsDarkMode(_ isDarkMode: Bool) { + darkModeState = isDarkMode + } + + weak var horizontalScrollWidthManager: LKTableViewHorizontalScrollWidthManager? + + private let backgroundColorLayer = CALayer() + private var selectedState = false + private var hoveredState = false + private var darkModeState = false + + override init(frame frameRect: NSRect) { + titleLabel = LKLabel() + subtitleLabel = LKLabel() + super.init(frame: frameRect) + wantsLayer = true + + backgroundColorLayer.lookin_removeImplicitAnimations() + layer?.addSublayer(backgroundColorLayer) + + titleLabel.lineBreakMode = .byTruncatingMiddle + addSubview(titleLabel) + + addSubview(subtitleLabel) + + // Through the method, so subclasses see the initial value. + setIsDarkMode(effectiveAppearance.lk_isDarkMode) + + updateBackgroundLayerColor() + } + + required init?(coder _: NSCoder) { + fatalError("LKTableRowView is not loaded from archives") + } + + override func layout() { + super.layout() + backgroundColorLayer.lkLayout.fullFrame() + } + + override var isFlipped: Bool { + true + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + setIsDarkMode(effectiveAppearance.lk_isDarkMode) + updateBackgroundLayerColor() + } + + private func updateBackgroundLayerColor() { + if isRowSelected { + backgroundColorLayer.backgroundColor = LKHelper.accentColor().cgColor + } else if isHovered { + backgroundColorLayer.backgroundColor = isDarkMode ? NSColor.lkBaseRGB(255, 255, 255, 0.15).cgColor : NSColor.lkBaseRGB(0, 0, 0, 0.1).cgColor + } else { + backgroundColorLayer.backgroundColor = NSColor.clear.cgColor + } + } +} + +@objc(LKTableBlankRowView) +final class LKTableBlankRowView: LKTableRowView {} diff --git a/LookInside/Base/LKTableView.h b/LookInside/Base/LKTableView.h deleted file mode 100644 index e3068129..00000000 --- a/LookInside/Base/LKTableView.h +++ /dev/null @@ -1,52 +0,0 @@ -// -// LKTableView.h -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - -#import - -@class LKTableView; - -@protocol LKTableViewDelegate - -@optional - -/// 以下方法的 row 均可能为 -1 -- (void)tableView:(LKTableView *)tableView didSelectRow:(NSInteger)row; -- (void)tableView:(LKTableView *)tableView didHoverAtRow:(NSInteger)row; -- (void)tableView:(LKTableView *)tableView didDoubleClickAtRow:(NSInteger)row; -// 点击了空白处 -- (void)tableViewDidClickBlankArea:(LKTableView *)tableView; - -@end - -@protocol LKTableViewDataSource - -@end - -@interface LKTableView : NSScrollView - -@property(nonatomic, strong, readonly) NSTableView *tableView; - -/// 默认为 YES -@property(nonatomic, assign) BOOL canScrollHorizontally; - -@property(nonatomic, weak) id delegate; -@property(nonatomic, weak) id dataSource; - -/// 默认为 YES -@property(nonatomic, assign) BOOL adjustsSelectionAutomatically; -/// 默认为 YES -@property(nonatomic, assign) BOOL adjustsHoverAutomatically; - -- (void)reloadData; - -/// reloadData 但是仍维持原本的滚动条位置(上面的 reloadData 会重置 offset) -- (void)reloadDataWithOffset; - -- (void)scrollRowToVisible:(NSInteger)row; - -@end diff --git a/LookInside/Base/LKTableView.m b/LookInside/Base/LKTableView.m deleted file mode 100644 index e62ec4e9..00000000 --- a/LookInside/Base/LKTableView.m +++ /dev/null @@ -1,316 +0,0 @@ -// -// LKTableView.m -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - -#import "LKTableView.h" -#import "LKTableRowView.h" -#import "LKTableViewHorizontalScrollWidthManager.h" - -@interface DisableKeyDownTableView: NSTableView -@property(nonatomic, weak) NSResponder *responder; -@end - -@implementation DisableKeyDownTableView -- (void)keyDown:(NSEvent *)event { - [self.responder keyDown:event]; -} -@end - -@interface LKTableView () - -@property(nonatomic, strong) LKTableViewHorizontalScrollWidthManager *horizontalScrollWidthManager; - -/// -1 表示不存在 -@property(nonatomic, assign) NSInteger hoveredRow; -@property(nonatomic, assign) NSInteger selectedRow; - -@end - -@implementation LKTableView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.adjustsSelectionAutomatically = YES; - self.adjustsHoverAutomatically = YES; - - _selectedRow = -1; - _hoveredRow = -1; - - self.backgroundColor = [NSColor clearColor]; - self.drawsBackground = NO; - self.hasVerticalScroller = YES; - self.autohidesScrollers = YES; - - DisableKeyDownTableView *tableView = [[DisableKeyDownTableView alloc] init]; - tableView.responder = self; - _tableView = tableView; - if (@available(macOS 11.0, *)) { - self.tableView.style = NSTableViewStylePlain; - } - self.tableView.delegate = self; - self.tableView.dataSource = self; - self.tableView.wantsLayer = YES; - self.tableView.backgroundColor = [NSColor clearColor]; - self.tableView.headerView = nil; - self.tableView.focusRingType = NSFocusRingTypeNone; - self.tableView.selectionHighlightStyle = NSTableViewSelectionHighlightStyleNone; - self.tableView.intercellSpacing = NSMakeSize(0, 0); - NSTableColumn *column = [[NSTableColumn alloc] initWithIdentifier:@"column"]; - column.editable = NO; - [self.tableView addTableColumn:column]; - self.contentView.documentView = self.tableView; - self.tableView.target = self; - self.tableView.action = @selector(_handleTableViewDefaultAction); - self.tableView.doubleAction = @selector(_handleDoubleClickTableView); - - self.canScrollHorizontally = YES; - } - return self; -} - -- (void)layout { - [super layout]; - if (!self.canScrollHorizontally || !self.horizontalScrollWidthManager) { - $(self.tableView).fullWidth; - return; - } - if (self.horizontalScrollWidthManager.maxRowWidth <= self.$width) { - $(self.tableView).fullWidth; - } else { - $(self.tableView).width(self.horizontalScrollWidthManager.maxRowWidth + 20); - } -} - -- (void)reloadData { - self.selectedRow = -1; - - self.horizontalScrollWidthManager = nil; - if (self.canScrollHorizontally) { - self.horizontalScrollWidthManager = [LKTableViewHorizontalScrollWidthManager new]; - @weakify(self); - self.horizontalScrollWidthManager.didReachNewMaxWidth = ^{ - @strongify(self); - [self handleRowViewMaxWidthChange]; - }; - } - - [self.tableView reloadData]; -} - -- (void)reloadDataWithOffset { - CGPoint offset = self.documentVisibleRect.origin; - [self reloadData]; - self.needsLayout = YES; - [self layoutSubtreeIfNeeded]; - [self.documentView scrollPoint:offset]; -} - -- (void)scrollRowToVisible:(NSInteger)row { - [self.tableView scrollRowToVisible:row]; -} - -- (LKTableRowView *)makeViewWithIdentifier:(NSString *)identifier owner:(id)owner { - return [self.tableView makeViewWithIdentifier:identifier owner:self]; -} - -- (void)setCanScrollHorizontally:(BOOL)canScrollHorizontally { - _canScrollHorizontally = canScrollHorizontally; - self.hasHorizontalScroller = canScrollHorizontally; - - if (!canScrollHorizontally) { - self.horizontalScrollWidthManager = nil; - } -} - -- (void)handleRowViewMaxWidthChange { - if (!self.canScrollHorizontally || !self.horizontalScrollWidthManager) { - return; - } - if (self.horizontalScrollWidthManager.maxRowWidth <= self.$width) { - return; - } - $(self.tableView).width(self.horizontalScrollWidthManager.maxRowWidth + 20); -} - -#pragma mark - Table View - -- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { - if ([self.delegate respondsToSelector:@selector(numberOfRowsInTableView:)]) { - return [self.dataSource numberOfRowsInTableView:tableView]; - } - return 0; -} - -- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row { - if ([self.delegate respondsToSelector:@selector(tableView:heightOfRow:)]) { - return [self.delegate tableView:tableView heightOfRow:row]; - } - return 0; -} - -- (NSTableRowView *)tableView:(NSTableView *)tableView rowViewForRow:(NSInteger)row { - if ([self.delegate respondsToSelector:@selector(tableView:rowViewForRow:)]) { - NSTableRowView *rowView = [self.delegate tableView:tableView rowViewForRow:row]; - if ([rowView isKindOfClass:[LKTableRowView class]]) { - ((LKTableRowView *)rowView).isHovered = (self.hoveredRow == row); - if (self.adjustsSelectionAutomatically) { - ((LKTableRowView *)rowView).isSelected = (self.selectedRow == row); - } - - if (self.canScrollHorizontally) { - ((LKTableRowView *)rowView).horizontalScrollWidthManager = self.horizontalScrollWidthManager; - } - } - return rowView; - } - return nil; -} - -- (NSView *)tableView:(NSTableView *)tableView viewForTableColumn:(NSTableColumn *)tableColumn row:(NSInteger)row { - return nil; -} - -- (BOOL)tableView:(NSTableView *)tableView shouldSelectRow:(NSInteger)row { - if ([self.delegate respondsToSelector:@selector(tableView:shouldSelectRow:)]) { - return [self.delegate tableView:tableView shouldSelectRow:row]; - } - return YES; -} - -- (void)tableViewSelectionIsChanging:(NSNotification *)notification { - NSInteger selectedRow = [self.tableView selectedRow]; - if (selectedRow < 0) { - return; - } - self.selectedRow = selectedRow; - - if (!self.adjustsSelectionAutomatically) { - if ([self.delegate respondsToSelector:@selector(tableView:didSelectRow:)]) { - [self.delegate tableView:self didSelectRow:selectedRow]; - } - } - - // 为什么加这一句呢?因为我们实际上不想依赖于 NSTableView 内建的 selectedRow 系统。想象这么一个场景:我们通过点击 NSTableView 把 selectedRow 变成了 10,然后通过点击 preview 选中了 row 20,但 NSTableView 仍然以为我们当前选中的是 10,于是当我们再次点击 NSTableView 的 row 10 时会发现 tableViewSelectionIsChanging: 不会被调用,因为 NSTableView 不认为 selectedRow 变化了。 - [self.tableView deselectRow:selectedRow]; -} - -#pragma mark - Event Handler - -// tableViewSelectionIsChanging 会在 mouseDown 时被调用,而这个 _handleTableViewDefaultAction 会在 mouseUp 时被调用,有点晚 -- (void)_handleTableViewDefaultAction { - NSInteger clickedRow = self.tableView.clickedRow; - if (clickedRow < 0 && [self.delegate respondsToSelector:@selector(tableViewDidClickBlankArea:)]) { - [self.delegate tableViewDidClickBlankArea:self]; - } -} - -- (void)_handleDoubleClickTableView { - NSInteger clickedRow = self.tableView.clickedRow; - if ([self.delegate respondsToSelector:@selector(tableView:didDoubleClickAtRow:)]) { - [self.delegate tableView:self didDoubleClickAtRow:clickedRow]; - } -} - -- (void)mouseMoved:(NSEvent *)event { - [super mouseMoved:event]; - if (!self.adjustsHoverAutomatically) { - return; - } - [self _updateHoveredRowWithMouseEvent:event]; -} - -// 当鼠标离开了某一个 row 进入另一个 row 时,rowView 的 mouseExited: 里的 [super mouseExited:] 这一句会调用到这里的这个方法 -- (void)mouseExited:(NSEvent *)event { - [super mouseExited:event]; - if (!self.adjustsHoverAutomatically) { - return; - } - NSPoint rawPoint = [event locationInWindow]; - NSPoint point = [self.tableView convertPoint:rawPoint fromView:nil]; - NSInteger row = [self.tableView rowAtPoint:point]; - self.hoveredRow = row; -} - -- (void)_updateHoveredRowWithMouseEvent:(NSEvent *)event { - NSPoint rawPoint = [event locationInWindow]; - NSPoint point = [self.tableView convertPoint:rawPoint fromView:nil]; - NSInteger row = [self.tableView rowAtPoint:point]; - self.hoveredRow = row; -} - -#pragma mark - Others - -- (void)setHoveredRow:(NSInteger)hoveredRow { - if (_hoveredRow == hoveredRow) { - return; - } - NSInteger prevHoveredRow = _hoveredRow; - _hoveredRow = hoveredRow; - - if (prevHoveredRow >= 0 && prevHoveredRow < self.tableView.numberOfRows) { - NSTableRowView *rowView = [self.tableView rowViewAtRow:prevHoveredRow makeIfNecessary:NO]; - if ([rowView isKindOfClass:[LKTableRowView class]]) { - ((LKTableRowView *)rowView).isHovered = NO; - } - } - - if (hoveredRow >= 0 && hoveredRow < self.tableView.numberOfRows) { - BOOL canHover = YES; - if ([self.delegate respondsToSelector:@selector(tableView:shouldSelectRow:)]) { - canHover = [self.delegate tableView:self.tableView shouldSelectRow:hoveredRow]; - } - if (canHover) { - NSTableRowView *rowView = [self.tableView rowViewAtRow:hoveredRow makeIfNecessary:NO]; - if ([rowView isKindOfClass:[LKTableRowView class]]) { - ((LKTableRowView *)rowView).isHovered = YES; - } - } - } - if ([self.delegate respondsToSelector:@selector(tableView:didHoverAtRow:)]) { - [self.delegate tableView:self didHoverAtRow:hoveredRow]; - } -} - -- (void)setSelectedRow:(NSInteger)selectedRow { - if (!self.adjustsSelectionAutomatically) { - return; - } - if (_selectedRow == selectedRow) { - return; - } - NSInteger prevSelectedRow = _selectedRow; - _selectedRow = selectedRow; - - if (prevSelectedRow >= 0 && prevSelectedRow < self.tableView.numberOfRows) { - NSTableRowView *rowView = [self.tableView rowViewAtRow:prevSelectedRow makeIfNecessary:NO]; - if ([rowView isKindOfClass:[LKTableRowView class]]) { - ((LKTableRowView *)rowView).isSelected = NO; - } - } - - if (selectedRow >= 0 && selectedRow < self.tableView.numberOfRows) { - NSTableRowView *rowView = [self.tableView rowViewAtRow:selectedRow makeIfNecessary:NO]; - if ([rowView isKindOfClass:[LKTableRowView class]]) { - ((LKTableRowView *)rowView).isSelected = YES; - } - if ([self.delegate respondsToSelector:@selector(tableView:didSelectRow:)]) { - [self.delegate tableView:self didSelectRow:selectedRow]; - } - } -} - -- (void)updateTrackingAreas { - [super updateTrackingAreas]; - [self.trackingAreas enumerateObjectsUsingBlock:^(NSTrackingArea * _Nonnull oldArea, NSUInteger idx, BOOL * _Nonnull stop) { - [self removeTrackingArea:oldArea]; - }]; - - NSTrackingArea *newArea = [[NSTrackingArea alloc] initWithRect:self.bounds options:NSTrackingMouseEnteredAndExited|NSTrackingMouseMoved|NSTrackingActiveInKeyWindow|NSTrackingInVisibleRect owner:self userInfo:nil]; - [self addTrackingArea:newArea]; -} - -@end diff --git a/LookInside/Base/LKTableView.swift b/LookInside/Base/LKTableView.swift new file mode 100644 index 00000000..57be84ac --- /dev/null +++ b/LookInside/Base/LKTableView.swift @@ -0,0 +1,312 @@ +// +// LKTableView.swift +// LookInside +// +// Created by Li Kai on 2019/4/20. +// https://lookin.work +// + +import AppKit + +/// The table inside an `LKTableView`. Arrow keys go to the scroll view's +/// responder chain instead of moving NSTableView's own selection. +private final class LKKeyForwardingTableView: NSTableView { + weak var responder: NSResponder? + + override func keyDown(with event: NSEvent) { + responder?.keyDown(with: event) + } +} + +/// Every row argument may be -1. +@objc(LKTableViewDelegate) +protocol LKTableViewDelegate: NSTableViewDelegate { + @objc(tableView:didSelectRow:) + optional func tableView(_ tableView: LKTableView!, didSelectRow row: Int) + @objc(tableView:didHoverAtRow:) + optional func tableView(_ tableView: LKTableView!, didHoverAtRow row: Int) + @objc(tableView:didDoubleClickAtRow:) + optional func tableView(_ tableView: LKTableView!, didDoubleClickAtRow row: Int) + /// A click on the blank area below the rows. + @objc(tableViewDidClickBlankArea:) + optional func tableViewDidClickBlankArea(_ tableView: LKTableView!) +} + +@objc(LKTableViewDataSource) +protocol LKTableViewDataSource: NSTableViewDataSource {} + +@objc(LKTableView) +class LKTableView: NSScrollView { + @objc let tableView: NSTableView! + + /// Defaults to true. + @objc var canScrollHorizontally: Bool = true { + didSet { + hasHorizontalScroller = canScrollHorizontally + if !canScrollHorizontally { + horizontalScrollWidthManager = nil + } + } + } + + @objc weak var delegate: LKTableViewDelegate? + @objc weak var dataSource: LKTableViewDataSource? + + /// Defaults to true. + @objc var adjustsSelectionAutomatically: Bool = true + /// Defaults to true. + @objc var adjustsHoverAutomatically: Bool = true + + private var horizontalScrollWidthManager: LKTableViewHorizontalScrollWidthManager? + + /// -1 when no row is hovered. + private var hoveredRow: Int = -1 { + didSet { + guard hoveredRow != oldValue else { + return + } + hoveredRowDidChange(from: oldValue) + } + } + + /// -1 when no row is selected. + private var selectedRow: Int = -1 + + @objc override init(frame frameRect: NSRect) { + let tableView = LKKeyForwardingTableView() + self.tableView = tableView + super.init(frame: frameRect) + + backgroundColor = .clear + drawsBackground = false + hasVerticalScroller = true + autohidesScrollers = true + + tableView.responder = self + tableView.style = .plain + tableView.delegate = self + tableView.dataSource = self + tableView.wantsLayer = true + tableView.backgroundColor = .clear + tableView.headerView = nil + tableView.focusRingType = .none + tableView.selectionHighlightStyle = .none + tableView.intercellSpacing = .zero + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("column")) + column.isEditable = false + tableView.addTableColumn(column) + contentView.documentView = tableView + tableView.target = self + tableView.action = #selector(handleTableViewDefaultAction) + tableView.doubleAction = #selector(handleDoubleClickTableView) + + // Initializers skip the property observer. + hasHorizontalScroller = true + } + + required init?(coder _: NSCoder) { + fatalError("LKTableView is not loaded from archives") + } + + override func layout() { + super.layout() + let tableLayout = LKHierarchyFrameLayout(tableView) + guard canScrollHorizontally, let manager = horizontalScrollWidthManager else { + tableLayout.fullWidth() + return + } + if manager.maxRowWidth <= frame.width { + tableLayout.fullWidth() + } else { + tableLayout.width(manager.maxRowWidth + 20) + } + } + + @objc func reloadData() { + setSelectedRow(-1) + + horizontalScrollWidthManager = nil + if canScrollHorizontally { + let manager = LKTableViewHorizontalScrollWidthManager() + manager.didReachNewMaxWidth = { [weak self] in + self?.handleRowViewMaxWidthChange() + } + horizontalScrollWidthManager = manager + } + + tableView.reloadData() + } + + /// Reloads but keeps the scroll position; `reloadData()` resets it. + @objc func reloadDataWithOffset() { + let offset = documentVisibleRect.origin + reloadData() + needsLayout = true + layoutSubtreeIfNeeded() + documentView?.scroll(offset) + } + + @objc(scrollRowToVisible:) + func scrollRowToVisible(_ row: Int) { + tableView.scrollRowToVisible(row) + } + + private func handleRowViewMaxWidthChange() { + guard canScrollHorizontally, let manager = horizontalScrollWidthManager else { + return + } + guard manager.maxRowWidth > frame.width else { + return + } + LKHierarchyFrameLayout(tableView).width(manager.maxRowWidth + 20) + } + + // MARK: - Event handlers + + /// Runs on mouse-up, after tableViewSelectionIsChanging(_:) ran on + /// mouse-down. + @objc private func handleTableViewDefaultAction() { + let clickedRow = tableView.clickedRow + if clickedRow < 0, let delegate, delegate.responds(to: #selector(LKTableViewDelegate.tableViewDidClickBlankArea(_:))) { + delegate.tableViewDidClickBlankArea?(self) + } + } + + @objc private func handleDoubleClickTableView() { + let clickedRow = tableView.clickedRow + delegate?.tableView?(self, didDoubleClickAtRow: clickedRow) + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + guard adjustsHoverAutomatically else { + return + } + hoveredRow = row(at: event) + } + + /// A row view's `mouseExited(with:)` reaches here through its `super` + /// call when the mouse moves from one row to another. + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + guard adjustsHoverAutomatically else { + return + } + hoveredRow = row(at: event) + } + + private func row(at event: NSEvent) -> Int { + let point = tableView.convert(event.locationInWindow, from: nil) + return tableView.row(at: point) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + for area in trackingAreas { + removeTrackingArea(area) + } + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + // MARK: - Hover and selection + + private func lkRowView(at row: Int) -> LKTableRowView? { + guard row >= 0, row < tableView.numberOfRows else { + return nil + } + return tableView.rowView(atRow: row, makeIfNecessary: false) as? LKTableRowView + } + + private func hoveredRowDidChange(from previousRow: Int) { + lkRowView(at: previousRow)?.isHovered = false + + if hoveredRow >= 0, hoveredRow < tableView.numberOfRows { + let canHover = delegate?.tableView?(tableView, shouldSelectRow: hoveredRow) ?? true + if canHover { + lkRowView(at: hoveredRow)?.isHovered = true + } + } + delegate?.tableView?(self, didHoverAtRow: hoveredRow) + } + + private func setSelectedRow(_ row: Int) { + guard adjustsSelectionAutomatically, selectedRow != row else { + return + } + let previousRow = selectedRow + selectedRow = row + + lkRowView(at: previousRow)?.isRowSelected = false + + if row >= 0, row < tableView.numberOfRows { + lkRowView(at: row)?.isRowSelected = true + delegate?.tableView?(self, didSelectRow: row) + } + } +} + +// MARK: - NSTableViewDataSource / NSTableViewDelegate + +extension LKTableView: NSTableViewDataSource, NSTableViewDelegate { + func numberOfRows(in tableView: NSTableView) -> Int { + // Asks the delegate whether it answers, then asks the data source, + // as the Objective-C version did; both are the same object for every + // caller. + guard let delegate, delegate.responds(to: #selector(NSTableViewDataSource.numberOfRows(in:))) else { + return 0 + } + return dataSource?.numberOfRows?(in: tableView) ?? 0 + } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + delegate?.tableView?(tableView, heightOfRow: row) ?? 0 + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + guard let delegate, delegate.responds(to: #selector(NSTableViewDelegate.tableView(_:rowViewForRow:))) else { + return nil + } + let rowView = delegate.tableView?(tableView, rowViewForRow: row) + if let rowView = rowView as? LKTableRowView { + rowView.isHovered = (hoveredRow == row) + if adjustsSelectionAutomatically { + rowView.isRowSelected = (selectedRow == row) + } + if canScrollHorizontally { + rowView.horizontalScrollWidthManager = horizontalScrollWidthManager + } + } + return rowView + } + + func tableView(_: NSTableView, viewFor _: NSTableColumn?, row _: Int) -> NSView? { + nil + } + + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + delegate?.tableView?(tableView, shouldSelectRow: row) ?? true + } + + func tableViewSelectionIsChanging(_: Notification) { + let row = tableView.selectedRow + guard row >= 0 else { + return + } + setSelectedRow(row) + + if !adjustsSelectionAutomatically { + delegate?.tableView?(self, didSelectRow: row) + } + + // Clear NSTableView's own selection so that clicking the same row + // again still reports it: after the user selects row 10 here and + // row 20 in the preview, NSTableView would otherwise still think 10 + // is selected and skip the change. + tableView.deselectRow(row) + } +} diff --git a/LookInside/Base/LKTableViewHorizontalScrollWidthManager.h b/LookInside/Base/LKTableViewHorizontalScrollWidthManager.h deleted file mode 100644 index 8dff52a3..00000000 --- a/LookInside/Base/LKTableViewHorizontalScrollWidthManager.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LKTableViewHorizontalScrollWidthManager.h -// LookinClient -// -// Created by likaimacbookhome on 2023/12/17. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import - -@interface LKTableViewHorizontalScrollWidthManager : NSObject - -@property(nonatomic, assign) CGFloat maxRowWidth; - -@property (nonatomic, copy) void (^didReachNewMaxWidth)(void); - -- (void)rowDidLayoutWithWidth:(CGFloat)width; - -@end diff --git a/LookInside/Base/LKTableViewHorizontalScrollWidthManager.m b/LookInside/Base/LKTableViewHorizontalScrollWidthManager.m deleted file mode 100644 index 381ba1ca..00000000 --- a/LookInside/Base/LKTableViewHorizontalScrollWidthManager.m +++ /dev/null @@ -1,22 +0,0 @@ -// -// LKTableViewHorizontalScrollWidthManager.m -// LookinClient -// -// Created by likaimacbookhome on 2023/12/17. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKTableViewHorizontalScrollWidthManager.h" - -@implementation LKTableViewHorizontalScrollWidthManager - -- (void)rowDidLayoutWithWidth:(CGFloat)width { - if (width > self.maxRowWidth) { - self.maxRowWidth = width; - if (self.didReachNewMaxWidth) { - self.didReachNewMaxWidth(); - } - } -} - -@end diff --git a/LookInside/Base/LKTableViewHorizontalScrollWidthManager.swift b/LookInside/Base/LKTableViewHorizontalScrollWidthManager.swift new file mode 100644 index 00000000..ce05da24 --- /dev/null +++ b/LookInside/Base/LKTableViewHorizontalScrollWidthManager.swift @@ -0,0 +1,25 @@ +// +// LKTableViewHorizontalScrollWidthManager.swift +// LookInside +// +// Created by likaimacbookhome on 2023/12/17. +// Copyright © 2023 hughkli. All rights reserved. +// + +import Foundation + +@objc(LKTableViewHorizontalScrollWidthManager) +class LKTableViewHorizontalScrollWidthManager: NSObject { + @objc var maxRowWidth: CGFloat = 0 + + @objc var didReachNewMaxWidth: (() -> Void)? + + @objc(rowDidLayoutWithWidth:) + func rowDidLayout(withWidth width: CGFloat) { + guard width > maxRowWidth else { + return + } + maxRowWidth = width + didReachNewMaxWidth?() + } +} diff --git a/LookInside/Base/LKTextControl.h b/LookInside/Base/LKTextControl.h deleted file mode 100644 index 484e9a83..00000000 --- a/LookInside/Base/LKTextControl.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LKTextControl.h -// Lookin -// -// Created by Li Kai on 2019/3/12. -// https://lookin.work -// - -#import "LKBaseControl.h" - -@interface LKTextControl : LKBaseControl - -@property(nonatomic, strong, readonly) LKLabel *label; - -@property(nonatomic, assign) NSEdgeInsets insets; - -@property(nonatomic, strong) NSImage *rightImage; -@property(nonatomic, assign) CGFloat spaceBetweenLabelAndImage; -@property(nonatomic, assign) CGFloat rightImageOffsetY; - -@end diff --git a/LookInside/Base/LKTextControl.m b/LookInside/Base/LKTextControl.m deleted file mode 100644 index 02acf116..00000000 --- a/LookInside/Base/LKTextControl.m +++ /dev/null @@ -1,88 +0,0 @@ -// -// LKTextControl.m -// Lookin -// -// Created by Li Kai on 2019/3/12. -// https://lookin.work -// - -#import "LKTextControl.h" - -@interface LKTextControl () - -@property(nonatomic, strong) NSImageView *rightImageView; - -@end - -@implementation LKTextControl - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _label = [LKLabel new]; - self.label.alignment = NSTextAlignmentCenter; - [self addSubview:self.label]; - } - return self; -} - -- (void)layout { - [super layout]; - CGFloat labelMaxX = self.$width - self.insets.right; - if (self.rightImageView) { - $(self.rightImageView).sizeToFit.verAlign.right(self.insets.right).offsetY(self.rightImageOffsetY); - labelMaxX = self.rightImageView.$x - self.spaceBetweenLabelAndImage; - } - $(self.label).x(self.insets.left).toMaxX(labelMaxX).heightToFit.verAlign; - if (self.insets.top != self.insets.bottom) { - $(self.label).offsetY(self.insets.top - self.insets.bottom); - } -} - -- (void)setInsets:(NSEdgeInsets)insets { - _insets = insets; - [self setNeedsLayout:YES]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat labelMaxWidth = limitedSize.width - self.insets.left - self.insets.right; - if (!self.rightImageView.hidden) { - labelMaxWidth -= ([self.rightImageView bestWidth] + self.spaceBetweenLabelAndImage); - } - - NSSize labelSize = [self.label sizeThatFits:NSMakeSize(labelMaxWidth, CGFLOAT_MAX)]; - - CGFloat resultWidth = labelSize.width + self.insets.left + self.insets.right; - if (!self.rightImageView.hidden) { - resultWidth += ([self.rightImageView bestWidth] + self.spaceBetweenLabelAndImage); - } - CGFloat resultHeight = labelSize.height + self.insets.top + self.insets.bottom; - - return NSMakeSize(resultWidth, resultHeight); -} - -- (void)setRightImage:(NSImage *)rightImage { - _rightImage = rightImage; - if (rightImage) { - if (!self.rightImageView) { - self.rightImageView = [NSImageView new]; - [self addSubview:self.rightImageView]; - } - self.rightImageView.image = rightImage; - - } else { - [self.rightImageView removeFromSuperview]; - } - [self setNeedsLayout:YES]; -} - -- (void)setSpaceBetweenLabelAndImage:(CGFloat)spaceBetweenLabelAndImage { - _spaceBetweenLabelAndImage = spaceBetweenLabelAndImage; - [self setNeedsLayout:YES]; -} - -- (void)setRightImageOffsetY:(CGFloat)rightImageOffsetY { - _rightImageOffsetY = rightImageOffsetY; - [self setNeedsLayout:YES]; -} - -@end diff --git a/LookInside/Base/LKTextControl.swift b/LookInside/Base/LKTextControl.swift new file mode 100644 index 00000000..7efb8085 --- /dev/null +++ b/LookInside/Base/LKTextControl.swift @@ -0,0 +1,91 @@ +// +// LKTextControl.swift +// Lookin +// +// Created by Li Kai on 2019/3/12. +// https://lookin.work +// + +import AppKit + +@objc(LKTextControl) +class LKTextControl: LKBaseControl { + @objc let label = LKLabel() + + @objc var insets = NSEdgeInsets() { + didSet { + needsLayout = true + } + } + + @objc var rightImage: NSImage? { + didSet { + if let rightImage { + if rightImageView == nil { + let imageView = NSImageView() + addSubview(imageView) + rightImageView = imageView + } + rightImageView?.image = rightImage + } else { + rightImageView?.removeFromSuperview() + } + needsLayout = true + } + } + + @objc var spaceBetweenLabelAndImage: CGFloat = 0 { + didSet { + needsLayout = true + } + } + + @objc var rightImageOffsetY: CGFloat = 0 { + didSet { + needsLayout = true + } + } + + /// Created by the first rightImage and kept (only taken out of the view + /// tree) when the image is cleared. + private var rightImageView: NSImageView? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + label.alignment = .center + addSubview(label) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + label.alignment = .center + addSubview(label) + } + + override func layout() { + super.layout() + var labelMaxX = frame.width - insets.right + if let rightImageView { + rightImageView.lkLayout.sizeToFit().verAlign().right(insets.right).offsetY(rightImageOffsetY) + labelMaxX = rightImageView.frame.minX - spaceBetweenLabelAndImage + } + label.lkLayout.x(insets.left).toMaxX(labelMaxX).heightToFit().verAlign() + if insets.top != insets.bottom { + label.lkLayout.offsetY(insets.top - insets.bottom) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + // Without an image view the image space still counts, as it did in + // Objective-C (a nil view reads as not hidden, 0 wide). + let reservesImageSpace = !(rightImageView?.isHidden ?? false) + let imageSpace = reservesImageSpace ? (rightImageView?.bestWidth() ?? 0) + spaceBetweenLabelAndImage : 0 + + let labelMaxWidth = limitedSize.width - insets.left - insets.right - imageSpace + let labelSize = label.sizeThatFits(NSSize(width: labelMaxWidth, height: .greatestFiniteMagnitude)) + + let resultWidth = labelSize.width + insets.left + insets.right + imageSpace + let resultHeight = labelSize.height + insets.top + insets.bottom + return NSSize(width: resultWidth, height: resultHeight) + } +} diff --git a/LookInside/Base/LKTextFieldView.h b/LookInside/Base/LKTextFieldView.h deleted file mode 100644 index ade9a914..00000000 --- a/LookInside/Base/LKTextFieldView.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// LKTextFieldView.h -// Lookin -// -// Created by Li Kai on 2019/2/27. -// https://lookin.work -// - -#import "LKBaseView.h" - -@interface LKTextFieldView : LKBaseView - -+ (instancetype)labelView; - -@property(nonatomic, strong, readonly) NSTextField *textField; - -@property(nonatomic, assign) NSEdgeInsets insets; - -@property(nonatomic, strong) LKTwoColors *textColors; - -@property(nonatomic, strong) NSImage *image; - -/// 调用该方法后,当输入框内有文字时右侧会有 closeButton -- (void)initCloseButton; -@property(nonatomic, strong, readonly) NSButton *closeButton; - -@end diff --git a/LookInside/Base/LKTextFieldView.m b/LookInside/Base/LKTextFieldView.m deleted file mode 100644 index bb9ded36..00000000 --- a/LookInside/Base/LKTextFieldView.m +++ /dev/null @@ -1,137 +0,0 @@ -// -// LKTextFieldView.m -// Lookin -// -// Created by Li Kai on 2019/2/27. -// https://lookin.work -// - -#import "LKTextFieldView.h" - -@interface LKTextFieldView () - -@property(nonatomic, strong) NSImageView *imageView; - - -@end - -@implementation LKTextFieldView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _textField = [NSTextField new]; - [self addSubview:self.textField]; - } - return self; -} - -+ (instancetype)labelView { - LKTextFieldView *view = [LKTextFieldView new]; - view.textField.wantsLayer = YES; - view.textField.backgroundColor = [NSColor clearColor]; - [view.textField setBezeled:NO]; - [view.textField setDrawsBackground:YES]; - [view.textField setEditable:NO]; - [view.textField setSelectable:NO]; - return view; -} - -- (void)layout { - [super layout]; - if (self.imageView) { - if (self.textField.editable) { - // 比如 hierarchy 底部的搜索框 - $(self.imageView).sizeToFit.x(self.insets.left).verAlign.offsetY(1); - if (self.closeButton) { - $(self.closeButton).y(1).toBottom(0).width(100).right(self.insets.right); - $(self.textField).x(self.imageView.$maxX + 5).toMaxX(self.closeButton.$x - 5).heightToFit.verAlign; - } else { - $(self.textField).x(self.imageView.$maxX + 5).toRight(self.insets.right).heightToFit.verAlign; - } - - } else { - // 纯 label,比如 autoLayout popover 的标题 - $(self.imageView, self.textField).sizeToFit.verAlign; - $(self.textField).x(self.imageView.$maxX); - $(self.imageView, self.textField).groupHorAlign; - } - } else { - $(self.textField).x(self.insets.left).toRight(self.insets.right).heightToFit.verAlign.offsetY(self.insets.top - self.insets.bottom); - } -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat textFieldMaxWidth = limitedSize.width - self.insets.left - self.insets.right; - if (self.imageView) { - textFieldMaxWidth -= self.image.size.width; - } - NSSize textFieldSize = [self.textField sizeThatFits:NSMakeSize(textFieldMaxWidth, CGFLOAT_MAX)]; - - CGFloat resultHeight = textFieldSize.height + self.insets.top + self.insets.bottom; - CGFloat resultWidth = textFieldSize.width + self.insets.left + self.insets.right; - if (self.imageView) { - resultWidth += self.image.size.width; - } - return NSMakeSize(resultWidth, resultHeight); -} - -- (void)setInsets:(NSEdgeInsets)insets { - _insets = insets; - [self setNeedsLayout:YES]; -} - -- (void)updateColors { - [super updateColors]; - if (self.textColors) { - self.textField.textColor = self.textColors.color; - } -} - -- (void)setTextColors:(LKTwoColors *)textColors { - _textColors = textColors; - [self updateColors]; -} - -- (void)setImage:(NSImage *)image { - _image = image; - if (image) { - if (!self.imageView) { - self.imageView = [NSImageView new]; - [self addSubview:self.imageView]; - } - self.imageView.image = image; - } else { - [self.imageView removeFromSuperview]; - self.imageView = nil; - } - [self setNeedsLayout:YES]; -} - -- (void)initCloseButton { - if (self.closeButton) { - return; - } - - _closeButton = [NSButton new]; - self.closeButton.title = NSLocalizedString(@"CancelSearch", nil); - self.closeButton.bezelStyle = NSBezelStyleRegularSquare; - [self addSubview:self.closeButton]; - [self setNeedsLayout:YES]; - - @weakify(self); - // rac_textSignal 只能监测到用户手动输入文字,observe stringValue 只能监测到代码手动设置文字(比如业务逻辑里按下 esc 来清除文字),这二者配合恰好 - [[self.textField.rac_textSignal combineLatestWith:RACObserve(self.textField, stringValue)] subscribeNext:^(RACTwoTuple * _Nullable x) { - @strongify(self); - self.closeButton.hidden = (self.textField.stringValue.length == 0); - }]; -} - -- (void)mouseDown:(NSEvent *)event { - [super mouseDown:event]; - // 如果不加这个逻辑的话,非编辑状态下点击到了右侧 closeButton 的位置就会无法激活输入框令用户困惑 - if (self.textField.isEditable) { - [self.textField becomeFirstResponder]; - } -} - -@end diff --git a/LookInside/Base/LKTextFieldView.swift b/LookInside/Base/LKTextFieldView.swift new file mode 100644 index 00000000..d54a5e90 --- /dev/null +++ b/LookInside/Base/LKTextFieldView.swift @@ -0,0 +1,148 @@ +// +// LKTextFieldView.swift +// LookInside +// + +import AppKit + +/// A text field with insets, an optional leading image and an optional +/// trailing button that clears a search. +@objc(LKTextFieldView) +class LKTextFieldView: LKBaseView { + @objc let textField = NSTextField() + + @objc var insets = NSEdgeInsetsZero { + didSet { needsLayout = true } + } + + @objc var textColors: LKTwoColors? { + didSet { updateColors() } + } + + @objc var image: NSImage? { + didSet { updateImageView() } + } + + /// Set by `initCloseButton()`. + @objc private(set) var closeButton: NSButton? + + private var imageView: NSImageView? + private var textDidChangeObserver: NSObjectProtocol? + private var stringValueObservation: NSKeyValueObservation? + + /// A read-only, borderless label. + @objc(labelView) static func label() -> LKTextFieldView { + let view = LKTextFieldView() + view.textField.wantsLayer = true + view.textField.backgroundColor = .clear + view.textField.isBezeled = false + view.textField.drawsBackground = true + view.textField.isEditable = false + view.textField.isSelectable = false + return view + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + addSubview(textField) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + addSubview(textField) + } + + deinit { + if let textDidChangeObserver { + NotificationCenter.default.removeObserver(textDidChangeObserver) + } + } + + override func layout() { + super.layout() + if let imageView { + if textField.isEditable { + // An input, like the hierarchy's filter field. + imageView.lkLayout.sizeToFit().x(insets.left).verAlign().offsetY(1) + if let closeButton { + closeButton.lkLayout.y(1).toBottom(0).width(100).right(insets.right) + textField.lkLayout.x(imageView.frame.maxX + 5).toMaxX(closeButton.frame.minX - 5).heightToFit().verAlign() + } else { + textField.lkLayout.x(imageView.frame.maxX + 5).toRight(insets.right).heightToFit().verAlign() + } + } else { + // A plain label, like the constraint popover's title. + LKFrameLayout([imageView, textField]).sizeToFit().verAlign() + textField.lkLayout.x(imageView.frame.maxX) + LKFrameLayout([imageView, textField]).groupHorAlign() + } + } else { + textField.lkLayout.x(insets.left).toRight(insets.right).heightToFit().verAlign().offsetY(insets.top - insets.bottom) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var textFieldMaxWidth = limitedSize.width - insets.left - insets.right + if imageView != nil { + textFieldMaxWidth -= image?.size.width ?? 0 + } + let textFieldSize = textField.sizeThatFits(NSSize(width: textFieldMaxWidth, height: .greatestFiniteMagnitude)) + var resultWidth = textFieldSize.width + insets.left + insets.right + if imageView != nil { + resultWidth += image?.size.width ?? 0 + } + return NSSize(width: resultWidth, height: textFieldSize.height + insets.top + insets.bottom) + } + + override func updateColors() { + super.updateColors() + if let textColors { + textField.textColor = textColors.color + } + } + + private func updateImageView() { + if let image { + let imageView = self.imageView ?? NSImageView() + if self.imageView == nil { + addSubview(imageView) + self.imageView = imageView + } + imageView.image = image + } else { + imageView?.removeFromSuperview() + imageView = nil + } + needsLayout = true + } + + /// Adds the trailing "cancel search" button, shown while the field has + /// text. + @objc func initCloseButton() { + guard closeButton == nil else { return } + let closeButton = NSButton() + closeButton.title = NSLocalizedString("CancelSearch", comment: "") + closeButton.bezelStyle = .regularSquare + addSubview(closeButton) + self.closeButton = closeButton + needsLayout = true + + // Typing posts text-did-change; code that sets stringValue (Esc + // clearing the filter) only shows up through KVO. Watch both. + let update = { [weak self] in + guard let self else { return } + self.closeButton?.isHidden = self.textField.stringValue.isEmpty + } + textDidChangeObserver = NotificationCenter.default.addObserver(forName: NSControl.textDidChangeNotification, object: textField, queue: nil) { _ in update() } + stringValueObservation = textField.observe(\.stringValue, options: [.initial]) { _, _ in update() } + } + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + // Without this, clicking where the close button would be does not + // start editing, which is confusing. + if textField.isEditable { + textField.becomeFirstResponder() + } + } +} diff --git a/LookInside/Base/LKTextsMenuView.h b/LookInside/Base/LKTextsMenuView.h deleted file mode 100644 index f927906d..00000000 --- a/LookInside/Base/LKTextsMenuView.h +++ /dev/null @@ -1,35 +0,0 @@ -// -// LKTextsMenuView.h -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - -#import "LKBaseView.h" - -typedef NS_ENUM(NSInteger, LKTextsMenuViewType) { - LKTextsMenuViewTypeJustified, // 左侧文字左对齐,右侧文字右对齐 - LKTextsMenuViewTypeCenter // 居中显示 -}; - -@interface LKTextsMenuView : LKBaseView - -/// 默认为 {0, 3, 0, 3} -@property(nonatomic, assign) NSEdgeInsets insets; - -@property(nonatomic, assign) LKTextsMenuViewType type; - -@property(nonatomic, copy) NSArray *texts; - -@property(nonatomic, strong) NSFont *font; - -/// 默认为 2 -@property(nonatomic, assign) CGFloat verSpace; -/// 默认为 10 -@property(nonatomic, assign) CGFloat horSpace; - -/// 在某一行的右侧加一个按钮,业务自己负责这个 button 的点击事件之类的 -- (void)addButton:(NSButton *)button atIndex:(NSUInteger)idx; - -@end diff --git a/LookInside/Base/LKTextsMenuView.m b/LookInside/Base/LKTextsMenuView.m deleted file mode 100644 index 83c8a424..00000000 --- a/LookInside/Base/LKTextsMenuView.m +++ /dev/null @@ -1,209 +0,0 @@ -// -// LKTextsMenuView.m -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - -#import "LKTextsMenuView.h" - -@interface LKTextsMenuView () - -@property(nonatomic, strong) NSMutableArray *leftLabels; -@property(nonatomic, strong) NSMutableArray *rightLabels; - -@property(nonatomic, strong) NSMutableDictionary *buttons; - -@end - -@implementation LKTextsMenuView { - CGFloat _buttonMarginLeft; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _buttonMarginLeft = 4; - _verSpace = 2; - _horSpace = 10; - _insets = NSEdgeInsetsMake(0, 3, 0, 5); - - self.leftLabels = [NSMutableArray array]; - self.rightLabels = [NSMutableArray array]; - } - return self; -} - -- (void)layout { - [super layout]; - - NSArray *visibleRightLabels = self.rightLabels.lk_visibleViews; - NSArray *visibleLeftLabels = self.leftLabels.lk_visibleViews; - - if (self.type == LKTextsMenuViewTypeCenter) { - __block CGFloat leftLabelMaxWidth = self.insets.left; - [visibleLeftLabels enumerateObjectsUsingBlock:^(LKLabel * _Nonnull leftLabel, NSUInteger idx, BOOL * _Nonnull stop) { - LKLabel *prevLeftLabel = (idx > 0 ? visibleLeftLabels[idx - 1] : nil); - CGFloat y = prevLeftLabel ? (prevLeftLabel.$maxY + self.verSpace) : 0; - $(leftLabel).sizeToFit.y(y); - leftLabelMaxWidth = MAX(leftLabelMaxWidth, leftLabel.$width + self.insets.left); - }]; - [visibleLeftLabels enumerateObjectsUsingBlock:^(LKLabel * _Nonnull leftLabel, NSUInteger idx, BOOL * _Nonnull stop) { - $(leftLabel).maxX(leftLabelMaxWidth); - - CGFloat midY = leftLabel.$midY; - - LKLabel *rightLabel = visibleRightLabels[idx]; - $(rightLabel).x(leftLabelMaxWidth + self.horSpace).sizeToFit.midY(midY); - - NSButton *button = self.buttons[@(idx)]; - if (button) { - CGFloat x = rightLabel.$maxX; - if (rightLabel.stringValue.length > 0) { - x += (self->_buttonMarginLeft); - } - $(button).sizeToFit.x(x).midY(midY + 1); - } - }]; - - } else { - [visibleRightLabels enumerateObjectsUsingBlock:^(LKLabel * _Nonnull rightLabel, NSUInteger idx, BOOL * _Nonnull stop) { - LKLabel *prevLeftLabel = (idx > 0 ? visibleLeftLabels[idx - 1] : nil); - - LKLabel *leftLabel = visibleLeftLabels[idx]; - CGFloat y = prevLeftLabel ? (prevLeftLabel.$maxY + self.verSpace) : 0; - $(leftLabel).sizeToFit.x(0).y(y); - - CGFloat rightLabelMaxX = self.$width; - NSButton *button = self.buttons[@(idx)]; - if (button) { - $(button).sizeToFit.right(0).midY(leftLabel.$midY); - rightLabelMaxX = button.$x - (self -> _buttonMarginLeft); - } - $(rightLabel).x(leftLabel.$maxX + self.horSpace).toMaxX(rightLabelMaxX).heightToFit.midY(leftLabel.$midY); - }]; - } -} - -- (void)setTexts:(NSArray *)texts { - _texts = texts.copy; - - [self.leftLabels lookin_dequeueWithCount:texts.count add:^LKLabel *(NSUInteger idx) { - LKLabel *label = [LKLabel new]; - label.selectable = YES; - label.font = self.font; - label.maximumNumberOfLines = 1; - label.lineBreakMode = NSLineBreakByTruncatingMiddle; - [self addSubview:label]; - return label; - } notDequeued:^(NSUInteger idx, LKLabel *obj) { - obj.hidden = YES; - } doNext:^(NSUInteger idx, LKLabel *obj) { - obj.hidden = NO; - obj.stringValue = texts[idx].first; - }]; - - [self.rightLabels lookin_dequeueWithCount:texts.count add:^LKLabel *(NSUInteger idx) { - LKLabel *label = [LKLabel new]; - label.selectable = YES; - label.font = self.font; - label.maximumNumberOfLines = 1; - label.lineBreakMode = NSLineBreakByTruncatingMiddle; - [self addSubview:label]; - return label; - } notDequeued:^(NSUInteger idx, LKLabel *obj) { - obj.hidden = YES; - } doNext:^(NSUInteger idx, LKLabel *obj) { - obj.hidden = NO; - obj.stringValue = texts[idx].second; - }]; - - NSAssert(self.leftLabels.count == self.rightLabels.count, @""); - - [self updateColors]; - [self _updateAlignments]; - [self setNeedsLayout:YES]; -} - -- (void)setType:(LKTextsMenuViewType)type { - _type = type; - [self _updateAlignments]; -} - -- (void)_updateAlignments { - [self.leftLabels enumerateObjectsUsingBlock:^(LKLabel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (self.type == LKTextsMenuViewTypeJustified) { - obj.alignment = NSTextAlignmentLeft; - } else { - obj.alignment = NSTextAlignmentRight; - } - }]; - [self.rightLabels enumerateObjectsUsingBlock:^(LKLabel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (self.type == LKTextsMenuViewTypeJustified) { - obj.alignment = NSTextAlignmentRight; - } else { - obj.alignment = NSTextAlignmentLeft; - } - }]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - __block CGFloat resultHeight = 0; - - __block CGFloat leftMaxWidth = 0; - __block CGFloat rightMaxWidth = 0; - [self.leftLabels.lk_visibleViews enumerateObjectsUsingBlock:^(LKLabel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSSize size = [obj bestSize]; - leftMaxWidth = MAX(leftMaxWidth, size.width); - resultHeight += size.height; - if (idx > 0) { - resultHeight += self.verSpace; - } - }]; - [self.rightLabels.lk_visibleViews enumerateObjectsUsingBlock:^(LKLabel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat width = [obj bestWidth]; - - NSButton *button = self.buttons[@(idx)]; - if (button) { - width += [button bestWidth]; - if (obj.stringValue.length > 0) { - width += (self -> _buttonMarginLeft); - } - } - - rightMaxWidth = MAX(rightMaxWidth, width); - }]; - - /// 这里多给个 1 的冗余量,来抵消 ShortCocoa 布局时取整带来的误差 - CGFloat resultWidth = leftMaxWidth + rightMaxWidth + self.horSpace + self.insets.left + self.insets.right; - return NSMakeSize(resultWidth, resultHeight); -} - -- (void)updateColors { - [super updateColors]; - [self.leftLabels enumerateObjectsUsingBlock:^(LKLabel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textColor = [(self.isDarkMode ? NSColorGray9 : NSColorGray1) colorWithAlphaComponent:.7]; - }]; - [self.rightLabels enumerateObjectsUsingBlock:^(LKLabel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textColor = self.isDarkMode ? NSColorWhite : NSColorBlack; - }]; -} - -- (void)addButton:(NSButton *)button atIndex:(NSUInteger)idx { - if (!button) { - NSAssert(NO, @""); - return; - } - if (self.buttons[@(idx)]) { - NSAssert(NO, @""); - return; - } - if (!self.buttons) { - self.buttons = [NSMutableDictionary dictionary]; - } - self.buttons[@(idx)] = button; - [self addSubview:button]; - [self setNeedsLayout:YES]; -} - -@end diff --git a/LookInside/Base/LKTextsMenuView.swift b/LookInside/Base/LKTextsMenuView.swift new file mode 100644 index 00000000..ba9e3225 --- /dev/null +++ b/LookInside/Base/LKTextsMenuView.swift @@ -0,0 +1,178 @@ +// +// LKTextsMenuView.swift +// LookInside +// + +import AppKit + +@objc enum LKTextsMenuViewType: Int { + /// Left column left-aligned, right column right-aligned. + case justified + /// Both columns meet in the middle. + case center +} + +/// Two columns of key / value labels, optionally with a button after a +/// value. +@objc(LKTextsMenuView) +class LKTextsMenuView: LKBaseView { + /// Defaults to {0, 3, 0, 5}. + @objc var insets = NSEdgeInsets(top: 0, left: 3, bottom: 0, right: 5) + + @objc var type: LKTextsMenuViewType = .justified { + didSet { updateAlignments() } + } + + @objc var texts: [LookinStringTwoTuple] = [] { + didSet { reloadLabels() } + } + + @objc var font: NSFont? + + /// Defaults to 2. + @objc var verSpace: CGFloat = 2 + /// Defaults to 10. + @objc var horSpace: CGFloat = 10 + + private var leftLabels: [LKLabel] = [] + private var rightLabels: [LKLabel] = [] + private var buttons: [Int: NSButton] = [:] + private let buttonMarginLeft: CGFloat = 4 + + override func layout() { + super.layout() + let visibleLeftLabels = leftLabels.filter { !$0.isHidden } + let visibleRightLabels = rightLabels.filter { !$0.isHidden } + + if type == .center { + var leftLabelMaxWidth = insets.left + for (idx, leftLabel) in visibleLeftLabels.enumerated() { + let y = idx > 0 ? visibleLeftLabels[idx - 1].frame.maxY + verSpace : 0 + leftLabel.lkLayout.sizeToFit().y(y) + leftLabelMaxWidth = max(leftLabelMaxWidth, leftLabel.frame.width + insets.left) + } + for (idx, leftLabel) in visibleLeftLabels.enumerated() { + leftLabel.lkLayout.maxX(leftLabelMaxWidth) + let midY = leftLabel.frame.midY + let rightLabel = visibleRightLabels[idx] + rightLabel.lkLayout.x(leftLabelMaxWidth + horSpace).sizeToFit().midY(midY) + if let button = buttons[idx] { + var x = rightLabel.frame.maxX + if !rightLabel.stringValue.isEmpty { + x += buttonMarginLeft + } + button.lkLayout.sizeToFit().x(x).midY(midY + 1) + } + } + } else { + for (idx, rightLabel) in visibleRightLabels.enumerated() { + let leftLabel = visibleLeftLabels[idx] + let y = idx > 0 ? visibleLeftLabels[idx - 1].frame.maxY + verSpace : 0 + leftLabel.lkLayout.sizeToFit().x(0).y(y) + var rightLabelMaxX = frame.width + if let button = buttons[idx] { + button.lkLayout.sizeToFit().right(0).midY(leftLabel.frame.midY) + rightLabelMaxX = button.frame.minX - buttonMarginLeft + } + rightLabel.lkLayout.x(leftLabel.frame.maxX + horSpace).toMaxX(rightLabelMaxX).heightToFit().midY(leftLabel.frame.midY) + } + } + } + + private func makeLabel() -> LKLabel { + let label = LKLabel() + label.isSelectable = true + label.font = font + label.maximumNumberOfLines = 1 + label.lineBreakMode = .byTruncatingMiddle + addSubview(label) + return label + } + + /// Reuses the existing labels, adds missing ones and hides the rest. + private func reloadLabels() { + for (idx, text) in texts.enumerated() { + if idx >= leftLabels.count { + leftLabels.append(makeLabel()) + } + leftLabels[idx].isHidden = false + leftLabels[idx].stringValue = text.first ?? "" + } + for label in leftLabels.dropFirst(texts.count) { + label.isHidden = true + } + for (idx, text) in texts.enumerated() { + if idx >= rightLabels.count { + rightLabels.append(makeLabel()) + } + rightLabels[idx].isHidden = false + rightLabels[idx].stringValue = text.second ?? "" + } + for label in rightLabels.dropFirst(texts.count) { + label.isHidden = true + } + updateColors() + updateAlignments() + needsLayout = true + } + + private func updateAlignments() { + for label in leftLabels { + label.alignment = type == .justified ? .left : .right + } + for label in rightLabels { + label.alignment = type == .justified ? .right : .left + } + } + + override func sizeThatFits(_: NSSize) -> NSSize { + var resultHeight: CGFloat = 0 + var leftMaxWidth: CGFloat = 0 + var rightMaxWidth: CGFloat = 0 + for (idx, label) in leftLabels.filter({ !$0.isHidden }).enumerated() { + let size = label.bestSize() + leftMaxWidth = max(leftMaxWidth, size.width) + resultHeight += size.height + if idx > 0 { + resultHeight += verSpace + } + } + for (idx, label) in rightLabels.filter({ !$0.isHidden }).enumerated() { + var width = label.bestWidth() + if let button = buttons[idx] { + width += button.bestWidth() + if !label.stringValue.isEmpty { + width += buttonMarginLeft + } + } + rightMaxWidth = max(rightMaxWidth, width) + } + let resultWidth = leftMaxWidth + rightMaxWidth + horSpace + insets.left + insets.right + return NSSize(width: resultWidth, height: resultHeight) + } + + override func updateColors() { + super.updateColors() + let isDarkMode = isDarkMode() + for label in leftLabels { + label.textColor = (isDarkMode ? NSColor.lkBaseRGB(216, 220, 228) : NSColor.lkBaseRGB(53, 60, 70)).withAlphaComponent(0.7) + } + for label in rightLabels { + label.textColor = isDarkMode ? .lkBaseRGB(250, 251, 252) : .lkBaseRGB(13, 20, 30) + } + } + + /// Puts `button` after the value on row `idx`; the caller handles its + /// action. + @objc(addButton:atIndex:) + func add(_ button: NSButton, at idx: UInt) { + let idx = Int(idx) + guard buttons[idx] == nil else { + assertionFailure("row \(idx) already has a button") + return + } + buttons[idx] = button + addSubview(button) + needsLayout = true + } +} diff --git a/LookInside/Base/LKTipsView.h b/LookInside/Base/LKTipsView.h deleted file mode 100644 index 5b2fdcb7..00000000 --- a/LookInside/Base/LKTipsView.h +++ /dev/null @@ -1,52 +0,0 @@ -// -// LKTipsView.h -// Lookin -// -// Created by Li Kai on 2019/5/8. -// https://lookin.work -// - -#import "LKBaseView.h" -#import "LookinAppInfo.h" - -@interface LKTipsView : LKBaseView - -@property(nonatomic, weak) id bindingObject; - -@property(nonatomic, copy) NSString *title; - -/// 请外部不要直接修改 button 的 text 等属性,而是要通过下面的 buttonText 和 buttonImage 来设置 -@property(nonatomic, strong, readonly) NSButton *button; - -/// buttonText 和 buttonImage 只有一个会生效,请勿同时设置 -@property(nonatomic, copy) NSString *buttonText; -@property(nonatomic, strong) NSImage *buttonImage; - -@property(nonatomic, strong) NSImage *image; - -- (void)setImageByDeviceType:(LookinAppInfoDevice)type; -- (void)setImageByAppInfo:(LookinAppInfo *)appInfo; - -@property(nonatomic, weak) id target; - -@property(nonatomic, assign) SEL clickAction; -@property(nonatomic, copy) void (^didClick)(LKTipsView *tipsView); - --(void)setInternalInsetsRight:(CGFloat)value; - -@end - -@interface LKYellowTipsView : LKTipsView - -- (void)startAnimation; -- (void)endAnimation; - -@end - - -@interface LKRedTipsView : LKTipsView - -- (void)startAnimation; -- (void)endAnimation; - -@end diff --git a/LookInside/Base/LKTipsView.m b/LookInside/Base/LKTipsView.m deleted file mode 100644 index 51a7d163..00000000 --- a/LookInside/Base/LKTipsView.m +++ /dev/null @@ -1,294 +0,0 @@ -// -// LKTipsView.m -// Lookin -// -// Created by Li Kai on 2019/5/8. -// https://lookin.work -// - -#import "LKTipsView.h" -#import "LookInside-Swift.h" - -@interface LKTipsView () - -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) NSImageView *imageView; -@property(nonatomic, strong) CALayer *sepLayer; - -- (NSColor *)buttonTextColor; - -@end - -@implementation LKTipsView { - CGFloat _insetLeft; - CGFloat _insetRightWithButton; - CGFloat _insetRightWithoutButton; - CGFloat _imageRight; - CGFloat _sepLeft; - NSSize _imageSize; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _insetLeft = 12; - _insetRightWithButton = 3; - _insetRightWithoutButton = 8; - _imageRight = 4; - _sepLeft = 7; - _imageSize = NSMakeSize(18, 18); - - self.layer.borderWidth = 1; - - self.imageView = [NSImageView new]; - self.imageView.hidden = YES; - [self addSubview:self.imageView]; - - self.titleLabel = [LKLabel new]; - self.titleLabel.font = NSFontMake(13); - [self addSubview:self.titleLabel]; - - _button = [NSButton new]; - self.button.font = NSFontMake(13); - self.button.bordered = NO; - [self.button setBezelStyle:NSBezelStyleSmallSquare]; - self.button.target = self; - self.button.action = @selector(_handleButton); - self.button.hidden = YES; - [self addSubview:self.button]; - - self.sepLayer = [CALayer layer]; - [self.sepLayer lookin_removeImplicitAnimations]; - self.sepLayer.hidden = YES; - [self.layer addSublayer:self.sepLayer]; - - [self updateColors]; - } - return self; -} - -- (void)layout { - [super layout]; - self.layer.cornerRadius = self.$height / 2.0; - - CGFloat x = _insetLeft; - if (!self.imageView.hidden) { - $(self.imageView).size(_imageSize).verAlign.x(x); - x = self.imageView.$maxX + _imageRight; - } - - $(self.titleLabel).sizeToFit.verAlign.x(x); - x = self.titleLabel.$maxX; - - if (!self.sepLayer.hidden) { - $(self.sepLayer).width(1).fullHeight.x(x + _sepLeft); - x = self.sepLayer.$maxX; - } - - if (!self.button.hidden) { - $(self.button).x(x).toRight(_insetRightWithButton).fullHeight; - } -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = 28; - - CGFloat width = _insetLeft; - if (!self.imageView.hidden) { - width += _imageSize.width + _imageRight; - } - - width += [self.titleLabel sizeThatFits:NSSizeMax].width; - - if (!self.sepLayer.hidden) { - width += _sepLeft + 1; - } - - if (!self.button.hidden) { - width += [self.button sizeThatFits:NSSizeMax].width + 16; - } else { - width += _insetRightWithoutButton; - } - - return NSMakeSize(width, height); -} - -- (void)updateColors { - [super updateColors]; - self.backgroundColor = self.isDarkMode ? LookinColorRGBAMake(0, 0, 0, .8) : LookinColorRGBAMake(255, 255, 255, .9); - self.titleLabel.textColor = self.isDarkMode ? LookinColorMake(197, 198, 199) : LookinColorMake(108, 109, 110); - - NSColor *borderColor = self.isDarkMode ? LookinColorMake(43, 44, 45) : LookinColorMake(216, 217, 218); - - self.layer.borderColor = borderColor.CGColor; - self.sepLayer.backgroundColor = borderColor.CGColor; - [self _updateButton]; -} - -- (void)setTitle:(NSString *)title { - _title = title; - self.titleLabel.stringValue = title; -} - -- (void)setImage:(NSImage *)image { - _image = image; - self.imageView.image = image; - self.imageView.hidden = !image; -} - -- (void)setImageByDeviceType:(LookinAppInfoDevice)type { - if (type == LookinAppInfoDeviceMac || type == LookinAppInfoDeviceMacCatalyst) { - // A Catalyst app runs on Mac hardware, so it gets the Mac icon even though its - // views are UIKit ones. - self.image = NSImageMake(@"icon_mac_big"); - } else if (type == LookinAppInfoDeviceSimulator) { - self.image = NSImageMake(@"icon_simulator_big"); - } else if (type == LookinAppInfoDeviceIPad) { - self.image = NSImageMake(@"icon_ipad_big"); - } else if (type == LookinAppInfoDeviceOthers) { - self.image = NSImageMake(@"icon_iphone_big"); - } else { - NSAssert(NO, @""); - } -} - -- (void)setImageByAppInfo:(LookinAppInfo *)appInfo { - // Prefer the icon of the actual hardware model the app runs on. Falls back to the - // per-family asset when the peer's LookinServer does not report a model identifier. - NSImage *deviceIcon = [LKDeviceIconProvider deviceIconForAppInfo:appInfo - pointSize:LKDeviceIconProvider.tipsPointSize]; - if (deviceIcon) { - self.image = deviceIcon; - return; - } - if ([LKHelper appInfoLooksLikeMacTarget:appInfo]) { - self.image = NSImageMake(@"icon_mac_big"); - return; - } - [self setImageByDeviceType:appInfo.deviceType]; -} - -- (void)setButtonText:(NSString *)buttonText { - _buttonText = buttonText.copy; - [self _updateButton]; -} - -- (void)setButtonImage:(NSImage *)buttonImage { - _buttonImage = buttonImage; - [self _updateButton]; -} - --(void)setInternalInsetsRight:(CGFloat)value { - _insetRightWithButton = value; - _insetRightWithoutButton = value; - _imageRight = value; -} - -- (void)_handleButton { - if (self.target && self.clickAction) { - [NSApp sendAction:self.clickAction to:self.target from:self]; - } - if (self.didClick) { - self.didClick(self); - } -} - -- (void)_updateButton { - self.button.hidden = NO; - self.sepLayer.hidden = NO; - - if (self.buttonText) { - self.button.attributedTitle = $(self.buttonText).textColor([self buttonTextColor]).attrString; - self.button.image = nil; - } else if (self.buttonImage) { - self.button.title = @""; - self.button.image = self.buttonImage; - } else { - self.button.hidden = YES; - self.sepLayer.hidden = YES; - } - [self setNeedsLayout:YES]; -} - -- (NSColor *)buttonTextColor { - return self.isDarkMode ? LookinColorMake(64, 134, 216) : LookinColorMake(74, 145, 228); -} - -@end - -@interface LKYellowTipsView () - -@property(nonatomic, assign) BOOL isAnimating; - -@end - -@implementation LKYellowTipsView - -- (void)startAnimation { - if (self.isAnimating) { - return; - } - CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"backgroundColor"]; - if ([self.effectiveAppearance lk_isDarkMode]) { - anim.fromValue = (id)[NSColor.systemOrangeColor colorWithAlphaComponent:0.7].CGColor; - anim.toValue = (id)[NSColor.systemOrangeColor colorWithAlphaComponent:0.64].CGColor; - } else { - anim.fromValue = (id)[NSColor.systemOrangeColor colorWithAlphaComponent:0.98].CGColor; - anim.toValue = (id)[NSColor.systemOrangeColor colorWithAlphaComponent:0.92].CGColor; - } - anim.duration = .8; - anim.repeatCount = HUGE_VALF; - anim.autoreverses = YES; - [self.layer removeAllAnimations]; - [self.layer addAnimation:anim forKey:nil]; - - self.isAnimating = YES; -} - -- (void)endAnimation { - [self.layer removeAllAnimations]; - self.isAnimating = NO; -} - -- (void)updateColors { - [super updateColors]; - self.titleLabel.textColor = [NSColor whiteColor]; - self.layer.borderColor = [NSColor clearColor].CGColor; - self.sepLayer.backgroundColor = LookinColorRGBAMake(255, 255, 255, .5).CGColor; -} - -- (NSColor *)buttonTextColor { - return [NSColor whiteColor]; -} - -@end - - -@implementation LKRedTipsView - -- (void)startAnimation { - CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"backgroundColor"]; - anim.fromValue = (id)LookinColorRGBAMake(208, 2, 27, .9).CGColor; - anim.toValue = (id)LookinColorRGBAMake(208, 2, 27, .7).CGColor; - anim.duration = .8; - anim.repeatCount = HUGE_VALF; - anim.autoreverses = YES; - [self.layer removeAllAnimations]; - [self.layer addAnimation:anim forKey:nil]; -} - -- (void)endAnimation { - [self.layer removeAllAnimations]; -} - -- (void)updateColors { - [super updateColors]; - self.titleLabel.textColor = [NSColor whiteColor]; - self.layer.borderColor = [NSColor clearColor].CGColor; - self.sepLayer.backgroundColor = LookinColorRGBAMake(255, 255, 255, .5).CGColor; -} - -- (NSColor *)buttonTextColor { - return [NSColor whiteColor]; -} - -@end diff --git a/LookInside/Base/LKTipsView.swift b/LookInside/Base/LKTipsView.swift new file mode 100644 index 00000000..45a39a4a --- /dev/null +++ b/LookInside/Base/LKTipsView.swift @@ -0,0 +1,279 @@ +// +// LKTipsView.swift +// LookInside +// + +import AppKit + +/// A pill-shaped notice: optional image, a title, and an optional button +/// after a separator. +@objc(LKTipsView) +class LKTipsView: LKBaseView { + @objc weak var bindingObject: AnyObject? + + @objc var title: String? { + didSet { titleLabel.stringValue = title ?? "" } + } + + /// Set `buttonText` or `buttonImage` rather than editing the button. + @objc let button = NSButton() + + /// Only one of `buttonText` and `buttonImage` takes effect; set one. + @objc var buttonText: String? { + didSet { updateButton() } + } + + @objc var buttonImage: NSImage? { + didSet { updateButton() } + } + + @objc var image: NSImage? { + didSet { + imageView.image = image + imageView.isHidden = image == nil + } + } + + @objc weak var target: AnyObject? + @objc var clickAction: Selector? + @objc var didClick: ((LKTipsView) -> Void)? + + let titleLabel = LKLabel() + private let imageView = NSImageView() + let sepLayer = CALayer() + + private let insetLeft: CGFloat = 12 + private var insetRightWithButton: CGFloat = 3 + private var insetRightWithoutButton: CGFloat = 8 + private var imageRight: CGFloat = 4 + private let sepLeft: CGFloat = 7 + private let imageSize = NSSize(width: 18, height: 18) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + private func setUp() { + layer?.borderWidth = 1 + + imageView.isHidden = true + addSubview(imageView) + + titleLabel.font = NSFont.systemFont(ofSize: 13) + addSubview(titleLabel) + + button.font = NSFont.systemFont(ofSize: 13) + button.isBordered = false + button.bezelStyle = .smallSquare + button.target = self + button.action = #selector(handleButton) + button.isHidden = true + addSubview(button) + + sepLayer.lookin_removeImplicitAnimations() + sepLayer.isHidden = true + layer?.addSublayer(sepLayer) + + updateColors() + } + + override func layout() { + super.layout() + layer?.cornerRadius = frame.height / 2 + + var x = insetLeft + if !imageView.isHidden { + imageView.lkLayout.size(imageSize).verAlign().x(x) + x = imageView.frame.maxX + imageRight + } + + titleLabel.lkLayout.sizeToFit().verAlign().x(x) + x = titleLabel.frame.maxX + + if !sepLayer.isHidden { + sepLayer.lkLayout.width(1).fullHeight().x(x + sepLeft) + x = sepLayer.frame.maxX + } + + if !button.isHidden { + button.lkLayout.x(x).toRight(insetRightWithButton).fullHeight() + } + } + + override func sizeThatFits(_: NSSize) -> NSSize { + let unlimited = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + var width = insetLeft + if !imageView.isHidden { + width += imageSize.width + imageRight + } + width += titleLabel.sizeThatFits(unlimited).width + if !sepLayer.isHidden { + width += sepLeft + 1 + } + if !button.isHidden { + width += button.sizeThatFits(unlimited).width + 16 + } else { + width += insetRightWithoutButton + } + return NSSize(width: width, height: 28) + } + + override func updateColors() { + super.updateColors() + let isDarkMode = isDarkMode() + backgroundColor = isDarkMode ? .lkBaseRGB(0, 0, 0, 0.8) : .lkBaseRGB(255, 255, 255, 0.9) + titleLabel.textColor = isDarkMode ? .lkBaseRGB(197, 198, 199) : .lkBaseRGB(108, 109, 110) + let borderColor: NSColor = isDarkMode ? .lkBaseRGB(43, 44, 45) : .lkBaseRGB(216, 217, 218) + layer?.borderColor = borderColor.cgColor + sepLayer.backgroundColor = borderColor.cgColor + updateButton() + } + + @objc(setImageByDeviceType:) + func setImage(byDeviceType type: LookinAppInfoDevice) { + switch type { + case .mac, .macCatalyst: + // A Catalyst app runs on Mac hardware, so it gets the Mac icon + // even though its views are UIKit ones. + image = NSImage(named: "icon_mac_big") + case .simulator: + image = NSImage(named: "icon_simulator_big") + case .iPad: + image = NSImage(named: "icon_ipad_big") + case .others: + image = NSImage(named: "icon_iphone_big") + @unknown default: + assertionFailure("unknown device type \(type.rawValue)") + } + } + + /// Prefers the icon of the hardware model the app runs on, falling back + /// to the device family's icon when the Server reports no model. + @objc(setImageByAppInfo:) + func setImage(byAppInfo appInfo: LookinAppInfo?) { + if let deviceIcon = LKDeviceIconProvider.deviceIcon(forAppInfo: appInfo, pointSize: LKDeviceIconProvider.tipsPointSize) { + image = deviceIcon + return + } + if LKHelper.appInfoLooksLikeMacTarget(appInfo) { + image = NSImage(named: "icon_mac_big") + return + } + setImage(byDeviceType: appInfo?.deviceType ?? .simulator) + } + + @objc(setInternalInsetsRight:) + func setInternalInsetsRight(_ value: CGFloat) { + insetRightWithButton = value + insetRightWithoutButton = value + imageRight = value + } + + @objc private func handleButton() { + if let target, let clickAction { + NSApp.sendAction(clickAction, to: target, from: self) + } + didClick?(self) + } + + private func updateButton() { + button.isHidden = false + sepLayer.isHidden = false + if let buttonText { + let attributedTitle = NSMutableAttributedString(string: buttonText) + if attributedTitle.length > 0 { + attributedTitle.addAttribute(.foregroundColor, value: buttonTextColor(), range: NSRange(location: 0, length: attributedTitle.length)) + } + button.attributedTitle = attributedTitle + button.image = nil + } else if let buttonImage { + button.title = "" + button.image = buttonImage + } else { + button.isHidden = true + sepLayer.isHidden = true + } + needsLayout = true + } + + /// Subclasses override this to recolour the button text. + func buttonTextColor() -> NSColor { + isDarkMode() ? .lkBaseRGB(64, 134, 216) : .lkBaseRGB(74, 145, 228) + } +} + +/// An orange notice that can pulse. +@objc(LKYellowTipsView) +class LKYellowTipsView: LKTipsView { + private var isAnimating = false + + @objc func startAnimation() { + guard !isAnimating else { return } + let animation = CABasicAnimation(keyPath: "backgroundColor") + if effectiveAppearance.lk_isDarkMode { + animation.fromValue = NSColor.systemOrange.withAlphaComponent(0.7).cgColor + animation.toValue = NSColor.systemOrange.withAlphaComponent(0.64).cgColor + } else { + animation.fromValue = NSColor.systemOrange.withAlphaComponent(0.98).cgColor + animation.toValue = NSColor.systemOrange.withAlphaComponent(0.92).cgColor + } + animation.duration = 0.8 + animation.repeatCount = .infinity + animation.autoreverses = true + layer?.removeAllAnimations() + layer?.add(animation, forKey: nil) + isAnimating = true + } + + @objc func endAnimation() { + layer?.removeAllAnimations() + isAnimating = false + } + + override func updateColors() { + super.updateColors() + titleLabel.textColor = .white + layer?.borderColor = NSColor.clear.cgColor + sepLayer.backgroundColor = NSColor.lkBaseRGB(255, 255, 255, 0.5).cgColor + } + + override func buttonTextColor() -> NSColor { + .white + } +} + +/// A red notice that pulses, used for connection loss. +@objc(LKRedTipsView) +class LKRedTipsView: LKTipsView { + @objc func startAnimation() { + let animation = CABasicAnimation(keyPath: "backgroundColor") + animation.fromValue = NSColor.lkBaseRGB(208, 2, 27, 0.9).cgColor + animation.toValue = NSColor.lkBaseRGB(208, 2, 27, 0.7).cgColor + animation.duration = 0.8 + animation.repeatCount = .infinity + animation.autoreverses = true + layer?.removeAllAnimations() + layer?.add(animation, forKey: nil) + } + + @objc func endAnimation() { + layer?.removeAllAnimations() + } + + override func updateColors() { + super.updateColors() + titleLabel.textColor = .white + layer?.borderColor = NSColor.clear.cgColor + sepLayer.backgroundColor = NSColor.lkBaseRGB(255, 255, 255, 0.5).cgColor + } + + override func buttonTextColor() -> NSColor { + .white + } +} diff --git a/LookInside/Base/LKTwoColors.h b/LookInside/Base/LKTwoColors.h deleted file mode 100644 index 925fc9a0..00000000 --- a/LookInside/Base/LKTwoColors.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// LKTwoColors.h -// Lookin -// -// Created by Li Kai on 2019/9/30. -// https://lookin.work -// - -#import - -/// colorA 是浅色模式下的颜色,colorB 是深色模式下的颜色 -#define LKColorsCombine(colorA, colorB) [LKTwoColors colorsWithColorInLightMode:colorA colorInDarkMode:colorB] - -@interface LKTwoColors : NSObject - -+ (instancetype)colorsWithColorInLightMode:(NSColor *)colorInLightMode colorInDarkMode:(NSColor *)colorInDarkMode; - -/// 如果当前是 darkMode 则该方法返回 colorInDarkMode,否则返回 colorInLightMode -@property(nonatomic, strong, readonly) NSColor *color; - -@property(nonatomic, strong) NSColor *colorInLightMode; -@property(nonatomic, strong) NSColor *colorInDarkMode; - -@end diff --git a/LookInside/Base/LKTwoColors.m b/LookInside/Base/LKTwoColors.m deleted file mode 100644 index 4f19e0a9..00000000 --- a/LookInside/Base/LKTwoColors.m +++ /dev/null @@ -1,29 +0,0 @@ -// -// LKTwoColors.m -// Lookin -// -// Created by Li Kai on 2019/9/30. -// https://lookin.work -// - -#import "LKTwoColors.h" - -@implementation LKTwoColors - -+ (instancetype)colorsWithColorInLightMode:(NSColor *)colorInLightMode colorInDarkMode:(NSColor *)colorInDarkMode { - LKTwoColors *colors = [LKTwoColors new]; - colors.colorInLightMode = colorInLightMode; - colors.colorInDarkMode = colorInDarkMode; - return colors; -} - -- (NSColor *)color { - BOOL isDarkMode = [NSApp effectiveAppearance].lk_isDarkMode; - if (isDarkMode) { - return self.colorInDarkMode; - } else { - return self.colorInLightMode; - } -} - -@end diff --git a/LookInside/Base/LKTwoColors.swift b/LookInside/Base/LKTwoColors.swift new file mode 100644 index 00000000..ad2c8562 --- /dev/null +++ b/LookInside/Base/LKTwoColors.swift @@ -0,0 +1,36 @@ +// +// LKTwoColors.swift +// LookInside +// + +import AppKit + +/// A colour pair resolved against the app's current appearance. +@objc(LKTwoColors) +final class LKTwoColors: NSObject { + @objc var colorInLightMode: NSColor? + @objc var colorInDarkMode: NSColor? + + @objc(colorsWithColorInLightMode:colorInDarkMode:) + static func colors(inLightMode colorInLightMode: NSColor?, inDarkMode colorInDarkMode: NSColor?) -> LKTwoColors { + let colors = LKTwoColors() + colors.colorInLightMode = colorInLightMode + colors.colorInDarkMode = colorInDarkMode + return colors + } + + /// The Swift spelling of +colorsWithColorInLightMode:colorInDarkMode:, + /// as Swift imported the Objective-C factory. + convenience init(colorInLightMode: NSColor?, colorInDarkMode: NSColor?) { + self.init() + self.colorInLightMode = colorInLightMode + self.colorInDarkMode = colorInDarkMode + } + + /// The dark colour while the app's effective appearance is dark, the + /// light one otherwise. + @objc var color: NSColor? { + let isDarkMode = NSApp?.effectiveAppearance.lk_isDarkMode ?? false + return isDarkMode ? colorInDarkMode : colorInLightMode + } +} diff --git a/LookInside/Base/LKUserActionManager.h b/LookInside/Base/LKUserActionManager.h deleted file mode 100644 index 2cb015b1..00000000 --- a/LookInside/Base/LKUserActionManager.h +++ /dev/null @@ -1,37 +0,0 @@ -// -// LKUserActionManager.h -// Lookin -// -// Created by Li Kai on 2019/8/30. -// https://lookin.work -// - -#import - -@class LKUserActionManager; - -typedef NS_ENUM(NSInteger, LKUserActionType) { - LKUserActionType_None, - LKUserActionType_PreviewOperation, // 在 preview 里执行了 click、double click、pan 之类的操作 - LKUserActionType_DashboardClick, // 点击了 dashboard - LKUserActionType_SelectedItemChange, // selectedItem 改变了 -}; - -@protocol LKUserActionManagerDelegate - -/// 当 sendAction 被业务调用时,该 delegate 方法也会被调用 -- (void)LKUserActionManager:(LKUserActionManager *)manager didAct:(LKUserActionType)type; - -@end - -@interface LKUserActionManager : NSObject - -+ (instancetype)sharedInstance; - -/// 业务调用该方法 -- (void)sendAction:(LKUserActionType)type; - -/// delegate 不会被该类强引用,也无需在 delegate 对象被 dealloc 时设法 removeDelegate 之类的,相同的 delegate 被重复添加只会视为被添加一次 -- (void)addDelegate:(id)delegate; - -@end diff --git a/LookInside/Base/LKUserActionManager.m b/LookInside/Base/LKUserActionManager.m deleted file mode 100644 index 4a860bf1..00000000 --- a/LookInside/Base/LKUserActionManager.m +++ /dev/null @@ -1,62 +0,0 @@ -// -// LKUserActionManager.m -// Lookin -// -// Created by Li Kai on 2019/8/30. -// https://lookin.work -// - -#import "LKUserActionManager.h" - -@interface LKUserActionManager () - -@property(nonatomic, strong) NSPointerArray *delegators; - -@end - -@implementation LKUserActionManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKUserActionManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -- (instancetype)init { - if (self = [super init]) { - self.delegators = [NSPointerArray weakObjectsPointerArray]; - } - return self; -} - -- (void)addDelegate:(id)delegate { - if (!delegate) { - NSAssert(NO, @""); - return; - } - if ([self.delegators lk_containsPointer:(void *)delegate]) { - return; - } - [self.delegators addPointer:(void *)delegate]; -} - -- (void)sendAction:(LKUserActionType)type { - if (type == LKUserActionType_None) { - NSAssert(NO, @""); - return; - } - [[self.delegators allObjects] enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if ([obj respondsToSelector:@selector(LKUserActionManager:didAct:)]) { - [obj LKUserActionManager:self didAct:type]; - } - }]; -} - -@end diff --git a/LookInside/Base/LKUserActionManager.swift b/LookInside/Base/LKUserActionManager.swift new file mode 100644 index 00000000..8f6e9adc --- /dev/null +++ b/LookInside/Base/LKUserActionManager.swift @@ -0,0 +1,58 @@ +// +// LKUserActionManager.swift +// LookInside +// + +import Foundation + +@objc enum LKUserActionType: Int { + @objc(LKUserActionType_None) case none + /// A click, double click, pan or similar in the preview. + @objc(LKUserActionType_PreviewOperation) case previewOperation + /// A click in the Dashboard. + @objc(LKUserActionType_DashboardClick) case dashboardClick + /// The selected item changed. + @objc(LKUserActionType_SelectedItemChange) case selectedItemChange +} + +@objc protocol LKUserActionManagerDelegate: NSObjectProtocol { + /// Called for every `sendAction:`. + @objc(LKUserActionManager:didAct:) + func lkUserActionManager(_ manager: LKUserActionManager, didAct type: LKUserActionType) +} + +/// Broadcasts user actions (preview gestures, Dashboard clicks) to weakly +/// held delegates, for example so an open Dashboard editor can close. +@objc(LKUserActionManager) +final class LKUserActionManager: NSObject { + private static let shared = LKUserActionManager() + + @objc static func sharedInstance() -> LKUserActionManager { + shared + } + + private let delegates = NSPointerArray.weakObjects() + + /// Delegates are held weakly and need no removal; adding one twice + /// keeps a single entry. + @objc(addDelegate:) + func add(_ delegate: LKUserActionManagerDelegate) { + if delegates.allObjects.contains(where: { ($0 as AnyObject) === delegate }) { + return + } + delegates.addPointer(Unmanaged.passUnretained(delegate as AnyObject).toOpaque()) + } + + @objc(sendAction:) + func send(_ type: LKUserActionType) { + guard type != .none else { + assertionFailure("sendAction: needs an action") + return + } + for case let delegate as LKUserActionManagerDelegate in delegates.allObjects + where delegate.responds(to: #selector(LKUserActionManagerDelegate.lkUserActionManager(_:didAct:))) + { + delegate.lkUserActionManager(self, didAct: type) + } + } +} diff --git a/LookInside/Base/LKWindow.h b/LookInside/Base/LKWindow.h deleted file mode 100644 index b6566db6..00000000 --- a/LookInside/Base/LKWindow.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKWindow.h -// Lookin -// -// Created by Li Kai on 2019/5/14. -// https://lookin.work -// - -#import - -@class LKPanelContentView; - -@interface LKWindow : NSWindow - -+ (instancetype)panelWindowWithWidth:(CGFloat)width height:(CGFloat)height contentView:(LKPanelContentView *)contentView; - -@end diff --git a/LookInside/Base/LKWindow.m b/LookInside/Base/LKWindow.m deleted file mode 100644 index 42c32aa8..00000000 --- a/LookInside/Base/LKWindow.m +++ /dev/null @@ -1,44 +0,0 @@ -// -// LKWindow.m -// Lookin -// -// Created by Li Kai on 2019/5/14. -// https://lookin.work -// - -#import "LKWindow.h" -#import "LKNavigationManager.h" -#import "LKPanelContentView.h" - -@implementation LKWindow - -+ (instancetype)panelWindowWithWidth:(CGFloat)width height:(CGFloat)height contentView:(LKPanelContentView *)contentView { - LKWindow *window = [[LKWindow alloc] initWithContentRect:NSMakeRect(0, 0, width, height) styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:YES]; - window.contentView = contentView; - return window; -} - -- (instancetype)initWithContentRect:(NSRect)contentRect styleMask:(NSWindowStyleMask)style backing:(NSBackingStoreType)backingStoreType defer:(BOOL)flag { - if (self = [super initWithContentRect:contentRect styleMask:style backing:backingStoreType defer:flag]) { - [self registerForDraggedTypes:@[NSPasteboardTypeFileURL]]; - } - return self; -} - -- (NSDragOperation)draggingEntered:(id)sender { - return NSDragOperationCopy; -} - -- (BOOL)performDragOperation:(id)sender { - NSString *path = [NSURL URLFromPasteboard:[sender draggingPasteboard]].path; - NSError *error; - BOOL isSucc = [[LKNavigationManager sharedInstance] showReaderWithFilePath:path error:&error]; - if (!isSucc) { - if (error) { - AlertError(error, self); - } - } - return isSucc; -} - -@end diff --git a/LookInside/Base/LKWindow.swift b/LookInside/Base/LKWindow.swift new file mode 100644 index 00000000..cd981b5e --- /dev/null +++ b/LookInside/Base/LKWindow.swift @@ -0,0 +1,35 @@ +// +// LKWindow.swift +// LookInside +// + +import AppKit + +/// The app's window class: dropping a file onto it opens it in the reader. +@objc(LKWindow) +class LKWindow: NSWindow { + override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) { + super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag) + registerForDraggedTypes([.fileURL]) + } + + @objc func draggingEntered(_: NSDraggingInfo) -> NSDragOperation { + .copy + } + + @objc func performDragOperation(_ sender: NSDraggingInfo) -> Bool { + guard let path = NSURL(from: sender.draggingPasteboard)?.path else { + return false + } + do { + try LKNavigationManager.shared.showReader(withFilePath: path) + return true + } catch { + let nsError = error as NSError + if nsError.code != LookinErrCode_Discard { + NSAlert(error: nsError).beginSheetModal(for: self, completionHandler: nil) + } + return false + } + } +} diff --git a/LookInside/Base/LKWindowController.h b/LookInside/Base/LKWindowController.h deleted file mode 100644 index b4fb3955..00000000 --- a/LookInside/Base/LKWindowController.h +++ /dev/null @@ -1,14 +0,0 @@ -// -// LKWindowController.h -// Lookin -// -// Created by Li Kai on 2019/4/18. -// https://lookin.work -// - -#import -#import "LKAppMenuManager.h" - -@interface LKWindowController : NSWindowController - -@end diff --git a/LookInside/Base/LKWindowController.m b/LookInside/Base/LKWindowController.m deleted file mode 100644 index 7a50ccf4..00000000 --- a/LookInside/Base/LKWindowController.m +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKWindowController.m -// Lookin -// -// Created by Li Kai on 2019/4/18. -// https://lookin.work -// - -#import "LKWindowController.h" - -@implementation LKWindowController - -- (void)dealloc { - NSLog(@"%@ dealloc", self.class); -} - -@end diff --git a/LookInside/Base/LKWindowController.swift b/LookInside/Base/LKWindowController.swift new file mode 100644 index 00000000..405f62da --- /dev/null +++ b/LookInside/Base/LKWindowController.swift @@ -0,0 +1,16 @@ +// +// LKWindowController.swift +// Lookin +// +// Created by Li Kai on 2019/4/18. +// https://lookin.work +// + +import AppKit + +@objc(LKWindowController) +class LKWindowController: NSWindowController, LKAppMenuManagerDelegate { + deinit { + NSLog("%@ dealloc", NSStringFromClass(type(of: self))) + } +} diff --git a/LookInside/Base/LookinClient-Bridging-Header.h b/LookInside/Base/LookinClient-Bridging-Header.h index aaba961b..a80a3005 100644 --- a/LookInside/Base/LookinClient-Bridging-Header.h +++ b/LookInside/Base/LookinClient-Bridging-Header.h @@ -1,52 +1,20 @@ // // Use this file to import your target's public headers that you would like to expose to Swift. +// Never import a header here that only forwards to LookInside-Swift.h (an +// import cycle). // -// ReactiveObjC underpins several host APIs that vend `RACSignal *` / `RACSubject *`; -// import the umbrella here so any Swift consumer of those headers sees their types. -#import "ReactiveObjC.h" - -#import "LKPreferenceManager.h" -#import "LKMessageManager.h" -#import "LKHelper.h" +// Only the C declarations of LookinCore and LookinServerBase. The app target +// compiles the LookinCore model classes from DerivedSource/LookinCoreImpl, +// where they are plain Swift classes with `@objc(OriginalName)`, so Swift +// sees them directly; no class header is kept. +#import "LookinDefines.h" #import "LookinAttrIdentifiers.h" #import "LookinAttrType.h" -#import "LookinAttribute.h" -#import "LookinAttributeModification.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LookinDashboardBlueprint.h" -#import "LookinDisplayItemDetail.h" -#import "LookinStaticAsyncUpdateTask.h" -#import "LookinDisplayItem.h" -#import "LookinAutoLayoutConstraint.h" -// Client-side display helpers (row title / subtitle, ancestor + subtree -// walks, class-chain predicates). The MCPBridge search route needs the -// same title and ancestry the inspector shows, and reimplementing them in -// Swift would fork the display logic. -#import "LookinDisplayItem+LookinClient.h" -#import "LookinObject.h" -#import "LookinAppInfo.h" +#import "LookinCodingValueType.h" +#import "LookinCoreTypes.h" +#import "LookinRuntimeShim.h" +#import "NSValue+Lookin.h" +#import "LookInsideServerLogger.h" +#import "LookinIvarTrace.h" #import "LookinHitTargetSize.h" -#import "LookinHierarchyInfo.h" -#import "LookinHierarchyFile.h" -#import "LookinArchiveDocument.h" -#import "LKReadWindowController.h" -#import "LKInspectableApp.h" -// The MCPBridge event publisher watches this manager's two RACSubjects -// (channelWillEnd, didReceivePush) to turn target disconnects and -// server-initiated pushes into bridge events. The channel type comes -// along because both signals carry one, and without the type visible -// Swift cannot see any API that mentions it -- including -// `-[LookinLiveDocumentController liveDocumentForChannel:]`. -#import "Lookin_PTChannel.h" -#import "LKConnectionManager.h" -#import "LKHierarchyDataSource.h" -#import "LKStaticHierarchyDataSource.h" -// The MCPBridge refresh route drives -reloadHierarchySignal, which lives on -// the window controller rather than the data source because the reload gate -// and the progress indicator it shares with the toolbar button are window -// state. -#import "LKStaticWindowController.h" -#import "LookinLiveDocument.h" -#import "LookinLiveDocumentController.h" diff --git a/LookInside/Base/Message/LKPeripheralAlerts.swift b/LookInside/Base/Message/LKPeripheralAlerts.swift new file mode 100644 index 00000000..830dbb01 --- /dev/null +++ b/LookInside/Base/Message/LKPeripheralAlerts.swift @@ -0,0 +1,45 @@ +// +// LKPeripheralAlerts.swift +// LookInside +// +// Swift counterparts of the `LookinErrorMake`, `AlertError` and +// `AlertErrorText` macros for the peripheral modules. +// + +import AppKit + +enum LKPeripheralAlerts { + /// `LookinErrorMake(title, detail)`: a default-code Lookin error. + static func error(title: String, detail: String) -> NSError { + NSError(domain: LookinErrorDomain, code: LookinErrCode_Default, userInfo: [ + NSLocalizedDescriptionKey: title, + NSLocalizedRecoverySuggestionErrorKey: detail, + ]) + } + + /// `AlertError(error, window)`: shows `error` as a sheet unless it is a + /// discarded request. Without a window the alert runs app-modal. + @MainActor + static func show(_ error: Error, in window: NSWindow?) { + let error = error as NSError + guard error.code != LookinErrCode_Discard else { return } + let alert = NSAlert(error: error) + if let window { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + alert.runModal() + } + } + + /// `AlertErrorText(title, detail, window)`. + @MainActor + static func show(title: String, detail: String, in window: NSWindow?) { + show(error(title: title, detail: detail), in: window) + } + + /// `CurrentKeyWindow`. + @MainActor + static var keyWindow: NSWindow? { + NSApp.keyWindow + } +} diff --git a/LookInside/Base/Message/LookinMsgAttribute.h b/LookInside/Base/Message/LookinMsgAttribute.h deleted file mode 100644 index 9755ff49..00000000 --- a/LookInside/Base/Message/LookinMsgAttribute.h +++ /dev/null @@ -1,83 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinMsgAttribute.h -// Lookin -// -// Created by Li Kai on 2019/8/19. -// https://lookin.work -// - - - -#import "LookinDefines.h" - -@interface LookinMsgActionParams : NSObject - -@property(nonatomic, strong) id value; -@property(nonatomic, assign) double doubleValue; -@property(nonatomic, assign) NSInteger integerValue; -@property(nonatomic, assign) BOOL boolValue; - -@property(nonatomic, weak) id relatedObject; -@property(nonatomic, strong) id userInfo; - -@end - -@interface LookinMsgAttribute : NSObject - -/// 创建一个示例并给予一个初始值 -+ (instancetype)attributeWithValue:(id)value; - -/// 当前的值 -@property(nonatomic, strong, readonly) id currentValue; - -/** - 使用 value 作为 currentValue 属性的值 - 调用该方法后,所有此前通过 subscribe:action: 相关方法注册的 subscriber 的 action 都会被调用,参数是一个 LookinMsgActionParams 对象 - 如果传入了 ignoreSubscriber,则 ignoreSubscriber 这个对象不会收到这次通知 - 如果传入的 value 和之前已有的 value 是 equal 的,则该方法不会产生任何效果 - 传入的 userInfo 对象可在 LookinMsgActionParams 中被读取 - */ -- (void)setValue:(id)value ignoreSubscriber:(id)ignoreSubscriber userInfo:(id)userInfo; - -/// target, relatedObject 均不会被强引用,action 方法的参数是一个 LookinMsgActionParams -/// 即使多次调用该方法添加同一个 target,target 也只会收到一次通知 -- (void)subscribe:(id)target action:(SEL)action relatedObject:(id)relatedObject; - -/// 如果 sendAtOnce 为 YES,则在该方法调用后,会立即收到一条消息 -- (void)subscribe:(id)target action:(SEL)action relatedObject:(id)relatedObject sendAtOnce:(BOOL)sendAtOnce; - -@end - -@interface LookinDoubleMsgAttribute : LookinMsgAttribute - -@property(nonatomic, assign, readonly) double currentDoubleValue; - -+ (instancetype)attributeWithDouble:(double)value; - -- (void)setDoubleValue:(double)doubleValue ignoreSubscriber:(id)ignoreSubscriber; - -@end - -@interface LookinIntegerMsgAttribute : LookinMsgAttribute - -@property(nonatomic, assign, readonly) NSInteger currentIntegerValue; - -+ (instancetype)attributeWithInteger:(NSInteger)value; - -- (void)setIntegerValue:(NSInteger)integerValue ignoreSubscriber:(id)ignoreSubscriber; - -@end - -@interface LookinBOOLMsgAttribute : LookinMsgAttribute - -@property(nonatomic, assign, readonly) BOOL currentBOOLValue; - -+ (instancetype)attributeWithBOOL:(BOOL)value; - -- (void)setBOOLValue:(BOOL)BOOLValue ignoreSubscriber:(id)ignoreSubscriber; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/LookInside/Base/Message/LookinMsgAttribute.m b/LookInside/Base/Message/LookinMsgAttribute.m deleted file mode 100644 index d4bd1afc..00000000 --- a/LookInside/Base/Message/LookinMsgAttribute.m +++ /dev/null @@ -1,195 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinMsgAttribute.m -// Lookin -// -// Created by Li Kai on 2019/8/19. -// https://lookin.work -// - -#import "LookinMsgAttribute.h" - - - -#import "LookinMsgTargetAction.h" - -@implementation LookinMsgActionParams - -- (double)doubleValue { - if (![self.value isKindOfClass:[NSNumber class]]) { - NSAssert(NO, @""); - return 0; - } - return ((NSNumber *)self.value).doubleValue; -} - -- (NSInteger)integerValue { - if (![self.value isKindOfClass:[NSNumber class]]) { - NSAssert(NO, @""); - return 0; - } - return ((NSNumber *)self.value).integerValue; -} - -- (BOOL)boolValue { - if (![self.value isKindOfClass:[NSNumber class]]) { - NSAssert(NO, @""); - return 0; - } - return ((NSNumber *)self.value).boolValue; -} - -@end - -@interface LookinMsgAttribute () - -@property(nonatomic, strong, readwrite) id currentValue; - -@property(nonatomic, strong) NSMutableArray *subscribers; - -@end - -@implementation LookinMsgAttribute - -+ (instancetype)attributeWithValue:(id)value { - LookinMsgAttribute *attr = [LookinMsgAttribute new]; - attr.currentValue = value; - return attr; -} - -- (instancetype)init { - if (self = [super init]) { - self.subscribers = [NSMutableArray array]; - } - return self; -} - -- (void)setValue:(id)value ignoreSubscriber:(id)ignoreSubscriber userInfo:(id)userInfo { - if ([self.currentValue isEqual:value]) { - // value 相等的话直接拒绝 - return; - } - self.currentValue = value; - - __block NSMutableIndexSet *invalidIdxs = nil; - [self.subscribers enumerateObjectsUsingBlock:^(LookinMsgTargetAction * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - id subscriberTarget = obj.target; - SEL subscriberAction = obj.action; - - if (!subscriberTarget) { - // target 已被释放,删掉这组 target-action 吧 - if (!invalidIdxs) { - invalidIdxs = [NSMutableIndexSet indexSet]; - } - [invalidIdxs addIndex:idx]; - return; - } - - if (subscriberTarget == ignoreSubscriber) { - // 不要通知 ignoreSubscriber - return; - } - - LookinMsgActionParams *params = [LookinMsgActionParams new]; - params.userInfo = userInfo; - params.value = value; - params.relatedObject = obj.relatedObject; -#if TARGET_OS_IPHONE - [[UIApplication sharedApplication] sendAction:subscriberAction to:subscriberTarget from:params forEvent:nil]; -#elif TARGET_OS_MAC - [NSApp sendAction:subscriberAction to:subscriberTarget from:params]; -#endif - }]; - - if (invalidIdxs.count) { - [self.subscribers removeObjectsAtIndexes:invalidIdxs]; - } -} - -- (void)subscribe:(id)target action:(SEL)action relatedObject:(id)relatedObject { - [self subscribe:target action:action relatedObject:relatedObject sendAtOnce:NO]; -} - -- (void)subscribe:(id)target action:(SEL)action relatedObject:(id)relatedObject sendAtOnce:(BOOL)sendAtOnce { - if (!target || !action) { - return; - } - LookinMsgTargetAction *newOne = [LookinMsgTargetAction new]; - newOne.target = target; - newOne.action = action; - newOne.relatedObject = relatedObject; - if ([self.subscribers containsObject:newOne]) { - return; - } - [self.subscribers addObject:newOne]; - - if (sendAtOnce) { - LookinMsgActionParams *params = [LookinMsgActionParams new]; - params.value = self.currentValue; - params.relatedObject = relatedObject; -#if TARGET_OS_IPHONE - [[UIApplication sharedApplication] sendAction:action to:target from:params forEvent:nil]; -#elif TARGET_OS_MAC - [NSApp sendAction:action to:target from:params]; -#endif - } -} - -@end - -@implementation LookinDoubleMsgAttribute - -+ (instancetype)attributeWithDouble:(double)value { - LookinDoubleMsgAttribute *attr = [LookinDoubleMsgAttribute new]; - attr.currentValue = @(value); - return attr; -} - -- (void)setDoubleValue:(double)doubleValue ignoreSubscriber:(id)ignoreSubscriber { - [self setValue:@(doubleValue) ignoreSubscriber:ignoreSubscriber userInfo:nil]; -} - -- (double)currentDoubleValue { - return ((NSNumber *)self.currentValue).doubleValue; -} - -@end - -@implementation LookinIntegerMsgAttribute - -+ (instancetype)attributeWithInteger:(NSInteger)value { - LookinIntegerMsgAttribute *attr = [LookinIntegerMsgAttribute new]; - attr.currentValue = @(value); - return attr; -} - -- (void)setIntegerValue:(NSInteger)integerValue ignoreSubscriber:(id)ignoreSubscriber { - [self setValue:@(integerValue) ignoreSubscriber:ignoreSubscriber userInfo:nil]; -} - -- (NSInteger)currentIntegerValue { - return ((NSNumber *)self.currentValue).integerValue; -} - -@end - -@implementation LookinBOOLMsgAttribute - -+ (instancetype)attributeWithBOOL:(BOOL)value { - LookinBOOLMsgAttribute *attr = [LookinBOOLMsgAttribute new]; - attr.currentValue = @(value); - return attr; -} - -- (void)setBOOLValue:(BOOL)BOOLValue ignoreSubscriber:(id)ignoreSubscriber { - [self setValue:@(BOOLValue) ignoreSubscriber:ignoreSubscriber userInfo:nil]; -} - -- (BOOL)currentBOOLValue { - return ((NSNumber *)self.currentValue).boolValue; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/LookInside/Base/Message/LookinMsgAttribute.swift b/LookInside/Base/Message/LookinMsgAttribute.swift new file mode 100644 index 00000000..f2214e89 --- /dev/null +++ b/LookInside/Base/Message/LookinMsgAttribute.swift @@ -0,0 +1,200 @@ +// +// LookinMsgAttribute.swift +// LookInside +// +// A value with target-action subscribers. Setting a different value sends the +// subscribers' actions through `NSApp.sendAction(_:to:from:)` with a +// `LookinMsgActionParams` as the sender. Targets and related objects are held +// weakly; subscribers whose target is gone are dropped on the next change. +// +// The class names and selectors are the ones the Objective-C version had: +// `LKPreferenceManager` and the inspector controllers still call them. +// + +import AppKit + +/// The sender of a subscriber's action. +@objc(LookinMsgActionParams) +@objcMembers +final class LookinMsgActionParams: NSObject { + var value: Any? + weak var relatedObject: AnyObject? + var userInfo: Any? + + var doubleValue: Double { + guard let number = value as? NSNumber else { + assertionFailure("LookinMsgActionParams.value is not a number") + return 0 + } + return number.doubleValue + } + + var integerValue: Int { + guard let number = value as? NSNumber else { + assertionFailure("LookinMsgActionParams.value is not a number") + return 0 + } + return number.intValue + } + + var boolValue: Bool { + guard let number = value as? NSNumber else { + assertionFailure("LookinMsgActionParams.value is not a number") + return false + } + return number.boolValue + } +} + +@objc(LookinMsgAttribute) +@objcMembers +class LookinMsgAttribute: NSObject { + /// One target-action pair. Two subscriptions are the same when the target + /// and the related object are the same objects and the action has the same name. + private final class Subscriber { + weak var target: AnyObject? + let action: Selector + weak var relatedObject: AnyObject? + + init(target: AnyObject, action: Selector, relatedObject: AnyObject?) { + self.target = target + self.action = action + self.relatedObject = relatedObject + } + + func matches(target: AnyObject, action: Selector, relatedObject: AnyObject?) -> Bool { + self.target === target && self.action == action && self.relatedObject === relatedObject + } + } + + private(set) var currentValue: Any? + + private var subscribers: [Subscriber] = [] + + override init() { + super.init() + } + + /// Creates an attribute holding `value`. + convenience init(value: Any?) { + self.init() + currentValue = value + } + + @objc(attributeWithValue:) + class func attribute(withValue value: Any?) -> LookinMsgAttribute { + LookinMsgAttribute(value: value) + } + + /// Stores `value` and sends every subscriber's action, except the subscriber + /// whose target is `ignoreSubscriber`. Does nothing when `value` equals the + /// current value. `userInfo` reaches the subscribers through the params. + func setValue(_ value: Any?, ignoreSubscriber: Any?, userInfo: Any?) { + if let current = currentValue as? NSObject, current.isEqual(value) { + return + } + currentValue = value + + let ignored = ignoreSubscriber as AnyObject? + subscribers.removeAll { $0.target == nil } + for subscriber in subscribers { + guard let target = subscriber.target, target !== ignored else { + continue + } + let params = LookinMsgActionParams() + params.userInfo = userInfo + params.value = value + params.relatedObject = subscriber.relatedObject + NSApp.sendAction(subscriber.action, to: target, from: params) + } + } + + /// Adds a subscriber. Subscribing the same target, action and related object + /// again has no effect. + func subscribe(_ target: Any?, action: Selector?, relatedObject: Any?) { + subscribe(target, action: action, relatedObject: relatedObject, sendAtOnce: false) + } + + /// Like `subscribe(_:action:relatedObject:)`; with `sendAtOnce` the action is + /// also sent right away with the current value. + func subscribe(_ target: Any?, action: Selector?, relatedObject: Any?, sendAtOnce: Bool) { + guard let target = target as AnyObject?, let action else { + return + } + let related = relatedObject as AnyObject? + if subscribers.contains(where: { $0.matches(target: target, action: action, relatedObject: related) }) { + return + } + subscribers.append(Subscriber(target: target, action: action, relatedObject: related)) + + if sendAtOnce { + let params = LookinMsgActionParams() + params.value = currentValue + params.relatedObject = related + NSApp.sendAction(action, to: target, from: params) + } + } +} + +@objc(LookinDoubleMsgAttribute) +@objcMembers +final class LookinDoubleMsgAttribute: LookinMsgAttribute { + convenience init(double value: Double) { + self.init(value: NSNumber(value: value)) + } + + @objc(attributeWithDouble:) + class func attribute(withDouble value: Double) -> LookinDoubleMsgAttribute { + LookinDoubleMsgAttribute(double: value) + } + + var currentDoubleValue: Double { + (currentValue as? NSNumber)?.doubleValue ?? 0 + } + + func setDoubleValue(_ doubleValue: Double, ignoreSubscriber: Any?) { + setValue(NSNumber(value: doubleValue), ignoreSubscriber: ignoreSubscriber, userInfo: nil) + } +} + +@objc(LookinIntegerMsgAttribute) +@objcMembers +final class LookinIntegerMsgAttribute: LookinMsgAttribute { + convenience init(integer value: Int) { + self.init(value: NSNumber(value: value)) + } + + @objc(attributeWithInteger:) + class func attribute(withInteger value: Int) -> LookinIntegerMsgAttribute { + LookinIntegerMsgAttribute(integer: value) + } + + var currentIntegerValue: Int { + (currentValue as? NSNumber)?.intValue ?? 0 + } + + func setIntegerValue(_ integerValue: Int, ignoreSubscriber: Any?) { + setValue(NSNumber(value: integerValue), ignoreSubscriber: ignoreSubscriber, userInfo: nil) + } +} + +@objc(LookinBOOLMsgAttribute) +@objcMembers +final class LookinBOOLMsgAttribute: LookinMsgAttribute { + convenience init(bool value: Bool) { + self.init(value: NSNumber(value: value)) + } + + @objc(attributeWithBOOL:) + class func attribute(withBOOL value: Bool) -> LookinBOOLMsgAttribute { + LookinBOOLMsgAttribute(bool: value) + } + + var currentBOOLValue: Bool { + (currentValue as? NSNumber)?.boolValue ?? false + } + + func setBOOLValue(_ boolValue: Bool, ignoreSubscriber: Any?) { + setValue(NSNumber(value: boolValue), ignoreSubscriber: ignoreSubscriber, userInfo: nil) + } +} diff --git a/LookInside/Base/Message/LookinMsgTargetAction.h b/LookInside/Base/Message/LookinMsgTargetAction.h deleted file mode 100644 index a171c021..00000000 --- a/LookInside/Base/Message/LookinMsgTargetAction.h +++ /dev/null @@ -1,26 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinMsgTargetAction.h -// Lookin -// -// Created by Li Kai on 2019/8/19. -// https://lookin.work -// - - - -#import - -/// target 和 relatedObject 相等,action 名字相同则认为 equal -@interface LookinMsgTargetAction : NSObject - -@property(nonatomic, weak) id target; - -@property(nonatomic, assign) SEL action; - -@property(nonatomic, weak) id relatedObject; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/LookInside/Base/Message/LookinMsgTargetAction.m b/LookInside/Base/Message/LookinMsgTargetAction.m deleted file mode 100644 index 9552d913..00000000 --- a/LookInside/Base/Message/LookinMsgTargetAction.m +++ /dev/null @@ -1,37 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinMsgTargetAction.m -// Lookin -// -// Created by Li Kai on 2019/8/19. -// https://lookin.work -// - -#import "LookinMsgTargetAction.h" - - - -@implementation LookinMsgTargetAction - -- (NSUInteger)hash { - return [self.target hash] ^ NSStringFromSelector(self.action).hash ^ [self.relatedObject hash]; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinMsgTargetAction class]]) { - return NO; - } - LookinMsgTargetAction *comparedObj = object; - if (self.target == comparedObj.target && [NSStringFromSelector(self.action) isEqual:NSStringFromSelector(comparedObj.action)] && self.relatedObject == comparedObj.relatedObject) { - return YES; - } - return NO; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/LookInside/Connection/LKAppsManager.h b/LookInside/Connection/LKAppsManager.h deleted file mode 100644 index c91a1a68..00000000 --- a/LookInside/Connection/LKAppsManager.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// LKDeviceManager.h -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import -#import "LKInspectableApp.h" - -@interface LKAppsManager : NSObject - -+ (instancetype)sharedInstance; - -/// 获取当前所有可查看的 iOS app -/// needImages 是否需要返回截图和图标,不需要则可加快速度 -/// localInfos 本地已经存在的 appInfos,传入该参数从而可增量更新 -/// data 为 NSArray,该方法不会 sendError -- (RACSignal *)fetchAppInfosWithImage:(BOOL)needImages localInfos:(NSArray *)localInfos; - -@end diff --git a/LookInside/Connection/LKAppsManager.m b/LookInside/Connection/LKAppsManager.m deleted file mode 100644 index 6e952081..00000000 --- a/LookInside/Connection/LKAppsManager.m +++ /dev/null @@ -1,123 +0,0 @@ -// -// LKDeviceManager.m -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKAppsManager.h" -#import "Lookin_PTChannel.h" -#import "LKConnectionManager.h" -#import "LookinDefines.h" -#import "LookinAppInfo.h" -#import "LookinHierarchyInfo.h" -#import "LookinConnectionResponseAttachment.h" - -@implementation LKAppsManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKAppsManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -// Phase F: `LKAppsManager` is now a stateless scanner only. The legacy -// single-slot "currently inspected app" property and its auto-reconnect -// pipeline have moved per-doc; see -// `LookinLiveDocument._lk_subscribeChannelLifecycle`. - -- (RACSignal *)fetchAppInfosWithImage:(BOOL)needImages localInfos:(NSArray *)localInfos { - NSArray *validAppInfos = [localInfos lookin_filter:^BOOL(LookinAppInfo *info) { - /// 超过 8 秒则认为过期 - return [[NSDate date] timeIntervalSince1970] - info.cachedTimestamp <= 8; - }]; - - NSArray *localInfoIdentifiers = [validAppInfos lookin_map:^id(NSUInteger idx, LookinAppInfo *value) { - return @(value.appInfoIdentifier); - }] ? : @[]; - NSDictionary *params = @{@"needImages":@(needImages), @"local":localInfoIdentifiers}; - - return [[[[LKConnectionManager sharedInstance] tryToConnectAllPorts] flattenMap:^__kindof RACSignal * _Nullable(NSArray *connectedChannels) { - if (!connectedChannels.count) { - // 没有任何 channel - return [RACSignal return:nil]; - } - - NSArray *signals = [connectedChannels lookin_map:^id(NSUInteger idx, Lookin_PTChannel *channel) { - return [[[LKConnectionManager sharedInstance] requestWithType:LookinRequestTypeApp data:params channel:channel] catch:^RACSignal * _Nonnull(NSError * _Nonnull error) { - if (error.code == LookinErrCode_ServerVersionTooHigh || - error.code == LookinErrCode_ServerVersionTooLow) { - // 这些 Lookin 版本不匹配错误应该被保留,因为业务需要显示这些错误 - return [RACSignal return:error]; - } else { - // 位于后台无法执行代码的 channel 会走到这里,应该过滤掉这些 channel - return [RACSignal return:nil]; - } - }]; - }]; - return [RACSignal zip:signals]; - - }] map:^id _Nullable(RACTuple * _Nullable x) { - NSArray *apps = [x.allObjects lookin_map:^id(NSUInteger idx, id value) { - if (value == [NSNull null]) { - // 位于后台无法执行代码的 app - return nil; - } - - if ([value isKindOfClass:[NSError class]]) { - // Lookin 版本不匹配的 app - LKInspectableApp *app = [[LKInspectableApp alloc] init]; - app.serverVersionError = value; - return app; - } - - if ([value isKindOfClass:[RACTuple class]]) { - RACTupleUnpack(LookinConnectionResponseAttachment *response, Lookin_PTChannel *relatedChannel) = value; - if (response.error) { - NSLog(@"LookinClient - app info request failed, domain:%@, code:%@, description:%@", - response.error.domain, - @(response.error.code), - response.error.localizedDescription); - if (response.error.code == LookinErrCode_LicenseRequired) { - return nil; - } - LKInspectableApp *app = [[LKInspectableApp alloc] init]; - app.serverVersionError = response.error; - app.channel = relatedChannel; - return app; - } else { - LookinAppInfo *receivedInfo = response.data; - receivedInfo.cachedTimestamp = [[NSDate date] timeIntervalSince1970]; - if (receivedInfo.shouldUseCache) { - // 使用之前拉取的旧 info - LookinAppInfo *localInfo = [validAppInfos lookin_firstFiltered:^BOOL(LookinAppInfo *obj) { - return obj.appInfoIdentifier == receivedInfo.appInfoIdentifier; - }]; - if (localInfo) { - receivedInfo = localInfo; - } - } - - LKInspectableApp *app = [[LKInspectableApp alloc] init]; - app.appInfo = receivedInfo; - app.channel = relatedChannel; - return app; - } - } - - NSAssert(NO, @""); - return nil; - }]; - return apps; - }]; -} - -@end diff --git a/LookInside/Connection/LKAppsManager.swift b/LookInside/Connection/LKAppsManager.swift new file mode 100644 index 00000000..73a4c73e --- /dev/null +++ b/LookInside/Connection/LKAppsManager.swift @@ -0,0 +1,153 @@ +// +// LKAppsManager.swift +// LookInside +// +// Created by Li Kai on 2018/11/3. +// https://lookin.work +// +// Finds the apps the Host can inspect. It keeps no state: each live +// document follows its own app across reconnects (LookinLiveDocument). +// + +import Foundation +import LookInsideHostCore + +@objc(LKAppsManager) +@MainActor +final class LKAppsManager: NSObject { + @objc(sharedInstance) + static let shared = LKAppsManager() + + override private init() { + super.init() + } + + /// Every app that answers on a connected port, in port order. + /// + /// - Parameters: + /// - needImages: whether to fetch icons and screenshots; without them + /// the scan is faster. + /// - localInfos: app infos fetched before; a fresh one is reused when + /// the Server says it did not change, so its images are not sent + /// again. + /// + /// An app whose Server is too old or too new is returned with + /// `serverVersionError` and no channel. Apps in the background, which + /// cannot answer, and apps that require a license for app info are left + /// out. + func fetchAppInfos(needImages: Bool, localInfos: [LookinAppInfo]?) async -> [LKInspectableApp] { + await scanApps(needImages: needImages, localInfos: localInfos).apps + } + + /// `fetchAppInfos` plus how many channels were connected. + func scanApps(needImages: Bool, localInfos: [LookinAppInfo]?) async -> (apps: [LKInspectableApp], channelCount: Int) { + let now = Date().timeIntervalSince1970 + let validAppInfos = (localInfos ?? []).filter { + AppInfoCachePolicy.isFresh(cachedTimestamp: $0.cachedTimestamp, now: now) + } + let localIdentifiers = NSArray(array: validAppInfos.map { NSNumber(value: $0.appInfoIdentifier) }) + let parameters = NSDictionary( + objects: [NSNumber(value: needImages), localIdentifiers], + forKeys: ["needImages" as NSString, "local" as NSString] + ) + + let channels = await LKConnectionManager.shared.connectAllPorts() + guard !channels.isEmpty else { + return ([], 0) + } + let outcomes = await requestAppInfos(parameters: parameters, channels: channels) + let apps = zip(outcomes, channels).compactMap { outcome, channel in + makeApp(from: outcome, channel: channel, validAppInfos: validAppInfos) + } + return (apps, channels.count) + } + + private enum AppInfoOutcome { + /// The app is in the background or did not answer. + case none + case response(LookinConnectionResponseAttachment?) + case versionError(NSError) + } + + /// Sends the app info request on every channel at once, in channel + /// order, and waits for all of them. + private func requestAppInfos(parameters: NSDictionary, channels: [LKChannel]) async -> [AppInfoOutcome] { + await withCheckedContinuation { continuation in + var outcomes = [AppInfoOutcome](repeating: .none, count: channels.count) + var remaining = channels.count + for (index, channel) in channels.enumerated() { + var settled = false + let settle: (AppInfoOutcome) -> Void = { outcome in + guard !settled else { return } + settled = true + outcomes[index] = outcome + remaining -= 1 + if remaining == 0 { + continuation.resume(returning: outcomes) + } + } + let sink = LKResponseSink { event in + switch event { + case let .response(attachment): + settle(.response(attachment)) + case let .failure(error): + if error.code == LookinErrCode_ServerVersionTooHigh || error.code == LookinErrCode_ServerVersionTooLow { + // The launch window shows these. + settle(.versionError(error)) + } else { + // A connected app in the background ends here. + settle(.none) + } + case .completion: + settle(.none) + } + } + LKConnectionManager.shared.request( + type: UInt32(LookinRequestTypeApp), + data: parameters, + channel: channel, + sink: sink + ) + } + } + } + + private func makeApp(from outcome: AppInfoOutcome, channel: LKChannel, validAppInfos: [LookinAppInfo]) -> LKInspectableApp? { + switch outcome { + case .none: + return nil + case let .versionError(error): + let app = LKInspectableApp() + app.serverVersionError = error + return app + case let .response(response): + if let error = response?.error as NSError? { + NSLog( + "LookinClient - app info request failed, domain:%@, code:%@, description:%@", + error.domain, + NSNumber(value: error.code), + error.localizedDescription + ) + if error.code == LookinErrCode_LicenseRequired { + return nil + } + let app = LKInspectableApp() + app.serverVersionError = error + app.channel = channel + return app + } + var receivedInfo = response?.data as? LookinAppInfo + receivedInfo?.cachedTimestamp = Date().timeIntervalSince1970 + if let info = receivedInfo, info.shouldUseCache, + let localInfo = validAppInfos.first(where: { $0.appInfoIdentifier == info.appInfoIdentifier }) + { + // The Server's info did not change; keep the one with images. + receivedInfo = localInfo + } + let app = LKInspectableApp() + app.appInfo = receivedInfo + app.channel = channel + return app + } + } +} diff --git a/LookInside/Connection/LKChannel.swift b/LookInside/Connection/LKChannel.swift new file mode 100644 index 00000000..e7e660b6 --- /dev/null +++ b/LookInside/Connection/LKChannel.swift @@ -0,0 +1,105 @@ +// +// LKChannel.swift +// LookInside +// +// One connection to an inspected app's Server: a LookinFrameChannel on a +// loopback TCP socket (Simulator and Mac apps) or a usbmuxd tunnel (USB +// devices), plus the state the connection manager keeps for it. Frames, +// send completions and the end arrive on the main queue. +// + +import Foundation + +/// Where a channel's Server runs. +enum LKChannelKind { + case simulator + case mac + case usb(deviceID: Int) +} + +/// Receives a channel's frames and its end, on the main actor. +@MainActor +protocol LKChannelDelegate: AnyObject { + /// Decides on each frame header before its payload is read; a rejected + /// frame's payload is skipped. + func channel(_ channel: LKChannel, shouldAcceptFrameOfType type: UInt32, tag: UInt32) -> Bool + /// An accepted frame; an empty payload is `Data()`. + func channel(_ channel: LKChannel, didReceiveFrameOfType type: UInt32, tag: UInt32, payload: Data) + /// The channel ended: the Server closed it, the stream was invalid, or + /// the Host closed it. Runs once, after the socket is closed. + func channelDidEnd(_ channel: LKChannel) +} + +@MainActor +final class LKChannel: NSObject { + let port: Int32 + let kind: LKChannelKind + /// Requests, license handshake and Server release of this connection; + /// released with the channel. + let connectionState = LKChannelConnectionState() + + private let frameChannel: LookinFrameChannel + + /// True until the channel ends or is closed. + var isConnected: Bool { + frameChannel.isConnected + } + + /// Takes over `fileDescriptor`, a connected socket, and starts reading. + /// `initialBytes` are frame bytes read before the channel existed (after + /// a usbmuxd Connect result). The channel stays alive until it ends. + init(fileDescriptor: Int32, initialBytes: Data = Data(), port: Int32, kind: LKChannelKind, delegate: LKChannelDelegate) { + self.port = port + self.kind = kind + // The Host takes any payload size, as Peertalk did: a hierarchy + // response can be large. + frameChannel = LookinFrameChannel( + fileDescriptor: fileDescriptor, + queue: .main, + maxPayloadSize: .max, + initialBytes: initialBytes + ) + super.init() + // The callbacks hold the channel until it ends, as Peertalk's + // dispatch I/O handlers did; the frame channel drops them after + // onEnd. + frameChannel.shouldAcceptFrame = { [self, weak delegate] header in + MainActor.assumeIsolated { + delegate?.channel(self, shouldAcceptFrameOfType: header.type, tag: header.tag) ?? false + } + } + frameChannel.onFrame = { [self, weak delegate] frame in + MainActor.assumeIsolated { + delegate?.channel(self, didReceiveFrameOfType: frame.type, tag: frame.tag, payload: frame.payload) + } + } + frameChannel.onEnd = { [self, weak delegate] _ in + MainActor.assumeIsolated { + delegate?.channelDidEnd(self) + } + } + frameChannel.start() + } + + /// Sends one frame; an empty payload sends the header only. + /// `completion` runs on the main actor with nil once the frame is + /// written, or the error (EPERM when the channel is closed). + func send(type: UInt32, tag: UInt32, payload: Data, completion: (@MainActor (Error?) -> Void)? = nil) { + frameChannel.send(type: type, tag: tag, payload: payload) { error in + guard let completion else { return } + MainActor.assumeIsolated { + completion(error) + } + } + } + + /// Ends the channel now; queued writes are dropped. The delegate hears + /// `channelDidEnd` once. + func close() { + frameChannel.close() + } + + override nonisolated var description: String { + String(format: "", UInt(bitPattern: Unmanaged.passUnretained(self).toOpaque()), port) + } +} diff --git a/LookInside/Connection/LKConnectionErrors.swift b/LookInside/Connection/LKConnectionErrors.swift new file mode 100644 index 00000000..f3b8f004 --- /dev/null +++ b/LookInside/Connection/LKConnectionErrors.swift @@ -0,0 +1,122 @@ +// +// LKConnectionErrors.swift +// LookInside +// +// The NSErrors the Connection layer reports. Swift does not import the +// LookinErr_* macros of LookinDefines.h, so these build the same errors: +// same domain, codes and localization keys. +// + +import Foundation +import LookInsideHostCore + +enum LKConnectionError { + private static func make(_ code: Int, _ description: String, recovery: String? = nil) -> NSError { + var userInfo: [String: Any] = [NSLocalizedDescriptionKey: description] + if let recovery { + userInfo[NSLocalizedRecoverySuggestionErrorKey] = recovery + } + return NSError(domain: LookinErrorDomain, code: code, userInfo: userInfo) + } + + /// `LookinErr_Inner` + static var inner: NSError { + make(LookinErrCode_Inner, NSLocalizedString("The operation failed due to an inner error.", comment: "")) + } + + /// `LookinErr_NoConnect` + static var noConnect: NSError { + make(LookinErrCode_NoConnect, NSLocalizedString("The operation failed due to disconnection with the iOS app.", comment: "")) + } + + /// `LookinErr_ObjNotFound` + static var objectNotFound: NSError { + make( + LookinErrCode_ObjectNotFound, + NSLocalizedString("Failed to get target object in iOS app", comment: ""), + recovery: NSLocalizedString("Perhaps the related object was deallocated. You can reload LookInside to get newest data.", comment: "") + ) + } + + static var discarded: NSError { + make(LookinErrCode_Discard, NSLocalizedString("The request is discarded due to a newer same request.", comment: "")) + } + + static var timeout: NSError { + make( + LookinErrCode_Timeout, + NSLocalizedString("Request timeout", comment: ""), + // LookinErrorText_Timeout + recovery: NSLocalizedString("Perhaps your iOS app is paused with breakpoint in Xcode, blocked by other tasks in main thread, or moved to background state.", comment: "") + ) + } + + /// Sending a frame failed. + static var transport: NSError { + make(LookinErrCode_PeerTalk, NSLocalizedString("The operation failed due to an inner error.", comment: "")) + } + + static var appInBackground: NSError { + make( + LookinErrCode_PingFailForBackgroundState, + NSLocalizedString("The operation failed because target iOS app has entered to the background state.", comment: "") + ) + } + + static var serverVersionTooLow: NSError { + make( + LookinErrCode_ServerVersionTooLow, + NSLocalizedString("Fail to inspect this target app due to a version problem.", comment: ""), + recovery: NSLocalizedString("Please update the embedded LookinServer integration in your target app to a newer version from this repository.", comment: "") + ) + } + + static var serverVersionTooHigh: NSError { + make( + LookinErrCode_ServerVersionTooHigh, + NSLocalizedString("LookInside app version is too low.", comment: ""), + recovery: NSLocalizedString("Target app is linked with a higher version LookinServer.framework. Update this community build from the current repository source.", comment: "") + ) + } + + static func licenseVerificationFailed(detail: String? = nil) -> NSError { + make( + LookinErrCode_LicenseRequired, + NSLocalizedString("LookInside license verification failed.", comment: ""), + recovery: detail + ) + } + + static var malformedLicenseChallenge: NSError { + make(LookinErrCode_Inner, NSLocalizedString("License challenge payload is malformed.", comment: "")) + } + + /// The error a failed license handshake is logged with. + static func licenseHandshakeError(_ failure: LicenseHandshakeFailure) -> NSError { + switch failure { + case let .challengeRejected(error), let .challengeTransport(error), + let .verifyRejected(error), let .verifyTransport(error): + error + case .malformedChallenge: + malformedLicenseChallenge + case let .signingFailed(detail): + licenseVerificationFailed(detail: detail ?? NSLocalizedString("License signing failed.", comment: "")) + case .retryHeldBack: + licenseVerificationFailed() + } + } + + /// The Server's error for a request, as the inspector shows it: the + /// object-not-found and inner-error codes become the localized errors, + /// anything else passes through. + static func localized(_ serverError: NSError) -> NSError { + switch serverError.code { + case LookinErrCode_ObjectNotFound: + objectNotFound + case LookinErrCode_Inner: + inner + default: + serverError + } + } +} diff --git a/LookInside/Connection/LKConnectionManager+License.swift b/LookInside/Connection/LKConnectionManager+License.swift new file mode 100644 index 00000000..53f11ca4 --- /dev/null +++ b/LookInside/Connection/LKConnectionManager+License.swift @@ -0,0 +1,233 @@ +// +// LKConnectionManager+License.swift +// LookInside +// +// The 220 LicenseChallenge / 221 LicenseVerify handshake that unlocks the +// licensed features on one channel. A request on a channel without a +// handshake still runs; the Server then withholds those features. +// + +import Foundation +import LookInsideHostCore + +extension LKConnectionManager { + /// Makes sure the license handshake has run on `channel`, then calls + /// `completion` with whether the channel is licensed. Concurrent callers + /// share one handshake. + /// + /// - Parameter force: run a new handshake even when the channel is + /// already licensed (the activation changed). + func ensureLicenseHandshake(on channel: LKChannel?, force: Bool, completion: ((Bool) -> Void)?) { + guard let channel, channel.isConnected else { + completion?(false) + return + } + let state = channel.connectionState + let gatekeeper = LKSwiftUISupportGatekeeper.sharedInstance() + let admission = state.licenseHandshake.admit( + force: force, + isActivated: gatekeeper.activationState == .activated, + completion: completion + ) + guard admission == .mayStart else { return } + + // The activation runtime's signing policy holds handshakes back + // before the first window, after a refused keychain prompt, and on a + // channel whose handshake already failed. The request then runs + // unlicensed. + guard gatekeeper.shouldStartLicenseHandshake(onChannel: state.licenseChannelID) else { + state.licenseHandshake.finish(verified: false) + return + } + + state.licenseHandshake.markStarted() + let environment = LKChannelLicenseEnvironment(channel: channel, manager: self) + LicenseHandshakeFlow.run(in: environment, channelDescription: String(format: "%p", channel)) { failure in + if let failure { + let error = LKConnectionError.licenseHandshakeError(failure) + NSLog( + "LookinClient - license handshake failed, domain:%@, code:%@, description:%@", + error.domain, + NSNumber(value: error.code), + error.localizedDescription + ) + } + state.licenseHandshake.finish(verified: failure == nil) + } + } + + // MARK: - Activation changes + + /// The license was activated, changed or lost. Without a license every + /// channel loses its licensed state; with one, every connected channel + /// runs a new handshake. + func handleActivationStateDidChange() { + let channels = connectedChannels() + guard LKSwiftUISupportGatekeeper.sharedInstance().activationState == .activated else { + for channel in channels { + channel.connectionState.licenseHandshake.revokeVerification() + } + return + } + for channel in channels { + ensureLicenseHandshake(on: channel, force: true) { verified in + NSLog("LookinClient - activation-state license handshake finished, verified:%@", NSNumber(value: verified)) + } + } + } + + /// Handshakes that the signing policy held back may start: the first + /// window appeared, Try Again or the keychain explainer cleared the wait + /// after a refused keychain prompt, or that wait ended. Each connected + /// channel that is not licensed tries once, through the policy; channels + /// that are already licensed are left alone. + func handleLicenseHandshakeAvailabilityDidChange() { + guard LKSwiftUISupportGatekeeper.sharedInstance().activationState == .activated else { return } + for channel in connectedChannels() { + ensureLicenseHandshake(on: channel, force: false) { verified in + NSLog("LookinClient - resumed license handshake finished, verified:%@", NSNumber(value: verified)) + } + } + } +} + +/// The Host side of `LicenseHandshakeFlow` for one channel: Lookin +/// frames through the connection manager, and the activation runtime's +/// signing policy and license key through the gatekeeper. +@MainActor +private final class LKChannelLicenseEnvironment: LicenseHandshakeEnvironment { + private let channel: LKChannel + private let manager: LKConnectionManager + private let channelID: String + private let timeoutInterval: TimeInterval + + init(channel: LKChannel, manager: LKConnectionManager) { + self.channel = channel + self.manager = manager + channelID = channel.connectionState.licenseChannelID + timeoutInterval = LKPreferenceManager.shared.licenseHandshakeTimeoutInterval + } + + var isChannelConnected: Bool { + channel.isConnected + } + + func now() -> TimeInterval { + Date().timeIntervalSince1970 + } + + func shouldStartHandshake() -> Bool { + LKSwiftUISupportGatekeeper.sharedInstance().shouldStartLicenseHandshake(onChannel: channelID) + } + + func noteHandshakeFailed() { + LKSwiftUISupportGatekeeper.sharedInstance().noteLicenseHandshakeFailed(onChannel: channelID) + } + + func sendChallenge(completion: @escaping (LicenseExchangeResult) -> Void) { + send(type: UInt32(LookinRequestTypeLicenseChallenge), data: nil, completion: completion) + } + + func sendVerify(_ request: LicenseVerifyRequest, completion: @escaping (LicenseExchangeResult) -> Void) { + // The 221 carries the challenge's own nonce and server instance id + // objects back, as the Objective-C Host did. + let challengePayload = request.challengePayload as? NSDictionary + let nonce = (challengePayload?["nonce"] as? NSData) ?? (request.challenge.nonce as NSData) + let serverInstanceID = (challengePayload?["server_instance_id"] as? NSString) + ?? (request.challenge.serverInstanceID as NSString) + let payload = NSDictionary( + objects: [ + nonce, + serverInstanceID, + request.signature as NSData, + request.intermediateCertificateDER as NSData, + request.udid as NSString, + ], + forKeys: [ + "nonce" as NSString, + "server_instance_id" as NSString, + "signature" as NSString, + "intermediate_cert_der" as NSString, + "udid" as NSString, + ] + ) + send(type: UInt32(LookinRequestTypeLicenseVerify), data: payload, completion: completion) + } + + /// Signing blocks until the key is free and the keychain answers, + /// possibly behind a prompt, so it runs off the main thread. + func sign(_ challenge: LicenseChallenge, completion: @escaping (LicenseSigning) -> Void) { + let channelID = channelID + let signed = LKMainThreadCallback(completion) + DispatchQueue.global(qos: .userInitiated).async { + let signing = Self.signChallenge(challenge, channelID: channelID) + DispatchQueue.main.async { + MainActor.assumeIsolated { + signed.callback(signing) + } + } + } + } + + func noteServerRelease(_ release: ServerRelease) { + LKServerUpgradeHint.noteRelease(release, on: channel) + } + + func log(_ message: String) { + NSLog("%@", message) + } + + private func send(type: UInt32, data: NSObject?, completion: @escaping (LicenseExchangeResult) -> Void) { + let sink = LKResponseSink { event in + switch event { + case let .response(attachment): + completion(.response(error: attachment?.error as NSError?, payload: attachment?.data)) + case let .failure(error): + completion(.transportFailure(error)) + case .completion: + break + } + } + manager.sendFrame(type: type, channel: channel, data: data, timeoutInterval: timeoutInterval, sink: sink) + } + + private nonisolated static func signChallenge(_ challenge: LicenseChallenge, channelID: String) -> LicenseSigning { + var signature: NSData? + var intermediateCertDER: NSData? + var udid: NSString? + var keyUseDuration: TimeInterval = 0 + var failureDescription: String? + do { + try LKSwiftUISupportGatekeeper.sharedInstance().signChallenge( + nonce: challenge.nonce, + serverInstanceID: challenge.serverInstanceID, + channelID: channelID, + signature: &signature, + intermediateCertDER: &intermediateCertDER, + udid: &udid, + keyUseDuration: &keyUseDuration + ) + } catch { + failureDescription = error.localizedDescription + } + return LicenseSigning( + signature: signature as Data?, + intermediateCertificateDER: intermediateCertDER as Data?, + udid: udid as String?, + keyUseDuration: keyUseDuration, + failureDescription: failureDescription, + failed: failureDescription != nil, + finishedAt: Date().timeIntervalSince1970 + ) + } +} + +/// Carries a main-actor callback through a background hop. +@MainActor +private final class LKMainThreadCallback { + let callback: (Value) -> Void + + init(_ callback: @escaping (Value) -> Void) { + self.callback = callback + } +} diff --git a/LookInside/Connection/LKConnectionManager.h b/LookInside/Connection/LKConnectionManager.h deleted file mode 100644 index b44adcff..00000000 --- a/LookInside/Connection/LKConnectionManager.h +++ /dev/null @@ -1,48 +0,0 @@ -// -// LKConnectionManager.h -// Lookin -// -// Created by Li Kai on 2018/11/2. -// https://lookin.work -// - -#import - -@class Lookin_PTChannel; - -/** - iOS 是 Server 端,macOS 是 Client 端 - - - 把 app 退到后台不会 kill 掉 server channel,仍旧会占用着端口(但可能无法执行代码) - - 把 app kill 掉后,server channel 也会被 kill,端口占用会被释放 - - - 一台电脑上的所有模拟器里的所有 app 共享同一批端口(在 Lookin 里是 47164 ~ 47169 这 6 个),比如依次启动“模拟器 A 的 app1”、“模拟器 A 的 app2”、“模拟器 B 的 app3”,则它们依次会占用 47164、47165、47166 这几个端口 - - 一台真机上的所有 app 共享同一批端口(在 Lookin 里是 47175 ~ 47179 这 5 个),比如依次启动“真机 A 的 app1”、“真机 A 的 app2”、“真机 B 的 app3”,则它们依次会占用 47175、47176、47175(注意不是 47177)这几个端口 - - */ -@interface LKConnectionManager : NSObject - -+ (instancetype)sharedInstance; - -/// 尝试连接所有可能的 Simulator 和 USB 端口,data 为数组,内含所有成功连接的 Lookin_PTChannel(虽然成功连接但是 app 可能在后台之类的无法执行代码) -/// 该方法不会 sendError -- (RACSignal *)tryToConnectAllPorts; - -/// 返回的 data 为 RACTuple -/// 在调用该方法发请求时,如果已有相同 type 的旧 request 尚未返回结果,则之前的旧 request 会被报告 Error,然后被丢弃 -- (RACSignal *)requestWithType:(unsigned int)requestType data:(NSObject *)requestData channel:(Lookin_PTChannel *)channel; - -/// 取消先前使用 requestWithType:data:channel: 方法发送的尚未完成的 request,这个 request 会被报告为 completion -- (void)cancelRequestWithType:(unsigned int)requestType channel:(Lookin_PTChannel *)channel; - -/// 如果发送的消息不需要 server 端回复,则请使用该方法而非 requestWithType: -/// 如果此时 server 端不在前台或处于断点等模式,则 server 端可能无法收到该消息 -- (void)pushWithType:(unsigned int)pushType data:(NSObject *)requestData channel:(Lookin_PTChannel *)channel; - -/// 即将关闭某个 channel,一般是因为 server 端断开(比如 iOS app 被 kill 掉或 USB 被拔掉) -@property(nonatomic, strong, readonly) RACSubject *channelWillEnd; - -/// 收到了 server 端发送的 push 消息,tuple 分别为 channel, pushType, data -@property(nonatomic, strong, readonly) RACSubject *didReceivePush; - -@end diff --git a/LookInside/Connection/LKConnectionManager.m b/LookInside/Connection/LKConnectionManager.m deleted file mode 100644 index 21edf148..00000000 --- a/LookInside/Connection/LKConnectionManager.m +++ /dev/null @@ -1,896 +0,0 @@ -// -// LKConnectionManager.m -// Lookin -// -// Created by Li Kai on 2018/11/2. -// https://lookin.work -// - -#import "LKConnectionManager.h" -#import "Lookin_PTChannel.h" -#import "LookinDefines.h" -#import "LookinConnectionResponseAttachment.h" -#import "LKPreferenceManager.h" -#import "LookinAppInfo.h" -#import "LKConnectionRequest.h" -#import "LKNavigationManager.h" -#import "LKLaunchWindowController.h" -#import "LookInside-Swift.h" - -static NSIndexSet * PushFrameTypeList(void) { - static NSIndexSet *list; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSMutableIndexSet *set = [NSMutableIndexSet indexSet]; - [set addIndex:LookinPush_SwiftUISupportDetected]; - [set addIndex:LookinPush_GestureDebug]; - list = set.copy; - }); - return list; -} - -static BOOL LKCanStartLicenseHandshakeForRequestType(unsigned int requestType) { - switch (requestType) { - case LookinRequestTypePing: - case LookinRequestTypeLicenseChallenge: - case LookinRequestTypeLicenseVerify: - return NO; - default: - return YES; - } -} - -static NSTimeInterval LKRequestTimeoutIntervalForRequestType(unsigned int requestType) { - switch (requestType) { - case LookinRequestTypeHierarchy: - case LookinRequestTypeHierarchyDetails: - return LKPreferenceManager.mainManager.hierarchyRequestTimeoutInterval; - default: - return 5; - } -} - -@interface Lookin_PTChannel (LKConnection) - -/// 已经发送但尚未收到全部回复的请求 -@property(nonatomic, strong) NSMutableSet *activeRequests; - -/// YES once the license handshake has succeeded on this channel. Reset on -/// channel end. Consulted by `requestWithType:data:channel:` before dispatching -/// a non-exempt request. -@property(nonatomic, assign) BOOL isLicenseVerified; - -/// YES while a challenge/verify exchange is in flight for this channel. -@property(nonatomic, assign) BOOL isLicenseHandshakeInFlight; - -/// Completion blocks waiting for the in-flight license handshake to settle. -@property(nonatomic, strong) NSMutableArray *licenseHandshakeCompletionBlocks; - -@end - -@implementation Lookin_PTChannel (LKConnection) - -- (void)setActiveRequests:(NSMutableSet *)activeRequests { - [self lookin_bindObject:activeRequests forKey:@"activeRequest"]; -} - -- (NSMutableSet *)activeRequests { - return [self lookin_getBindObjectForKey:@"activeRequest"]; -} - -- (void)setIsLicenseVerified:(BOOL)isLicenseVerified { - [self lookin_bindObject:@(isLicenseVerified) forKey:@"isLicenseVerified"]; -} - -- (BOOL)isLicenseVerified { - return [[self lookin_getBindObjectForKey:@"isLicenseVerified"] boolValue]; -} - -- (void)setIsLicenseHandshakeInFlight:(BOOL)isLicenseHandshakeInFlight { - [self lookin_bindObject:@(isLicenseHandshakeInFlight) forKey:@"isLicenseHandshakeInFlight"]; -} - -- (BOOL)isLicenseHandshakeInFlight { - return [[self lookin_getBindObjectForKey:@"isLicenseHandshakeInFlight"] boolValue]; -} - -- (void)setLicenseHandshakeCompletionBlocks:(NSMutableArray *)licenseHandshakeCompletionBlocks { - [self lookin_bindObject:licenseHandshakeCompletionBlocks forKey:@"licenseHandshakeCompletionBlocks"]; -} - -- (NSMutableArray *)licenseHandshakeCompletionBlocks { - return [self lookin_getBindObjectForKey:@"licenseHandshakeCompletionBlocks"]; -} - -@end - -@interface LKSimulatorConnectionPort : NSObject - -@property(nonatomic, assign) int portNumber; - -@property(nonatomic, strong) Lookin_PTChannel *connectedChannel; - -@end - -@implementation LKSimulatorConnectionPort - -- (NSString *)description { - return [NSString stringWithFormat:@"number:%@", @(self.portNumber)]; -} - -@end - -@interface LKMacConnectionPort : NSObject - -@property(nonatomic, assign) int portNumber; - -@property(nonatomic, strong) Lookin_PTChannel *connectedChannel; - -@end - -@implementation LKMacConnectionPort - -- (NSString *)description { - return [NSString stringWithFormat:@"number:%@", @(self.portNumber)]; -} - -@end - -@interface LKUSBConnectionPort : NSObject - -@property(nonatomic, assign) int portNumber; - -@property(nonatomic, strong) NSNumber *deviceID; - -@property(nonatomic, strong) Lookin_PTChannel *connectedChannel; - -@end - -@implementation LKUSBConnectionPort - -- (NSString *)description { - return [NSString stringWithFormat:@"number:%@, deviceID:%@, connectedChannel:%@", @(self.portNumber), self.deviceID, self.connectedChannel]; -} - -@end - -@interface LKConnectionManager () - -@property(nonatomic, copy) NSArray *allSimulatorPorts; -@property(nonatomic, copy) NSArray *allMacPorts; -@property(nonatomic, strong) NSMutableArray *allUSBPorts; - -@end - -@implementation LKConnectionManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKConnectionManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone { - return [self sharedInstance]; -} - -- (instancetype)init { - if (self = [super init]) { - _channelWillEnd = [RACSubject subject]; - _didReceivePush = [RACSubject subject]; - - self.allSimulatorPorts = ({ - NSMutableArray *ports = [NSMutableArray array]; - for (int number = LookinSimulatorIPv4PortNumberStart; number <= LookinSimulatorIPv4PortNumberEnd; number++) { - LKSimulatorConnectionPort *port = [LKSimulatorConnectionPort new]; - port.portNumber = number; - [ports addObject:port]; - } - ports; - }); - self.allMacPorts = ({ - NSMutableArray *ports = [NSMutableArray array]; - for (int number = LookinMacIPv4PortNumberStart; number <= LookinMacIPv4PortNumberEnd; number++) { - LKMacConnectionPort *port = [LKMacConnectionPort new]; - port.portNumber = number; - [ports addObject:port]; - } - ports; - }); - self.allUSBPorts = [NSMutableArray array]; - - [self _startListeningForUSBDevices]; - [NSNotificationCenter.defaultCenter addObserverForName:LKSwiftUISupportGatekeeper.activationStateDidChangeNotificationName object:nil queue:nil usingBlock:^(__unused NSNotification *note) { - [self _handleActivationStateDidChange]; - }]; - } - return self; -} - -#pragma mark - Ports Connect - -- (RACSignal *)tryToConnectAllPorts { - return [[RACSignal zip:@[[self _tryToConnectAllSimulatorPorts], - [self _tryToConnectAllMacPorts], - [self _tryToConnectAllUSBDevices]]] map:^id _Nullable(RACTuple * _Nullable value) { - RACTupleUnpack(NSArray *simulatorChannels, NSArray *macChannels, NSArray *usbChannels) = value; - NSArray *connectedChannels = [[simulatorChannels arrayByAddingObjectsFromArray:macChannels] arrayByAddingObjectsFromArray:usbChannels]; - return connectedChannels; - }]; -} - -/// 返回的 x 为所有已成功链接的 Lookin_PTChannel 数组,该方法不会 sendError: -- (RACSignal *)_tryToConnectAllSimulatorPorts { - NSArray *tries = [self.allSimulatorPorts lookin_map:^id(NSUInteger idx, LKSimulatorConnectionPort *port) { - return [[self _connectToSimulatorPort:port] catch:^RACSignal * _Nonnull(NSError * _Nonnull error) { - return [RACSignal return:nil]; - }]; - }]; - return [[RACSignal zip:tries] map:^id _Nullable(RACTuple * _Nullable value) { - NSArray *connectedChannels = [value.allObjects lookin_filter:^BOOL(id obj) { - return (obj != [NSNull null]); - }]; - return connectedChannels; - }]; -} - -- (RACSignal *)_tryToConnectAllMacPorts { - NSArray *tries = [self.allMacPorts lookin_map:^id(NSUInteger idx, LKMacConnectionPort *port) { - return [[self _connectToMacPort:port] catch:^RACSignal * _Nonnull(NSError * _Nonnull error) { - return [RACSignal return:nil]; - }]; - }]; - return [[RACSignal zip:tries] map:^id _Nullable(RACTuple * _Nullable value) { - NSArray *connectedChannels = [value.allObjects lookin_filter:^BOOL(id obj) { - return (obj != [NSNull null]); - }]; - return connectedChannels; - }]; -} - -/// 返回的 x 为成功链接的 Lookin_PTChannel -/// 注意,如果某个 app 被退到了后台但是没有被 kill,则在这个方法里它的 channel 仍然会被成功连接 -- (RACSignal *)_connectToSimulatorPort:(LKSimulatorConnectionPort *)port { - return [RACSignal createSignal:^RACDisposable * _Nullable(id _Nonnull subscriber) { - if (port.connectedChannel) { - // 该 port 本来就已经成功连接 - [subscriber sendNext:port.connectedChannel]; - [subscriber sendCompleted]; - return nil; - } - - Lookin_PTChannel *localChannel = [Lookin_PTChannel channelWithDelegate:self]; - [localChannel connectToPort:port.portNumber IPv4Address:INADDR_LOOPBACK callback:^(NSError *error, Lookin_PTAddress *address) { - if (error) { - if (error.domain == NSPOSIXErrorDomain && (error.code == ECONNREFUSED || error.code == ETIMEDOUT)) { - // 没有 iOS 客户端 - } else { - // 意外 - } - [localChannel close]; - [subscriber sendError:error]; - } else { - port.connectedChannel = localChannel; - [subscriber sendNext:localChannel]; - [subscriber sendCompleted]; - } - }]; - return nil; - }]; -} - -- (RACSignal *)_connectToMacPort:(LKMacConnectionPort *)port { - return [RACSignal createSignal:^RACDisposable * _Nullable(id _Nonnull subscriber) { - if (port.connectedChannel) { - [subscriber sendNext:port.connectedChannel]; - [subscriber sendCompleted]; - return nil; - } - - Lookin_PTChannel *localChannel = [Lookin_PTChannel channelWithDelegate:self]; - [localChannel connectToPort:port.portNumber IPv4Address:INADDR_LOOPBACK callback:^(NSError *error, Lookin_PTAddress *address) { - if (error) { - [localChannel close]; - [subscriber sendError:error]; - } else { - port.connectedChannel = localChannel; - [subscriber sendNext:localChannel]; - [subscriber sendCompleted]; - } - }]; - return nil; - }]; -} - -/// 返回的 x 为所有已成功链接的 Lookin_PTChannel 数组,该方法不会 sendError: -- (RACSignal *)_tryToConnectAllUSBDevices { - if (!self.allUSBPorts.count) { - return [RACSignal return:[NSArray array]]; - } - NSArray *tries = [self.allUSBPorts lookin_map:^id(NSUInteger idx, LKUSBConnectionPort *port) { - return [[self _connectToUSBPort:port] catch:^RACSignal * _Nonnull(NSError * _Nonnull error) { - return [RACSignal return:nil]; - }]; - }]; - return [[RACSignal zip:tries] map:^id _Nullable(RACTuple * _Nullable value) { - NSArray *connectedChannels = [value.allObjects lookin_filter:^BOOL(id obj) { - return (obj != [NSNull null]); - }]; - return connectedChannels; - }]; -} - -/// 返回的 x 为成功链接的 Lookin_PTChannel -- (RACSignal *)_connectToUSBPort:(LKUSBConnectionPort *)port { - return [RACSignal createSignal:^RACDisposable * _Nullable(id _Nonnull subscriber) { - if (port.connectedChannel) { - // 该 port 本来就已经成功连接 - [subscriber sendNext:port.connectedChannel]; - [subscriber sendCompleted]; - return nil; - } - - Lookin_PTChannel *channel = [Lookin_PTChannel channelWithDelegate:self]; - [channel connectToPort:port.portNumber overUSBHub:Lookin_PTUSBHub.sharedHub deviceID:port.deviceID callback:^(NSError *error) { - if (error) { - if (error.domain == Lookin_PTUSBHubErrorDomain && error.code == PTUSBHubErrorConnectionRefused) { - // error - } else { - // error - } - [channel close]; - [subscriber sendError:error]; - } else { - // succ - port.connectedChannel = channel; - [subscriber sendNext:channel]; - [subscriber sendCompleted]; - } - }]; - return nil; - }]; -} - -#pragma mark - Request - -- (void)pushWithType:(unsigned int)pushType data:(NSObject *)data channel:(Lookin_PTChannel *)channel { - if (!channel || !channel.isConnected) { - return; - } - NSError *archiveError = nil; - dispatch_data_t payload = [[NSKeyedArchiver archivedDataWithRootObject:data requiringSecureCoding:YES error:&archiveError] createReferencingDispatchData]; - if (archiveError) { - NSAssert(NO, @""); - } - NSLog(@"LookinClient - pushData, type:%@", @(pushType)); - [channel sendFrameOfType:pushType tag:0 withPayload:payload callback:nil]; -} - -- (RACSignal *)requestWithType:(unsigned int)requestType data:(NSObject *)requestData channel:(Lookin_PTChannel *)channel { - return [RACSignal createSignal:^RACDisposable * _Nullable(id _Nonnull subscriber) { - // NSLog(@"LookinClient, level1 - will ping for request:%@, port:%@", @(type), @(channel.portNumber)); - NSTimeInterval timeoutInterval; - if (requestType == LookinRequestTypeApp) { - // 如果某个 iOS app 连接上了 channel 却又处于后台的话,而这个 timeout 时间又过长的话,就会导致初次进入 launch 时非常慢(因为必须等到 timeoutInterval 结束),因此这里为了体验缩小这个时间 - timeoutInterval = .5; - } else { - timeoutInterval = 2; - } - - [self _requestWithType:LookinRequestTypePing channel:channel data:nil timeoutInterval:timeoutInterval succ:^(LookinConnectionResponseAttachment *pingResponse) { - // ping 成功了 - // NSLog(@"LookinClient, level1 - ping succ, will send request:%@, port:%@", @(type), @(channel.portNumber)); - NSError *versionErr = [self _checkServerVersionWithResponse:pingResponse]; - if (versionErr) { - // LookinServer 版本有问题 - [subscriber sendError:versionErr]; - return; - } - - void (^dispatchRealRequest)(void) = ^{ - [self _requestWithType:requestType channel:channel data:requestData timeoutInterval:LKRequestTimeoutIntervalForRequestType(requestType) succ:^(id responseData) { - RACTuple *tupleResult = [RACTuple tupleWithObjects:responseData, channel, nil]; - [subscriber sendNext:tupleResult]; - } fail:^(NSError *error) { - [subscriber sendError:error]; - } completion:^{ - [subscriber sendCompleted]; - }]; - }; - - if (!LKCanStartLicenseHandshakeForRequestType(requestType)) { - dispatchRealRequest(); - return; - } - - [self _ensureLicenseHandshakeOnChannel:channel completion:^(__unused BOOL verified) { - dispatchRealRequest(); - }]; - - } fail:^(NSError *error) { - // ping 失败了 - [subscriber sendError:error]; - - } completion:nil]; - return nil; - }]; -} - -- (void)_ensureLicenseHandshakeOnChannel:(Lookin_PTChannel *)channel - completion:(void (^)(BOOL verified))completionBlock { - [self _ensureLicenseHandshakeOnChannel:channel force:NO completion:completionBlock]; -} - -- (void)_ensureLicenseHandshakeOnChannel:(Lookin_PTChannel *)channel - force:(BOOL)force - completion:(void (^)(BOOL verified))completionBlock { - if (!channel || !channel.isConnected) { - if (completionBlock) completionBlock(NO); - return; - } - - if (channel.isLicenseVerified && !force) { - if (completionBlock) completionBlock(YES); - return; - } - - if ([LKSwiftUISupportGatekeeper sharedInstance].activationState != LKSwiftUISupportActivationStateActivated) { - if (completionBlock) completionBlock(NO); - return; - } - - if (!channel.licenseHandshakeCompletionBlocks) { - channel.licenseHandshakeCompletionBlocks = [NSMutableArray array]; - } - if (completionBlock) { - [channel.licenseHandshakeCompletionBlocks addObject:[completionBlock copy]]; - } - - if (channel.isLicenseHandshakeInFlight) { - return; - } - - channel.isLicenseHandshakeInFlight = YES; - [self _performLicenseHandshakeOnChannel:channel succ:^{ - [self _finishLicenseHandshakeOnChannel:channel verified:YES]; - } fail:^(NSError *error) { - NSLog(@"LookinClient - license handshake failed, domain:%@, code:%@, description:%@", - error.domain, - @(error.code), - error.localizedDescription); - [self _finishLicenseHandshakeOnChannel:channel verified:NO]; - }]; -} - -- (void)_finishLicenseHandshakeOnChannel:(Lookin_PTChannel *)channel - verified:(BOOL)verified { - channel.isLicenseHandshakeInFlight = NO; - if (verified) { - channel.isLicenseVerified = YES; - } - - NSArray *completionBlocks = channel.licenseHandshakeCompletionBlocks.copy; - channel.licenseHandshakeCompletionBlocks = nil; - [completionBlocks enumerateObjectsUsingBlock:^(void (^block)(BOOL verified), __unused NSUInteger idx, __unused BOOL *stop) { - block(verified); - }]; -} - -- (void)_performLicenseHandshakeOnChannel:(Lookin_PTChannel *)channel - succ:(void (^)(void))succBlock - fail:(void (^)(NSError *error))failBlock { - NSLog(@"LookInside - License: starting handshake on channel %p (sending 220 LicenseChallenge).", channel); - NSTimeInterval timeoutInterval = LKPreferenceManager.mainManager.licenseHandshakeTimeoutInterval; - [self _requestWithType:LookinRequestTypeLicenseChallenge channel:channel data:nil timeoutInterval:timeoutInterval succ:^(LookinConnectionResponseAttachment *challengeAttachment) { - if (challengeAttachment.error) { - NSLog(@"LookInside - License: 220 challenge errored: %@", challengeAttachment.error.localizedDescription); - if (failBlock) failBlock(challengeAttachment.error); - return; - } - - NSDictionary *challenge = [challengeAttachment.data isKindOfClass:[NSDictionary class]] ? (NSDictionary *)challengeAttachment.data : nil; - NSData *nonce = [challenge[@"nonce"] isKindOfClass:[NSData class]] ? challenge[@"nonce"] : nil; - NSString *serverID = [challenge[@"server_instance_id"] isKindOfClass:[NSString class]] ? challenge[@"server_instance_id"] : nil; - if (nonce.length != 32 || serverID.length == 0) { - NSLog(@"LookInside - License: 220 challenge payload malformed (nonce_len=%lu, server_instance_id_len=%lu).", (unsigned long)nonce.length, (unsigned long)serverID.length); - if (failBlock) { - failBlock([NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_Inner userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"License challenge payload is malformed.", nil)}]); - } - return; - } - NSLog(@"LookInside - License: 220 challenge OK (server_instance_id=%@); requesting signature from auth helper.", serverID); - - dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ - NSError *signError = nil; - NSData *signature = nil; - NSData *intermediateCertDER = nil; - NSString *udid = nil; - BOOL ok = [[LKSwiftUISupportGatekeeper sharedInstance] - signChallengeWithNonce:nonce - serverInstanceID:serverID - signature:&signature - intermediateCertDER:&intermediateCertDER - udid:&udid - error:&signError]; - dispatch_async(dispatch_get_main_queue(), ^{ - if (!ok || signature.length == 0 || intermediateCertDER.length == 0) { - NSString *detail = signError.localizedDescription ?: NSLocalizedString(@"License signing failed.", nil); - NSLog(@"LookInside - License: auth helper sign_challenge failed: %@", detail); - NSError *reportedError = [NSError errorWithDomain:LookinErrorDomain - code:LookinErrCode_LicenseRequired - userInfo:@{ - NSLocalizedDescriptionKey: NSLocalizedString(@"LookInside license verification failed.", nil), - NSLocalizedRecoverySuggestionErrorKey: detail, - }]; - if (failBlock) failBlock(reportedError); - return; - } - NSLog(@"LookInside - License: signature obtained (sig=%lub, intermediate=%lub, udid=%@); sending 221 LicenseVerify.", (unsigned long)signature.length, (unsigned long)intermediateCertDER.length, udid.length ? udid : @""); - - NSDictionary *verifyPayload = @{ - @"nonce": nonce, - @"server_instance_id": serverID, - @"signature": signature, - @"intermediate_cert_der": intermediateCertDER, - @"udid": udid ?: @"", - }; - [self _requestWithType:LookinRequestTypeLicenseVerify channel:channel data:verifyPayload timeoutInterval:timeoutInterval succ:^(LookinConnectionResponseAttachment *verifyResponse) { - if (verifyResponse.error) { - NSLog(@"LookInside - License: 221 verify rejected by server: %@", verifyResponse.error.localizedDescription); - if (failBlock) failBlock(verifyResponse.error); - return; - } - - NSLog(@"LookInside - License: 221 verify accepted; channel %p marked licensed.", channel); - if (succBlock) succBlock(); - } fail:^(NSError *verifyError) { - NSLog(@"LookInside - License: 221 verify transport error: %@", verifyError.localizedDescription); - if (failBlock) failBlock(verifyError); - } completion:nil]; - }); - }); - } fail:^(NSError *challengeError) { - NSLog(@"LookInside - License: 220 challenge transport error: %@", challengeError.localizedDescription); - if (failBlock) failBlock(challengeError); - } completion:nil]; -} - -- (NSError *)_checkServerVersionWithResponse:(LookinConnectionResponseAttachment *)pingResponse { - int serverVersion = pingResponse.lookinServerVersion; - if (serverVersion == -1 || serverVersion == 100) { - // 说明用的还是旧版本的内部版本 LookinServer,这里兼容一下 - NSError *versionErr = [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_ServerVersionTooLow userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"Fail to inspect this target app due to a version problem.", nil), NSLocalizedRecoverySuggestionErrorKey:NSLocalizedString(@"Please update the embedded LookinServer integration in your target app to a newer version from this repository.", nil)}]; - return versionErr; - } - - if (serverVersion > LOOKIN_SUPPORTED_SERVER_MAX) { - // server 版本过高,需要升级 client - NSError *versionErr = [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_ServerVersionTooHigh userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"LookInside app version is too low.", nil), NSLocalizedRecoverySuggestionErrorKey:NSLocalizedString(@"Target app is linked with a higher version LookinServer.framework. Update this community build from the current repository source.", nil)}]; - return versionErr; - - } - - if (serverVersion < LOOKIN_SUPPORTED_SERVER_MIN) { - // server 版本过低,需要升级 server - NSError *versionErr = [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_ServerVersionTooLow userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"Fail to inspect this target app due to a version problem.", nil), NSLocalizedRecoverySuggestionErrorKey:NSLocalizedString(@"Please update the embedded LookinServer integration in your target app to a newer version from this repository.", nil)}]; - return versionErr; - } - - return nil; -} - -#pragma mark - Private - -- (void)_handleActivationStateDidChange { - NSArray *channels = [self _connectedChannels]; - LKSwiftUISupportActivationState state = [LKSwiftUISupportGatekeeper sharedInstance].activationState; - if (state != LKSwiftUISupportActivationStateActivated) { - [channels enumerateObjectsUsingBlock:^(Lookin_PTChannel *channel, __unused NSUInteger idx, __unused BOOL *stop) { - channel.isLicenseVerified = NO; - }]; - return; - } - - [channels enumerateObjectsUsingBlock:^(Lookin_PTChannel *channel, __unused NSUInteger idx, __unused BOOL *stop) { - [self _ensureLicenseHandshakeOnChannel:channel force:YES completion:^(BOOL verified) { - NSLog(@"LookinClient - activation-state license handshake finished, verified:%@", @(verified)); - }]; - }]; -} - -- (void)_handlePushWithType:(uint32_t)type data:(NSObject *)data channel:(Lookin_PTChannel *)channel { - if (type != LookinPush_SwiftUISupportDetected) { - return; - } - - NSLog(@"LookinClient - received SwiftUI support detection push from channel:%@ data:%@", channel, data); - dispatch_async(dispatch_get_main_queue(), ^{ - LKSwiftUISupportGatekeeper *gatekeeper = [LKSwiftUISupportGatekeeper sharedInstance]; - [gatekeeper noteDetectedSwiftUISupport]; - - NSWindow *keyWindow = [NSApplication sharedApplication].keyWindow; - NSWindow *launchWindow = [LKNavigationManager sharedInstance].launchWindowController.window; - if (keyWindow && keyWindow != launchWindow) { - [gatekeeper promptForPendingDetectedSwiftUISupportIfNeededForWindow:keyWindow]; - } - }); -} - -- (NSArray *)_connectedChannels { - NSMutableArray *channels = [NSMutableArray array]; - [self.allSimulatorPorts enumerateObjectsUsingBlock:^(LKSimulatorConnectionPort *port, __unused NSUInteger idx, __unused BOOL *stop) { - if (port.connectedChannel.isConnected) { - [channels addObject:port.connectedChannel]; - } - }]; - [self.allMacPorts enumerateObjectsUsingBlock:^(LKMacConnectionPort *port, __unused NSUInteger idx, __unused BOOL *stop) { - if (port.connectedChannel.isConnected) { - [channels addObject:port.connectedChannel]; - } - }]; - [self.allUSBPorts enumerateObjectsUsingBlock:^(LKUSBConnectionPort *port, __unused NSUInteger idx, __unused BOOL *stop) { - if (port.connectedChannel.isConnected) { - [channels addObject:port.connectedChannel]; - } - }]; - return channels.copy; -} - -- (void)_requestWithType:(unsigned int)requestType channel:(Lookin_PTChannel *)channel data:(NSObject *)data timeoutInterval:(NSTimeInterval)timeoutInterval succ:(void (^)(id data))succBlock fail:(void (^)(NSError *error))failBlock completion:(void (^)(void))completionBlock { - if (!channel) { - NSAssert(NO, @""); - if (failBlock) { - failBlock(LookinErr_Inner); - } - return; - } - if (!channel.isConnected) { - if (failBlock) { - failBlock(LookinErr_NoConnect); - } - return; - } - if (channel.activeRequests.count && requestType != LookinRequestTypePing) { - // 检查是否有相同 type 的旧请求尚在进行中,如果有则移除之前的旧请求(旧请求会被报告 error) - NSSet *requestsToBeDiscarded = [channel.activeRequests lookin_filter:^BOOL(LKConnectionRequest *obj) { - return (obj.type == requestType); - }]; - [requestsToBeDiscarded enumerateObjectsUsingBlock:^(LKConnectionRequest * _Nonnull obj, BOOL * _Nonnull stop) { - if (obj.failBlock) { - NSError *error = [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_Discard userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"The request is discarded due to a newer same request.", nil)}]; - obj.failBlock(error); - } - [obj endTimeoutCount]; - [channel.activeRequests removeObject:obj]; - - NSLog(@"LookinClient - will discard request, type:%@, tag:%@", @(obj.type), @(obj.tag)); - }]; - } - - LKConnectionRequest *request = [[LKConnectionRequest alloc] init]; - request.type = requestType; - request.tag = (uint32_t)[[NSDate date] timeIntervalSince1970]; - request.succBlock = succBlock; - request.failBlock = failBlock; - request.completionBlock = completionBlock; - request.timeoutInterval = timeoutInterval; - @weakify(channel); - request.timeoutBlock = ^(LKConnectionRequest *selfRequest) { - @strongify(channel); - NSError *error = [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_Timeout userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"Request timeout", nil), NSLocalizedRecoverySuggestionErrorKey:LookinErrorText_Timeout}]; - selfRequest.failBlock(error); - [channel.activeRequests removeObject:selfRequest]; - }; - - LookinConnectionAttachment *attachment = [LookinConnectionAttachment new]; - attachment.data = data; - NSError *archiveError = nil; - dispatch_data_t payload = [[NSKeyedArchiver archivedDataWithRootObject:attachment requiringSecureCoding:YES error:&archiveError] createReferencingDispatchData]; - if (archiveError) { - NSAssert(NO, @""); - } - [channel sendFrameOfType:requestType tag:request.tag withPayload:payload callback:^(NSError *error) { -// NSLog(@"LookinClient - sendRequest, type:%@", @(requestType)); - if (error) { - if (failBlock) { - NSError *error = [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_PeerTalk userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"The operation failed due to an inner error.", nil)}]; - failBlock(error); - } - } else { - // 成功发出了该 request - if (!channel.activeRequests) { - channel.activeRequests = [NSMutableSet set]; - } - [channel.activeRequests addObject:request]; - [request resetTimeoutCount]; - } - }]; -} - -- (void)cancelRequestWithType:(unsigned int)requestType channel:(Lookin_PTChannel *)channel { - LKConnectionRequest *activeRequest = [channel.activeRequests lookin_firstFiltered:^BOOL(LKConnectionRequest *obj) { - return obj.type == requestType; - }]; - if (!activeRequest) { - return; - } - [activeRequest endTimeoutCount]; - [channel.activeRequests removeObject:activeRequest]; - if (activeRequest.completionBlock) { - activeRequest.completionBlock(); - } - NSLog(@"Lookin - 用户手动取消 request, type:%@", @(requestType)); -} - -- (void)_startListeningForUSBDevices { - NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; - - [nc addObserverForName:Lookin_PTUSBDeviceDidAttachNotification object:Lookin_PTUSBHub.sharedHub queue:nil usingBlock:^(NSNotification *note) { - NSNumber *deviceID = [note.userInfo objectForKey:@"DeviceID"]; - - /// 仅一台真机 device 上的所有 app 共享同一批端口(在 Lookin 里是 47175 ~ 47179 这 5 个),不同真机互不影响。比如依次启动“真机 A 的 app1”、“真机 A 的 app2”、“真机 B 的 app3”,则它们依次会占用 47175、47176、47175(注意不是 47177)这几个端口 - for (int number = LookinUSBDeviceIPv4PortNumberStart; number <= LookinUSBDeviceIPv4PortNumberEnd; number++) { - LKUSBConnectionPort *port = [LKUSBConnectionPort new]; - port.portNumber = number; - port.deviceID = deviceID; - [self.allUSBPorts addObject:port]; - } - NSLog(@"Lookin - USB 设备插入,DeviceID: %@", deviceID); - }]; - - [nc addObserverForName:Lookin_PTUSBDeviceDidDetachNotification object:Lookin_PTUSBHub.sharedHub queue:nil usingBlock:^(NSNotification *note) { - NSNumber *deviceID = [note.userInfo objectForKey:@"DeviceID"]; - [self.allUSBPorts.copy enumerateObjectsUsingBlock:^(LKUSBConnectionPort * _Nonnull port, NSUInteger idx, BOOL * _Nonnull stop) { - if ([port.deviceID isEqual:deviceID]) { - [self.allUSBPorts removeObject:port]; - } - }]; - NSLog(@"Lookin - USB 设备拔出,DeviceID: %@", deviceID); - }]; -} - -#pragma mark - - -- (BOOL)ioFrameChannel:(Lookin_PTChannel*)channel shouldAcceptFrameOfType:(uint32_t)type tag:(uint32_t)tag payloadSize:(uint32_t)payloadSize { - if ([PushFrameTypeList() containsIndex:type]) { - return YES; - } - - LKConnectionRequest *activeRequest = [channel.activeRequests lookin_firstFiltered:^BOOL(LKConnectionRequest *obj) { - return (obj.type == type && obj.tag == tag); - }]; - if (activeRequest) { - return YES; - } else { - NSLog(@"LookinClient - will refuse, type:%@, tag:%@", @(type), @(tag)); - return NO; - } -} - -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didReceiveFrameOfType:(uint32_t)type tag:(uint32_t)tag payload:(Lookin_PTData*)payload { - if ([PushFrameTypeList() containsIndex:type]) { - NSData *data = [NSData dataWithContentsOfDispatchData:payload.dispatchData]; - NSError *unarchiveError = nil; - NSSet *allowedClasses = [NSSet setWithObjects: - [NSString class], - [NSDictionary class], - [NSArray class], - [NSNumber class], - [NSData class], - [NSNull class], - nil]; - NSObject *unarchivedData = [NSKeyedUnarchiver unarchivedObjectOfClasses:allowedClasses fromData:data error:&unarchiveError]; - if (unarchiveError) { - // NSAssert(NO, @""); - } - - RACTuple *tuple = [RACTuple tupleWithObjects:channel, @(type), unarchivedData, nil]; - [self.didReceivePush sendNext:tuple]; - [self _handlePushWithType:type data:unarchivedData channel:channel]; - return; - } - -// NSLog(@"LookinClient - did receive, port:%@, type:%@, tag:%@", @(channel.portNumber), @(type), @(tag)); - LKConnectionRequest *activeRequest = [channel.activeRequests lookin_firstFiltered:^BOOL(LKConnectionRequest *obj) { - return (obj.type == type && obj.tag == tag); - }]; - if (!activeRequest) { - // 也许在 shouldAcceptFrameOfType 和 didReceiveFrame 两个时机之间,该 request 因为超时而被销毁了?有点玄学但确实偶尔会走到这里。 - return; - } - - NSData *data = [NSData dataWithContentsOfDispatchData:payload.dispatchData]; -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - // Wire-compat with upstream Lookin / LookInsideServer send path: payload is a non-secure - // archive of LookinConnectionResponseAttachment whose `.data` is arbitrary id graphs - // (Lookin* model + Foundation collections). Using NSSecureCoding with [NSObject class] - // as the allow-list spams the runtime warning every frame, so we stay on the legacy API. - LookinConnectionResponseAttachment *attachment = [NSKeyedUnarchiver unarchiveObjectWithData:data]; -#pragma clang diagnostic pop - - if (attachment.appIsInBackground) { - // app 处于后台模式 - - [activeRequest endTimeoutCount]; - [channel.activeRequests removeObject:activeRequest]; - - if (activeRequest.failBlock) { - NSError *error = [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_PingFailForBackgroundState userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"The operation failed because target iOS app has entered to the background state.", nil)}]; - activeRequest.failBlock(error); - } - - NSLog(@"Lookin - iOS app 报告自己处于后台,request fail"); - - return; - } - - if (activeRequest.succBlock) { - activeRequest.succBlock(attachment); - } - - static NSUInteger dataSize = 0; - static CFTimeInterval startTime = 0; - if (activeRequest.receivedDataCount == 0) { - dataSize = 0; - startTime = CACurrentMediaTime(); - } - dataSize += data.length; - - BOOL hasReceivedAllResponses = NO; - if (attachment.dataTotalCount > 0) { - activeRequest.receivedDataCount += attachment.currentDataCount; - if (activeRequest.receivedDataCount >= attachment.dataTotalCount) { - hasReceivedAllResponses = YES; - } - } else { - hasReceivedAllResponses = YES; - } - - if (hasReceivedAllResponses) { - [activeRequest endTimeoutCount]; - [channel.activeRequests removeObject:activeRequest]; - if (activeRequest.completionBlock) { - activeRequest.completionBlock(); - } - - CFTimeInterval timeDuration = CACurrentMediaTime() - startTime; - CGFloat totalSize = dataSize / 1024.0 / 1024.0; - if (totalSize > 0.5) { - NSMutableString *logString = [[NSMutableString alloc] initWithString:@"Lookin - "]; - [logString appendFormat:@"已收到全部请求 %@ / %@,总耗时:%.2f, 数据总大小:%.2fM", @(activeRequest.receivedDataCount), @(attachment.dataTotalCount), timeDuration, totalSize]; - NSLog(@"%@", logString); - } - } else { - /// 对于多 response 的请求,每收到一次 response 则重置 timeout 倒计时 - [activeRequest resetTimeoutCount]; -// NSLog(@"Lookin - 收到请求 %@ / %@", @(activeRequest.receivedDataCount), @(attachment.dataTotalCount)); - } -} - -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didEndWithError:(NSError*)error { - // iOS 客户端断开 - [self.allSimulatorPorts enumerateObjectsUsingBlock:^(LKSimulatorConnectionPort * _Nonnull port, NSUInteger idx, BOOL * _Nonnull stop) { - if (port.connectedChannel == channel) { - port.connectedChannel = nil; - } - }]; - [self.allMacPorts enumerateObjectsUsingBlock:^(LKMacConnectionPort * _Nonnull port, NSUInteger idx, BOOL * _Nonnull stop) { - if (port.connectedChannel == channel) { - port.connectedChannel = nil; - } - }]; - [self.allUSBPorts enumerateObjectsUsingBlock:^(LKUSBConnectionPort * _Nonnull port, NSUInteger idx, BOOL * _Nonnull stop) { - if (port.connectedChannel == channel) { - port.connectedChannel = nil; - } - }]; - [self.channelWillEnd sendNext:channel]; - - [channel close]; -} - -@end diff --git a/LookInside/Connection/LKConnectionManager.swift b/LookInside/Connection/LKConnectionManager.swift new file mode 100644 index 00000000..707c46df --- /dev/null +++ b/LookInside/Connection/LKConnectionManager.swift @@ -0,0 +1,590 @@ +// +// LKConnectionManager.swift +// LookInside +// +// Created by Li Kai on 2018/11/2. +// https://lookin.work +// +// The inspected app runs the Server; LookInside is the client. +// +// - Moving the app to the background does not close its Server channel; the +// port stays taken, but the app may not run code. +// - Killing the app closes the channel and frees the port. +// - Every app in every Simulator on a Mac shares one range of ports +// (47164-47169): the first app to start takes 47164, the next 47165, and +// so on. AppKit apps on the Mac use 47170-47174. +// - Every app on one USB device shares 47175-47179; each device has its own +// range, so apps on two devices can both take 47175. +// +// Channels deliver frames, send completions and their end on the main +// queue, a TCP connect finishes before it returns, and the usbmuxd client +// calls back on the main queue, so all state here lives on the main actor. +// + +import AppKit +import LookInsideHostCore + +/// A push frame the Server sent without a request. +struct LKConnectionPush { + let channel: LKChannel + let type: UInt32 + let data: Any? +} + +/// Connection state the manager keeps for one channel. +@MainActor +final class LKChannelConnectionState { + /// Requests sent on this channel whose responses have not all arrived. + var activeRequests: [LKConnectionRequest] = [] + let licenseHandshake = LicenseHandshakeGate() + /// Identifies this channel to the activation runtime's signing policy, + /// which remembers a failed handshake per channel. + let licenseChannelID = UUID().uuidString + /// The release the Server reported in its last 220 reply; nil before + /// the first handshake. Drives the upgrade hint only. + var serverRelease: ServerRelease? + + func activeRequest(type: UInt32, tag: UInt32) -> LKConnectionRequest? { + activeRequests.first { $0.type == type && $0.tag == tag } + } + + func remove(_ request: LKConnectionRequest) { + activeRequests.removeAll { $0 === request } + } +} + +/// One port the Host tries, and the channel connected on it. +@MainActor +private final class LKConnectionPort { + let number: Int32 + let kind: LKChannelKind + var connectedChannel: LKChannel? + + init(number: Int32, kind: LKChannelKind) { + self.number = number + self.kind = kind + } +} + +@objc(LKConnectionManager) +@MainActor +final class LKConnectionManager: NSObject { + @objc(sharedInstance) + static let shared = LKConnectionManager() + + private let simulatorPorts: [LKConnectionPort] + private let macPorts: [LKConnectionPort] + private var usbPorts: [LKConnectionPort] = [] + private let usbMuxClient = LookinUSBMuxClient(queue: .main) + + private let channelWillEndBroadcaster = AsyncBroadcaster() + private let pushBroadcaster = AsyncBroadcaster() + + /// Totals for the log line of a large multi-frame response. + private var receivedResponseBytes = 0 + private var responseStartTime: CFTimeInterval = 0 + + override private init() { + simulatorPorts = (Int32(LookinSimulatorIPv4PortNumberStart) ... Int32(LookinSimulatorIPv4PortNumberEnd)).map { + LKConnectionPort(number: $0, kind: .simulator) + } + macPorts = (Int32(LookinMacIPv4PortNumberStart) ... Int32(LookinMacIPv4PortNumberEnd)).map { + LKConnectionPort(number: $0, kind: .mac) + } + super.init() + + startListeningForUSBDevices() + let center = NotificationCenter.default + center.addObserver( + forName: LKSwiftUISupportGatekeeper.activationStateDidChangeNotification, + object: nil, + queue: nil + ) { [weak self] _ in + lkRunOnMain { self?.handleActivationStateDidChange() } + } + center.addObserver( + forName: LKSwiftUISupportGatekeeper.licenseHandshakeAvailabilityDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + lkRunOnMain { self?.handleLicenseHandshakeAvailabilityDidChange() } + } + } + + // MARK: - Events + + /// Channels that ended (the app was killed, the USB cable unplugged, or + /// the Host closed the channel), delivered once the socket is closed. + func channelWillEndEvents() -> AsyncStream { + channelWillEndBroadcaster.subscribe() + } + + /// Push frames from the Server. + func pushEvents() -> AsyncStream { + pushBroadcaster.subscribe() + } + + // MARK: - Connecting + + /// Connects every port that may have a Server and returns the connected + /// channels: Simulator ports first, then Mac ports, then USB ports. A + /// channel can be connected although its app is in the background and + /// cannot answer. + func connectAllPorts() async -> [LKChannel] { + await withCheckedContinuation { continuation in + connectAllPorts { continuation.resume(returning: $0) } + } + } + + /// Callback form of `connectAllPorts()`. A TCP connect finishes before + /// it returns, so with no USB device the completion runs synchronously. + func connectAllPorts(completion: @escaping ([LKChannel]) -> Void) { + let ports = simulatorPorts + macPorts + usbPorts + var channels = [LKChannel?](repeating: nil, count: ports.count) + var remaining = ports.count + guard remaining > 0 else { + completion([]) + return + } + for (index, port) in ports.enumerated() { + connect(port) { channel in + channels[index] = channel + remaining -= 1 + if remaining == 0 { + completion(channels.compactMap { $0 }) + } + } + } + } + + private func connect(_ port: LKConnectionPort, completion: @escaping (LKChannel?) -> Void) { + if let connected = port.connectedChannel { + completion(connected) + return + } + switch port.kind { + case .simulator, .mac: + // A blocking connect: on loopback it succeeds or fails with + // ECONNREFUSED at once, so the completion runs before this + // returns. + guard let fd = try? LookinSocket.connectLoopback(port: UInt16(port.number)) else { + completion(nil) + return + } + completion(attachChannel(fileDescriptor: fd, initialBytes: Data(), to: port)) + case let .usb(deviceID): + usbMuxClient.connect(deviceID: deviceID, port: UInt16(port.number)) { [weak self, weak port] result in + MainActor.assumeIsolated { + guard case let .success(stream) = result else { + // Refused (no Server on this port), timed out, or + // usbmuxd is gone. + completion(nil) + return + } + guard let self, let port else { + Darwin.close(stream.fileDescriptor) + completion(nil) + return + } + completion(self.attachChannel(fileDescriptor: stream.fileDescriptor, initialBytes: stream.initialBytes, to: port)) + } + } + } + } + + private func attachChannel(fileDescriptor: Int32, initialBytes: Data, to port: LKConnectionPort) -> LKChannel { + let channel = LKChannel( + fileDescriptor: fileDescriptor, + initialBytes: initialBytes, + port: port.number, + kind: port.kind, + delegate: self + ) + port.connectedChannel = channel + return channel + } + + func connectedChannels() -> [LKChannel] { + (simulatorPorts + macPorts + usbPorts).compactMap { port in + guard let channel = port.connectedChannel, channel.isConnected else { return nil } + return channel + } + } + + /// Tracks USB devices through usbmuxd. A device's channels end on their + /// own when it goes away. + private func startListeningForUSBDevices() { + usbMuxClient.startListening { [weak self] event in + MainActor.assumeIsolated { + guard let self else { return } + switch event { + case let .attached(deviceID, _): + // Each device has its own port range; see the file comment. + for number in Int32(LookinUSBDeviceIPv4PortNumberStart) ... Int32(LookinUSBDeviceIPv4PortNumberEnd) { + self.usbPorts.append(LKConnectionPort(number: number, kind: .usb(deviceID: deviceID))) + } + NSLog("Lookin - USB device attached, DeviceID: %@", NSNumber(value: deviceID)) + case let .detached(deviceID): + self.usbPorts.removeAll { port in + if case let .usb(portDeviceID) = port.kind { + return portDeviceID == deviceID + } + return false + } + NSLog("Lookin - USB device detached, DeviceID: %@", NSNumber(value: deviceID)) + } + } + } + } + + // MARK: - Requests + + /// Sends a push frame, which the Server does not answer. The Server may + /// miss it while its app is in the background or stopped at a breakpoint. + func push(type: UInt32, data: NSObject?, channel: LKChannel?) { + guard let channel, channel.isConnected else { return } + let payload = archivedPayload(rootObject: data) + NSLog("LookinClient - pushData, type:%@", NSNumber(value: type)) + channel.send(type: type, tag: 0, payload: payload) + } + + /// Pings the Server, checks its version, runs the license handshake when + /// the request type allows one, then sends the request. `sink` receives + /// each response frame, then a failure or a completion. + /// + /// An older request of the same type still waiting on this channel fails + /// with `LookinErrCode_Discard` when this one is sent. + func request(type: UInt32, data: NSObject?, channel: LKChannel, sink: LKResponseSink) { + // An app in the background keeps its channel but cannot answer; a + // long ping timeout would hold up the launch window's app list. + let pingTimeout: TimeInterval = type == UInt32(LookinRequestTypeApp) ? 0.5 : 2 + + let pingSink = LKResponseSink { [weak self] event in + guard let self else { return } + switch event { + case let .response(pingResponse): + if let versionError = serverVersionError(for: pingResponse) { + sink.send(.failure(versionError)) + return + } + let sendRequest = { [weak self] in + self?.sendFrame( + type: type, + channel: channel, + data: data, + timeoutInterval: Self.timeoutInterval(forRequestType: type), + sink: sink + ) + } + guard Self.canStartLicenseHandshake(forRequestType: type) else { + sendRequest() + return + } + ensureLicenseHandshake(on: channel, force: false) { _ in + sendRequest() + } + case let .failure(error): + sink.send(.failure(error)) + case .completion: + break + } + } + sendFrame(type: UInt32(LookinRequestTypePing), channel: channel, data: nil, timeoutInterval: pingTimeout, sink: pingSink) + } + + /// Ends the waiting request of `type` on `channel` and reports it as + /// completed. + func cancelRequest(type: UInt32, channel: LKChannel?) { + guard let state = channel?.connectionState, + let request = state.activeRequests.first(where: { $0.type == type }) + else { + return + } + request.endTimeoutCount() + state.remove(request) + request.sink.send(.completion) + NSLog("Lookin - request cancelled by the user, type:%@", NSNumber(value: type)) + } + + /// Response frames of one request, until it completes. The stream + /// throws the transport error or, for a frame that cannot be decoded, + /// `LookinErr_Inner`. Cancelling the consuming task stops the delivery; + /// the request on the channel runs to its end or its timeout. + func responses(type: UInt32, data: NSObject?, channel: LKChannel) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let sink = LKResponseSink { event in + switch event { + case let .response(attachment): + if let attachment { + continuation.yield(attachment) + } else { + continuation.finish(throwing: LKConnectionError.inner) + } + case let .failure(error): + continuation.finish(throwing: error) + case .completion: + continuation.finish() + } + } + continuation.onTermination = { _ in + Task { @MainActor in sink.detach() } + } + request(type: type, data: data, channel: channel, sink: sink) + } + } + + /// The first response of a request that answers once. + func request(type: UInt32, data: NSObject?, channel: LKChannel) async throws -> LookinConnectionResponseAttachment { + for try await attachment in responses(type: type, data: data, channel: channel) { + return attachment + } + throw LKConnectionError.inner + } + + // MARK: - Request policy + + /// Ping and the license exchange itself never wait for a handshake. + nonisolated static func canStartLicenseHandshake(forRequestType type: UInt32) -> Bool { + switch Int(type) { + case LookinRequestTypePing, LookinRequestTypeLicenseChallenge, LookinRequestTypeLicenseVerify: + false + default: + true + } + } + + static func timeoutInterval(forRequestType type: UInt32) -> TimeInterval { + switch Int(type) { + case LookinRequestTypeHierarchy, LookinRequestTypeHierarchyDetails: + LKPreferenceManager.shared.hierarchyRequestTimeoutInterval + default: + 5 + } + } + + private func serverVersionError(for pingResponse: LookinConnectionResponseAttachment?) -> NSError? { + let serverVersion = Int(pingResponse?.lookinServerVersion ?? 0) + let supported = Int(LOOKIN_SUPPORTED_SERVER_MIN) ... Int(LOOKIN_SUPPORTED_SERVER_MAX) + switch ServerVersionCompatibility(serverVersion: serverVersion, supported: supported) { + case .compatible: + return nil + case .serverTooOld: + return LKConnectionError.serverVersionTooLow + case .serverTooNew: + return LKConnectionError.serverVersionTooHigh + } + } + + // MARK: - Frames + + /// The keyed archive the Server expects: secure coding, and a nil root + /// written as `$null`, the bytes `+archivedDataWithRootObject: + /// requiringSecureCoding:YES error:` produced in the ObjC original. + /// + /// A non-nil root goes through that same throwing class method, so an + /// object that does not adopt `NSSecureCoding` comes back as an error + /// instead of an uncaught exception. As before, an archiving failure + /// sends the frame with no payload (an empty payload is the header only, + /// the bytes Peertalk sent for a nil payload). Swift cannot pass a nil + /// root to the class method (it would box it as `NSNull`), so a nil root + /// is encoded with an archiver instance, which cannot fail on nil. + private func archivedPayload(rootObject: Any?) -> Data { + guard let rootObject else { + let archiver = NSKeyedArchiver(requiringSecureCoding: true) + archiver.encode(nil as Any?, forKey: NSKeyedArchiveRootObjectKey) + archiver.finishEncoding() + return archiver.encodedData + } + do { + return try NSKeyedArchiver.archivedData(withRootObject: rootObject, requiringSecureCoding: true) + } catch { + assertionFailure("archiving the request failed: \(error)") + return Data() + } + } + + /// Sends one frame and tracks it until its last response, a failure or + /// its timeout. + func sendFrame(type: UInt32, channel: LKChannel, data: NSObject?, timeoutInterval: TimeInterval, sink: LKResponseSink) { + guard channel.isConnected else { + sink.send(.failure(LKConnectionError.noConnect)) + return + } + let state = channel.connectionState + if type != UInt32(LookinRequestTypePing) { + // A newer request replaces an older one of the same type, which + // fails. + for discarded in state.activeRequests where discarded.type == type { + discarded.sink.send(.failure(LKConnectionError.discarded)) + discarded.endTimeoutCount() + state.remove(discarded) + NSLog("LookinClient - will discard request, type:%@, tag:%@", NSNumber(value: discarded.type), NSNumber(value: discarded.tag)) + } + } + + let tag = UInt32(truncatingIfNeeded: Int64(Date().timeIntervalSince1970)) + let request = LKConnectionRequest(type: type, tag: tag, timeoutInterval: timeoutInterval, sink: sink) { [weak channel] request in + request.sink.send(.failure(LKConnectionError.timeout)) + channel?.connectionState.remove(request) + } + + let attachment = LookinConnectionAttachment() + attachment.data = data + let payload = archivedPayload(rootObject: attachment) + // Track the request before the write: the write completion and the + // reply reach the main queue independently, and on loopback the + // reply can come first. A request registered only on completion + // refuses its own reply and then times out. + state.activeRequests.append(request) + request.resetTimeoutCount() + channel.send(type: type, tag: tag, payload: payload) { error in + guard error != nil, state.activeRequests.contains(where: { $0 === request }) else { return } + request.endTimeoutCount() + state.remove(request) + sink.send(.failure(LKConnectionError.transport)) + } + } + + // MARK: - Frame delivery + + private static let pushFrameTypes: Set = [ + UInt32(LookinPush_SwiftUISupportDetected), + UInt32(LookinPush_GestureDebug), + ] + + private func shouldAcceptFrame(on channel: LKChannel, type: UInt32, tag: UInt32) -> Bool { + if Self.pushFrameTypes.contains(type) { + return true + } + if channel.connectionState.activeRequest(type: type, tag: tag) != nil { + return true + } + NSLog("LookinClient - will refuse, type:%@, tag:%@", NSNumber(value: type), NSNumber(value: tag)) + return false + } + + private func didReceiveFrame(on channel: LKChannel, type: UInt32, tag: UInt32, data: Data) { + if Self.pushFrameTypes.contains(type) { + let unarchived = try? NSKeyedUnarchiver.unarchivedObject( + ofClasses: [NSString.self, NSDictionary.self, NSArray.self, NSNumber.self, NSData.self, NSNull.self], + from: data + ) + pushBroadcaster.yield(LKConnectionPush(channel: channel, type: type, data: unarchived)) + handlePush(type: type, data: unarchived, channel: channel) + return + } + + let state = channel.connectionState + guard let request = state.activeRequest(type: type, tag: tag) else { + // The request may have timed out between shouldAccept and now. + return + } + + // Wire compatibility with upstream Lookin and the LookInside Server + // send path: the payload is a non-secure archive of + // LookinConnectionResponseAttachment whose `data` is an arbitrary + // graph of Lookin models and Foundation collections. Secure coding + // with NSObject as the allowed class warns on every frame, so the + // receive path stays non-secure on purpose. + let attachment = Self.unarchiveResponse(data) + + if attachment?.appIsInBackground == true { + request.endTimeoutCount() + state.remove(request) + request.sink.send(.failure(LKConnectionError.appInBackground)) + NSLog("Lookin - the iOS app reported that it is in the background; request failed") + return + } + + request.sink.send(.response(attachment)) + + if request.frameProgress.isAtFirstFrame { + receivedResponseBytes = 0 + responseStartTime = CACurrentMediaTime() + } + receivedResponseBytes += data.count + + let isComplete = request.frameProgress.record( + currentDataCount: Int(attachment?.currentDataCount ?? 0), + dataTotalCount: Int(attachment?.dataTotalCount ?? 0) + ) + if isComplete { + request.endTimeoutCount() + state.remove(request) + request.sink.send(.completion) + + let duration = CACurrentMediaTime() - responseStartTime + let megabytes = Double(receivedResponseBytes) / 1024 / 1024 + if megabytes > 0.5 { + NSLog( + "Lookin - received all responses %@ / %@, %.2fs, %.2fM", + NSNumber(value: request.frameProgress.receivedDataCount), + NSNumber(value: attachment?.dataTotalCount ?? 0), + duration, + megabytes + ) + } + } else { + // A multi-frame response restarts the timeout with each frame. + request.resetTimeoutCount() + } + } + + private static func unarchiveResponse(_ data: Data) -> LookinConnectionResponseAttachment? { + guard let unarchiver = try? NSKeyedUnarchiver(forReadingFrom: data) else { + return nil + } + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + return unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) as? LookinConnectionResponseAttachment + } + + private func handlePush(type: UInt32, data: Any?, channel: LKChannel) { + guard type == UInt32(LookinPush_SwiftUISupportDetected) else { return } + NSLog("LookinClient - received SwiftUI support detection push from channel:%@ data:%@", channel, data.map { String(describing: $0) } ?? "(null)") + DispatchQueue.main.async { + let gatekeeper = LKSwiftUISupportGatekeeper.sharedInstance() + gatekeeper.noteDetectedSwiftUISupport() + let keyWindow = NSApplication.shared.keyWindow + let launchWindow = LKNavigationManager.shared.launchWindowController?.window + if let keyWindow, keyWindow !== launchWindow { + gatekeeper.promptForPendingDetectedSwiftUISupportIfNeeded(window: keyWindow) + } + } + } + + private func endChannel(_ channel: LKChannel) { + for port in simulatorPorts + macPorts + usbPorts where port.connectedChannel === channel { + port.connectedChannel = nil + } + channelWillEndBroadcaster.yield(channel) + LKSwiftUISupportGatekeeper.sharedInstance().licenseHandshakeChannelDidEnd(channel.connectionState.licenseChannelID) + channel.close() + } +} + +// MARK: - LKChannelDelegate + +extension LKConnectionManager: LKChannelDelegate { + func channel(_ channel: LKChannel, shouldAcceptFrameOfType type: UInt32, tag: UInt32) -> Bool { + shouldAcceptFrame(on: channel, type: type, tag: tag) + } + + func channel(_ channel: LKChannel, didReceiveFrameOfType type: UInt32, tag: UInt32, payload: Data) { + didReceiveFrame(on: channel, type: type, tag: tag, data: payload) + } + + func channelDidEnd(_ channel: LKChannel) { + endChannel(channel) + } +} + +/// Runs `body` on the main actor: at once on the main thread, otherwise on +/// the next turn of the main queue. +func lkRunOnMain(_ body: @escaping @MainActor () -> Void) { + if Thread.isMainThread { + MainActor.assumeIsolated(body) + } else { + DispatchQueue.main.async(execute: body) + } +} diff --git a/LookInside/Connection/LKConnectionRequest.h b/LookInside/Connection/LKConnectionRequest.h deleted file mode 100644 index 341358c9..00000000 --- a/LookInside/Connection/LKConnectionRequest.h +++ /dev/null @@ -1,38 +0,0 @@ -// -// LKConnectionRequest.h -// Lookin -// -// Created by Li Kai on 2019/6/24. -// https://lookin.work -// - -#import - -@interface LKConnectionRequest : NSObject - -@property(nonatomic, assign) uint32_t type; - -@property(nonatomic, assign) uint32_t tag; - -@property(nonatomic, assign) NSUInteger receivedDataCount; - -@property(nonatomic, copy) void (^succBlock)(id); - -@property(nonatomic, copy) void (^completionBlock)(void); - -@property(nonatomic, copy) void (^failBlock)(NSError *); - -/** - 调用 resetTimeoutCount 开始倒计时,如果 timeoutInterval 时间内没有通过 endTimeoutCount 结束倒计时,则 timeoutBlock 会被调用 - */ - -/// 超时时间,必须先设置该属性 -@property(nonatomic, assign) NSTimeInterval timeoutInterval; -/// 超时后,该 block 会被调用 -@property(nonatomic, copy) void (^timeoutBlock)(LKConnectionRequest *request); -/// 开始或重设倒计时 -- (void)resetTimeoutCount; -/// 结束倒计时。在试图销毁该 LKConnectionRequest 对象前必须先调用该方法结束倒计时 -- (void)endTimeoutCount; - -@end diff --git a/LookInside/Connection/LKConnectionRequest.m b/LookInside/Connection/LKConnectionRequest.m deleted file mode 100644 index d6d532d8..00000000 --- a/LookInside/Connection/LKConnectionRequest.m +++ /dev/null @@ -1,32 +0,0 @@ -// -// LKConnectionRequest.m -// Lookin -// -// Created by Li Kai on 2019/6/24. -// https://lookin.work -// - -#import "LKConnectionRequest.h" - -@implementation LKConnectionRequest - -- (void)resetTimeoutCount { - [self endTimeoutCount]; - if (self.timeoutInterval > 0) { - [self performSelector:@selector(_handleTimeout) withObject:nil afterDelay:self.timeoutInterval]; - } else { - NSAssert(NO, @"timeoutInterval 为 0"); - } -} - -- (void)endTimeoutCount { - [NSObject cancelPreviousPerformRequestsWithTarget:self]; -} - -- (void)_handleTimeout { - if (self.timeoutBlock) { - self.timeoutBlock(self); - } -} - -@end diff --git a/LookInside/Connection/LKConnectionRequest.swift b/LookInside/Connection/LKConnectionRequest.swift new file mode 100644 index 00000000..3024c87d --- /dev/null +++ b/LookInside/Connection/LKConnectionRequest.swift @@ -0,0 +1,97 @@ +// +// LKConnectionRequest.swift +// LookInside +// +// One request the Host sent on a channel and has not heard the end of. +// + +import Foundation +import LookInsideHostCore + +/// What a request reports, in order: zero or more responses, then either a +/// failure or a completion. +enum LKResponseEvent { + /// One response frame. `nil` when its payload could not be decoded. + case response(LookinConnectionResponseAttachment?) + case failure(NSError) + case completion +} + +/// Receives the events of one request on the main actor until it is +/// detached or a failure or completion ends it. Detaching stops the +/// delivery only; the request itself runs to its end. +@MainActor +final class LKResponseSink { + private var onEvent: ((LKResponseEvent) -> Void)? + + init(_ onEvent: @escaping (LKResponseEvent) -> Void) { + self.onEvent = onEvent + } + + var isDetached: Bool { + onEvent == nil + } + + func send(_ event: LKResponseEvent) { + guard let onEvent else { return } + switch event { + case .response: + break + case .failure, .completion: + self.onEvent = nil + } + onEvent(event) + } + + func detach() { + onEvent = nil + } +} + +/// A request in flight on one channel. The timeout uses +/// `-performSelector:withObject:afterDelay:` like the Objective-C class it +/// replaces, so it fires on the main run loop in the default mode only. +@MainActor +final class LKConnectionRequest: NSObject { + let type: UInt32 + let tag: UInt32 + let sink: LKResponseSink + var frameProgress = ResponseFrameProgress() + + /// Must be positive. + private let timeoutInterval: TimeInterval + private let onTimeout: (LKConnectionRequest) -> Void + + init( + type: UInt32, + tag: UInt32, + timeoutInterval: TimeInterval, + sink: LKResponseSink, + onTimeout: @escaping (LKConnectionRequest) -> Void + ) { + self.type = type + self.tag = tag + self.timeoutInterval = timeoutInterval + self.sink = sink + self.onTimeout = onTimeout + } + + /// Starts or restarts the countdown. + func resetTimeoutCount() { + endTimeoutCount() + guard timeoutInterval > 0 else { + assertionFailure("timeoutInterval is 0") + return + } + perform(#selector(handleTimeout), with: nil, afterDelay: timeoutInterval) + } + + /// Stops the countdown. Called before the request is dropped. + func endTimeoutCount() { + NSObject.cancelPreviousPerformRequests(withTarget: self) + } + + @objc private func handleTimeout() { + onTimeout(self) + } +} diff --git a/LookInside/Connection/LKInspectableApp.h b/LookInside/Connection/LKInspectableApp.h deleted file mode 100644 index 9e000e84..00000000 --- a/LookInside/Connection/LKInspectableApp.h +++ /dev/null @@ -1,83 +0,0 @@ -// -// LKDeviceItem.h -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import -#import "LookinAppInfo.h" -#import "LookinAttributeModification.h" -#import "LookinCustomAttrModification.h" -#import "LookinAttributesGroup.h" - -@class Lookin_PTChannel, LookinDisplayItemTrace, LookinInvocationRequest, LookinHierarchyInfo, LookinStaticAsyncUpdateTasksPackage, LookinStaticAsyncUpdateTask; - -@interface LKInspectableApp : NSObject - -@property(nonatomic, strong) NSError *serverVersionError; - -@property(nonatomic, strong) LookinAppInfo *appInfo; - -@property(nonatomic, weak) Lookin_PTChannel *channel; - -- (RACSignal *)fetchHierarchyData; - -/// Versioned controls for the optional SwiftUI gesture capture service. -- (RACSignal *)controlGestureDebug:(NSDictionary *)parameters; - -- (RACSignal *)submitInbuiltModification:(LookinAttributeModification *)modification; - -/// Variant of `-submitInbuiltModification:` that surfaces the server's raw -/// `LookinErrCode_*` values verbatim instead of remapping them onto the -/// localized `LookinErr_*` constants. The MCPBridge modification route -/// consumes this so it can translate raw codes into structured wire -/// error codes (`modify.objectNotFound`, `modify.invalidSetter`, -/// `modify.exception`, etc.) on the success path the response is the -/// unwrapped `LookinDisplayItemDetail *` exactly as the server sent it. -- (RACSignal *)rawSubmitInbuiltModification:(LookinAttributeModification *)modification; -- (RACSignal *)submitCustomModification:(LookinCustomAttrModification *)modification; - -- (RACSignal *)fetchHierarchyDetailWithTaskPackages:(NSArray *)packages; - -/// Variant of `-fetchHierarchyDetailWithTaskPackages:` that surfaces the -/// server's raw `LookinErrCode_*` values verbatim instead of remapping -/// them onto the localized `LookinErr_*` constants. The MCPBridge -/// `details.read` route consumes this so it can map raw codes onto -/// structured `details.*` wire codes. RACSignal semantics match the -/// inspector-facing variant: the signal emits ONE value PER PACKAGE -/// frame (the server streams one detail array per package), then -/// completes when the full batch lands. -- (RACSignal *)rawFetchHierarchyDetailWithTaskPackages:(NSArray *)packages; - -- (void)cancelHierarchyDetailFetching; - -- (RACSignal *)fetchModificationPatchWithTasks:(NSArray *)tasks; - -- (RACSignal *)fetchObjectWithOid:(unsigned long)oid; - -- (RACSignal *)fetchSelectorNamesWithClass:(NSString *)className hasArg:(BOOL)hasArg; - -- (RACSignal *)invokeMethodWithOid:(unsigned long)oid text:(NSString *)text; - -/// Variant of `-invokeMethodWithOid:text:` that surfaces the server's raw -/// response dictionary and raw `NSError` codes verbatim, without the -/// console-facing `LookinStringFlag_VoidReturn` → localized description -/// substitution and without remapping `LookinErrCode_ObjectNotFound` / -/// `LookinErrCode_Inner` onto the localized `LookinErr_*` constants. The -/// MCPBridge invocation route consumes this so it can translate the raw -/// server signal into structured machine-readable bridge error codes -/// (`invoke.objectNotFound`, `invoke.invalidSelector`, etc.) while preserving -/// the `returnedVoid` flag on the success path. -- (RACSignal *)rawInvokeMethodWithOid:(unsigned long)oid text:(NSString *)text; - -/// 获取某个 imageView 的 image 对象,oid 是 imageView 的 oid -- (RACSignal *)fetchImageWithImageViewOid:(unsigned long)oid; - -/// 修改一个 gestureRecognizer 的 enable 属性。如果 shouldBeEnabled 为 YES 则表示想要把它的 enable 属性修改为 YES -- (RACSignal *)modifyGestureRecognizer:(unsigned long)oid toBeEnabled:(BOOL)shouldBeEnabled; - -#pragma mark - Push From iOS - -@end diff --git a/LookInside/Connection/LKInspectableApp.m b/LookInside/Connection/LKInspectableApp.m deleted file mode 100644 index ffe9e9c3..00000000 --- a/LookInside/Connection/LKInspectableApp.m +++ /dev/null @@ -1,200 +0,0 @@ -// -// LKDeviceItem.m -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKInspectableApp.h" -#import "LKConnectionManager.h" -#import "LookinConnectionResponseAttachment.h" -#import "LKNavigationManager.h" -#import "LKPreferenceManager.h" -#import "LKSwiftUIHierarchyDisplayMode.h" - -@implementation LKInspectableApp - -- (RACSignal *)controlGestureDebug:(NSDictionary *)parameters { - return [self _requestWithType:LookinRequestTypeGestureDebug data:parameters]; -} - -- (RACSignal *)fetchHierarchyData { - /// Lookin 1.0.4 开始加入这个参数 - NSMutableDictionary *param = [NSMutableDictionary dictionaryWithDictionary:@{ - @"clientVersion": [LKHelper lookinReadableVersion], - }]; - param[LookinParam_SwiftUIDisplayMode] = @([LKSwiftUIHierarchyDisplayModeStore currentMode]); - // Backing-layer toggle (backing-layer-toggle proposal): the server only - // emits backing layer nodes when this is YES. Old servers ignore the key. - param[@"showBackingLayers"] = @([LKPreferenceManager mainManager].showBackingLayers.currentBOOLValue); - return [self _requestWithType:LookinRequestTypeHierarchy data:param]; -} - -- (RACSignal *)submitInbuiltModification:(LookinAttributeModification *)modification { - return [self _requestWithType:LookinRequestTypeInbuiltAttrModification data:modification]; -} - -- (RACSignal *)rawSubmitInbuiltModification:(LookinAttributeModification *)modification { - if (!modification) { - return [RACSignal error:LookinErr_Inner]; - } - if (!self.channel) { - return [RACSignal error:LookinErr_NoConnect]; - } - return [[[LKConnectionManager sharedInstance] requestWithType:LookinRequestTypeInbuiltAttrModification data:modification channel:self.channel] flattenMap:^__kindof RACSignal * _Nullable(RACTuple * _Nullable tuple) { - LookinConnectionResponseAttachment *attachment = tuple.first; - if (attachment.error) { - // Surface raw error codes so the MCPBridge can map them to - // structured wire codes; the console-facing -submit variant - // does the LookinErrCode_* → LookinErr_* localization, we - // deliberately skip it here. - return [RACSignal error:attachment.error]; - } - return [RACSignal return:attachment.data]; - }]; -} - -- (RACSignal *)submitCustomModification:(LookinCustomAttrModification *)modification { - return [self _requestWithType:LookinRequestTypeCustomAttrModification data:modification]; -} - -- (RACSignal *)fetchHierarchyDetailWithTaskPackages:(NSArray *)packages { - return [self _requestWithType:LookinRequestTypeHierarchyDetails data:packages]; -} - -- (RACSignal *)rawFetchHierarchyDetailWithTaskPackages:(NSArray *)packages { - if (!self.channel) { - return [RACSignal error:LookinErr_NoConnect]; - } - return [[[LKConnectionManager sharedInstance] requestWithType:LookinRequestTypeHierarchyDetails data:(packages ?: @[]) channel:self.channel] flattenMap:^__kindof RACSignal * _Nullable(RACTuple * _Nullable tuple) { - LookinConnectionResponseAttachment *attachment = tuple.first; - if (attachment.error) { - // Raw error code preserved so MCPBridge can map LookinErrCode_* - // onto structured details.* wire codes; the console-facing - // variant above does the LookinErr_* localization swap. - return [RACSignal error:attachment.error]; - } - return [RACSignal return:attachment.data]; - }]; -} - -- (void)cancelHierarchyDetailFetching { - [self _cancelRequestWithType:LookinRequestTypeHierarchyDetails]; - [self _pushWithType:LookinPush_CanceHierarchyDetails data:nil]; -} - -- (RACSignal *)fetchModificationPatchWithTasks:(NSArray *)tasks { - return [self _requestWithType:LookinRequestTypeAttrModificationPatch data:tasks]; -} - -- (RACSignal *)fetchObjectWithOid:(unsigned long)oid { - if (!oid) { - return [RACSignal error:LookinErr_Inner]; - } - return [self _requestWithType:LookinRequestTypeFetchObject data:@(oid)]; -} - -- (RACSignal *)fetchSelectorNamesWithClass:(NSString *)className hasArg:(BOOL)hasArg { - return [self _requestWithType:LookinRequestTypeAllSelectorNames data:@{@"className":className, @"hasArg":@(hasArg)}]; -} - -- (RACSignal *)invokeMethodWithOid:(unsigned long)oid text:(NSString *)text { - if (oid == 0 || !text.length) { - return [RACSignal error:LookinErr_Inner]; - } - NSDictionary *param = @{@"oid":@(oid), @"text":text}; - return [[self _requestWithType:LookinRequestTypeInvokeMethod data:param] map:^id _Nullable(NSDictionary * _Nullable value) { - if ([value[@"description"] isEqualToString:LookinStringFlag_VoidReturn]) { - // 方法没有返回值时,替换成本地说明 - NSMutableDictionary *newValue = [value mutableCopy]; - newValue[@"description"] = NSLocalizedString(@"The method was invoked successfully and no value was returned.", nil); - return newValue; - } else { - return value; - } - }]; -} - -- (RACSignal *)rawInvokeMethodWithOid:(unsigned long)oid text:(NSString *)text { - if (oid == 0 || !text.length) { - return [RACSignal error:LookinErr_Inner]; - } - if (!self.channel) { - return [RACSignal error:LookinErr_NoConnect]; - } - NSDictionary *param = @{@"oid":@(oid), @"text":text}; - return [[[LKConnectionManager sharedInstance] requestWithType:LookinRequestTypeInvokeMethod data:param channel:self.channel] flattenMap:^__kindof RACSignal * _Nullable(RACTuple * _Nullable tuple) { - LookinConnectionResponseAttachment *attachment = tuple.first; - if (attachment.error) { - // Surface the server's raw error code untouched so the MCPBridge - // route can map LookinErrCode_ObjectNotFound / _Inner / etc. to - // structured bridge error codes. The console-facing variant above - // does this translation; we deliberately skip it here. - return [RACSignal error:attachment.error]; - } - return [RACSignal return:attachment.data]; - }]; -} - -- (RACSignal *)fetchAttrGroupListWithOid:(unsigned long)oid { - if (!oid) { - return [RACSignal error:LookinErr_Inner]; - } - return [self _requestWithType:LookinRequestTypeAllAttrGroups data:@(oid)]; -} - -- (RACSignal *)fetchImageWithImageViewOid:(unsigned long)oid { - if (!oid) { - return [RACSignal error:LookinErr_Inner]; - } - return [self _requestWithType:LookinRequestTypeFetchImageViewImage data:@(oid)]; -} - -- (RACSignal *)modifyGestureRecognizer:(unsigned long)oid toBeEnabled:(BOOL)shouldBeEnabled { - if (!oid) { - return [RACSignal error:LookinErr_Inner]; - } - return [self _requestWithType:LookinRequestTypeModifyRecognizerEnable data:@{@"oid":@(oid), @"enable":@(shouldBeEnabled)}]; -} - -#pragma mark - Push From iOS - - -#pragma mark - Private - -- (void)_pushWithType:(uint32_t)pushType data:(id)data { - if (!self.channel) { - return; - } - [[LKConnectionManager sharedInstance] pushWithType:pushType data:data channel:self.channel]; -} - -- (RACSignal *)_requestWithType:(uint32_t)requestType data:(id)data { - if (!self.channel) { - return [RACSignal error:LookinErr_NoConnect]; - } - return [[[LKConnectionManager sharedInstance] requestWithType:requestType data:data channel:self.channel] flattenMap:^__kindof RACSignal * _Nullable(RACTuple *tuple) { - LookinConnectionResponseAttachment *attachment = tuple.first; - if (attachment.error) { - // 翻译成本地文字 - if (attachment.error.code == LookinErrCode_ObjectNotFound) { - attachment.error = LookinErr_ObjNotFound; - } else if (attachment.error.code == LookinErrCode_Inner) { - attachment.error = LookinErr_Inner; - } - return [RACSignal error:attachment.error]; - } else { - return [RACSignal return:attachment.data]; - } - }]; -} - -- (void)_cancelRequestWithType:(uint32_t)requestType { - if (!self.channel) { - return; - } - [[LKConnectionManager sharedInstance] cancelRequestWithType:requestType channel:self.channel]; -} - -@end diff --git a/LookInside/Connection/LKInspectableApp.swift b/LookInside/Connection/LKInspectableApp.swift new file mode 100644 index 00000000..71bb7e39 --- /dev/null +++ b/LookInside/Connection/LKInspectableApp.swift @@ -0,0 +1,288 @@ +// +// LKInspectableApp.swift +// LookInside +// +// Created by Li Kai on 2018/11/3. +// https://lookin.work +// +// One app the Host can inspect, and the requests it sends to that app's +// Server. +// + +import Foundation + +/// What a request to the inspected app reports: zero or more values (the +/// `data` of each response), then a failure or a completion. +enum LKAppResponseEvent { + case value(Any?) + case failure(NSError) + case completion +} + +@objc(LKInspectableApp) +final class LKInspectableApp: NSObject { + /// Set when the app's Server is too old or too new for this Host, or + /// answered the app info request with another error. + @objc dynamic var serverVersionError: NSError? + + @objc dynamic var appInfo: LookinAppInfo? + + @objc dynamic weak var channel: LKChannel? + + // MARK: - Requests + + /// Sends a request on `channel`. Each response's error becomes a + /// failure, localized for the object-not-found and inner-error codes; + /// otherwise its `data`, passed through `transform`, becomes a value. + /// The returned sink stops the delivery when detached. + @MainActor + static func startRequest( + on channel: LKChannel, + type: UInt32, + data: NSObject?, + transform: ((Any?) -> Any?)? = nil, + onEvent: @escaping (LKAppResponseEvent) -> Void + ) -> LKResponseSink { + var hasEnded = false + let sink = LKResponseSink { event in + guard !hasEnded else { return } + switch event { + case let .response(attachment): + if let serverError = attachment?.error { + hasEnded = true + onEvent(.failure(LKConnectionError.localized(serverError as NSError))) + } else { + let value = attachment?.data + onEvent(.value(transform.map { $0(value) } ?? value)) + } + case let .failure(error): + hasEnded = true + onEvent(.failure(error)) + case .completion: + hasEnded = true + onEvent(.completion) + } + } + LKConnectionManager.shared.request(type: type, data: data, channel: channel, sink: sink) + return sink + } + + /// Every value of a request until it completes. + @MainActor + func responses(type: UInt32, data: NSObject?, transform: ((Any?) -> Any?)? = nil) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + guard let channel else { + continuation.finish(throwing: LKConnectionError.noConnect) + return + } + let sink = Self.startRequest(on: channel, type: type, data: data, transform: transform) { event in + switch event { + case let .value(value): + continuation.yield(value) + case let .failure(error): + continuation.finish(throwing: error) + case .completion: + continuation.finish() + } + } + continuation.onTermination = { _ in + Task { @MainActor in sink.detach() } + } + } + } + + /// The values of a request, each cast to `Value`. A value of another + /// type ends the stream with `LookinErr_Inner`. + @MainActor + func typedResponses(type: UInt32, data: NSObject?, as _: Value.Type) -> AsyncThrowingStream { + let untyped = responses(type: type, data: data) + return AsyncThrowingStream { continuation in + let task = Task { @MainActor in + do { + for try await value in untyped { + guard let typed = value as? Value else { + throw LKConnectionError.inner + } + continuation.yield(typed) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// The single value of a request that answers once, cast to `Value`. + /// A request that completes without a value, or with a value of + /// another type, throws `LookinErr_Inner`. + @MainActor + func response(type: UInt32, data: NSObject?, as _: Value.Type, transform: ((Any?) -> Any?)? = nil) async throws -> Value { + for try await value in responses(type: type, data: data, transform: transform) { + guard let typed = value as? Value else { + throw LKConnectionError.inner + } + return typed + } + throw LKConnectionError.inner + } + + /// Sends a push frame; nothing when the channel is gone. + @MainActor + func push(type: UInt32, data: NSObject?) { + guard let channel else { return } + LKConnectionManager.shared.push(type: type, data: data, channel: channel) + } + + /// Ends the waiting request of `type` and reports it as completed. + @MainActor + func cancelRequest(type: UInt32) { + guard let channel else { return } + LKConnectionManager.shared.cancelRequest(type: type, channel: channel) + } + + // MARK: - Request payloads + + /// The hierarchy request's parameters: the Host version (since Lookin + /// 1.0.4), the SwiftUI display mode, and whether to include backing + /// layers (old Servers ignore the keys they do not know). + static func hierarchyRequestParameters() -> NSDictionary { + let parameters = NSMutableDictionary(dictionary: ["clientVersion": LKHelper.lookinReadableVersion() as NSString]) + parameters[LookinParam_SwiftUIDisplayMode] = NSNumber(value: LKSwiftUIHierarchyDisplayModeStore.currentMode().rawValue) + parameters["showBackingLayers"] = NSNumber(value: LKPreferenceManager.shared.showBackingLayers.currentBOOLValue) + return parameters + } + + static func selectorNamesParameters(className: String, hasArg: Bool) -> NSDictionary { + NSDictionary( + objects: [className as NSString, NSNumber(value: hasArg)], + forKeys: ["className" as NSString, "hasArg" as NSString] + ) + } + + static func invokeMethodParameters(oid: UInt, text: String) -> NSDictionary { + NSDictionary( + objects: [NSNumber(value: oid), text as NSString], + forKeys: ["oid" as NSString, "text" as NSString] + ) + } + + static func gestureRecognizerParameters(oid: UInt, enabled: Bool) -> NSDictionary { + NSDictionary( + objects: [NSNumber(value: oid), NSNumber(value: enabled)], + forKeys: ["oid" as NSString, "enable" as NSString] + ) + } + + /// A method without a return value is reported with a local sentence + /// instead of the Server's marker. + static func localizingVoidReturn(_ value: Any?) -> Any? { + guard let dictionary = value as? NSDictionary, + (dictionary["description"] as? String) == LookinStringFlag_VoidReturn + else { + return value + } + let localized = dictionary.mutableCopy() as! NSMutableDictionary + localized["description"] = NSLocalizedString("The method was invoked successfully and no value was returned.", comment: "") + return localized + } +} + +// MARK: - Async API + +extension LKInspectableApp { + @MainActor + func hierarchy() async throws -> LookinHierarchyInfo { + try await response(type: UInt32(LookinRequestTypeHierarchy), data: Self.hierarchyRequestParameters(), as: LookinHierarchyInfo.self) + } + + /// Returns the updated detail of the modified item. + @MainActor + func submit(_ modification: LookinAttributeModification) async throws -> LookinDisplayItemDetail { + try await response(type: UInt32(LookinRequestTypeInbuiltAttrModification), data: modification, as: LookinDisplayItemDetail.self) + } + + @MainActor + func submit(_ modification: LookinCustomAttrModification) async throws -> Any? { + try await response(type: UInt32(LookinRequestTypeCustomAttrModification), data: modification, as: Any?.self) + } + + /// One array of details per package the Server finished. + @MainActor + func hierarchyDetails(packages: [LookinStaticAsyncUpdateTasksPackage]) -> AsyncThrowingStream<[LookinDisplayItemDetail], Error> { + typedResponses(type: UInt32(LookinRequestTypeHierarchyDetails), data: packages as NSArray, as: [LookinDisplayItemDetail].self) + } + + /// Stops the running hierarchy details request: it completes, and the + /// Server is told to stop sending. + @objc + @MainActor + func cancelHierarchyDetailFetching() { + cancelRequest(type: UInt32(LookinRequestTypeHierarchyDetails)) + push(type: UInt32(LookinPush_CanceHierarchyDetails), data: nil) + } + + /// One detail per modified item, as the Server finishes them. + @MainActor + func modificationPatch(tasks: [LookinStaticAsyncUpdateTask]) -> AsyncThrowingStream { + typedResponses(type: UInt32(LookinRequestTypeAttrModificationPatch), data: tasks as NSArray, as: LookinDisplayItemDetail.self) + } + + @MainActor + func object(oid: UInt) async throws -> LookinObject { + guard oid != 0 else { throw LKConnectionError.inner } + return try await response(type: UInt32(LookinRequestTypeFetchObject), data: NSNumber(value: oid), as: LookinObject.self) + } + + @MainActor + func attributeGroups(oid: UInt) async throws -> [LookinAttributesGroup] { + guard oid != 0 else { throw LKConnectionError.inner } + return try await response(type: UInt32(LookinRequestTypeAllAttrGroups), data: NSNumber(value: oid), as: [LookinAttributesGroup].self) + } + + @MainActor + func selectorNames(className: String, hasArg: Bool) async throws -> [String] { + try await response( + type: UInt32(LookinRequestTypeAllSelectorNames), + data: Self.selectorNamesParameters(className: className, hasArg: hasArg), + as: [String].self + ) + } + + /// The Server's result dictionary (`description` and related keys). + @MainActor + func invokeMethod(oid: UInt, text: String) async throws -> NSDictionary { + guard oid != 0, !text.isEmpty else { throw LKConnectionError.inner } + return try await response( + type: UInt32(LookinRequestTypeInvokeMethod), + data: Self.invokeMethodParameters(oid: oid, text: text), + as: NSDictionary.self, + transform: Self.localizingVoidReturn + ) + } + + /// The image of the image view `oid`, as encoded image data. + @MainActor + func image(imageViewOid oid: UInt) async throws -> Data? { + guard oid != 0 else { throw LKConnectionError.inner } + return try await response(type: UInt32(LookinRequestTypeFetchImageViewImage), data: NSNumber(value: oid), as: Data?.self) + } + + /// Versioned controls for the optional SwiftUI gesture capture service. + @MainActor + func controlGestureDebug(_ parameters: NSDictionary) async throws -> NSDictionary { + try await response(type: UInt32(LookinRequestTypeGestureDebug), data: parameters, as: NSDictionary.self) + } + + /// Returns whether the recognizer is enabled afterwards. + @MainActor + func setGestureRecognizer(oid: UInt, enabled: Bool) async throws -> Bool { + guard oid != 0 else { throw LKConnectionError.inner } + return try await response( + type: UInt32(LookinRequestTypeModifyRecognizerEnable), + data: Self.gestureRecognizerParameters(oid: oid, enabled: enabled), + as: Bool.self + ) + } +} diff --git a/LookInside/Connection/LKServerUpgradeHint.swift b/LookInside/Connection/LKServerUpgradeHint.swift new file mode 100644 index 00000000..e82e25c8 --- /dev/null +++ b/LookInside/Connection/LKServerUpgradeHint.swift @@ -0,0 +1,50 @@ +// +// LKServerUpgradeHint.swift +// LookInside +// +// The one-time hint that the inspected app's Server is old (0.2.9 or +// earlier, which does not report its release in the 220 reply). The +// handshake records the reported release on the channel; the inspector +// window shows the hint, at most once per inspected app per Host launch. +// It is a reminder only, never a security boundary. +// + +import Foundation +import LookInsideHostCore + +@MainActor +enum LKServerUpgradeHint { + /// Posted on the main thread when a channel's 220 reply arrived; the + /// object is the channel. + static let releaseDidArriveNotification = Notification.Name("LKServerUpgradeHintReleaseDidArrive") + + private static let ledger = ServerUpgradeHintLedger() + + /// Records the release `channel`'s Server reported. + static func noteRelease(_ release: ServerRelease, on channel: LKChannel) { + channel.connectionState.serverRelease = release + NotificationCenter.default.post(name: releaseDidArriveNotification, object: channel) + } + + /// `true` when the hint should be shown now for `app`: its Server + /// reported an old release and no window showed the hint for this app + /// during this launch. Answering `true` records the hint as shown. + static func shouldShowHint(for app: LKInspectableApp?) -> Bool { + guard let app, + let release = app.channel?.connectionState.serverRelease, + let appKey = appKey(for: app.appInfo) + else { + return false + } + return ledger.shouldShowHint(for: release, appKey: appKey) + } + + /// The app's bundle identifier, which stays the same across relaunches + /// and reconnections of that app. + private static func appKey(for appInfo: LookinAppInfo?) -> String? { + guard let bundleIdentifier = appInfo?.appBundleIdentifier, !bundleIdentifier.isEmpty else { + return nil + } + return bundleIdentifier + } +} diff --git a/LookInside/Connection/LookinLiveDocument.h b/LookInside/Connection/LookinLiveDocument.h deleted file mode 100644 index 81a5c5a8..00000000 --- a/LookInside/Connection/LookinLiveDocument.h +++ /dev/null @@ -1,79 +0,0 @@ -// -// LookinLiveDocument.h -// LookInside -// -// Phase B of multi-document support: NSDocument that owns a single live -// Peertalk inspection session against one LKInspectableApp. The document -// is "untitled" by default, never autosaves, and exposes Save As to export -// the current hierarchy snapshot as a `.lookin` archive. -// - -#import - -@class LKInspectableApp, LKStaticHierarchyDataSource, LKStaticAsyncUpdateManager, LKStaticWindowController; - -NS_ASSUME_NONNULL_BEGIN - -/// Posted (object = the document) once a Live Doc has been added to the -/// shared document controller and its window controllers exist, so an -/// observer can read `inspectableApp` / `hierarchyDataSource` right away. -/// -/// `NSDocumentController` vends no such notification of its own, and the -/// MCPBridge event publisher needs both a "a target attached" signal and a -/// place to hook each document's own reload subject. Rather than have that -/// publisher reach into the document controller, the two moments announce -/// themselves. -extern NSNotificationName const LookinLiveDocumentDidOpenNotification; - -/// Posted (object = the document) at the start of `-close`, while the -/// document is still fully formed. Observers must not assume anything -/// about it survives the return of this notification. -extern NSNotificationName const LookinLiveDocumentWillCloseNotification; - -@interface LookinLiveDocument : NSDocument - -/// The inspectable app this document represents. Stable for callers in the -/// sense that the document keeps its identity, but Phase D reconnect logic -/// will swap the underlying `LKInspectableApp` (new channel + new -/// LookinAppInfo) when the previous channel goes away and a matching app -/// reappears. Public surface remains readonly; internal class extension -/// declares the readwrite override. -@property(nonatomic, strong, readonly) LKInspectableApp *inspectableApp; - -/// Phase D: human-readable reason for the current connection loss, or nil -/// when the doc is connected. Phase E will surface this as a banner in -/// the window controller; Phase D leaves it as a side-channel for that -/// upcoming UI work and for debugging in the meantime. -@property(nonatomic, copy, readonly, nullable) NSString *connectionLossBannerMessage; - -/// Convenience accessor that returns the per-doc hierarchy data source owned -/// by this document's window controller. Returns nil before -/// -makeWindowControllers has run. -@property(nonatomic, weak, readonly, nullable) LKStaticHierarchyDataSource *hierarchyDataSource; - -/// Convenience accessor for this document's async update manager. Returns nil -/// before -makeWindowControllers has run. -@property(nonatomic, weak, readonly, nullable) LKStaticAsyncUpdateManager *asyncUpdateManager; - -/// This document's window controller. Returns nil before -/// -makeWindowControllers has run. Exposed so callers that need a whole -/// window-level operation rather than one of its parts — the MCPBridge -/// `hierarchy.refresh` route reaching -reloadHierarchySignal — do not have -/// to re-derive it from `windowControllers`. -@property(nonatomic, weak, readonly, nullable) LKStaticWindowController *staticWindowController; - -/// Designated initializer. Returns nil with `outError` populated when `app` -/// is nil. Phase B does not establish or validate the channel here; that is -/// the caller's responsibility. -- (nullable instancetype)initWithInspectableApp:(LKInspectableApp *)app - error:(NSError *_Nullable *_Nullable)outError; - -/// Returns the Live Doc whose window hosts `window`, or nil if `window` -/// is not bound to a Live Doc. Used by views and per-window utilities -/// that need to resolve "the inspectable app for this window" without -/// holding a direct doc reference. -+ (nullable instancetype)documentInWindow:(nullable NSWindow *)window; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Connection/LookinLiveDocument.m b/LookInside/Connection/LookinLiveDocument.m deleted file mode 100644 index 7ec2aac8..00000000 --- a/LookInside/Connection/LookinLiveDocument.m +++ /dev/null @@ -1,293 +0,0 @@ -// -// LookinLiveDocument.m -// LookInside -// - -#import "LookinLiveDocument.h" -#import "LKInspectableApp.h" -#import "LKStaticHierarchyDataSource.h" -#import "LKStaticAsyncUpdateManager.h" -#import "LKStaticWindowController.h" -#import "LKAppsManager.h" -#import "LKConnectionManager.h" -#import "Lookin_PTChannel.h" -#import "LookinAppInfo.h" -#import "LookinHierarchyInfo.h" -#import "LKExportManager.h" -#import "LookinDefines.h" - -NSNotificationName const LookinLiveDocumentDidOpenNotification = @"LookinLiveDocumentDidOpenNotification"; -NSNotificationName const LookinLiveDocumentWillCloseNotification = @"LookinLiveDocumentWillCloseNotification"; - -@interface LookinLiveDocument () - -/// Internal readwrite override of the public readonly property. Phase D -/// auto-reconnect replaces this when the original channel dies and a -/// matching app reappears. -@property(nonatomic, strong, readwrite) LKInspectableApp *inspectableApp; - -/// Subscription that fires once when the current `inspectableApp.channel` -/// is reported as ending by `LKConnectionManager.channelWillEnd`. Replaced -/// after a successful reconnect because the new app carries a different -/// channel identity. -@property(nonatomic, strong) RACDisposable *channelDidEndDisposable; - -/// Periodic `fetchAppInfosWithImage:NO` poll that runs while the doc is -/// disconnected. Disposed on close, on successful reconnect, or when the -/// channel comes back via a new path. -@property(nonatomic, strong) RACDisposable *reconnectAttemptDisposable; - -/// Snapshot of the previously-connected app info, used to match the same -/// logical iOS/macOS process across reconnects via -/// `-isEqualToAppInfo:`. Captured the moment the channel ends so it is -/// not affected by future replacements. -@property(nonatomic, strong) LookinAppInfo *lastKnownAppInfo; - -@property(nonatomic, copy, readwrite, nullable) NSString *connectionLossBannerMessage; - -@end - -@implementation LookinLiveDocument - -- (nullable instancetype)initWithInspectableApp:(LKInspectableApp *)app - error:(NSError *_Nullable *_Nullable)outError { - if (!app) { - if (outError) { - *outError = LookinErr_Inner; - } - return nil; - } - if (self = [super init]) { - _inspectableApp = app; - [self _lk_subscribeChannelLifecycle]; - } - return self; -} - -+ (instancetype)documentInWindow:(NSWindow *)window { - if (!window) { - return nil; - } - NSDocument *doc = [[NSDocumentController sharedDocumentController] documentForWindow:window]; - if ([doc isKindOfClass:[LookinLiveDocument class]]) { - return (LookinLiveDocument *)doc; - } - return nil; -} - -- (LKStaticWindowController *)staticWindowController { - LKStaticWindowController *wc = (LKStaticWindowController *)self.windowControllers.firstObject; - if ([wc isKindOfClass:[LKStaticWindowController class]]) { - return wc; - } - return nil; -} - -- (LKStaticHierarchyDataSource *)hierarchyDataSource { - return self.staticWindowController.hierarchyDataSource; -} - -- (LKStaticAsyncUpdateManager *)asyncUpdateManager { - return self.staticWindowController.asyncUpdateManager; -} - -#pragma mark - NSDocument overrides - -- (NSString *)displayName { - LookinAppInfo *info = self.inspectableApp.appInfo; - if (info.appName.length && info.deviceDescription.length) { - return [NSString stringWithFormat:@"%@ — %@", info.appName, info.deviceDescription]; - } else if (info.appName.length) { - return info.appName; - } - return [super displayName]; -} - -- (BOOL)hasUnautosavedChanges { - return NO; -} - -- (BOOL)isDocumentEdited { - return NO; -} - -+ (BOOL)autosavesInPlace { - return NO; -} - -+ (BOOL)autosavesDrafts { - return NO; -} - -+ (BOOL)preservesVersions { - return NO; -} - -+ (BOOL)usesUbiquitousStorage { - return NO; -} - -- (NSArray *)writableTypesForSaveOperation:(NSSaveOperationType)saveOperation { - if (saveOperation == NSSaveAsOperation) { - return @[@"com.lookin.lookin"]; - } - return @[]; -} - -- (NSData *)dataOfType:(NSString *)typeName error:(NSError **)outError { - if (![typeName isEqualToString:@"com.lookin.lookin"]) { - if (outError) { - *outError = LookinErr_Inner; - } - return nil; - } - LookinHierarchyInfo *info = self.hierarchyDataSource.rawHierarchyInfo; - if (!info) { - if (outError) { - *outError = LookinErr_Inner; - } - return nil; - } - NSData *data = [[LKExportManager sharedInstance] dataFromHierarchyInfo:info - imageCompression:1.0 - fileName:NULL]; - if (!data) { - if (outError) { - *outError = LookinErr_Inner; - } - return nil; - } - return data; -} - -- (void)makeWindowControllers { - LKStaticWindowController *wc = [[LKStaticWindowController alloc] initWithInspectableApp:self.inspectableApp]; - [self addWindowController:wc]; -} - -- (void)close { - // Announced before any teardown so observers can still read this - // document's app info and data source while deciding what to report. - [[NSNotificationCenter defaultCenter] postNotificationName:LookinLiveDocumentWillCloseNotification object:self]; - - // Phase D: cancel any in-flight reconnect work *before* the doc is torn - // down. Channel lifecycle subjects keep weak references to `self`, but - // disposing here makes the teardown order explicit and avoids one extra - // tick of work during close. - [self.channelDidEndDisposable dispose]; - self.channelDidEndDisposable = nil; - [self.reconnectAttemptDisposable dispose]; - self.reconnectAttemptDisposable = nil; - [super close]; -} - -#pragma mark - Phase D: channel lifecycle - -- (void)_lk_subscribeChannelLifecycle { - LKInspectableApp *currentApp = self.inspectableApp; - Lookin_PTChannel *currentChannel = currentApp.channel; - if (!currentChannel) { - // Without a live channel there is nothing to watch for end-of-life; - // a future reconnect will rewire this when one appears. - return; - } - - [self.channelDidEndDisposable dispose]; - - @weakify(self); - RACSignal *currentChannelEnd = [[[LKConnectionManager sharedInstance].channelWillEnd - filter:^BOOL(Lookin_PTChannel *channel) { - @strongify(self); - return channel == self.inspectableApp.channel; - }] take:1]; - - self.channelDidEndDisposable = [currentChannelEnd subscribeNext:^(Lookin_PTChannel *channel) { - @strongify(self); - if (!self) { - return; - } - [self _lk_handleChannelDidEnd]; - }]; -} - -- (void)_lk_handleChannelDidEnd { - LookinAppInfo *priorInfo = self.inspectableApp.appInfo; - self.lastKnownAppInfo = priorInfo; - - NSString *appName = priorInfo.appName.length ? priorInfo.appName : NSLocalizedString(@"Inspected app", nil); - self.connectionLossBannerMessage = [NSString stringWithFormat: - NSLocalizedString(@"Connection to %@ lost. Trying to reconnect…", nil), - appName]; - - [self _lk_attemptAutoReconnect]; -} - -- (void)_lk_attemptAutoReconnect { - if (!self.lastKnownAppInfo) { - return; - } - - [self.reconnectAttemptDisposable dispose]; - - @weakify(self); - RACSignal *poll = [[RACSignal interval:3 onScheduler:[RACScheduler scheduler]] flattenMap:^__kindof RACSignal *(NSDate *_Nullable tick) { - @strongify(self); - if (!self) { - return [RACSignal empty]; - } - return [self _lk_fetchInspectableAppMatchingLastKnown]; - }]; - - self.reconnectAttemptDisposable = [poll subscribeNext:^(LKInspectableApp *newApp) { - @strongify(self); - if (!self || !newApp) { - return; - } - // Reconnected — rewire identity, clear banner, and resubscribe lifecycle - // for the *new* channel. The doc's hierarchy data source is reloaded - // by the caller (toolbar reload, window controller, etc.) the next time - // a fetch is triggered, so we deliberately do not force a refetch here. - self.inspectableApp = newApp; - // The window controller and its async update manager hold - // `inspectableApp` weakly under the assumption that this doc is - // the strong owner. Overwriting that strong reference would let the - // old `LKInspectableApp` deallocate, nilling both weak slots and - // silently breaking the picker / reload / RPC paths. Republish - // the new app so the per-window graph stays consistent. - for (NSWindowController *windowController in self.windowControllers) { - if (![windowController isKindOfClass:[LKStaticWindowController class]]) { - continue; - } - LKStaticWindowController *staticController = (LKStaticWindowController *)windowController; - staticController.inspectableApp = newApp; - staticController.asyncUpdateManager.inspectableApp = newApp; - } - self.connectionLossBannerMessage = nil; - [self.reconnectAttemptDisposable dispose]; - self.reconnectAttemptDisposable = nil; - [self _lk_subscribeChannelLifecycle]; - }]; -} - -- (RACSignal *)_lk_fetchInspectableAppMatchingLastKnown { - LookinAppInfo *targetInfo = self.lastKnownAppInfo; - if (!targetInfo) { - return [RACSignal return:nil]; - } - return [[[[LKAppsManager sharedInstance] fetchAppInfosWithImage:NO localInfos:nil] map:^id _Nullable(NSArray *allApps) { - LKInspectableApp *match = [allApps lookin_firstFiltered:^BOOL(LKInspectableApp *candidate) { - return [targetInfo isEqualToAppInfo:candidate.appInfo]; - }]; - if (match) { - // Preserve the previously known app icon — `needImages:NO` means - // the response carries no icon and we'd otherwise lose it on - // reconnect. - match.appInfo.appIcon = targetInfo.appIcon; - } - return match; - }] catch:^RACSignal *(NSError *error) { - return [RACSignal return:nil]; - }]; -} - -@end diff --git a/LookInside/Connection/LookinLiveDocument.swift b/LookInside/Connection/LookinLiveDocument.swift new file mode 100644 index 00000000..517e8a23 --- /dev/null +++ b/LookInside/Connection/LookinLiveDocument.swift @@ -0,0 +1,212 @@ +// +// LookinLiveDocument.swift +// LookInside +// +// The document of one live inspection session against one inspectable +// app over its channel. It is untitled, never autosaves, and Save As exports +// the current hierarchy as a `.lookin` file. When the app's channel ends, +// the document shows a banner and looks for the same app every 3 seconds; +// when it reappears, the document switches to its new channel. +// + +import AppKit + +@objc(LookinLiveDocument) +final class LookinLiveDocument: NSDocument { + /// The app this document inspects. Replaced by a new `LKInspectableApp` + /// (new channel and app info) when the app reconnects. Observable with + /// KVO. + @objc private(set) dynamic var inspectableApp: LKInspectableApp + + /// Why the connection was lost, or nil while connected. Observable with + /// KVO; the window shows it as a banner. + @objc private(set) dynamic var connectionLossBannerMessage: String? + + /// The app info from before the channel ended, matched against the + /// apps found while reconnecting. + private var lastKnownAppInfo: LookinAppInfo? + private var channelEndTask: Task? + private var reconnectTask: Task? + + init(inspectableApp: LKInspectableApp) { + self.inspectableApp = inspectableApp + super.init() + subscribeChannelLifecycle() + } + + /// A document can be freed without `close()` (for example when its + /// window never opened). The tasks hold `self` weakly, so they would + /// stay parked and the channel-end subscriber would stay registered; + /// cancelling ends the stream, which unregisters it. + deinit { + channelEndTask?.cancel() + reconnectTask?.cancel() + } + + /// The live document whose window is `window`, or nil. + @objc(documentInWindow:) + static func document(in window: NSWindow?) -> LookinLiveDocument? { + guard let window else { return nil } + return NSDocumentController.shared.document(for: window) as? LookinLiveDocument + } + + /// Nil before `makeWindowControllers()` ran. + private var staticWindowController: LKStaticWindowController? { + windowControllers.first as? LKStaticWindowController + } + + /// Nil before `makeWindowControllers()` ran. + @objc var hierarchyDataSource: LKStaticHierarchyDataSource? { + staticWindowController?.hierarchyDataSource + } + + /// Nil before `makeWindowControllers()` ran. + @objc var asyncUpdateManager: LKStaticAsyncUpdateManager? { + staticWindowController?.asyncUpdateManager + } + + // MARK: - NSDocument + + override var displayName: String! { + get { + let info = inspectableApp.appInfo + let appName = info?.appName ?? "" + let deviceDescription = info?.deviceDescription ?? "" + if !appName.isEmpty, !deviceDescription.isEmpty { + return "\(appName) — \(deviceDescription)" + } else if !appName.isEmpty { + return appName + } + return super.displayName + } + set { + super.displayName = newValue + } + } + + override var hasUnautosavedChanges: Bool { + false + } + + override var isDocumentEdited: Bool { + false + } + + override class var autosavesInPlace: Bool { + false + } + + override class var autosavesDrafts: Bool { + false + } + + override class var preservesVersions: Bool { + false + } + + override class var usesUbiquitousStorage: Bool { + false + } + + override func writableTypes(for saveOperation: NSDocument.SaveOperationType) -> [String] { + saveOperation == .saveAsOperation ? ["com.lookin.lookin"] : [] + } + + override func data(ofType typeName: String) throws -> Data { + guard typeName == "com.lookin.lookin", + let info = hierarchyDataSource?.rawHierarchyInfo, + let data = LKExportManager.sharedInstance().data(from: info, imageCompression: 1.0, fileName: nil) + else { + throw LKConnectionError.inner + } + return data + } + + override func makeWindowControllers() { + addWindowController(LKStaticWindowController(inspectableApp: inspectableApp)) + } + + override func close() { + channelEndTask?.cancel() + channelEndTask = nil + reconnectTask?.cancel() + reconnectTask = nil + super.close() + } + + // MARK: - Channel lifecycle + + /// Watches the current app's channel; a reconnect calls this again for + /// the new channel. + private func subscribeChannelLifecycle() { + guard inspectableApp.channel != nil else { + // Nothing to watch until a reconnect brings a channel. + return + } + channelEndTask?.cancel() + let endedChannels = LKConnectionManager.shared.channelWillEndEvents() + channelEndTask = Task { [weak self] in + for await channel in endedChannels { + guard let self else { return } + if channel === inspectableApp.channel { + handleChannelDidEnd() + return + } + } + } + } + + private func handleChannelDidEnd() { + let priorInfo = inspectableApp.appInfo + lastKnownAppInfo = priorInfo + let appName = (priorInfo?.appName).flatMap { $0.isEmpty ? nil : $0 } + ?? NSLocalizedString("Inspected app", comment: "") + connectionLossBannerMessage = String( + format: NSLocalizedString("Connection to %@ lost. Trying to reconnect…", comment: ""), + appName + ) + attemptAutoReconnect() + } + + private func attemptAutoReconnect() { + guard lastKnownAppInfo != nil else { return } + reconnectTask?.cancel() + reconnectTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled, let self else { return } + guard let newApp = await fetchInspectableAppMatchingLastKnown() else { continue } + guard !Task.isCancelled else { return } + adoptReconnectedApp(newApp) + return + } + } + } + + /// The hierarchy is not fetched again here; the next reload uses the + /// new channel. + private func adoptReconnectedApp(_ newApp: LKInspectableApp) { + inspectableApp = newApp + // The window controller and its async update manager hold the app + // weakly and rely on this document to keep it alive; replacing it + // here would leave them with nil, so they get the new app too. + for case let windowController as LKStaticWindowController in windowControllers { + windowController.inspectableApp = newApp + windowController.asyncUpdateManager?.inspectableApp = newApp + } + connectionLossBannerMessage = nil + reconnectTask = nil + subscribeChannelLifecycle() + } + + private func fetchInspectableAppMatchingLastKnown() async -> LKInspectableApp? { + guard let targetInfo = lastKnownAppInfo else { return nil } + let apps = await LKAppsManager.shared.fetchAppInfos(needImages: false, localInfos: nil) + guard let match = apps.first(where: { targetInfo.isEqual(to: $0.appInfo) }) else { + return nil + } + // Without images the response has no icon; keep the one we had. + match.appInfo?.appIcon = targetInfo.appIcon + return match + } +} diff --git a/LookInside/Connection/LookinLiveDocumentController.h b/LookInside/Connection/LookinLiveDocumentController.h deleted file mode 100644 index 14839cda..00000000 --- a/LookInside/Connection/LookinLiveDocumentController.h +++ /dev/null @@ -1,45 +0,0 @@ -// -// LookinLiveDocumentController.h -// LookInside -// -// Phase D of multi-document support: thin facade that opens (or activates) -// a `LookinLiveDocument` for a given inspectable app. Acts as the single -// entry point used by the launch screen, the toolbar app picker, and any -// future "New Inspection" menu item, so that the rules around channel -// de-duplication (Q3=P) and "always open in a new window" (Q1=X) live in -// exactly one place. -// - -#import - -@class LookinLiveDocument, LKInspectableApp, Lookin_PTChannel; - -NS_ASSUME_NONNULL_BEGIN - -@interface LookinLiveDocumentController : NSObject - -+ (instancetype)sharedInstance; - -/// Opens — or activates an already-open — Live Doc for `app`. -/// -/// If a `LookinLiveDocument` already exists whose `inspectableApp.channel` -/// matches `app.channel`, that document's window is brought to front and the -/// completion is invoked with `alreadyOpen=YES` (Q3=P). Otherwise a fresh -/// `LookinLiveDocument` is constructed, registered with -/// `NSDocumentController`, and shown. -/// -/// Completion fires on the main thread. If document construction fails -/// `doc` is nil and `err` is populated. -- (void)openLiveDocumentForInspectableApp:(LKInspectableApp *)app - completion:(nullable void (^)(LookinLiveDocument *_Nullable doc, - BOOL alreadyOpen, - NSError *_Nullable err))completion; - -/// Returns the Live Doc currently bound to `channel`, if any. Used by the -/// auto-reconnect path so each Doc only reacts to its own channel ending. -/// Returns nil when `channel` is nil or no Live Doc owns it. -- (nullable LookinLiveDocument *)liveDocumentForChannel:(nullable Lookin_PTChannel *)channel; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Connection/LookinLiveDocumentController.m b/LookInside/Connection/LookinLiveDocumentController.m deleted file mode 100644 index e20f15e2..00000000 --- a/LookInside/Connection/LookinLiveDocumentController.m +++ /dev/null @@ -1,97 +0,0 @@ -// -// LookinLiveDocumentController.m -// LookInside -// - -#import "LookinLiveDocumentController.h" - -#import - -#import "LookinLiveDocument.h" -#import "LKInspectableApp.h" -#import "Lookin_PTChannel.h" -#import "LookinDefines.h" - -@implementation LookinLiveDocumentController - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LookinLiveDocumentController *instance = nil; - dispatch_once(&onceToken, ^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone { - return [self sharedInstance]; -} - -- (void)openLiveDocumentForInspectableApp:(LKInspectableApp *)app - completion:(void (^)(LookinLiveDocument *_Nullable doc, - BOOL alreadyOpen, - NSError *_Nullable err))completion { - if (!app) { - if (completion) { - completion(nil, NO, LookinErr_Inner); - } - return; - } - - void (^finish)(LookinLiveDocument *, BOOL, NSError *) = ^(LookinLiveDocument *doc, - BOOL alreadyOpen, - NSError *err) { - if (!completion) { - return; - } - if ([NSThread isMainThread]) { - completion(doc, alreadyOpen, err); - } else { - dispatch_async(dispatch_get_main_queue(), ^{ - completion(doc, alreadyOpen, err); - }); - } - }; - - LookinLiveDocument *existing = [self liveDocumentForChannel:app.channel]; - if (existing) { - // Q3=P: same channel already has a Live Doc, just bring it forward. - [existing showWindows]; - finish(existing, YES, nil); - return; - } - - NSError *initError = nil; - LookinLiveDocument *doc = [[LookinLiveDocument alloc] initWithInspectableApp:app error:&initError]; - if (!doc) { - finish(nil, NO, initError ?: LookinErr_Inner); - return; - } - - [[NSDocumentController sharedDocumentController] addDocument:doc]; - [doc makeWindowControllers]; - [doc showWindows]; - // Posted after -makeWindowControllers so observers can reach the - // document's data source, which lives on its window controller. - [[NSNotificationCenter defaultCenter] postNotificationName:LookinLiveDocumentDidOpenNotification object:doc]; - finish(doc, NO, nil); -} - -- (LookinLiveDocument *)liveDocumentForChannel:(Lookin_PTChannel *)channel { - if (!channel) { - return nil; - } - NSDocumentController *controller = [NSDocumentController sharedDocumentController]; - for (NSDocument *doc in controller.documents) { - if (![doc isKindOfClass:[LookinLiveDocument class]]) { - continue; - } - LookinLiveDocument *liveDoc = (LookinLiveDocument *)doc; - if (liveDoc.inspectableApp.channel == channel) { - return liveDoc; - } - } - return nil; -} - -@end diff --git a/LookInside/Connection/LookinLiveDocumentController.swift b/LookInside/Connection/LookinLiveDocumentController.swift new file mode 100644 index 00000000..5881cc93 --- /dev/null +++ b/LookInside/Connection/LookinLiveDocumentController.swift @@ -0,0 +1,64 @@ +// +// LookinLiveDocumentController.swift +// LookInside +// +// The one place that opens a live document for an inspectable app: the +// launch window, the toolbar's app picker and the debug dump all go +// through it. Picking a different app always opens a new window; picking +// an app whose channel already has a document brings that document's +// window forward instead. +// + +import AppKit + +@objc(LookinLiveDocumentController) +@MainActor +final class LookinLiveDocumentController: NSObject { + @objc(sharedInstance) + static let shared = LookinLiveDocumentController() + + override private init() { + super.init() + } + + /// Opens a live document for `app`, or brings forward the one already + /// open on the same channel. + /// + /// - Returns: the document, and whether it was already open. + func openLiveDocument(for app: LKInspectableApp) -> (document: LookinLiveDocument, alreadyOpen: Bool) { + if let existing = liveDocument(for: app.channel) { + existing.showWindows() + return (existing, true) + } + let document = LookinLiveDocument(inspectableApp: app) + NSDocumentController.shared.addDocument(document) + document.makeWindowControllers() + document.showWindows() + return (document, false) + } + + /// Objective-C form of `openLiveDocument(for:)`. `completion` runs on + /// the main thread before this returns; with a nil app it gets + /// `LookinErr_Inner` and no document. + @objc(openLiveDocumentForInspectableApp:completion:) + func openLiveDocument( + for app: LKInspectableApp?, + completion: ((LookinLiveDocument?, Bool, NSError?) -> Void)? + ) { + guard let app else { + completion?(nil, false, LKConnectionError.inner) + return + } + let (document, alreadyOpen) = openLiveDocument(for: app) + completion?(document, alreadyOpen, nil) + } + + /// The live document whose app uses `channel`, or nil. + @objc(liveDocumentForChannel:) + func liveDocument(for channel: LKChannel?) -> LookinLiveDocument? { + guard let channel else { return nil } + return NSDocumentController.shared.documents + .compactMap { $0 as? LookinLiveDocument } + .first { $0.inspectableApp.channel === channel } + } +} diff --git a/LookInside/Console/LKConsoleDataSource.h b/LookInside/Console/LKConsoleDataSource.h deleted file mode 100644 index 532a2e2f..00000000 --- a/LookInside/Console/LKConsoleDataSource.h +++ /dev/null @@ -1,44 +0,0 @@ -// -// LKConsoleDataSource.h -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import - -@class LKConsoleDataSourceRowItem, LookinObject, LKHierarchyDataSource, LookinLiveDocument; - -@interface LKConsoleDataSource : NSObject - -- (instancetype)initWithHierarchyDataSource:(LKHierarchyDataSource *)hierarchyDataSource; - -/// Phase F: Live Doc that owns this console session. Read-only invocations -/// (`-submit:` / `-makeObjectAsCurrent:`) route their RPCs through -/// `liveDocument.inspectableApp`. Owners (LKStaticViewController) inject -/// the doc immediately after creating the data source. -@property(nonatomic, weak) LookinLiveDocument *liveDocument; - -@property(nonatomic, copy) NSArray *rowItems; - -@property(nonatomic, strong, readonly) LookinObject *currentObject; -- (RACSignal *)makeObjectAsCurrent:(LookinObject *)obj; -- (NSArray *)currentObjectSelectorNameList; - -- (RACSignal *)submit:(NSString *)text; -- (RACSignal *)submitWithObj:(LookinObject *)obj text:(NSString *)text; - -/// 越晚被加入的 object 在 recentObjects 数组中的 idx 越小,tuple.first 是 LookinObject,tuple.second 是当初返回这个对象时输入的命令文字 -@property(nonatomic, strong, readonly) NSMutableArray *recentObjects; - -/// 当前在主窗口中高亮选择的 View/Layer/ViewController 对象 -@property(nonatomic, strong, readonly) NSArray *selectedObjects; - -/// 清空记录 -- (void)clearHistoryContents; - -/// 当 console 被显示和隐藏时请及时设置该属性,当该属性为 YES 时,该 dataSource 会自动拉取当前所选 UI 对象的数据(当 syncConsoleTarget 为 YES 时) -@property(nonatomic, assign) BOOL isShowingConsole; - -@end diff --git a/LookInside/Console/LKConsoleDataSource.m b/LookInside/Console/LKConsoleDataSource.m deleted file mode 100644 index 85fdc082..00000000 --- a/LookInside/Console/LKConsoleDataSource.m +++ /dev/null @@ -1,193 +0,0 @@ -// -// LKConsoleDataSource.m -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import "LKConsoleDataSource.h" -#import "LKConsoleDataSourceRowItem.h" -#import "LKStaticHierarchyDataSource.h" -#import "LookinDisplayItem.h" -#import "LKInspectableApp.h" -#import "LookinLiveDocument.h" -#import "LKPreferenceManager.h" - -@interface LKConsoleDataSource () - -/** - @{ - @"UIView": @{ - @"selector": @[@"layoutSubviews", ...], - @"ivar": @[@"_name", ...] - }, - @"UIViewController": @{ - @"selector": @[@"viewDidAppear:", ...], - @"ivar": @[@"_didAppear", ...] - }, - ... - }; - */ -@property(nonatomic, strong) NSMutableDictionary *> *classesToSelsDict; -@property(nonatomic, strong, readwrite) LookinObject *currentObject; -@property(nonatomic, strong, readwrite) NSArray *selectedObjects; - -@end - -@implementation LKConsoleDataSource - -- (instancetype)initWithHierarchyDataSource:(LKHierarchyDataSource *)hierarchyDataSource { - if (self = [self init]) { - self.classesToSelsDict = [NSMutableDictionary dictionary]; - - LKConsoleDataSourceRowItem *item = [LKConsoleDataSourceRowItem new]; - item.type = LKConsoleDataSourceRowItemTypeInput; - self.rowItems = @[item]; - - RAC(self, selectedObjects) = [RACObserve(hierarchyDataSource, selectedItem) - map:^id _Nullable(LookinDisplayItem *item) { - if (!item) { - return nil; - } - NSArray *objs = $(item.hostViewControllerObject, item.hostWindowControllerObject, item.layerObject, item.viewObject, item.windowObject, item.kindObject).array; - return objs; - }]; - } - return self; -} - -- (RACSignal *)submit:(NSString *)text { - return [self submitWithObj:self.currentObject text:text]; -} - -- (RACSignal *)submitWithObj:(LookinObject *)obj text:(NSString *)text { - if (!self.currentObject) { - return [RACSignal error:LookinErr_Inner]; - } - if (!text.length) { - return [RACSignal error:LookinErrorMake(NSLocalizedString(@"Content is empty.", nil), @"")]; - } - LKInspectableApp *inspectableApp = self.liveDocument.inspectableApp; - if (!inspectableApp) { - return [RACSignal error:LookinErr_NoConnect]; - } - if ([text containsString:@":"]) { - NSString *className = obj.rawClassName; - NSString *address = obj.memoryAddress; - NSString *errDesc = [NSString stringWithFormat:NSLocalizedString(@"You can click \"Pause\" button near the bottom-left corner in Xcode to pause your iOS app, and input in Xcode console like the contents below:\nexpr [((%@ *)%@) %@]", nil), className, address, text]; - return [RACSignal error:LookinErrorMake(NSLocalizedString(@"LookInside doesn't support invoking methods with arguments yet.", nil), errDesc)]; - } - if ([text containsString:@"."]) { - return [RACSignal error:LookinErrorMake(NSLocalizedString(@"LookInside doesn't support this syntax yet. Please input a method or property name.", nil), @"")]; - } - @weakify(self); - return [[inspectableApp invokeMethodWithOid:obj.oid text:text] doNext:^(NSDictionary *dict) { - NSString *returnDescription = dict[@"description"]; - LookinObject *returnObject = dict[@"object"]; - - @strongify(self); - NSMutableArray *rowItems = self.rowItems.mutableCopy; - [rowItems insertObject:({ - LKConsoleDataSourceRowItem *item = [LKConsoleDataSourceRowItem new]; - item.type = LKConsoleDataSourceRowItemTypeSubmit; - item.normalText = text; - item.highlightText = [NSString stringWithFormat:@"<%@: %@>", obj.lk_simpleDemangledClassName, obj.memoryAddress]; - item; - }) atIndex:(rowItems.count - 1)]; - if (returnDescription.length) { - [rowItems insertObject:({ - LKConsoleDataSourceRowItem *item = [LKConsoleDataSourceRowItem new]; - item.type = LKConsoleDataSourceRowItemTypeReturn; - item.normalText = returnDescription; - item; - }) atIndex:(rowItems.count - 1)]; - } - if (returnObject) { - NSString *message = [NSString stringWithFormat:@"<%@: %@> => %@", obj.lk_simpleDemangledClassName, obj.memoryAddress, text]; - [self _addRecentObject:returnObject message:message]; - } - - self.rowItems = rowItems; - }]; -} - -- (RACSignal *)makeObjectAsCurrent:(LookinObject *)obj { - NSString *className = obj.rawClassName; - if (!className.length) { - return [RACSignal error:LookinErr_Inner]; - } - LKInspectableApp *inspectableApp = self.liveDocument.inspectableApp; - if (!inspectableApp) { - return [RACSignal error:LookinErr_NoConnect]; - } - if ([self.classesToSelsDict objectForKey:className]) { - self.currentObject = obj; - return [RACSignal return:nil]; - } - - @weakify(self); - return [[inspectableApp fetchSelectorNamesWithClass:obj.rawClassName hasArg:YES] doNext:^(NSArray *sels) { - @strongify(self); - self.classesToSelsDict[className] = sels; - self.currentObject = obj; - }]; -} - -- (NSArray *)currentObjectSelectorNameList { - return self.classesToSelsDict[self.currentObject.rawClassName]; -} - -- (void)clearHistoryContents { - LKConsoleDataSourceRowItem *item = [LKConsoleDataSourceRowItem new]; - item.type = LKConsoleDataSourceRowItemTypeInput; - self.rowItems = @[item]; -} - -- (void)setSelectedObjects:(NSArray *)selectedObjects { - _selectedObjects = selectedObjects.copy; - [self _syncConsoleTargetIfNeeded]; -} - -- (void)setIsShowingConsole:(BOOL)isShowingConsole { - _isShowingConsole = isShowingConsole; - [self _syncConsoleTargetIfNeeded]; -} - -- (void)_syncConsoleTargetIfNeeded { - if (self.isShowingConsole && self.selectedObjects.count) { - if ([LKPreferenceManager mainManager].syncConsoleTarget || !self.currentObject) { - [[self makeObjectAsCurrent:self.selectedObjects.lastObject] subscribeNext:^(id _Nullable x) { - - } error:^(NSError * _Nullable error) { - - }]; - } - } -} - -- (void)_addRecentObject:(LookinObject *)object message:(NSString *)message { - if (!object) { - return; - } - NSUInteger sameObjIdx = [self.recentObjects indexOfObjectPassingTest:^BOOL(RACTwoTuple * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - return ((LookinObject *)obj.first).oid == object.oid; - }]; - if (sameObjIdx != NSNotFound) { - [self.recentObjects removeObjectAtIndex:sameObjIdx]; - } - - NSUInteger maxCount = 5; - if (!self.recentObjects) { - _recentObjects = [NSMutableArray arrayWithCapacity:maxCount]; - } - - RACTwoTuple *newTuple = [RACTwoTuple tupleWithObjectsFromArray:@[object, message]]; - [self.recentObjects insertObject:newTuple atIndex:0]; - - if (self.recentObjects.count > maxCount) { - [self.recentObjects removeLastObject]; - } -} - -@end diff --git a/LookInside/Console/LKConsoleDataSource.swift b/LookInside/Console/LKConsoleDataSource.swift new file mode 100644 index 00000000..2bd733e3 --- /dev/null +++ b/LookInside/Console/LKConsoleDataSource.swift @@ -0,0 +1,169 @@ +// +// LKConsoleDataSource.swift +// LookInside +// +// The Console's state: the transcript rows, the target object, the objects +// highlighted in the hierarchy and the objects recent calls returned. It +// calls methods on the inspected app through the live document's +// LKInspectableApp. +// + +import AppKit +import LookInsideHostCore + +/// One transcript row. +struct LKConsoleRowItem { + enum Kind { + /// The input field, always the last row. + case input + /// A call: the target in `highlightText`, the method in `normalText`. + case submit + /// A call's returned description in `normalText`. + case returnValue + } + + var kind: Kind + var highlightText: String? + var normalText: String? +} + +@MainActor +final class LKConsoleDataSource { + /// An object returned by a call, with the call that returned it. + struct RecentObject { + var object: LookinObject + var message: String + } + + weak var liveDocument: LookinLiveDocument? + + /// Called after `rowItems` changes. + var rowItemsDidChange: (() -> Void)? + /// Called after `currentObject` changes. + var currentObjectDidChange: (() -> Void)? + + private(set) var rowItems: [LKConsoleRowItem] = [LKConsoleRowItem(kind: .input)] { + didSet { rowItemsDidChange?() } + } + + /// The object calls are sent to. + private(set) var currentObject: LookinObject? { + didSet { currentObjectDidChange?() } + } + + /// The objects of the item selected in the hierarchy: its view + /// controller, window controller, layer, view, window and kind object. + private(set) var selectedObjects: [LookinObject] = [] { + didSet { syncConsoleTargetIfNeeded() } + } + + private(set) var recentObjects = ConsoleRecentList() + + var isShowingConsole = false { + didSet { syncConsoleTargetIfNeeded() } + } + + /// Selector names by raw class name, fetched once per class. + private var selectorNamesByClass: [String: [String]] = [:] + private var selectionObservation: NSKeyValueObservation? + + init(hierarchyDataSource: LKHierarchyDataSource) { + selectionObservation = hierarchyDataSource.observe(\.selectedItem, options: [.initial, .new]) { + [weak self] dataSource, _ in + let objects = Self.objects(of: dataSource.selectedItem) + MainActor.assumeIsolated { + self?.selectedObjects = objects + } + } + } + + private nonisolated static func objects(of item: LookinDisplayItem?) -> [LookinObject] { + guard let item else { return [] } + return [item.hostViewControllerObject, item.hostWindowControllerObject, item.layerObject, + item.viewObject, item.windowObject, item.kindObject].compactMap { $0 } + } + + var currentObjectSelectorNames: [String] { + currentObject.flatMap { selectorNamesByClass[$0.rawClassName()] } ?? [] + } + + func submit(_ text: String) async throws { + try await submit(object: currentObject, text: text) + } + + /// Calls `text` on `object` and appends the call and its result to the + /// transcript. An object it returns joins the recent objects. + func submit(object: LookinObject?, text: String) async throws { + guard currentObject != nil, let object else { + throw LKConnectionError.inner + } + let check = ConsoleInputCheck(text) + if check == .empty { + throw LKPeripheralAlerts.error(title: NSLocalizedString("Content is empty.", comment: ""), detail: "") + } + guard let inspectableApp = liveDocument?.inspectableApp else { + throw LKConnectionError.noConnect + } + switch check { + case .hasArguments: + let format = NSLocalizedString("You can click \"Pause\" button near the bottom-left corner in Xcode to pause your iOS app, and input in Xcode console like the contents below:\nexpr [((%@ *)%@) %@]", comment: "") + let detail = String(format: format, object.rawClassName() ?? "(null)", object.memoryAddress ?? "(null)", text) + throw LKPeripheralAlerts.error( + title: NSLocalizedString("LookInside doesn't support invoking methods with arguments yet.", comment: ""), + detail: detail + ) + case .unsupportedSyntax: + throw LKPeripheralAlerts.error( + title: NSLocalizedString("LookInside doesn't support this syntax yet. Please input a method or property name.", comment: ""), + detail: "" + ) + case .accepted, .empty: + break + } + + let result = try await inspectableApp.invokeMethod(oid: object.oid, text: text) + let returnDescription = result["description"] as? String + let returnObject = result["object"] as? LookinObject + let target = "<\(object.lk_simpleDemangledClassName()): \(object.memoryAddress ?? "(null)")>" + + var rows = rowItems + rows.insert(LKConsoleRowItem(kind: .submit, highlightText: target, normalText: text), at: rows.count - 1) + if let returnDescription, !returnDescription.isEmpty { + rows.insert(LKConsoleRowItem(kind: .returnValue, normalText: returnDescription), at: rows.count - 1) + } + if let returnObject { + recentObjects.insert(RecentObject(object: returnObject, message: "\(target) => \(text)"), id: returnObject.oid) + } + rowItems = rows + } + + /// Makes `object` the call target, fetching its class's selector names first when needed. + func makeObjectCurrent(_ object: LookinObject?) async throws { + guard let object, let className = object.rawClassName(), !className.isEmpty else { + throw LKConnectionError.inner + } + guard let inspectableApp = liveDocument?.inspectableApp else { + throw LKConnectionError.noConnect + } + if selectorNamesByClass[className] != nil { + currentObject = object + return + } + let names = try await inspectableApp.selectorNames(className: className, hasArg: true) + selectorNamesByClass[className] = names + currentObject = object + } + + func clearHistoryContents() { + rowItems = [LKConsoleRowItem(kind: .input)] + } + + private func syncConsoleTargetIfNeeded() { + guard isShowingConsole, let lastSelected = selectedObjects.last else { return } + if LKPreferenceManager.shared.syncConsoleTarget || currentObject == nil { + Task { + try? await makeObjectCurrent(lastSelected) + } + } + } +} diff --git a/LookInside/Console/LKConsoleDataSourceRowItem.h b/LookInside/Console/LKConsoleDataSourceRowItem.h deleted file mode 100644 index 0f169a80..00000000 --- a/LookInside/Console/LKConsoleDataSourceRowItem.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// LKConsoleDataSourceRowItem.h -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import - -typedef NS_ENUM(NSInteger, LKConsoleDataSourceRowItemType) { - LKConsoleDataSourceRowItemTypeInput, - LKConsoleDataSourceRowItemTypeSubmit, - LKConsoleDataSourceRowItemTypeReturn, -}; - -@interface LKConsoleDataSourceRowItem : NSObject - -@property(nonatomic, assign) LKConsoleDataSourceRowItemType type; - -/// 仅 type 为 LKConsoleDataSourceRowItemTypeReturn 时,该属性有效 -@property(nonatomic, copy) NSString *highlightText; - -@property(nonatomic, copy) NSString *normalText; - -@end diff --git a/LookInside/Console/LKConsoleDataSourceRowItem.m b/LookInside/Console/LKConsoleDataSourceRowItem.m deleted file mode 100644 index c008f42f..00000000 --- a/LookInside/Console/LKConsoleDataSourceRowItem.m +++ /dev/null @@ -1,14 +0,0 @@ -// -// LKConsoleDataSourceRowItem.m -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import "LKConsoleDataSourceRowItem.h" - -@implementation LKConsoleDataSourceRowItem - - -@end diff --git a/LookInside/Console/LKConsoleInputRowView.h b/LookInside/Console/LKConsoleInputRowView.h deleted file mode 100644 index 9ccb70c9..00000000 --- a/LookInside/Console/LKConsoleInputRowView.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LKConsoleInputRowView.h -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import "LKTableRowView.h" - -@class LKConsoleDataSource; - -@interface LKConsoleInputRowView : LKTableRowView - -- (instancetype)initWithDataSource:(LKConsoleDataSource *)dataSource; - -- (void)makeTextFieldAsFirstResponder; - -@end diff --git a/LookInside/Console/LKConsoleInputRowView.m b/LookInside/Console/LKConsoleInputRowView.m deleted file mode 100644 index 3e25ce97..00000000 --- a/LookInside/Console/LKConsoleInputRowView.m +++ /dev/null @@ -1,147 +0,0 @@ -// -// LKConsoleInputRowView.m -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import "LKConsoleInputRowView.h" -#import "LKConsoleDataSource.h" -#import "LookinObject.h" -#import "LKInputSearchView.h" -#import "LKInputSearchSuggestionItem.h" -#import "LKConsoleSelectPopoverController.h" - -@interface LKConsoleInputRowView () - -@property(nonatomic, strong) LKConsoleDataSource *dataSource; -@property(nonatomic, strong) NSButton *selectButton; -@property(nonatomic, strong) LKInputSearchView *inputView; -@property(nonatomic, strong) LKConsoleSelectPopoverController *selectPopoverController; - -@end - -@implementation LKConsoleInputRowView - -- (instancetype)initWithDataSource:(LKConsoleDataSource *)dataSource { - if (self = [self initWithFrame:NSZeroRect]) { - self.dataSource = dataSource; - -// self.layer.borderColor = [NSColor greenColor].CGColor; -// self.layer.borderWidth = 1; - self.selectPopoverController = [[LKConsoleSelectPopoverController alloc] initWithDataSource:dataSource]; - @weakify(self); - self.selectPopoverController.needShowError = ^(NSError *error) { - @strongify(self); - [[NSAlert alertWithError:error] beginSheetModalForWindow:self.window completionHandler:nil]; - }; - - self.selectButton = [NSButton new]; - self.selectButton.font = NSFontMake(13); - self.selectButton.imagePosition = NSImageRight; - self.selectButton.bezelStyle = NSBezelStyleRoundRect; - self.selectButton.bordered = NO; - self.selectButton.image = NSImageMake(@"Console_UpDownArrow"); - self.selectButton.target = self; - self.selectButton.action = @selector(_handleSelectButton); - [self addSubview:self.selectButton]; - - self.inputView = [[LKInputSearchView alloc] initWithThrottleTime:.15]; - self.inputView.delegate = self; - self.inputView.textField.focusRingType = NSFocusRingTypeNone; - [self addSubview:self.inputView]; - - self.isDarkMode = self.effectiveAppearance.lk_isDarkMode; - - [RACObserve(self.dataSource, currentObject) subscribeNext:^(LookinObject *obj) { - @strongify(self); - [self _handleCurrentObjectDidChange]; - }]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.selectButton).fullHeight.widthToFit.lk_maxWidth(self.$width * .5).x(ConsoleInsetLeft); - $(self.inputView).x(self.selectButton.$maxX + 5).toRight(ConsoleInsetRight).fullHeight; -} - -- (void)setIsDarkMode:(BOOL)isDarkMode { - [super setIsDarkMode:isDarkMode]; - // 更新 selectButton textColor - [self _handleCurrentObjectDidChange]; -} - -- (void)makeTextFieldAsFirstResponder { - [self.window makeFirstResponder:self.inputView.textField]; -} - -- (void)_handleSelectButton { - [self.selectPopoverController reRender]; - NSPopover *popover = [[NSPopover alloc] init]; - popover.animates = NO; - popover.behavior = NSPopoverBehaviorTransient; - popover.contentSize = NSMakeSize(IsEnglish ? 465 : 400, [self.selectPopoverController bestHeight]); - popover.contentViewController = self.selectPopoverController; - [popover showRelativeToRect:NSMakeRect(0, 0, self.selectButton.bounds.size.width, self.selectButton.bounds.size.height) ofView:self.selectButton preferredEdge:NSRectEdgeMinY]; - - @weakify(popover); - self.selectPopoverController.needClose = ^{ - @strongify(popover); - [popover close]; - }; -} - -- (void)_handleCurrentObjectDidChange { - [self.inputView clearContentAndSuggestions]; - - NSColor *selectButtonColor = self.isDarkMode ? LookinColorMake(85, 200, 95) : LookinColorMake(54, 155, 62); - LookinObject *obj = self.dataSource.currentObject; - if (obj) { - NSString *string = [NSString stringWithFormat:@"<%@: %@>", obj.lk_simpleDemangledClassName, obj.memoryAddress]; - self.selectButton.attributedTitle = $(string).textColor(selectButtonColor).attrString; - self.inputView.textField.editable = YES; - self.inputView.textField.placeholderString = NSLocalizedString(@"Type property or method name here", nil); - } else { - NSString *str = NSLocalizedString(@"Select target object", nil); - self.selectButton.attributedTitle = $(str).textColor(selectButtonColor).attrString; - self.inputView.textField.editable = NO; - self.inputView.textField.placeholderString = @""; - } - [self setNeedsLayout:YES]; -} - -#pragma mark - - -- (NSArray *)inputSearchView:(LKInputSearchView *)view suggestionsForString:(NSString *)string { - if (string.length < 3) { - return nil; - } - NSMutableArray *array = [NSMutableArray array]; - - NSArray *candidates = [self.dataSource currentObjectSelectorNameList]; - NSArray *suggestions = [LKHelper bestMatchesInCandidates:candidates input:string maxResultsCount:8]; - [suggestions enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - LKInputSearchSuggestionItem *item = [LKInputSearchSuggestionItem new]; - item.image = NSImageMake(@"icon_method"); - item.text = obj; - [array addObject:item]; - }]; - - return array.copy; -} - -- (void)inputSearchView:(LKInputSearchView *)view submitText:(NSString *)text { - @weakify(view); - [[self.dataSource submit:text] subscribeNext:^(id _Nullable x) { - @strongify(view); - [view clearContentAndSuggestions]; - - } error:^(NSError * _Nullable error) { - AlertError(error, self.window); - }]; -} - -@end diff --git a/LookInside/Console/LKConsoleReturnRowView.h b/LookInside/Console/LKConsoleReturnRowView.h deleted file mode 100644 index 8071ca15..00000000 --- a/LookInside/Console/LKConsoleReturnRowView.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// LKConsoleReturnRowView.h -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import "LKTableRowView.h" - -@interface LKConsoleReturnRowView : LKTableRowView - -- (CGFloat)heightForWidth:(CGFloat)width; - -@end diff --git a/LookInside/Console/LKConsoleReturnRowView.m b/LookInside/Console/LKConsoleReturnRowView.m deleted file mode 100644 index 012e3bb2..00000000 --- a/LookInside/Console/LKConsoleReturnRowView.m +++ /dev/null @@ -1,42 +0,0 @@ -// -// LKConsoleReturnRowView.m -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import "LKConsoleReturnRowView.h" - -@implementation LKConsoleReturnRowView { - NSEdgeInsets _insets; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _insets = NSEdgeInsetsMake(0, ConsoleInsetLeft, 5, ConsoleInsetRight); - - self.titleLabel.selectable = YES; - self.titleLabel.textColor = [NSColor labelColor]; - self.titleLabel.font = NSFontMake(13); - self.titleLabel.lineBreakMode = NSLineBreakByWordWrapping; - self.titleLabel.maximumNumberOfLines = 0; - -// self.layer.borderColor = [NSColor redColor].CGColor; -// self.layer.borderWidth = 1; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.titleLabel).x(_insets.left).toRight(_insets.right).heightToFit.y(_insets.top); -} - -- (CGFloat)heightForWidth:(CGFloat)width { - CGFloat height = [self.titleLabel sizeThatFits:NSMakeSize(width - _insets.left -_insets.right, CGFLOAT_MAX)].height; - height += _insets.top + _insets.bottom; - return height; -} - -@end diff --git a/LookInside/Console/LKConsoleRowViews.swift b/LookInside/Console/LKConsoleRowViews.swift new file mode 100644 index 00000000..e6d45e8c --- /dev/null +++ b/LookInside/Console/LKConsoleRowViews.swift @@ -0,0 +1,215 @@ +// +// LKConsoleRowViews.swift +// LookInside +// +// The Console transcript rows: the input row (target picker and text +// field), a submitted call, and a returned description. +// + +import AppKit + +private func lkpConsoleTargetColor(isDarkMode: Bool) -> NSColor { + isDarkMode + ? NSColor(red: 85 / 255.0, green: 200 / 255.0, blue: 95 / 255.0, alpha: 1) + : NSColor(red: 54 / 255.0, green: 155 / 255.0, blue: 62 / 255.0, alpha: 1) +} + +/// The last transcript row: a button showing the call target (it opens the +/// target picker) and the input field with selector suggestions. +final class LKConsoleInputRowView: LKTableRowView, LKInputSearchViewDelegate { + private let dataSource: LKConsoleDataSource + private let selectButton = NSButton() + private let inputView = LKInputSearchView(throttleTime: 0.15) + private let selectPopoverController: LKConsoleSelectPopoverController + + init(dataSource: LKConsoleDataSource) { + self.dataSource = dataSource + selectPopoverController = LKConsoleSelectPopoverController(dataSource: dataSource) + super.init(frame: .zero) + + selectPopoverController.needShowError = { [weak self] error in + LKPeripheralAlerts.show(error, in: self?.window) + } + + selectButton.font = .systemFont(ofSize: 13) + selectButton.imagePosition = .imageRight + selectButton.bezelStyle = .roundRect + selectButton.isBordered = false + selectButton.image = NSImage(named: "Console_UpDownArrow") + selectButton.target = self + selectButton.action = #selector(handleSelectButton) + addSubview(selectButton) + + inputView.delegate = self + inputView.textField.focusRingType = .none + addSubview(inputView) + + setIsDarkMode(effectiveAppearance.lkpIsDarkMode) + + dataSource.currentObjectDidChange = { [weak self] in + self?.handleCurrentObjectDidChange() + } + handleCurrentObjectDidChange() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + selectButton.lkpFullHeight() + selectButton.lkpWidthToFit() + selectButton.lkpMaxWidth(bounds.width * 0.5) + selectButton.lkpSetX(ConsoleInsetLeft) + + inputView.lkpSetX(selectButton.frame.maxX + 5) + inputView.lkpToRight(ConsoleInsetRight) + inputView.lkpFullHeight() + } + + override func setIsDarkMode(_ isDarkMode: Bool) { + super.setIsDarkMode(isDarkMode) + // The target button's color follows the appearance. + handleCurrentObjectDidChange() + } + + func makeTextFieldFirstResponder() { + window?.makeFirstResponder(inputView.textField) + } + + @objc private func handleSelectButton() { + selectPopoverController.reRender() + let popover = NSPopover() + popover.animates = false + popover.behavior = .transient + popover.contentSize = NSSize(width: LKHelper.isEnglish() ? 465 : 400, + height: selectPopoverController.bestHeight()) + popover.contentViewController = selectPopoverController + popover.show(relativeTo: NSRect(origin: .zero, size: selectButton.bounds.size), + of: selectButton, preferredEdge: .minY) + selectPopoverController.needClose = { [weak popover] in + popover?.close() + } + } + + private func handleCurrentObjectDidChange() { + // Called from LKTableRowView's initializer through setIsDarkMode + // before this row is set up; the init call repeats it. + guard selectButton.target != nil else { return } + inputView.clearContentAndSuggestions() + + let color = lkpConsoleTargetColor(isDarkMode: isDarkMode) + let title: String + if let object = dataSource.currentObject { + title = "<\(object.lk_simpleDemangledClassName()): \(object.memoryAddress ?? "(null)")>" + inputView.textField.isEditable = true + inputView.textField.placeholderString = NSLocalizedString("Type property or method name here", comment: "") + } else { + title = NSLocalizedString("Select target object", comment: "") + inputView.textField.isEditable = false + inputView.textField.placeholderString = "" + } + selectButton.attributedTitle = NSAttributedString(string: title, attributes: [.foregroundColor: color]) + needsLayout = true + } + + // MARK: - LKInputSearchViewDelegate + + func inputSearchView(_: LKInputSearchView, suggestionsFor string: String) -> [LKInputSearchSuggestionItem]? { + guard string.count >= 3 else { return nil } + let matches = LKHelper.bestMatches(inCandidates: dataSource.currentObjectSelectorNames, input: string, + maxResultsCount: 8) + return matches.map { name in + let item = LKInputSearchSuggestionItem() + item.image = NSImage(named: "icon_method") + item.text = name + return item + } + } + + func inputSearchView(_ view: LKInputSearchView, submitText text: String) { + Task { [weak self, weak view] in + guard let self else { return } + do { + try await dataSource.submit(text) + view?.clearContentAndSuggestions() + } catch { + LKPeripheralAlerts.show(error, in: window) + } + } + } +} + +/// A submitted call: the target in green, then the method name. +final class LKConsoleSubmitRowView: LKTableRowView { + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + titleLabel.isSelectable = true + titleLabel.font = .systemFont(ofSize: 13) + + subtitleLabel.isSelectable = true + subtitleLabel.textColor = .labelColor + subtitleLabel.font = .systemFont(ofSize: 13) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + var titleSize = titleLabel.sizeThatFits(lkpMaxSize) + titleSize.width = min(titleSize.width, bounds.width * 0.5) + titleLabel.lkpSetX(ConsoleInsetLeft) + titleLabel.lkpSetWidth(titleSize.width) + titleLabel.lkpSetHeight(titleSize.height) + titleLabel.lkpVerAlign() + + subtitleLabel.lkpSetX(titleLabel.frame.maxX + 5) + subtitleLabel.lkpToRight(ConsoleInsetRight) + subtitleLabel.lkpHeightToFit() + subtitleLabel.lkpVerAlign() + } + + override func setIsDarkMode(_ isDarkMode: Bool) { + super.setIsDarkMode(isDarkMode) + titleLabel.textColor = lkpConsoleTargetColor(isDarkMode: isDarkMode) + } +} + +/// A returned description, wrapped to the row width. +final class LKConsoleReturnRowView: LKTableRowView { + private static let insets = NSEdgeInsets(top: 0, left: ConsoleInsetLeft, bottom: 5, right: ConsoleInsetRight) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + titleLabel.isSelectable = true + titleLabel.textColor = .labelColor + titleLabel.font = .systemFont(ofSize: 13) + titleLabel.lineBreakMode = .byWordWrapping + titleLabel.maximumNumberOfLines = 0 + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + let insets = Self.insets + titleLabel.lkpSetX(insets.left) + titleLabel.lkpToRight(insets.right) + titleLabel.lkpHeightToFit() + titleLabel.lkpSetY(insets.top) + } + + func height(forWidth width: CGFloat) -> CGFloat { + let insets = Self.insets + let limit = NSSize(width: width - insets.left - insets.right, height: .greatestFiniteMagnitude) + return titleLabel.sizeThatFits(limit).height + insets.top + insets.bottom + } +} diff --git a/LookInside/Console/LKConsoleSelectPopoverController.h b/LookInside/Console/LKConsoleSelectPopoverController.h deleted file mode 100644 index edf0e6b1..00000000 --- a/LookInside/Console/LKConsoleSelectPopoverController.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// LKConsoleSelectPopoverController.h -// Lookin -// -// Created by Li Kai on 2019/6/19. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LKConsoleDataSource; - -@interface LKConsoleSelectPopoverController : LKBaseViewController - -- (instancetype)initWithDataSource:(LKConsoleDataSource *)dataSource; - -- (CGFloat)bestHeight; - -- (void)reRender; - -@property(nonatomic, copy) void (^needShowError)(NSError *error); -@property(nonatomic, copy) void (^needClose)(void); - -@end diff --git a/LookInside/Console/LKConsoleSelectPopoverController.m b/LookInside/Console/LKConsoleSelectPopoverController.m deleted file mode 100644 index f8575c92..00000000 --- a/LookInside/Console/LKConsoleSelectPopoverController.m +++ /dev/null @@ -1,237 +0,0 @@ -// -// LKConsoleSelectPopoverController.m -// Lookin -// -// Created by Li Kai on 2019/6/19. -// https://lookin.work -// - -#import "LKConsoleSelectPopoverController.h" -#import "LKConsoleDataSource.h" -#import "LKConsoleSelectPopoverItemControl.h" -#import "LKImageTextView.h" -#import "LookinObject.h" -#import "LKPreferenceManager.h" - -@interface LKConsoleSelectPopoverController () - -@property(nonatomic, strong) LKConsoleDataSource *dataSource; - -@property(nonatomic, strong) LKImageTextView *historyTitleView; -@property(nonatomic, copy) NSArray *historyControls; -@property(nonatomic, copy) NSArray *highlightControls; -@property(nonatomic, strong) LKImageTextView *highlightTitleView; -@property(nonatomic, strong) NSButton *toggleButton; -@property(nonatomic, strong) CALayer *sepLayer; - -@end - -@implementation LKConsoleSelectPopoverController { - NSEdgeInsets _insets; - CGFloat _titleMarginTop; - CGFloat _itemControlMarginTop; - CGFloat _toggleButtonMarginTop; -} - -- (instancetype)initWithDataSource:(LKConsoleDataSource *)dataSource { - if (self = [self initWithContainerView:nil]) { - self.dataSource = dataSource; - _insets = NSEdgeInsetsMake(8, 12, 8, 12); - _titleMarginTop = 16; - _itemControlMarginTop = 5; - _toggleButtonMarginTop = 22; - } - return self; -} - -- (NSView *)makeContainerView { - LKBaseView *view = [LKBaseView new]; - - self.historyTitleView = [LKImageTextView new]; - self.historyTitleView.imageMargins = HorizontalMarginsMake(0, 5); - self.historyTitleView.imageView.image = NSImageMake(@"console_history"); - self.historyTitleView.label.stringValue = NSLocalizedString(@"Objects returned recently in console", nil); - [view addSubview:self.historyTitleView]; - - self.highlightTitleView = [LKImageTextView new]; - self.highlightTitleView.imageMargins = HorizontalMarginsMake(0, 5); - self.highlightTitleView.imageView.image = NSImageMake(@"icon_cursor"); - self.highlightTitleView.label.stringValue = NSLocalizedString(@"Objects highlighted in hierarchy panel", nil); - [view addSubview:self.highlightTitleView]; - - self.historyControls = [NSArray array]; - self.highlightControls = [NSArray array]; - - self.toggleButton = [NSButton new]; - [self.toggleButton setButtonType:NSButtonTypeSwitch]; - self.toggleButton.title = NSLocalizedString(@"Automatically make highlighted view in hierarchy panel as console target", nil); - self.toggleButton.font = NSFontMake(12); - self.toggleButton.target = self; - self.toggleButton.action = @selector(_handleToggleSyncButton); - [view addSubview:self.toggleButton]; - - RAC(self.toggleButton, state) = [RACObserve([LKPreferenceManager mainManager], syncConsoleTarget) map:^id _Nullable(NSNumber *value) { - BOOL shouldChecked = value.boolValue; - if (shouldChecked) { - return @(NSControlStateValueOn); - } else { - return @(NSControlStateValueOff); - } - }]; - - @weakify(self); - view.didChangeAppearanceBlock = ^(LKBaseView *view, BOOL isDarkMode) { - @strongify(self); - if (isDarkMode) { - self.sepLayer.backgroundColor = [NSColor colorWithWhite:1 alpha:.2].CGColor; - } else { - self.sepLayer.backgroundColor = [NSColor colorWithWhite:0 alpha:.12].CGColor; - } - }; - self.sepLayer = [CALayer layer]; - [self.sepLayer lookin_removeImplicitAnimations]; - [view.layer addSublayer:self.sepLayer]; - - return view; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - - __block CGFloat y = _insets.top; - - if (self.historyTitleView.isVisible) { - $(self.historyTitleView).sizeToFit.x(_insets.left).y(y); - y = self.historyTitleView.$maxY; - } - [self.historyControls enumerateObjectsUsingBlock:^(LKConsoleSelectPopoverItemControl * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - $(obj).x(self->_insets.left).toRight(self->_insets.right).heightToFit.y(y + self->_itemControlMarginTop); - y = obj.$maxY; - }]; - if (self.highlightTitleView.isVisible) { - if (self.historyTitleView.isVisible) { - y += _titleMarginTop; - } - $(self.highlightTitleView).sizeToFit.x(_insets.left).y(y); - y = self.highlightTitleView.$maxY; - } - [self.highlightControls enumerateObjectsUsingBlock:^(LKConsoleSelectPopoverItemControl * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - $(obj).x(self->_insets.left).toRight(self->_insets.right).heightToFit.y(y + self->_itemControlMarginTop); - y = obj.$maxY; - }]; - - $(self.toggleButton).x(_insets.left).toRight(_insets.right).y(y + _toggleButtonMarginTop).height([self.toggleButton sizeThatFits:NSSizeMax].height + 2); - $(self.sepLayer).x(_insets.left).toRight(_insets.right).height(1).y(self.toggleButton.$y - 7); -} - -- (CGFloat)bestHeight { - __block CGFloat height = _insets.top + _insets.bottom; - if (self.historyTitleView.isVisible) { - height += [self.historyTitleView sizeThatFits:NSSizeMax].height; - } - if (self.highlightTitleView.isVisible) { - height += [self.highlightTitleView sizeThatFits:NSSizeMax].height; - - if (self.historyTitleView.isVisible) { - height += _titleMarginTop; - } - } - [[self.historyControls arrayByAddingObjectsFromArray:self.highlightControls] enumerateObjectsUsingBlock:^(LKConsoleSelectPopoverItemControl * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - height += [obj sizeThatFits:NSSizeMax].height + self->_itemControlMarginTop; - }]; - - height += [self.toggleButton sizeThatFits:NSSizeMax].height + _toggleButtonMarginTop; - return height; -} - -- (void)reRender { - if (self.dataSource.recentObjects.count == 0) { - self.historyControls = [self.historyControls lookin_resizeWithCount:1 add:^LKConsoleSelectPopoverItemControl *(NSUInteger idx) { - LKConsoleSelectPopoverItemControl *control = [LKConsoleSelectPopoverItemControl new]; - [control addTarget:self clickAction:@selector(_handleControl:)]; - [self.view addSubview:control]; - return control; - - } remove:^(NSUInteger idx, LKConsoleSelectPopoverItemControl *control) { - [control removeFromSuperview]; - - } doNext:^(NSUInteger idx, LKConsoleSelectPopoverItemControl *control) { - control.title = NSLocalizedString(@"No object was returned yet", nil); - control.isChecked = NO; - control.representedObject = nil; - }]; - } else { - self.historyControls = [self.historyControls lookin_resizeWithCount:self.dataSource.recentObjects.count add:^LKConsoleSelectPopoverItemControl *(NSUInteger idx) { - LKConsoleSelectPopoverItemControl *control = [LKConsoleSelectPopoverItemControl new]; - [control addTarget:self clickAction:@selector(_handleControl:)]; - [self.view addSubview:control]; - return control; - - } remove:^(NSUInteger idx, LKConsoleSelectPopoverItemControl *control) { - [control removeFromSuperview]; - - } doNext:^(NSUInteger idx, LKConsoleSelectPopoverItemControl *control) { - RACTwoTuple *tuple = self.dataSource.recentObjects[idx]; - LookinObject *targetObject = tuple.first; - control.title = [NSString stringWithFormat:@"<%@: %@>", targetObject.lk_simpleDemangledClassName, targetObject.memoryAddress]; - control.subtitle = tuple.second; - control.isChecked = (self.dataSource.currentObject.oid == targetObject.oid); - control.representedObject = targetObject; - }]; - } - - self.highlightTitleView.hidden = (self.dataSource.selectedObjects.count == 0); - self.highlightControls = [self.highlightControls lookin_resizeWithCount:self.dataSource.selectedObjects.count add:^LKConsoleSelectPopoverItemControl *(NSUInteger idx) { - LKConsoleSelectPopoverItemControl *control = [LKConsoleSelectPopoverItemControl new]; - [control addTarget:self clickAction:@selector(_handleControl:)]; - [self.view addSubview:control]; - return control; - - } remove:^(NSUInteger idx, LKConsoleSelectPopoverItemControl *control) { - [control removeFromSuperview]; - - } doNext:^(NSUInteger idx, LKConsoleSelectPopoverItemControl *control) { - LookinObject *targetObject = self.dataSource.selectedObjects[idx]; - control.title = [NSString stringWithFormat:@"<%@: %@>", targetObject.lk_simpleDemangledClassName, targetObject.memoryAddress]; - control.isChecked = (self.dataSource.currentObject.oid == targetObject.oid); - control.representedObject = targetObject; - }]; - - [self.view setNeedsLayout:YES]; -} - -- (void)_handleControl:(LKConsoleSelectPopoverItemControl *)control { - LookinObject *obj = control.representedObject; - if (!obj) { - return; - } - @weakify(self); - [[self.dataSource makeObjectAsCurrent:obj] subscribeNext:^(id _Nullable x) { - @strongify(self); - if (obj.oid != self.dataSource.selectedObjects.lastObject.oid) { - [LKPreferenceManager mainManager].syncConsoleTarget = NO; - } - if (self.needClose) { - self.needClose(); - } - - } error:^(NSError * _Nullable error) { - if (self.needShowError) { - self.needShowError(LookinErr_NoConnect); - } - }]; -} - -- (void)_handleToggleSyncButton { - LKPreferenceManager *mng = [LKPreferenceManager mainManager]; - mng.syncConsoleTarget = !mng.syncConsoleTarget; - if (mng.syncConsoleTarget) { - @weakify(self); - [[self.dataSource makeObjectAsCurrent:self.dataSource.selectedObjects.lastObject] subscribeNext:^(id _Nullable x) { - @strongify(self); - [self reRender]; - }]; - } -} - -@end diff --git a/LookInside/Console/LKConsoleSelectPopoverController.swift b/LookInside/Console/LKConsoleSelectPopoverController.swift new file mode 100644 index 00000000..ffc94400 --- /dev/null +++ b/LookInside/Console/LKConsoleSelectPopoverController.swift @@ -0,0 +1,330 @@ +// +// LKConsoleSelectPopoverController.swift +// LookInside +// +// The Console's target picker: objects recent calls returned, objects of +// the item highlighted in the hierarchy, and the switch that makes the +// highlighted item the target automatically. +// + +import AppKit + +final class LKConsoleSelectPopoverController: LKBaseViewController { + private static let insets = NSEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + private static let titleMarginTop: CGFloat = 16 + private static let itemControlMarginTop: CGFloat = 5 + private static let toggleButtonMarginTop: CGFloat = 22 + + var needShowError: ((Error) -> Void)? + var needClose: (() -> Void)? + + private let dataSource: LKConsoleDataSource + private let historyTitleView = LKImageTextView() + private let highlightTitleView = LKImageTextView() + private let toggleButton = NSButton() + private let separatorLayer = CALayer() + private var historyControls: [LKConsoleSelectPopoverItemControl] = [] + private var highlightControls: [LKConsoleSelectPopoverItemControl] = [] + private var syncObservation: NSKeyValueObservation? + + init(dataSource: LKConsoleDataSource) { + self.dataSource = dataSource + super.init(containerView: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func makeContainerView() -> NSView { + let view = LKBaseView() + + historyTitleView.imageMargins = HorizontalMargins(left: 0, right: 5) + historyTitleView.imageView.image = NSImage(named: "console_history") + historyTitleView.label.stringValue = NSLocalizedString("Objects returned recently in console", comment: "") + view.addSubview(historyTitleView) + + highlightTitleView.imageMargins = HorizontalMargins(left: 0, right: 5) + highlightTitleView.imageView.image = NSImage(named: "icon_cursor") + highlightTitleView.label.stringValue = NSLocalizedString("Objects highlighted in hierarchy panel", comment: "") + view.addSubview(highlightTitleView) + + toggleButton.setButtonType(.switch) + toggleButton.title = NSLocalizedString("Automatically make highlighted view in hierarchy panel as console target", comment: "") + toggleButton.font = .systemFont(ofSize: 12) + toggleButton.target = self + toggleButton.action = #selector(handleToggleSyncButton) + view.addSubview(toggleButton) + + syncObservation = LKPreferenceManager.shared.observe(\.syncConsoleTarget, options: [.initial, .new]) { + [weak self] manager, _ in + let isOn = manager.syncConsoleTarget + MainActor.assumeIsolated { + self?.toggleButton.state = isOn ? .on : .off + } + } + + separatorLayer.lookin_removeImplicitAnimations() + view.layer?.addSublayer(separatorLayer) + view.didChangeAppearanceBlock = { [weak self] _, isDarkMode in + self?.separatorLayer.backgroundColor = isDarkMode + ? NSColor(white: 1, alpha: 0.2).cgColor + : NSColor(white: 0, alpha: 0.12).cgColor + } + return view + } + + override func viewDidLayout() { + super.viewDidLayout() + let insets = Self.insets + var y = insets.top + + func layoutControls(_ controls: [LKConsoleSelectPopoverItemControl]) { + for control in controls { + control.lkpSetX(insets.left) + control.lkpToRight(insets.right) + control.lkpHeightToFit() + control.lkpSetY(y + Self.itemControlMarginTop) + y = control.frame.maxY + } + } + + if historyTitleView.isVisible { + historyTitleView.lkpSizeToFit() + historyTitleView.lkpSetX(insets.left) + historyTitleView.lkpSetY(y) + y = historyTitleView.frame.maxY + } + layoutControls(historyControls) + if highlightTitleView.isVisible { + if historyTitleView.isVisible { + y += Self.titleMarginTop + } + highlightTitleView.lkpSizeToFit() + highlightTitleView.lkpSetX(insets.left) + highlightTitleView.lkpSetY(y) + y = highlightTitleView.frame.maxY + } + layoutControls(highlightControls) + + toggleButton.lkpSetX(insets.left) + toggleButton.lkpToRight(insets.right) + toggleButton.lkpSetY(y + Self.toggleButtonMarginTop) + toggleButton.lkpSetHeight(toggleButton.sizeThatFits(lkpMaxSize).height + 2) + + separatorLayer.lkpSetFrame(x: insets.left) + separatorLayer.lkpToRight(insets.right) + separatorLayer.lkpSetFrame(y: toggleButton.frame.minY - 7, height: 1) + } + + func bestHeight() -> CGFloat { + let insets = Self.insets + var height = insets.top + insets.bottom + if historyTitleView.isVisible { + height += historyTitleView.sizeThatFits(lkpMaxSize).height + } + if highlightTitleView.isVisible { + height += highlightTitleView.sizeThatFits(lkpMaxSize).height + if historyTitleView.isVisible { + height += Self.titleMarginTop + } + } + for control in historyControls + highlightControls { + height += control.sizeThatFits(lkpMaxSize).height + Self.itemControlMarginTop + } + height += toggleButton.sizeThatFits(lkpMaxSize).height + Self.toggleButtonMarginTop + return height + } + + /// Rebuilds the rows from the data source's recent and highlighted objects. + func reRender() { + let currentOid = dataSource.currentObject?.oid + let recentObjects = dataSource.recentObjects.entries.map(\.element) + if recentObjects.isEmpty { + historyControls = resizedControls(historyControls, count: 1) { _, control in + control.title = NSLocalizedString("No object was returned yet", comment: "") + control.isChecked = false + control.representedObject = nil + } + } else { + historyControls = resizedControls(historyControls, count: recentObjects.count) { index, control in + let recent = recentObjects[index] + control.title = Self.title(of: recent.object) + control.subtitle = recent.message + control.isChecked = currentOid == recent.object.oid + control.representedObject = recent.object + } + } + + let selectedObjects = dataSource.selectedObjects + highlightTitleView.isHidden = selectedObjects.isEmpty + highlightControls = resizedControls(highlightControls, count: selectedObjects.count) { index, control in + let object = selectedObjects[index] + control.title = Self.title(of: object) + control.isChecked = currentOid == object.oid + control.representedObject = object + } + view.needsLayout = true + } + + private static func title(of object: LookinObject) -> String { + "<\(object.lk_simpleDemangledClassName()): \(object.memoryAddress ?? "(null)")>" + } + + /// Keeps the first `count` controls, adds new ones as needed, removes the + /// rest, and configures each kept or added control. + private func resizedControls( + _ controls: [LKConsoleSelectPopoverItemControl], + count: Int, + configure: (Int, LKConsoleSelectPopoverItemControl) -> Void + ) -> [LKConsoleSelectPopoverItemControl] { + var result: [LKConsoleSelectPopoverItemControl] = [] + for index in 0 ..< count { + let control: LKConsoleSelectPopoverItemControl + if index < controls.count { + control = controls[index] + } else { + control = LKConsoleSelectPopoverItemControl() + control.addTarget(self, clickAction: #selector(handleControl(_:))) + view.addSubview(control) + } + result.append(control) + configure(index, control) + } + for control in controls.dropFirst(count) { + control.removeFromSuperview() + } + return result + } + + @objc private func handleControl(_ control: LKConsoleSelectPopoverItemControl) { + guard let object = control.representedObject else { return } + Task { + do { + try await dataSource.makeObjectCurrent(object) + if object.oid != dataSource.selectedObjects.last?.oid { + LKPreferenceManager.shared.syncConsoleTarget = false + } + needClose?() + } catch { + needShowError?(LKConnectionError.noConnect) + } + } + } + + @objc private func handleToggleSyncButton() { + let manager = LKPreferenceManager.shared + manager.syncConsoleTarget = !manager.syncConsoleTarget + guard manager.syncConsoleTarget else { return } + Task { + if (try? await dataSource.makeObjectCurrent(dataSource.selectedObjects.last)) != nil { + reRender() + } + } + } +} + +/// One object in the target picker: a check mark when it is the target, its +/// class and address, and optionally the call that returned it. +final class LKConsoleSelectPopoverItemControl: LKBaseControl { + private static let subtitleMarginTop: CGFloat = 0 + + var isChecked = false { + didSet { imageView.isHidden = !isChecked } + } + + var title: String? { + didSet { + titleLabel.stringValue = title ?? "" + needsLayout = true + } + } + + var subtitle: String? { + didSet { + subtitleLabel.stringValue = subtitle ?? "" + subtitleLabel.isHidden = (subtitle ?? "").isEmpty + needsLayout = true + } + } + + var representedObject: LookinObject? { + didSet { + titleLabel.textColor = representedObject == nil ? .secondaryLabelColor : .labelColor + } + } + + private let imageView = NSImageView() + private let titleLabel = LKLabel() + private let subtitleLabel = LKLabel() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + imageView.image = NSImage(named: "Console_Checked") + addSubview(imageView) + + titleLabel.font = .systemFont(ofSize: 12) + titleLabel.maximumNumberOfLines = 1 + titleLabel.textColor = .labelColor + titleLabel.lineBreakMode = .byTruncatingMiddle + addSubview(titleLabel) + + subtitleLabel.maximumNumberOfLines = 1 + subtitleLabel.lineBreakMode = .byTruncatingMiddle + subtitleLabel.font = .systemFont(ofSize: 11) + subtitleLabel.textColor = .secondaryLabelColor + subtitleLabel.isHidden = true + addSubview(subtitleLabel) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + imageView.lkpSizeToFit() + imageView.lkpSetX(3) + imageView.lkpVerAlign() + + titleLabel.lkpSetX(imageView.frame.maxX + 4) + titleLabel.lkpToRight(0) + titleLabel.lkpHeightToFit() + if subtitleLabel.lkpIsVisibleLabel { + subtitleLabel.lkpSetX(titleLabel.frame.minX) + subtitleLabel.lkpToRight(0) + subtitleLabel.lkpHeightToFit() + subtitleLabel.lkpSetY(titleLabel.frame.maxY + Self.subtitleMarginTop) + } + + // Center the visible labels vertically as a group. + let labels = [titleLabel, subtitleLabel].filter(\.lkpIsShown) + guard let top = labels.map(\.frame.minY).min(), let bottom = labels.map(\.frame.maxY).max() else { return } + let delta = bounds.height / 2 - (top + (bottom - top) / 2) + for label in labels { + label.lkpOffsetY(delta) + } + } + + override func sizeThatFits(_ size: NSSize) -> NSSize { + let imageHeight = imageView.image?.size.height ?? 0 + var textHeight = titleLabel.sizeThatFits(lkpMaxSize).height + if subtitleLabel.lkpIsVisibleLabel { + textHeight += subtitleLabel.sizeThatFits(lkpMaxSize).height + Self.subtitleMarginTop + } + return NSSize(width: size.width, height: max(imageHeight, textHeight)) + } + + override func sizeToFit() { + lkpSetSize(sizeThatFits(lkpMaxSize)) + } +} + +private extension NSView { + /// `-[NSView isVisible]` from NSView+LookinClient: not hidden and not transparent. + var lkpIsVisibleLabel: Bool { + !isHidden && alphaValue > 0 + } +} diff --git a/LookInside/Console/LKConsoleSelectPopoverItemControl.h b/LookInside/Console/LKConsoleSelectPopoverItemControl.h deleted file mode 100644 index edde4f5c..00000000 --- a/LookInside/Console/LKConsoleSelectPopoverItemControl.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LKConsoleSelectPopoverItemControl.h -// Lookin -// -// Created by Li Kai on 2019/6/19. -// https://lookin.work -// - -#import "LKBaseControl.h" - -@class LookinObject; - -@interface LKConsoleSelectPopoverItemControl : LKBaseControl - -@property(nonatomic, assign) BOOL isChecked; -@property(nonatomic, copy) NSString *title; -@property(nonatomic, copy) NSString *subtitle; - -@property(nonatomic, strong) LookinObject *representedObject; - -@end diff --git a/LookInside/Console/LKConsoleSelectPopoverItemControl.m b/LookInside/Console/LKConsoleSelectPopoverItemControl.m deleted file mode 100644 index 7069ac06..00000000 --- a/LookInside/Console/LKConsoleSelectPopoverItemControl.m +++ /dev/null @@ -1,96 +0,0 @@ -// -// LKConsoleSelectPopoverItemControl.m -// Lookin -// -// Created by Li Kai on 2019/6/19. -// https://lookin.work -// - -#import "LKConsoleSelectPopoverItemControl.h" - -@interface LKConsoleSelectPopoverItemControl () - -@property(nonatomic, strong) NSImageView *imageView; -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKLabel *subtitleLabel; - -@end - -@implementation LKConsoleSelectPopoverItemControl { - CGFloat _subtitleMarginTop; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _subtitleMarginTop = 0; - - self.imageView = [NSImageView new]; - self.imageView.image = NSImageMake(@"Console_Checked"); - [self addSubview:self.imageView]; - - self.titleLabel = [LKLabel new]; - self.titleLabel.font = NSFontMake(12); - self.titleLabel.maximumNumberOfLines = 1; - self.titleLabel.textColor = [NSColor labelColor]; - self.titleLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - [self addSubview:self.titleLabel]; - - self.subtitleLabel = [LKLabel new]; - self.subtitleLabel.maximumNumberOfLines = 1; - self.subtitleLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - self.subtitleLabel.font = NSFontMake(11); - self.subtitleLabel.textColor = [NSColor secondaryLabelColor]; - self.subtitleLabel.hidden = YES; - [self addSubview:self.subtitleLabel]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.imageView).sizeToFit.x(3).verAlign; - $(self.titleLabel).x(self.imageView.$maxX + 4).toRight(0).heightToFit; - if (self.subtitleLabel.isVisible) { - $(self.subtitleLabel).x(self.titleLabel.$x).toRight(0).heightToFit.y(self.titleLabel.$maxY + _subtitleMarginTop); - } - $(self.titleLabel, self.subtitleLabel).visibles.groupVerAlign; -} - -- (NSSize)sizeThatFits:(NSSize)size { - CGFloat imageHeight = self.imageView.image.size.height; - CGFloat textHeight = [self.titleLabel sizeThatFits:NSSizeMax].height; - if (self.subtitleLabel.isVisible) { - textHeight += [self.subtitleLabel sizeThatFits:NSSizeMax].height + _subtitleMarginTop; - } - size.height = MAX(imageHeight, textHeight); - return size; -} - -- (void)sizeToFit { - $(self).size([self sizeThatFits:NSSizeMax]); -} - -- (void)setTitle:(NSString *)title { - _title = title.copy; - self.titleLabel.stringValue = title; - [self setNeedsLayout:YES]; -} - -- (void)setSubtitle:(NSString *)subtitle { - _subtitle = subtitle.copy; - self.subtitleLabel.stringValue = subtitle; - self.subtitleLabel.hidden = !subtitle.length; - [self setNeedsLayout:YES]; -} - -- (void)setIsChecked:(BOOL)isChecked { - _isChecked = isChecked; - self.imageView.hidden = !isChecked; -} - -- (void)setRepresentedObject:(LookinObject *)representedObject { - _representedObject = representedObject; - self.titleLabel.textColor = representedObject ? [NSColor labelColor] : [NSColor secondaryLabelColor]; -} - -@end diff --git a/LookInside/Console/LKConsoleSubmitRowView.h b/LookInside/Console/LKConsoleSubmitRowView.h deleted file mode 100644 index a69d8ad7..00000000 --- a/LookInside/Console/LKConsoleSubmitRowView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKConsoleSubmitRowView.h -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import "LKTableRowView.h" - -@interface LKConsoleSubmitRowView : LKTableRowView - -@end diff --git a/LookInside/Console/LKConsoleSubmitRowView.m b/LookInside/Console/LKConsoleSubmitRowView.m deleted file mode 100644 index 290bfc22..00000000 --- a/LookInside/Console/LKConsoleSubmitRowView.m +++ /dev/null @@ -1,41 +0,0 @@ -// -// LKConsoleSubmitRowView.m -// Lookin -// -// Created by Li Kai on 2019/6/1. -// https://lookin.work -// - -#import "LKConsoleSubmitRowView.h" - -@implementation LKConsoleSubmitRowView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.titleLabel.selectable = YES; - self.titleLabel.font = NSFontMake(13); - - self.subtitleLabel.selectable = YES; - self.subtitleLabel.textColor = [NSColor labelColor]; - self.subtitleLabel.font = NSFontMake(13); - -// self.layer.borderColor = [NSColor blueColor].CGColor; -// self.layer.borderWidth = 1; - } - return self; -} - -- (void)layout { - [super layout]; - NSSize titleSize = [self.titleLabel sizeThatFits:NSSizeMax]; - titleSize.width = MIN(titleSize.width, self.$width * .5); - $(self.titleLabel).x(ConsoleInsetLeft).width(titleSize.width).height(titleSize.height).verAlign; - $(self.subtitleLabel).x(self.titleLabel.$maxX + 5).toRight(ConsoleInsetRight).heightToFit.verAlign; -} - -- (void)setIsDarkMode:(BOOL)isDarkMode { - [super setIsDarkMode:isDarkMode]; - self.titleLabel.textColor = isDarkMode ? LookinColorMake(85, 200, 95) : LookinColorMake(54, 155, 62); -} - -@end diff --git a/LookInside/Console/LKConsoleViewController.h b/LookInside/Console/LKConsoleViewController.h deleted file mode 100644 index 73bdc3e7..00000000 --- a/LookInside/Console/LKConsoleViewController.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// LKConsoleViewController.h -// Lookin -// -// Created by Li Kai on 2019/4/19. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LookinObject, LKHierarchyDataSource, LookinLiveDocument; - -@interface LKConsoleViewController : LKBaseViewController - -- (instancetype)initWithHierarchyDataSource:(LKHierarchyDataSource *)dataSource; - -@property(nonatomic, assign) BOOL isControllerShowing; - -/// Phase F: forwarded onto the controller's `LKConsoleDataSource` so RPC -/// invocations route through the owning Live Doc's inspectable app. -@property(nonatomic, weak) LookinLiveDocument *liveDocument; - -- (void)submitWithObj:(LookinObject *)obj text:(NSString *)text; - -@end diff --git a/LookInside/Console/LKConsoleViewController.m b/LookInside/Console/LKConsoleViewController.m deleted file mode 100644 index f527c3fd..00000000 --- a/LookInside/Console/LKConsoleViewController.m +++ /dev/null @@ -1,214 +0,0 @@ -// -// LKConsoleViewController.m -// Lookin -// -// Created by Li Kai on 2019/4/19. -// https://lookin.work -// - -#import "LKConsoleViewController.h" -#import "LKConsoleDataSource.h" -#import "LookinLiveDocument.h" -#import "LKConsoleDataSourceRowItem.h" -#import "LKTableView.h" -#import "LKConsoleSubmitRowView.h" -#import "LKConsoleReturnRowView.h" -#import "LKConsoleInputRowView.h" -#import "LKTableRowView.h" -#import "LookinObject.h" - -@interface LKConsoleViewController () - -@property(nonatomic, strong) LKConsoleDataSource *dataSource; -@property(nonatomic, strong) LKTableView *tableView; -@property(nonatomic, strong) NSButton *clearButton; - -@property(nonatomic, strong) LKConsoleInputRowView *inputRowView; -@property(nonatomic, strong) CALayer *topBorderLayer; - -@end - -@implementation LKConsoleViewController - -- (instancetype)initWithHierarchyDataSource:(LKHierarchyDataSource *)dataSource { - self.dataSource = [[LKConsoleDataSource alloc] initWithHierarchyDataSource:dataSource]; - - if (self = [self initWithContainerView:nil]) { - @weakify(self); - [RACObserve(self.dataSource, rowItems) subscribeNext:^(id _Nullable x) { - @strongify(self); - [self.tableView reloadData]; - [self.tableView layoutSubtreeIfNeeded]; - - if (self.dataSource.rowItems.count) { - [self.tableView scrollRowToVisible:(self.dataSource.rowItems.count - 1)]; - } - [self.inputRowView makeTextFieldAsFirstResponder]; - }]; - } - return self; -} - -- (NSView *)makeContainerView { - LKBaseView *containerView = [LKBaseView new]; - containerView.backgroundColorName = @"ConsoleBackgroundColor"; - - self.inputRowView = [[LKConsoleInputRowView alloc] initWithDataSource:self.dataSource]; - - self.tableView = [LKTableView new]; - self.tableView.drawsBackground = NO; - self.tableView.delegate = self; - self.tableView.dataSource = self; - self.tableView.canScrollHorizontally = NO; - self.tableView.adjustsSelectionAutomatically = NO; - self.tableView.adjustsHoverAutomatically = NO; - self.tableView.automaticallyAdjustsContentInsets = NO; - self.tableView.contentInsets = NSEdgeInsetsMake(5, 0, 5, 0); - [containerView addSubview:self.tableView]; - - NSImage *clearButtonImage = NSImageMake(@"icon_delete"); - clearButtonImage.template = YES; - self.clearButton = [NSButton new]; - self.clearButton.image = clearButtonImage; - self.clearButton.bezelStyle = NSBezelStyleRoundRect; - self.clearButton.bordered = NO; - self.clearButton.target = self; - self.clearButton.action = @selector(_handleClearButton); - [containerView addSubview:self.clearButton]; - - self.topBorderLayer = [CALayer layer]; - [self.topBorderLayer lookin_removeImplicitAnimations]; - [containerView.layer addSublayer:self.topBorderLayer]; - @weakify(self); - containerView.didChangeAppearanceBlock = ^(LKBaseView *view, BOOL isDarkMode) { - @strongify(self); - if (isDarkMode) { - self.topBorderLayer.backgroundColor = [NSColor colorWithWhite:1 alpha:.12].CGColor; - } else { - self.topBorderLayer.backgroundColor = [NSColor colorWithWhite:0 alpha:.15].CGColor; - } - }; - - return containerView; -} - -- (void)viewDidAppear { - [super viewDidAppear]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self.tableView scrollRowToVisible:(self.dataSource.rowItems.count - 1)]; - [self.inputRowView makeTextFieldAsFirstResponder]; - }); -} - -- (void)viewDidLayout { - [super viewDidLayout]; - $(self.topBorderLayer).fullWidth.height(1).y(0); - - $(self.tableView).fullFrame; - $(self.clearButton).sizeToFit.right(20).bottom(8); - - CGFloat prevWidth = [self lookin_getBindDoubleForKey:@"prevWidth"]; - if (prevWidth != self.view.$width) { - [self lookin_bindDouble:self.view.$width forKey:@"prevWidth"]; - [self.tableView reloadData]; - } -} - -- (void)_handleClearButton { - [self.dataSource clearHistoryContents]; -} - -- (void)setIsControllerShowing:(BOOL)isControllerShowing { - self.dataSource.isShowingConsole = isControllerShowing; -} - -- (BOOL)isControllerShowing { - return self.dataSource.isShowingConsole; -} - -- (void)submitWithObj:(LookinObject *)obj text:(NSString *)text { - [[self.dataSource submitWithObj:obj text:text] subscribeNext:^(id _Nullable x) { - } error:^(NSError * _Nullable error) { - NSLog(@"Submit error: %@", error); - }]; -} - -- (void)setLiveDocument:(LookinLiveDocument *)liveDocument { - _liveDocument = liveDocument; - self.dataSource.liveDocument = liveDocument; -} - -#pragma mark - LKTableView - -- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { - NSInteger count = self.dataSource.rowItems.count; - return count; -} - -- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row { - LKConsoleDataSourceRowItem *item = [self.dataSource.rowItems lookin_safeObjectAtIndex:row]; - if (!item) { - return 0; - } - if (item.type == LKConsoleDataSourceRowItemTypeInput) { - return 20; - } - - if (item.type == LKConsoleDataSourceRowItemTypeSubmit) { - return 20; - } - - if (item.type == LKConsoleDataSourceRowItemTypeReturn) { - LKConsoleReturnRowView *calculatingView = [self lookin_getBindObjectForKey:@"calculatingReturnRowView"]; - if (!calculatingView) { - calculatingView = [LKConsoleReturnRowView new]; - [self lookin_bindObject:calculatingView forKey:@"calculatingReturnRowView"]; - } - calculatingView.titleLabel.stringValue = item.normalText; - CGFloat height = [calculatingView heightForWidth:self.tableView.$width]; - return height; - } - return 0; -} - -- (NSTableRowView *)tableView:(NSTableView *)tableView rowViewForRow:(NSInteger)row { - LKConsoleDataSourceRowItem *item = [self.dataSource.rowItems lookin_safeObjectAtIndex:row]; - if (!item) { - return [LKTableBlankRowView new]; - } - - if (item.type == LKConsoleDataSourceRowItemTypeInput) { - return self.inputRowView; - } - - if (item.type == LKConsoleDataSourceRowItemTypeSubmit) { - LKConsoleSubmitRowView *view = (LKConsoleSubmitRowView *)[tableView makeViewWithIdentifier:@"submit" owner:self]; - if (!view) { - view = [LKConsoleSubmitRowView new]; - view.identifier = @"submit"; - } - view.titleLabel.stringValue = item.highlightText; - view.subtitleLabel.stringValue = item.normalText; - [view setNeedsLayout:YES]; - return view; - } - - if (item.type == LKConsoleDataSourceRowItemTypeReturn) { - LKConsoleReturnRowView *view = (LKConsoleReturnRowView *)[tableView makeViewWithIdentifier:@"return" owner:self]; - if (!view) { - view = [LKConsoleReturnRowView new]; - view.identifier = @"return"; - } - view.titleLabel.stringValue = item.normalText; - [view setNeedsLayout:YES]; - return view; - } - - return [LKTableBlankRowView new]; -} - -- (void)tableViewDidClickBlankArea:(LKTableView *)tableView { - [self.inputRowView makeTextFieldAsFirstResponder]; -} - -@end diff --git a/LookInside/Console/LKConsoleViewController.swift b/LookInside/Console/LKConsoleViewController.swift new file mode 100644 index 00000000..b670b6c3 --- /dev/null +++ b/LookInside/Console/LKConsoleViewController.swift @@ -0,0 +1,189 @@ +// +// LKConsoleViewController.swift +// LookInside +// +// The Console below the inspector: a transcript of method calls on the +// target object, an input row with selector suggestions, and a button that +// clears the transcript. +// + +import AppKit + +@objc(LKConsoleViewController) +final class LKConsoleViewController: LKBaseViewController, LKTableViewDelegate, LKTableViewDataSource { + private let dataSource: LKConsoleDataSource + private let tableView = LKTableView() + private let clearButton = NSButton() + private let inputRowView: LKConsoleInputRowView + private let topBorderLayer = CALayer() + private lazy var measuringReturnRowView = LKConsoleReturnRowView() + private var previousWidth: CGFloat = 0 + + @objc weak var liveDocument: LookinLiveDocument? { + didSet { dataSource.liveDocument = liveDocument } + } + + /// Whether the Console is on screen; while it is, the highlighted + /// hierarchy item can become the call target. + @objc var isControllerShowing: Bool { + get { dataSource.isShowingConsole } + set { dataSource.isShowingConsole = newValue } + } + + @objc(initWithHierarchyDataSource:) + init(hierarchyDataSource: LKHierarchyDataSource) { + dataSource = LKConsoleDataSource(hierarchyDataSource: hierarchyDataSource) + inputRowView = LKConsoleInputRowView(dataSource: dataSource) + super.init(containerView: nil) + + dataSource.rowItemsDidChange = { [weak self] in + self?.handleRowItemsDidChange() + } + handleRowItemsDidChange() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func makeContainerView() -> NSView { + let containerView = LKBaseView() + containerView.layer?.backgroundColor = NSColor(named: "ConsoleBackgroundColor")?.cgColor + + tableView.drawsBackground = false + tableView.delegate = self + tableView.dataSource = self + tableView.canScrollHorizontally = false + tableView.adjustsSelectionAutomatically = false + tableView.adjustsHoverAutomatically = false + tableView.automaticallyAdjustsContentInsets = false + tableView.contentInsets = NSEdgeInsets(top: 5, left: 0, bottom: 5, right: 0) + containerView.addSubview(tableView) + + let clearButtonImage = NSImage(named: "icon_delete") + clearButtonImage?.isTemplate = true + clearButton.image = clearButtonImage + clearButton.bezelStyle = .roundRect + clearButton.isBordered = false + clearButton.target = self + clearButton.action = #selector(handleClearButton) + containerView.addSubview(clearButton) + + topBorderLayer.lookin_removeImplicitAnimations() + containerView.layer?.addSublayer(topBorderLayer) + containerView.didChangeAppearanceBlock = { [weak self] _, isDarkMode in + self?.topBorderLayer.backgroundColor = isDarkMode + ? NSColor(white: 1, alpha: 0.12).cgColor + : NSColor(white: 0, alpha: 0.15).cgColor + } + return containerView + } + + private func handleRowItemsDidChange() { + tableView.reloadData() + tableView.layoutSubtreeIfNeeded() + if !dataSource.rowItems.isEmpty { + tableView.scrollRowToVisible(dataSource.rowItems.count - 1) + } + inputRowView.makeTextFieldFirstResponder() + } + + override func viewDidAppear() { + super.viewDidAppear() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [self] in + tableView.scrollRowToVisible(dataSource.rowItems.count - 1) + inputRowView.makeTextFieldFirstResponder() + } + } + + override func viewDidLayout() { + super.viewDidLayout() + topBorderLayer.lkpFullWidth() + topBorderLayer.lkpSetFrame(y: 0, height: 1) + + tableView.lkpFullFrame() + clearButton.lkpSizeToFit() + clearButton.lkpRight(20) + clearButton.lkpBottom(8) + + let width = view.bounds.width + if previousWidth != width { + previousWidth = width + tableView.reloadData() + } + } + + @objc private func handleClearButton() { + dataSource.clearHistoryContents() + } + + /// Calls `text` on `obj` and adds the call to the transcript; errors are only logged. + @objc(submitWithObj:text:) + func submit(with obj: LookinObject?, text: String) { + Task { + do { + try await dataSource.submit(object: obj, text: text) + } catch { + NSLog("Submit error: %@", String(describing: error)) + } + } + } + + // MARK: - LKTableViewDelegate, LKTableViewDataSource + + func numberOfRows(in _: NSTableView) -> Int { + dataSource.rowItems.count + } + + func tableView(_: NSTableView, heightOfRow row: Int) -> CGFloat { + guard dataSource.rowItems.indices.contains(row) else { return 0 } + let item = dataSource.rowItems[row] + switch item.kind { + case .input, .submit: + return 20 + case .returnValue: + let measuringView = measuringReturnRowView + measuringView.titleLabel.stringValue = item.normalText ?? "" + return measuringView.height(forWidth: tableView.bounds.width) + } + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + guard dataSource.rowItems.indices.contains(row) else { + return LKTableBlankRowView() + } + let item = dataSource.rowItems[row] + switch item.kind { + case .input: + return inputRowView + case .submit: + let identifier = NSUserInterfaceItemIdentifier("submit") + let view = tableView.makeView(withIdentifier: identifier, owner: self) as? LKConsoleSubmitRowView + ?? { + let view = LKConsoleSubmitRowView() + view.identifier = identifier + return view + }() + view.titleLabel.stringValue = item.highlightText ?? "" + view.subtitleLabel.stringValue = item.normalText ?? "" + view.needsLayout = true + return view + case .returnValue: + let identifier = NSUserInterfaceItemIdentifier("return") + let view = tableView.makeView(withIdentifier: identifier, owner: self) as? LKConsoleReturnRowView + ?? { + let view = LKConsoleReturnRowView() + view.identifier = identifier + return view + }() + view.titleLabel.stringValue = item.normalText ?? "" + view.needsLayout = true + return view + } + } + + func tableViewDidClickBlankArea(_: LKTableView) { + inputRowView.makeTextFieldFirstResponder() + } +} diff --git a/LookInside/DanceScript/DanceScriptManager.h b/LookInside/DanceScript/DanceScriptManager.h deleted file mode 100644 index 56577c45..00000000 --- a/LookInside/DanceScript/DanceScriptManager.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// DanceScriptManager.h -// LookinClient -// -// Created by likai.123 on 2023/12/18. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import - -@interface DanceScriptManager : NSObject - -+ (instancetype)shared; - -- (void)handleText:(NSString *)text; - -@end diff --git a/LookInside/DanceScript/DanceScriptManager.m b/LookInside/DanceScript/DanceScriptManager.m deleted file mode 100644 index 2867bc90..00000000 --- a/LookInside/DanceScript/DanceScriptManager.m +++ /dev/null @@ -1,119 +0,0 @@ -// -// DanceScriptManager.m -// LookinClient -// -// Created by likai.123 on 2023/12/18. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "DanceScriptManager.h" - -@implementation DanceScriptManager - -+ (instancetype)shared { - static dispatch_once_t onceToken; - static DanceScriptManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -- (void)handleText:(NSString *)json { - if (!json) { - AlertError(LookinErr_Inner, CurrentKeyWindow); - return; - } - NSData *jsonData = [json dataUsingEncoding:NSUTF8StringEncoding]; - NSError *error; - NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:&error]; - if (error) { - NSString *msg = [NSString stringWithFormat:@"Failed to parse: %@", json]; - AlertErrorText(@"Failed", msg, CurrentKeyWindow); - NSAssert(NO, @""); - return; - } - if (![dict isKindOfClass:[NSDictionary class]]) { - NSString *msg = [NSString stringWithFormat:@"Unexpected format: %@", json]; - AlertErrorText(@"Failed", msg, CurrentKeyWindow); - return; - } - NSString *type = dict[@"type"]; - NSString *method = dict[@"method"]; - NSString *path = dict[@"build_path"]; - if (!type || !method || !path) { - NSString *msg = [NSString stringWithFormat:@"Unexpected format: %@", json]; - AlertErrorText(@"Failed", msg, CurrentKeyWindow); - return; - } - [self executeWithType:type method:method path:path]; -} - -// 把 sh 脚本从 App Content 里 Copy 出来(不确定有没有必要) -- (NSString *)copyScriptFromAppToDisk { - NSString *newScriptPath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"Lookin_DanceJump.sh"]; - // NSString *desktopPath = [NSSearchPathForDirectoriesInDomains(NSDesktopDirectory, NSUserDomainMask, YES) firstObject]; - // NSString *newScriptPath = [desktopPath stringByAppendingPathComponent:@"Lookin_DanceJump.sh"]; - if ([[NSFileManager defaultManager] fileExistsAtPath:newScriptPath]) { - return newScriptPath; - } - - NSBundle *mainBundle = [NSBundle mainBundle]; - NSString *scriptPath = [[mainBundle URLForResource:@"DanceScript" withExtension:@"sh"] relativePath]; - if (!scriptPath || ![[NSFileManager defaultManager] fileExistsAtPath:scriptPath]) { - NSString *text = [NSString stringWithFormat: @"Cannot find script: %@", scriptPath]; - AlertErrorText(@"Failed", text, CurrentKeyWindow); - return nil; - } - BOOL succ = [[NSFileManager defaultManager] copyItemAtPath:scriptPath toPath:newScriptPath error:nil]; - NSDictionary *attributes = [NSDictionary dictionaryWithObject:[NSNumber numberWithShort:0755] forKey:NSFilePosixPermissions]; - [[NSFileManager defaultManager] setAttributes:attributes ofItemAtPath:newScriptPath error:nil]; - if (!succ) { - AlertErrorText(@"Failed", @"Copy script error", CurrentKeyWindow); - return nil; - } - if (![[NSFileManager defaultManager] fileExistsAtPath:newScriptPath]) { - AlertErrorText(@"Failed", @"Copy script weird error", CurrentKeyWindow); - return nil; - } - return newScriptPath; -} - -/* - - 对应 lookin_source 的类型是这个 json 结构 - {"type":"DanceUIApp.ContentView","method":"body.get","build_path":"\/Users\/bytedance\/Library\/Developer\/Xcode\/DerivedData\/DanceUIApp-ayaxbucdgzeouqefaavnvvhsjpvj\/Build\/Products\/Debug-iphonesimulator\/DanceUIApp.app\/DanceUIApp"} - - 传参的顺序是 sh viewdebug.sh build_path type method - */ -- (void)executeWithType:(NSString *)type method:(NSString *)method path:(NSString *)buildPath { - NSString *scriptPath = [self copyScriptFromAppToDisk]; - // 创建 NSTask 对象 - NSTask *task = [[NSTask alloc] init]; - - // 设置要执行的命令 - [task setLaunchPath:@"/bin/bash"]; - NSArray *arguments = [NSArray arrayWithObjects: - @"-c" , - [NSString stringWithFormat:@"%@ %@ %@ %@", scriptPath, buildPath, type, method], - nil]; - [task setArguments:arguments]; - - // 创建管道用于获取输出 - NSPipe *pipe = [NSPipe pipe]; - [task setStandardOutput:pipe]; - - // 开始任务 - [task launch]; - - // 从管道中读取输出 - NSFileHandle *file = [pipe fileHandleForReading]; - NSData *data = [file readDataToEndOfFile]; - NSString *output = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - - // 输出脚本执行结果 - NSLog(@"脚本执行输出:%@", output); - -} - -@end diff --git a/LookInside/DanceScript/DanceScriptManager.swift b/LookInside/DanceScript/DanceScriptManager.swift new file mode 100644 index 00000000..c5920268 --- /dev/null +++ b/LookInside/DanceScript/DanceScriptManager.swift @@ -0,0 +1,111 @@ +// +// DanceScriptManager.swift +// LookInside +// +// Jumps from a DanceUI source location to Xcode. The dashboard hands over a +// JSON payload like +// {"type":"DanceUIApp.ContentView","method":"body.get","build_path":"…/DanceUIApp.app/DanceUIApp"}; +// the bundled DanceScript.sh is copied to the temporary directory once and +// run as `DanceScript.sh `. +// + +import AppKit + +@objc(DanceScriptManager) +final class DanceScriptManager: NSObject { + private static let sharedManager = DanceScriptManager() + + @objc class func shared() -> DanceScriptManager { + sharedManager + } + + /// The fields of the dashboard's JSON payload. + struct Request: Equatable { + var type: String + var method: String + var buildPath: String + } + + enum ParseError: Error, Equatable { + case invalidJSON + case unexpectedFormat + } + + static func parse(_ json: String) -> Result { + guard let object = try? JSONSerialization.jsonObject(with: Data(json.utf8)) else { + return .failure(.invalidJSON) + } + guard let dict = object as? [String: Any] else { + return .failure(.unexpectedFormat) + } + guard let type = dict["type"] as? String, let method = dict["method"] as? String, + let buildPath = dict["build_path"] as? String + else { + return .failure(.unexpectedFormat) + } + return .success(Request(type: type, method: method, buildPath: buildPath)) + } + + @MainActor + @objc(handleText:) + func handleText(_ json: String?) { + guard let json else { + LKPeripheralAlerts.show(LKConnectionError.inner, in: LKPeripheralAlerts.keyWindow) + return + } + switch Self.parse(json) { + case let .success(request): + execute(request) + case .failure(.invalidJSON): + LKPeripheralAlerts.show(title: "Failed", detail: "Failed to parse: \(json)", in: LKPeripheralAlerts.keyWindow) + assertionFailure("DanceScript payload is not JSON") + case .failure(.unexpectedFormat): + LKPeripheralAlerts.show(title: "Failed", detail: "Unexpected format: \(json)", in: LKPeripheralAlerts.keyWindow) + } + } + + @MainActor + private func copyScriptFromAppToDisk() -> String? { + let fileManager = FileManager.default + let newScriptPath = (NSTemporaryDirectory() as NSString).appendingPathComponent("Lookin_DanceJump.sh") + if fileManager.fileExists(atPath: newScriptPath) { + return newScriptPath + } + let scriptPath = Bundle.main.url(forResource: "DanceScript", withExtension: "sh")?.relativePath + guard let scriptPath, fileManager.fileExists(atPath: scriptPath) else { + LKPeripheralAlerts.show(title: "Failed", detail: "Cannot find script: \(scriptPath ?? "(null)")", + in: LKPeripheralAlerts.keyWindow) + return nil + } + let copied = (try? fileManager.copyItem(atPath: scriptPath, toPath: newScriptPath)) != nil + try? fileManager.setAttributes([.posixPermissions: NSNumber(value: Int16(0o755))], ofItemAtPath: newScriptPath) + guard copied else { + LKPeripheralAlerts.show(title: "Failed", detail: "Copy script error", in: LKPeripheralAlerts.keyWindow) + return nil + } + guard fileManager.fileExists(atPath: newScriptPath) else { + LKPeripheralAlerts.show(title: "Failed", detail: "Copy script weird error", in: LKPeripheralAlerts.keyWindow) + return nil + } + return newScriptPath + } + + @MainActor + private func execute(_ request: Request) { + let scriptPath = copyScriptFromAppToDisk() ?? "(null)" + let task = Process() + task.executableURL = URL(fileURLWithPath: "/bin/bash") + task.arguments = ["-c", "\(scriptPath) \(request.buildPath) \(request.type) \(request.method)"] + let pipe = Pipe() + task.standardOutput = pipe + do { + try task.run() + } catch { + NSLog("DanceScript failed to launch: %@", String(describing: error)) + return + } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + NSLog("脚本执行输出:%@", output) + } +} diff --git a/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.h b/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.h deleted file mode 100644 index d598e2f5..00000000 --- a/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// LKConstraintPopoverController.h -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LookinAutoLayoutConstraint, LKHierarchyDataSource, LookinObject; - -@interface LKConstraintPopoverController : LKBaseViewController - -/// canJumpToObject decides at render time whether an endpoint's jump button -/// is enabled — a target can be absent from the current tree (released, -/// not captured, or data predating oid identity). Pass nil to enable all. -- (instancetype)initWithConstraint:(LookinAutoLayoutConstraint *)constraint - canJumpToObject:(BOOL (^)(LookinObject *lookinObj))canJumpToObject; - -- (NSSize)contentSize; - -@property(nonatomic, copy) void (^requestJumpingToObject)(LookinObject *lookinObj); - -@end diff --git a/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.m b/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.m deleted file mode 100644 index 44f5ec67..00000000 --- a/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.m +++ /dev/null @@ -1,133 +0,0 @@ -// -// LKConstraintPopoverController.m -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - -#import "LKConstraintPopoverController.h" -#import "LookinAutoLayoutConstraint.h" -#import "LKTextsMenuView.h" -#import "LKTextFieldView.h" -#import "LookinObject.h" -#import "LookinAutoLayoutConstraint+LookinClient.h" - -@interface LKConstraintPopoverController () - -@property(nonatomic, strong) LKTextFieldView *titleView; -@property(nonatomic, strong) LKTextsMenuView *textsView; -@property(nonatomic, strong) LookinAutoLayoutConstraint *constraint; - -@end - -@implementation LKConstraintPopoverController { - CGFloat _horInset; - CGFloat _insetBottom; - CGFloat _titleHeight; - CGFloat _textsViewMarginTop; -} - -- (instancetype)initWithConstraint:(LookinAutoLayoutConstraint *)constraint - canJumpToObject:(BOOL (^)(LookinObject *lookinObj))canJumpToObject { - if (self = [self initWithContainerView:nil]) { - _horInset = 5; - _insetBottom = 10; - _titleHeight = 26; - _textsViewMarginTop = 10; - self.constraint = constraint; - - if (!constraint.effective) { - self.titleView = [LKTextFieldView labelView]; - self.titleView.textField.font = NSFontMake(IsEnglish ? 12 : 13); - self.titleView.textColors = LKColorsCombine(NSColorGray1, NSColorGray9); - self.titleView.textField.alignment = NSTextAlignmentCenter; - self.titleView.textField.stringValue = NSLocalizedString(@"The layout of selected view is not affected by this constraint.", nil); - self.titleView.backgroundColors = LKColorsCombine(LookinColorRGBAMake(0, 0, 0, 0.1), LookinColorRGBAMake(0, 0, 0, 0.2)); - self.titleView.image = NSImageMake(@"Constraint_Popover_Info"); - self.titleView.insets = NSEdgeInsetsMake(0, _horInset, 0, _horInset); - [self.view addSubview:self.titleView]; - } - - self.textsView = [LKTextsMenuView new]; - self.textsView.verSpace = 8; - self.textsView.horSpace = 4; - self.textsView.font = NSFontMake(13); - self.textsView.type = LKTextsMenuViewTypeCenter; - [self.view addSubview:self.textsView]; - - NSMutableArray *texts = [NSMutableArray array]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"FirstItem" second:[LookinAutoLayoutConstraint descriptionWithItemObject:constraint.firstItem type:constraint.firstItemType detailed:YES]]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"FirstAttribute" second:[LookinAutoLayoutConstraint descriptionWithAttributeInt:constraint.firstAttribute].lk_capitalizedString]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Relation" second:[LookinAutoLayoutConstraint descriptionWithRelation:constraint.relation]]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"SecondItem" second:[LookinAutoLayoutConstraint descriptionWithItemObject:constraint.secondItem type:constraint.secondItemType detailed:YES]]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"SecondAttribute" second:[LookinAutoLayoutConstraint descriptionWithAttributeInt:constraint.secondAttribute].lk_capitalizedString]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Multiplier" second:[NSString stringWithFormat:@"%@", @(constraint.multiplier)]]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Constant" second:[NSString stringWithFormat:@"%@", @(constraint.constant)]]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Priority" second:[NSString stringWithFormat:@"%@", @(constraint.priority)]]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Active" second:constraint.active ? @"YES" : @"NO"]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"ShouldBeArchived" second:constraint.shouldBeArchived ? @"YES" : @"NO"]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Identifier" second:constraint.identifier ? : @""]]; - - LookinObject *firstJumpObject = [constraint jumpableItemObjectForEndpoint:LookinConstraintEndpointFirst]; - if (firstJumpObject) { - [self.textsView addButton:[self _jumpButtonForObject:firstJumpObject canJumpToObject:canJumpToObject] atIndex:0]; - } - LookinObject *secondJumpObject = [constraint jumpableItemObjectForEndpoint:LookinConstraintEndpointSecond]; - if (secondJumpObject) { - [self.textsView addButton:[self _jumpButtonForObject:secondJumpObject canJumpToObject:canJumpToObject] atIndex:3]; - } - - self.textsView.texts = texts; - } - return self; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - if (self.titleView) { - $(self.titleView).fullWidth.height(_titleHeight).y(0); - } - - CGFloat y = (self.titleView ? _titleHeight : 0); - $(self.textsView).sizeToFit.horAlign.y(y + _textsViewMarginTop); -} - -- (NSSize)contentSize { - NSSize resultSize = [self.textsView sizeThatFits:NSSizeMax]; - - if (self.titleView) { - CGFloat titleWidth = [self.titleView sizeThatFits:NSSizeMax].width; - resultSize.width = MAX(titleWidth, resultSize.width); - resultSize.height += _titleHeight; - } - - resultSize.width += _horInset * 2; - resultSize.height += (_insetBottom + _textsViewMarginTop); - - return resultSize; -} - -- (NSButton *)_jumpButtonForObject:(LookinObject *)jumpObject canJumpToObject:(BOOL (^)(LookinObject *lookinObj))canJumpToObject { - NSButton *button = [NSButton lk_buttonWithImage:NSImageMake(@"Icon_JumpDisclosure") target:self action:@selector(_handleJumpButton:)]; - [button lookin_bindObject:jumpObject forKey:@"jumpObject"]; - BOOL canJump = canJumpToObject ? canJumpToObject(jumpObject) : YES; - if (!canJump) { - button.enabled = NO; - button.toolTip = NSLocalizedString(@"This object is not in the current hierarchy — it may have been released, filtered out, or come from data captured before it had an identity.", nil); - } - return button; -} - -- (void)_handleJumpButton:(NSButton *)button { - LookinObject *object = [button lookin_getBindObjectForKey:@"jumpObject"]; - if (!object) { - NSAssert(NO, @""); - return; - } - if (self.requestJumpingToObject) { - self.requestJumpingToObject(object); - } -} - -@end diff --git a/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.swift b/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.swift new file mode 100644 index 00000000..6df8623f --- /dev/null +++ b/LookInside/Dashboard/AttributeView/Constraint/LKConstraintPopoverController.swift @@ -0,0 +1,118 @@ +// +// LKConstraintPopoverController.swift +// LookInside +// +// Created by Li Kai on 2019/9/28. +// https://lookin.work +// + +import AppKit + +/// Every field of one constraint, with buttons that jump to its items. +final class LKConstraintPopoverController: LKBaseViewController { + private let horInset: CGFloat = 5 + private let insetBottom: CGFloat = 10 + private let titleHeight: CGFloat = 26 + private let textsViewMarginTop: CGFloat = 10 + + private var titleView: LKTextFieldView? + private let textsView = LKTextsMenuView() + private var jumpObjects: [ObjectIdentifier: LookinObject] = [:] + + /// Asked to jump to an item of the constraint. + var requestJumpingToObject: ((LookinObject) -> Void)? + + /// `canJumpToObject` decides whether an item's jump button is enabled: + /// the item can be missing from the current tree (released, not + /// captured, or from data that predates object identity). Nil enables + /// every button. + init(constraint: LookinAutoLayoutConstraint, canJumpToObject: ((LookinObject) -> Bool)?) { + super.init(containerView: nil) + + if !constraint.effective { + let titleView = LKTextFieldView.label() + titleView.textField.font = LKDashboardStyle.font(LKHelper.isEnglish() ? 12 : 13) + titleView.textColors = LKTwoColors(colorInLightMode: LKDashboardStyle.rgb(53, 60, 70), colorInDarkMode: LKDashboardStyle.rgb(216, 220, 228)) + titleView.textField.alignment = .center + titleView.textField.stringValue = NSLocalizedString("The layout of selected view is not affected by this constraint.", comment: "") + titleView.backgroundColors = LKTwoColors(colorInLightMode: LKDashboardStyle.rgb(0, 0, 0, 0.1), colorInDarkMode: LKDashboardStyle.rgb(0, 0, 0, 0.2)) + titleView.image = LKDashboardStyle.image("Constraint_Popover_Info") + titleView.insets = NSEdgeInsets(top: 0, left: horInset, bottom: 0, right: horInset) + view.addSubview(titleView) + self.titleView = titleView + } + + textsView.verSpace = 8 + textsView.horSpace = 4 + textsView.font = LKDashboardStyle.font(13) + textsView.type = .center + view.addSubview(textsView) + + func tuple(_ first: String, _ second: String?) -> LookinStringTwoTuple { + LookinStringTwoTuple(first: first, second: second ?? "") + } + let texts = [ + tuple("FirstItem", LookinAutoLayoutConstraint.description(withItemObject: constraint.firstItem, type: constraint.firstItemType, detailed: true)), + tuple("FirstAttribute", (LookinAutoLayoutConstraint.description(withAttributeInt: constraint.firstAttribute) as NSString).lk_capitalizedString()), + tuple("Relation", LookinAutoLayoutConstraint.description(with: constraint.relation)), + tuple("SecondItem", LookinAutoLayoutConstraint.description(withItemObject: constraint.secondItem, type: constraint.secondItemType, detailed: true)), + tuple("SecondAttribute", (LookinAutoLayoutConstraint.description(withAttributeInt: constraint.secondAttribute) as NSString).lk_capitalizedString()), + tuple("Multiplier", "\(NSNumber(value: Double(constraint.multiplier)))"), + tuple("Constant", "\(NSNumber(value: Double(constraint.constant)))"), + tuple("Priority", "\(NSNumber(value: Double(constraint.priority)))"), + tuple("Active", constraint.active ? "YES" : "NO"), + tuple("ShouldBeArchived", constraint.shouldBeArchived ? "YES" : "NO"), + tuple("Identifier", constraint.identifier ?? ""), + ] + + if let firstJumpObject = constraint.jumpableItemObject(for: .first) { + textsView.add(jumpButton(for: firstJumpObject, canJumpToObject: canJumpToObject), at: 0) + } + if let secondJumpObject = constraint.jumpableItemObject(for: .second) { + textsView.add(jumpButton(for: secondJumpObject, canJumpToObject: canJumpToObject), at: 3) + } + + textsView.texts = texts + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLayout() { + super.viewDidLayout() + titleView?.dashboardLayout.fullWidth().height(titleHeight).y(0) + let y = titleView == nil ? 0 : titleHeight + textsView.dashboardLayout.sizeToFit().horAlign().y(y + textsViewMarginTop) + } + + func contentSize() -> NSSize { + var resultSize = textsView.sizeThatFits(LKDashboardMetrics.maxSize) + if let titleView { + let titleWidth = titleView.sizeThatFits(LKDashboardMetrics.maxSize).width + resultSize.width = max(titleWidth, resultSize.width) + resultSize.height += titleHeight + } + resultSize.width += horInset * 2 + resultSize.height += insetBottom + textsViewMarginTop + return resultSize + } + + private func jumpButton(for jumpObject: LookinObject, canJumpToObject: ((LookinObject) -> Bool)?) -> NSButton { + let button = NSButton.lk_button(with: LKDashboardStyle.image("Icon_JumpDisclosure"), target: self, action: #selector(handleJumpButton(_:))) + jumpObjects[ObjectIdentifier(button)] = jumpObject + if let canJumpToObject, !canJumpToObject(jumpObject) { + button.isEnabled = false + button.toolTip = NSLocalizedString("This object is not in the current hierarchy — it may have been released, filtered out, or come from data captured before it had an identity.", comment: "") + } + return button + } + + @objc private func handleJumpButton(_ button: NSButton) { + guard let object = jumpObjects[ObjectIdentifier(button)] else { + assertionFailure() + return + } + requestJumpingToObject?(object) + } +} diff --git a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.h b/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.h deleted file mode 100644 index b85fad99..00000000 --- a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKDashboardAttributeConstraintsItemControl.h -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - -#import "LKTextControl.h" - -@class LookinAutoLayoutConstraint; - -@interface LKDashboardAttributeConstraintsItemControl : LKTextControl - -@property(nonatomic, strong) LookinAutoLayoutConstraint *constraint; - -@end diff --git a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.m b/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.m deleted file mode 100644 index 0cd7535a..00000000 --- a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsItemControl.m +++ /dev/null @@ -1,92 +0,0 @@ -// -// LKDashboardAttributeConstraintsItemControl.m -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - -#import "LKDashboardAttributeConstraintsItemControl.h" -#import "LookinAutoLayoutConstraint.h" -#import "LookinObject.h" -#import "LookinAutoLayoutConstraint+LookinClient.h" - -@implementation LKDashboardAttributeConstraintsItemControl - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - @weakify(self); - self.label.alignment = NSTextAlignmentLeft; - self.label.font = NSFontMake(12); - self.didChangeAppearance = ^(LKBaseControl *control, BOOL isDarkMode) { - @strongify(self); - [self _updateLabelColor]; - }; - } - return self; -} - -- (void)setConstraint:(LookinAutoLayoutConstraint *)constraint { - _constraint = constraint; - self.label.stringValue = [self _stringFromConstraint:constraint]; - [self _updateLabelColor]; -} - -- (void)_updateLabelColor { - if (self.constraint.effective) { - self.label.textColor = [NSColor labelColor]; - } else { - if ([self.effectiveAppearance lk_isDarkMode]) { - self.label.textColor = LookinColorMake(130, 131, 132); - } else { - self.label.textColor = LookinColorMake(150, 151, 152); - } - } -} - -- (NSString *)_stringFromConstraint:(LookinAutoLayoutConstraint *)constraint { - NSMutableString *string = [NSMutableString string]; - [string appendFormat:@"%@.%@ %@", - [LookinAutoLayoutConstraint descriptionWithItemObject:constraint.firstItem type:constraint.firstItemType detailed:NO], - [LookinAutoLayoutConstraint descriptionWithAttributeInt:constraint.firstAttribute], - [LookinAutoLayoutConstraint symbolWithRelation:constraint.relation]]; - - if (constraint.secondAttribute == 0) { - [string appendFormat:@" %@", [NSString lookin_stringFromDouble:constraint.constant decimal:3]]; - } else { - [string appendFormat:@" %@.%@", - [LookinAutoLayoutConstraint descriptionWithItemObject:constraint.secondItem type:constraint.secondItemType detailed:NO], - [LookinAutoLayoutConstraint descriptionWithAttributeInt:constraint.secondAttribute]]; - - if (constraint.multiplier != 1) { - [string appendFormat:@" * %@", [NSString lookin_stringFromDouble:constraint.multiplier decimal:3]]; - } - if (constraint.constant > 0) { - [string appendFormat:@" + %@", [NSString lookin_stringFromDouble:constraint.constant decimal:3]]; - } else if (constraint.constant < 0) { - [string appendFormat:@" - %@", [NSString lookin_stringFromDouble:-constraint.constant decimal:3]]; - } - } - - if (constraint.priority != 1000) { - [string appendFormat:@" @ %@", @(constraint.priority)]; - } - - return string; -} - -- (BOOL)shouldTrackMouseEnteredAndExited { - return YES; -} - -- (void)mouseEntered:(NSEvent *)event { - [super mouseEntered:event]; - self.alphaValue = .5; -} - -- (void)mouseExited:(NSEvent *)event { - [super mouseExited:event]; - self.alphaValue = 1; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.h b/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.h deleted file mode 100644 index 8467645f..00000000 --- a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeConstraintsView.h -// Lookin -// -// Created by Li Kai on 2019/9/12. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeConstraintsView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.m b/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.m deleted file mode 100644 index c3e0757a..00000000 --- a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.m +++ /dev/null @@ -1,142 +0,0 @@ -// -// LKDashboardAttributeConstraintsView.m -// Lookin -// -// Created by Li Kai on 2019/9/12. -// https://lookin.work -// - -#import "LKDashboardAttributeConstraintsView.h" -#import "LookinObject.h" -#import "LKDashboardAttributeConstraintsItemControl.h" -#import "LookinAutoLayoutConstraint.h" -#import "LKConstraintPopoverController.h" -#import "LKDashboardViewController.h" -#import "LKHierarchyDataSource.h" -#import "LookinDisplayItem.h" - -@interface LKDashboardAttributeConstraintsView () - -@property(nonatomic, strong) NSMutableArray *textControls; - -@end - -@implementation LKDashboardAttributeConstraintsView { - CGFloat _verInterSpace; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _verInterSpace = 8; - - self.textControls = [NSMutableArray array]; - } - return self; -} - -- (void)renderWithAttribute { - [super renderWithAttribute]; - - NSArray *rawData = self.attribute.value; - NSArray *sortedRawData = [self _sortedRawDataFromData:rawData]; - - [self.textControls lookin_dequeueWithCount:sortedRawData.count add:^LKDashboardAttributeConstraintsItemControl *(NSUInteger idx) { - LKDashboardAttributeConstraintsItemControl *control = [LKDashboardAttributeConstraintsItemControl new]; - [control addTarget:self clickAction:@selector(_handleClickItem:)]; - [self addSubview:control]; - return control; - - } notDequeued:^(NSUInteger idx, LKDashboardAttributeConstraintsItemControl *control) { - control.hidden = YES; - - } doNext:^(NSUInteger idx, LKDashboardAttributeConstraintsItemControl *control) { - control.hidden = NO; - control.constraint = sortedRawData[idx]; - [control setNeedsLayout:YES]; - }]; - [self setNeedsLayout:YES]; -} - -- (void)layout { - [super layout]; - - __block CGFloat y = 0; - [self.textControls enumerateObjectsUsingBlock:^(LKTextControl * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hidden) { - return; - } - $(obj).fullFrame.heightToFit.y(y); - y = obj.$maxY + self->_verInterSpace; - }]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - NSArray *visibleControls = [self.textControls lookin_filter:^BOOL(LKTextControl *obj) { - return !obj.hidden; - }]; - limitedSize.height = [visibleControls lookin_reduceCGFloat:^CGFloat(CGFloat accumulator, NSUInteger idx, LKTextControl *obj) { - CGFloat labelHeight = [obj sizeThatFits:limitedSize].height; - accumulator += labelHeight; - if (idx) { - accumulator += self->_verInterSpace; - } - return accumulator; - } initialAccumlator:0]; - return limitedSize; -} - -- (NSArray *)_sortedRawDataFromData:(NSArray *)rawData { - return [rawData sortedArrayUsingComparator:^NSComparisonResult(LookinAutoLayoutConstraint *obj1, LookinAutoLayoutConstraint *obj2) { - if (obj1.effective != obj2.effective) { - if (obj1.effective) { - return NSOrderedAscending; - } else { - return NSOrderedDescending; - } - } - - if (obj1.firstItemType > obj2.firstItemType) { - return NSOrderedDescending; - } else if (obj1.firstItemType < obj2.firstItemType) { - return NSOrderedAscending; - } - - if (obj1.firstAttribute > obj2.firstAttribute) { - return NSOrderedDescending; - } else if (obj1.firstAttribute < obj2.firstAttribute) { - return NSOrderedAscending; - } - - return NSOrderedSame; - }]; -} - -- (void)_handleClickItem:(LKDashboardAttributeConstraintsItemControl *)control { - LookinAutoLayoutConstraint *constraint = control.constraint; - LKHierarchyDataSource *jumpDataSource = [self.dashboardViewController currentDataSource]; - LKConstraintPopoverController *vc = [[LKConstraintPopoverController alloc] initWithConstraint:constraint canJumpToObject:^BOOL(LookinObject *lookinObj) { - return lookinObj.oid != 0 && [jumpDataSource displayItemWithOid:lookinObj.oid] != nil; - }]; - - NSPopover *popover = [[NSPopover alloc] init]; - popover.animates = NO; - popover.behavior = NSPopoverBehaviorTransient; - popover.contentSize = [vc contentSize]; - popover.contentViewController = vc; - @weakify(popover); - vc.requestJumpingToObject = ^(LookinObject *lookinObj) { - @strongify(popover); - [popover close]; - - LKHierarchyDataSource *dataSource = [self.dashboardViewController currentDataSource]; - LookinDisplayItem *item = [dataSource displayItemWithOid:lookinObj.oid]; - // 注意这里要先 expand 然后再 select 以使得可以滚动到目标位置 - if (!item.displayingInHierarchy) { - [dataSource expandToShowItem:item]; - } - dataSource.selectedItem = item; - }; - [popover showRelativeToRect:NSMakeRect(0, 0, control.bounds.size.width, control.bounds.size.height) ofView:control preferredEdge:NSRectEdgeMaxX]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.swift b/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.swift new file mode 100644 index 00000000..62429b89 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/Constraint/LKDashboardAttributeConstraintsView.swift @@ -0,0 +1,163 @@ +// +// LKDashboardAttributeConstraintsView.swift +// LookInside +// +// Created by Li Kai on 2019/9/12. +// https://lookin.work +// + +import AppKit + +/// One constraint as a line of text, such as "self.width = 100 @ 750". +/// Constraints that do not affect the view are greyed out. +final class LKDashboardAttributeConstraintsItemControl: LKTextControl { + var constraint: LookinAutoLayoutConstraint? { + didSet { + label.stringValue = constraint.map(Self.string(from:)) ?? "" + updateLabelColor() + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + label.alignment = .left + label.font = LKDashboardStyle.font(12) + didChangeAppearance = { [weak self] _, _ in + self?.updateLabelColor() + } + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + private func updateLabelColor() { + if constraint?.effective == true { + label.textColor = .labelColor + } else if effectiveAppearance.lk_isDarkMode { + label.textColor = LKDashboardStyle.rgb(130, 131, 132) + } else { + label.textColor = LKDashboardStyle.rgb(150, 151, 152) + } + } + + static func string(from constraint: LookinAutoLayoutConstraint) -> String { + let firstItem = LookinAutoLayoutConstraint.description(withItemObject: constraint.firstItem, type: constraint.firstItemType, detailed: false) + let firstAttribute = LookinAutoLayoutConstraint.description(withAttributeInt: constraint.firstAttribute) + let relation = LookinAutoLayoutConstraint.symbol(with: constraint.relation) + var string = "\(firstItem).\(firstAttribute) \(relation)" + if constraint.secondAttribute == 0 { + string += " " + NSString.lookin_string(from: Double(constraint.constant), decimal: 3) + } else { + let secondItem = LookinAutoLayoutConstraint.description(withItemObject: constraint.secondItem, type: constraint.secondItemType, detailed: false) + let secondAttribute = LookinAutoLayoutConstraint.description(withAttributeInt: constraint.secondAttribute) + string += " \(secondItem).\(secondAttribute)" + if constraint.multiplier != 1 { + string += " * " + NSString.lookin_string(from: Double(constraint.multiplier), decimal: 3) + } + if constraint.constant > 0 { + string += " + " + NSString.lookin_string(from: Double(constraint.constant), decimal: 3) + } else if constraint.constant < 0 { + string += " - " + NSString.lookin_string(from: Double(-constraint.constant), decimal: 3) + } + } + if constraint.priority != 1000 { + string += " @ \(NSNumber(value: Double(constraint.priority)))" + } + return string + } + + override func shouldTrackMouseEnteredAndExited() -> Bool { + true + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + alphaValue = 0.5 + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + alphaValue = 1 + } +} + +/// The constraints that affect (or mention) the view; a click shows the +/// details of one. +@objc(LKDashboardAttributeConstraintsView) +final class LKDashboardAttributeConstraintsView: LKDashboardAttributeView { + private let verInterSpace: CGFloat = 8 + private var textControls: [LKDashboardAttributeConstraintsItemControl] = [] + + override func renderWithAttribute() { + super.renderWithAttribute() + let rawData = attribute?.value as? [LookinAutoLayoutConstraint] ?? [] + let constraints = LKDashboardConstraintOrder.sorted(rawData) + + while textControls.count < constraints.count { + let control = LKDashboardAttributeConstraintsItemControl() + control.addTarget(self, clickAction: #selector(handleClickItem(_:))) + addSubview(control) + textControls.append(control) + } + for (idx, control) in textControls.enumerated() { + if idx < constraints.count { + control.isHidden = false + control.constraint = constraints[idx] + control.needsLayout = true + } else { + control.isHidden = true + } + } + needsLayout = true + } + + override func layout() { + super.layout() + var y: CGFloat = 0 + for control in textControls where !control.isHidden { + control.dashboardLayout.fullFrame().heightToFit().y(y) + y = control.frame.maxY + verInterSpace + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var height: CGFloat = 0 + for (idx, control) in textControls.filter({ !$0.isHidden }).enumerated() { + height += control.sizeThatFits(limitedSize).height + if idx > 0 { + height += verInterSpace + } + } + var size = limitedSize + size.height = height + return size + } + + @objc private func handleClickItem(_ control: LKDashboardAttributeConstraintsItemControl) { + guard let constraint = control.constraint else { return } + let jumpDataSource = dashboardViewController?.currentDataSource() + let viewController = LKConstraintPopoverController(constraint: constraint) { lookinObject in + lookinObject.oid != 0 && jumpDataSource?.displayItem(withOid: lookinObject.oid) != nil + } + + let popover = NSPopover() + popover.animates = false + popover.behavior = .transient + popover.contentSize = viewController.contentSize() + popover.contentViewController = viewController + viewController.requestJumpingToObject = { [weak popover, weak self] lookinObject in + popover?.close() + + guard let dataSource = self?.dashboardViewController?.currentDataSource(), + let item = dataSource.displayItem(withOid: lookinObject.oid) + else { return } + // Expand first so selecting can scroll to the item. + if !item.displayingInHierarchy { + dataSource.expand(toShow: item) + } + dataSource.selectedItem = item + } + popover.show(relativeTo: NSRect(x: 0, y: 0, width: control.bounds.width, height: control.bounds.height), of: control, preferredEdge: .maxX) + } +} diff --git a/LookInside/Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.h b/LookInside/Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.h deleted file mode 100644 index 922516d2..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LKDashboardAttributeJsonView.h -// LookinClient -// -// Created by likai.123 on 2023/11/30. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKDashboardAttributeView.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LKDashboardAttributeJsonView : LKDashboardAttributeView - -- (void)showInNewWindow; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.m b/LookInside/Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.m deleted file mode 100644 index 60005002..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKDashboardAttributeJsonView.m +++ /dev/null @@ -1,71 +0,0 @@ -// -// LKDashboardAttributeJsonView.m -// LookinClient -// -// Created by likai.123 on 2023/11/30. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKDashboardAttributeJsonView.h" -#import "LKNavigationManager.h" -#import "LKJSONAttributeContentView.h" -#import "LKDashboardViewController.h" - -@interface LKDashboardAttributeJsonView () - -@property(nonatomic, strong) LKJSONAttributeContentView *contentView; - -@end - -@implementation LKDashboardAttributeJsonView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - // self.layer.borderWidth = 1; - // self.layer.borderColor = [NSColor redColor].CGColor; - self.layer.cornerRadius = DashboardCardControlCornerRadius; - - self.contentView = [[LKJSONAttributeContentView alloc] initWithBigFont:NO]; - @weakify(self); - self.contentView.didReloadData = ^{ - @strongify(self); - [self.dashboardViewController.view setNeedsLayout:YES]; - }; - [self addSubview:self.contentView]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.contentView).fullFrame; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = [self.contentView queryContentHeight]; - limitedSize.height = height; - return limitedSize; -} - -- (void)renderWithAttribute { - [super renderWithAttribute]; - NSString *json = self.attribute.value; - if (![json isKindOfClass:[NSString class]]) { - [self.contentView renderWithJSON:nil]; - NSAssert(NO, @""); - return; - } - [self.contentView renderWithJSON:json]; -} - -- (void)showInNewWindow { - NSString *json = self.attribute.value; - if (![json isKindOfClass:[NSString class]]) { - NSAssert(NO, @""); - return; - } - - [[LKNavigationManager sharedInstance] showJsonWindow:json]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeContentView.h b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeContentView.h deleted file mode 100644 index 60ecd012..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeContentView.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LKJSONAttributeContentView.h -// LookinClient -// -// Created by likai.123 on 2023/12/10. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import - -@interface LKJSONAttributeContentView : LKBaseView - -- (instancetype)initWithBigFont:(BOOL)bigFont; - -- (void)renderWithJSON:(NSString *)json; - -- (CGFloat)queryContentHeight; - -@property (nonatomic, copy) void (^didReloadData)(void); - -@end diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeContentView.m b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeContentView.m deleted file mode 100644 index 13bfd6eb..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeContentView.m +++ /dev/null @@ -1,206 +0,0 @@ -// -// LKJSONAttributeContentView.m -// LookinClient -// -// Created by likai.123 on 2023/12/10. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKJSONAttributeContentView.h" -#import "LKJSONAttributeItem.h" -#import "LKTableView.h" -#import "LKOutlineRowView.h" -#import "LKTableRowView.h" - -@interface LKJSONAttributeContentRowView : LKOutlineRowView - -@end - -@implementation LKJSONAttributeContentRowView - -+ (CGFloat)insetLeft { - return 0; -} - -@end - -@interface LKJSONAttributeContentView () - -@property(nonatomic, assign) BOOL useBigFont; - -@property(nonatomic, strong) NSArray *rootItems; -@property(nonatomic, strong) NSMutableArray *flatItems; - -@property(nonatomic, strong) LKTableView *tableView; - -@property(nonatomic, assign) CGFloat rowHeight; - -@end - -@implementation LKJSONAttributeContentView - - -- (instancetype)initWithBigFont:(BOOL)bigFont { - if (self = [super initWithFrame:NSZeroRect]) { - self.tableView = [LKTableView new]; - self.tableView.drawsBackground = NO; - self.tableView.delegate = self; - self.tableView.dataSource = self; - self.tableView.adjustsSelectionAutomatically = NO; - self.tableView.adjustsHoverAutomatically = NO; - self.tableView.automaticallyAdjustsContentInsets = NO; - if (bigFont) { - self.tableView.contentInsets = NSEdgeInsetsMake(5, 0, 5, 0); - self.rowHeight = 25; - } else { - self.tableView.contentInsets = NSEdgeInsetsMake(0, 0, 0, 0); - self.rowHeight = 20; - } - [self addSubview:self.tableView]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.tableView).fullFrame; -} - -- (void)renderWithJSON:(NSString *)json { - [self buildModel:json]; - [self render]; -} - -- (void)buildModel:(NSString *)json { - self.rootItems = nil; - - if (!json) { - return; - } - - NSData *jsonData = [json dataUsingEncoding:NSUTF8StringEncoding]; - NSError *error; - NSArray *array = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:&error]; - if (error) { - NSLog(@"转换失败: %@", error); - NSAssert(NO, @""); - return; - } - if (![array isKindOfClass:[NSArray class]]) { - return; - } - self.rootItems = [self createItemsFromArray:array]; -} - -- (NSArray *)createItemsFromArray:(NSArray *)rawArray { - if (![rawArray isKindOfClass:[NSArray class]]) { - return nil; - } - NSMutableArray *resultArray = [NSMutableArray array]; - - [rawArray enumerateObjectsUsingBlock:^(NSDictionary * _Nonnull dict, NSUInteger idx, BOOL * _Nonnull stop) { - if (![dict isKindOfClass:[NSDictionary class]]) { - return; - } - NSString *title = dict[@"title"]; - NSString *desc = dict[@"desc"]; - NSArray *details = dict[@"details"]; - - LKJSONAttributeItem *item = [LKJSONAttributeItem new]; - item.titleText = title; - item.desc = desc; - item.expanded = YES; - item.subItems = [self createItemsFromArray:details]; - - [resultArray addObject:item]; - }]; - - return resultArray; -} - -- (void)render { - self.flatItems = [NSMutableArray array]; - [self.rootItems enumerateObjectsUsingBlock:^(LKJSONAttributeItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [self.flatItems addObjectsFromArray:[obj flatItems]]; - }]; - - [self.tableView reloadData]; -} - -#pragma mark - LKTableView - -- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { - NSInteger count = self.flatItems.count; - return count; -} - -- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row { - return self.rowHeight; -} - -- (NSTableRowView *)tableView:(NSTableView *)tableView rowViewForRow:(NSInteger)row { - LKJSONAttributeItem *item = [self.flatItems lookin_safeObjectAtIndex:row]; - if (!item) { - return [LKTableBlankRowView new]; - } - - LKJSONAttributeContentRowView *view = (LKJSONAttributeContentRowView *)[tableView makeViewWithIdentifier:@"myView" owner:self]; - if (!view) { - view = [[LKJSONAttributeContentRowView alloc] initWithCompactUI:YES]; - view.titleLabel.textColor = [NSColor secondaryLabelColor]; - if (self.useBigFont) { - view.titleLabel.font = [NSFont monospacedDigitSystemFontOfSize:14 weight:NSFontWeightRegular]; - view.subtitleLabel.font = [NSFont monospacedDigitSystemFontOfSize:14 weight:NSFontWeightRegular]; - } else { - view.titleLabel.font = [NSFont monospacedDigitSystemFontOfSize:12 weight:NSFontWeightRegular]; - view.subtitleLabel.font = [NSFont monospacedDigitSystemFontOfSize:12 weight:NSFontWeightRegular]; - } - - view.titleLabel.selectable = YES; - view.subtitleLabel.textColor = [NSColor labelColor]; - view.subtitleLabel.selectable = YES; - view.disclosureButton.target = self; - view.disclosureButton.action = @selector(handleExpand:); - view.identifier = @"myView"; - } - view.titleLabel.stringValue = item.titleText; - if (item.desc) { - view.subtitleLabel.stringValue = item.desc; - } else { - view.subtitleLabel.stringValue = @""; - } - view.disclosureButton.tag = row; - view.indentLevel = item.indentation; - if (item.subItems.count > 0) { - if (item.expanded) { - view.status = LKOutlineRowViewStatusExpanded; - } else { - view.status = LKOutlineRowViewStatusCollapsed; - } - } else { - view.status = LKOutlineRowViewStatusNotExpandable; - } - [view setNeedsLayout:YES]; - return view; -} - -- (void)handleExpand:(NSButton *)button { - NSUInteger row = button.tag; - LKJSONAttributeItem *item = [self.flatItems lookin_safeObjectAtIndex:row]; - if (!item) { - return; - } - item.expanded = !item.expanded; - [self render]; - - if (self.didReloadData) { - self.didReloadData(); - } -} - -- (CGFloat)queryContentHeight { - NSInteger count = self.flatItems.count; - return count * self.rowHeight; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.h b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.h deleted file mode 100644 index d879d014..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// LKJSONAttributeItem.h -// LookinClient -// -// Created by likai.123 on 2023/12/4. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import - -@interface LKJSONAttributeItem : NSObject - -@property(nonatomic, copy) NSString *titleText; -@property(nonatomic, copy) NSString *desc; -@property(nonatomic, assign) BOOL expanded; -@property(nonatomic, assign) NSUInteger indentation; - -@property(nonatomic, strong) NSArray *subItems; - -- (NSArray *)flatItems; - -@end diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.m b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.m deleted file mode 100644 index 6aeb3048..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.m +++ /dev/null @@ -1,37 +0,0 @@ -// -// LKJSONAttributeItem.m -// LookinClient -// -// Created by likai.123 on 2023/12/4. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKJSONAttributeItem.h" - -@implementation LKJSONAttributeItem - -- (instancetype)init { - self = [super init]; - if (self) { - self.expanded = YES; - self.subItems = [NSMutableArray array]; - } - return self; -} - -- (NSArray *)flatItems { - NSMutableArray *array = [NSMutableArray array]; - - [array addObject:self]; - - if (self.expanded) { - [self.subItems enumerateObjectsUsingBlock:^(LKJSONAttributeItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.indentation = self.indentation + 1; - [array addObjectsFromArray:[obj flatItems]]; - }]; - } - - return array; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.swift b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.swift new file mode 100644 index 00000000..014b4b39 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeItem.swift @@ -0,0 +1,66 @@ +// +// LKJSONAttributeItem.swift +// LookInside +// +// Created by likai.123 on 2023/12/4. +// +// The tree model of a JSON attribute: an array of +// {"title", "desc", "details": [...]} objects. Foundation only, so the +// tests can compile it on its own. +// + +import Foundation + +final class LKJSONAttributeItem { + var titleText: String? + var desc: String? + var expanded = true + var indentation = 0 + var subItems: [LKJSONAttributeItem] = [] + + /// The item, then (when expanded) its sub-items' flat items, each one + /// indented one level deeper than its parent. + func flatItems() -> [LKJSONAttributeItem] { + var array = [self] + if expanded { + for item in subItems { + item.indentation = indentation + 1 + array.append(contentsOf: item.flatItems()) + } + } + return array + } + + /// The root items of `json`; nil when it is nil, not valid JSON, or not + /// an array. Entries that are not objects are skipped. + static func rootItems(fromJSON json: String?) -> [LKJSONAttributeItem]? { + guard let json else { return nil } + let object: Any + do { + object = try JSONSerialization.jsonObject(with: Data(json.utf8)) + } catch { + NSLog("转换失败: %@", String(describing: error)) + assertionFailure() + return nil + } + return items(from: object) + } + + private static func items(from rawArray: Any?) -> [LKJSONAttributeItem]? { + guard let rawArray = rawArray as? [Any] else { return nil } + return rawArray.compactMap { element in + guard let dict = element as? [String: Any] else { return nil } + let item = LKJSONAttributeItem() + item.titleText = dict["title"] as? String + item.desc = dict["desc"] as? String + item.expanded = true + item.subItems = items(from: dict["details"]) ?? [] + return item + } + } + + /// The rows to show for `rootItems`. + static func flatItems(of rootItems: [LKJSONAttributeItem]?) -> [LKJSONAttributeItem] { + (rootItems ?? []).flatMap { $0.flatItems() } + } +} diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViewController.h b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViewController.h deleted file mode 100644 index a46ea262..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViewController.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// LKJSONAttributeViewController.h -// LookinClient -// -// Created by likai.123 on 2023/12/4. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKBaseViewController.h" - -@interface LKJSONAttributeViewController : LKBaseViewController - -- (void)renderWithJSON:(NSString *)json; - -@end diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViewController.m b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViewController.m deleted file mode 100644 index 6d858862..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViewController.m +++ /dev/null @@ -1,166 +0,0 @@ -// -// LKJSONAttributeViewController.m -// LookinClient -// -// Created by likai.123 on 2023/12/4. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKJSONAttributeViewController.h" -#import "LKJSONAttributeItem.h" -#import "LKTableView.h" -#import "LKOutlineRowView.h" -#import "LKTableRowView.h" - -@interface LKJSONAttributeViewController () - -@property(nonatomic, strong) NSArray *rootItems; -@property(nonatomic, strong) NSMutableArray *flatItems; - -@property(nonatomic, strong) LKTableView *tableView; - -@end - -@implementation LKJSONAttributeViewController - -- (NSView *)makeContainerView { - LKBaseView *containerView = [LKBaseView new]; - - self.tableView = [LKTableView new]; - self.tableView.drawsBackground = NO; - self.tableView.delegate = self; - self.tableView.dataSource = self; - self.tableView.canScrollHorizontally = NO; - self.tableView.adjustsSelectionAutomatically = NO; - self.tableView.adjustsHoverAutomatically = NO; - self.tableView.automaticallyAdjustsContentInsets = NO; - self.tableView.contentInsets = NSEdgeInsetsMake(5, 0, 5, 0); - [containerView addSubview:self.tableView]; - - return containerView; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - $(self.tableView).fullFrame.toY(28); -} - -- (void)renderWithJSON:(NSString *)json { - [self buildModel:json]; - [self render]; -} - -- (void)buildModel:(NSString *)json { - self.rootItems = nil; - - NSData *jsonData = [json dataUsingEncoding:NSUTF8StringEncoding]; - NSError *error; - NSArray *array = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:&error]; - if (error) { - NSLog(@"转换失败: %@", error); - NSAssert(NO, @""); - return; - } - if (![array isKindOfClass:[NSArray class]]) { - return; - } - self.rootItems = [self createItemsFromArray:array]; -} - -- (NSArray *)createItemsFromArray:(NSArray *)rawArray { - if (![rawArray isKindOfClass:[NSArray class]]) { - return nil; - } - NSMutableArray *resultArray = [NSMutableArray array]; - - [rawArray enumerateObjectsUsingBlock:^(NSDictionary * _Nonnull dict, NSUInteger idx, BOOL * _Nonnull stop) { - if (![dict isKindOfClass:[NSDictionary class]]) { - return; - } - NSString *title = dict[@"title"]; - NSString *desc = dict[@"desc"]; - NSArray *details = dict[@"details"]; - - LKJSONAttributeItem *item = [LKJSONAttributeItem new]; - item.titleText = title; - item.desc = desc; - item.expanded = YES; - item.subItems = [self createItemsFromArray:details]; - - [resultArray addObject:item]; - }]; - - return resultArray; -} - -- (void)render { - self.flatItems = [NSMutableArray array]; - [self.rootItems enumerateObjectsUsingBlock:^(LKJSONAttributeItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [self.flatItems addObjectsFromArray:[obj flatItems]]; - }]; - - [self.tableView reloadData]; -} - -#pragma mark - LKTableView - -- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { - NSInteger count = self.flatItems.count; - return count; -} - -- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row { - return 25; -} - -- (NSTableRowView *)tableView:(NSTableView *)tableView rowViewForRow:(NSInteger)row { - LKJSONAttributeItem *item = [self.flatItems lookin_safeObjectAtIndex:row]; - if (!item) { - return [LKTableBlankRowView new]; - } - - LKOutlineRowView *view = (LKOutlineRowView *)[tableView makeViewWithIdentifier:@"myView" owner:self]; - if (!view) { - view = [LKOutlineRowView new]; - view.titleLabel.textColor = [NSColor secondaryLabelColor]; - view.titleLabel.font = [NSFont monospacedDigitSystemFontOfSize:14 weight:NSFontWeightRegular]; - view.titleLabel.selectable = YES; - view.subtitleLabel.textColor = [NSColor labelColor]; - view.subtitleLabel.font = [NSFont monospacedDigitSystemFontOfSize:14 weight:NSFontWeightRegular]; - view.subtitleLabel.selectable = YES; - view.disclosureButton.target = self; - view.disclosureButton.action = @selector(handleExpand:); - view.identifier = @"myView"; - } - view.titleLabel.stringValue = item.titleText; - if (item.desc) { - view.subtitleLabel.stringValue = item.desc; - } else { - view.subtitleLabel.stringValue = @""; - } - view.disclosureButton.tag = row; - view.indentLevel = item.indentation; - if (item.subItems.count > 0) { - if (item.expanded) { - view.status = LKOutlineRowViewStatusExpanded; - } else { - view.status = LKOutlineRowViewStatusCollapsed; - } - } else { - view.status = LKOutlineRowViewStatusNotExpandable; - } - [view setNeedsLayout:YES]; - return view; -} - -- (void)handleExpand:(NSButton *)button { - NSUInteger row = button.tag; - LKJSONAttributeItem *item = [self.flatItems lookin_safeObjectAtIndex:row]; - if (!item) { - return; - } - item.expanded = !item.expanded; - [self render]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViews.swift b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViews.swift new file mode 100644 index 00000000..5596dfab --- /dev/null +++ b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeViews.swift @@ -0,0 +1,299 @@ +// +// LKJSONAttributeViews.swift +// LookInside +// +// Created by likai.123 on 2023/11/30. +// +// A JSON attribute as a collapsible tree: inline on the card, and in a +// window of its own. +// + +import AppKit + +/// The JSON tree rows of the card, flush with the card's left edge. +private final class LKJSONAttributeContentRowView: LKOutlineRowView { + override class func insetLeft() -> CGFloat { + 0 + } +} + +/// The tree of rows shared by the card and the window. +private final class LKJSONAttributeTree { + var rootItems: [LKJSONAttributeItem]? + private(set) var flatItems: [LKJSONAttributeItem] = [] + + func render(json: String?) { + rootItems = LKJSONAttributeItem.rootItems(fromJSON: json) + rebuild() + } + + func rebuild() { + flatItems = LKJSONAttributeItem.flatItems(of: rootItems) + } + + func item(at row: Int) -> LKJSONAttributeItem? { + flatItems.indices.contains(row) ? flatItems[row] : nil + } + + /// Fills a row with `item`: title, description, indentation and + /// disclosure state. + static func configure(_ view: LKOutlineRowView, row: Int, with item: LKJSONAttributeItem) { + view.titleLabel.stringValue = item.titleText ?? "" + view.subtitleLabel.stringValue = item.desc ?? "" + view.disclosureButton.tag = row + view.indentLevel = UInt(item.indentation) + if item.subItems.isEmpty { + view.status = .notExpandable + } else { + view.status = item.expanded ? .expanded : .collapsed + } + view.needsLayout = true + } + + /// Toggles the item of the row whose disclosure button was clicked. + func toggle(row: Int) -> Bool { + guard let item = item(at: row) else { return false } + item.expanded.toggle() + rebuild() + return true + } +} + +/// The tree on a Dashboard card. +@objc(LKJSONAttributeContentView) +final class LKJSONAttributeContentView: LKBaseView, LKTableViewDelegate, LKTableViewDataSource { + private static let rowIdentifier = NSUserInterfaceItemIdentifier("myView") + + private let tableView = LKTableView() + private let tree = LKJSONAttributeTree() + private let rowHeight: CGFloat + /// Never set: the rows use the small font even in the big-font layout, + /// as they always have. + private let useBigFont = false + + /// Called after a row is expanded or collapsed. + var didReloadData: (() -> Void)? + + init(bigFont: Bool) { + rowHeight = bigFont ? 25 : 20 + super.init(frame: .zero) + tableView.drawsBackground = false + tableView.delegate = self + tableView.dataSource = self + tableView.adjustsSelectionAutomatically = false + tableView.adjustsHoverAutomatically = false + tableView.automaticallyAdjustsContentInsets = false + tableView.contentInsets = bigFont ? NSEdgeInsets(top: 5, left: 0, bottom: 5, right: 0) : NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + addSubview(tableView) + } + + @available(*, unavailable) + override init(frame _: NSRect) { + fatalError("use init(bigFont:)") + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + tableView.dashboardLayout.fullFrame() + } + + func render(json: String?) { + tree.render(json: json) + tableView.reloadData() + } + + func queryContentHeight() -> CGFloat { + CGFloat(tree.flatItems.count) * rowHeight + } + + // MARK: - LKTableView + + func numberOfRows(in _: NSTableView) -> Int { + tree.flatItems.count + } + + func tableView(_: NSTableView, heightOfRow _: Int) -> CGFloat { + rowHeight + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + guard let item = tree.item(at: row) else { + return LKTableBlankRowView() + } + let view: LKOutlineRowView + if let reused = tableView.makeView(withIdentifier: Self.rowIdentifier, owner: self) as? LKOutlineRowView { + view = reused + } else { + view = LKJSONAttributeContentRowView(compactUI: true) + view.titleLabel.textColor = .secondaryLabelColor + let fontSize: CGFloat = useBigFont ? 14 : 12 + view.titleLabel.font = .monospacedDigitSystemFont(ofSize: fontSize, weight: .regular) + view.subtitleLabel.font = .monospacedDigitSystemFont(ofSize: fontSize, weight: .regular) + view.titleLabel.isSelectable = true + view.subtitleLabel.textColor = .labelColor + view.subtitleLabel.isSelectable = true + view.disclosureButton.target = self + view.disclosureButton.action = #selector(handleExpand(_:)) + view.identifier = Self.rowIdentifier + } + LKJSONAttributeTree.configure(view, row: row, with: item) + return view + } + + @objc private func handleExpand(_ button: NSButton) { + guard tree.toggle(row: button.tag) else { return } + tableView.reloadData() + didReloadData?() + } +} + +/// A JSON attribute on a card; the section's pop-out button shows it in a +/// window. +@objc(LKDashboardAttributeJsonView) +final class LKDashboardAttributeJsonView: LKDashboardAttributeView { + private let contentView = LKJSONAttributeContentView(bigFont: false) + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + layer?.cornerRadius = LKDashboardMetrics.cardControlCornerRadius + contentView.didReloadData = { [weak self] in + self?.dashboardViewController?.view.needsLayout = true + } + addSubview(contentView) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + contentView.dashboardLayout.fullFrame() + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + size.height = contentView.queryContentHeight() + return size + } + + override func renderWithAttribute() { + guard let json = attribute?.value as? String else { + contentView.render(json: nil) + assertionFailure() + return + } + contentView.render(json: json) + } + + func showInNewWindow() { + guard let json = attribute?.value as? String else { + assertionFailure() + return + } + LKNavigationManager.shared.showJSONWindow(json) + } +} + +/// The JSON tree in a window of its own, with the bigger font. +@objc(LKJSONAttributeViewController) +final class LKJSONAttributeViewController: LKBaseViewController, LKTableViewDelegate, LKTableViewDataSource { + private static let rowIdentifier = NSUserInterfaceItemIdentifier("myView") + + private var tableView: LKTableView! + private let tree = LKJSONAttributeTree() + + override func makeContainerView() -> NSView { + let containerView = LKBaseView() + + let tableView = LKTableView() + tableView.drawsBackground = false + tableView.delegate = self + tableView.dataSource = self + tableView.canScrollHorizontally = false + tableView.adjustsSelectionAutomatically = false + tableView.adjustsHoverAutomatically = false + tableView.automaticallyAdjustsContentInsets = false + tableView.contentInsets = NSEdgeInsets(top: 5, left: 0, bottom: 5, right: 0) + containerView.addSubview(tableView) + self.tableView = tableView + + return containerView + } + + override func viewDidLayout() { + super.viewDidLayout() + tableView.dashboardLayout.fullFrame().toY(28) + } + + @objc(renderWithJSON:) + func render(json: String?) { + tree.render(json: json) + tableView.reloadData() + } + + // MARK: - LKTableView + + func numberOfRows(in _: NSTableView) -> Int { + tree.flatItems.count + } + + func tableView(_: NSTableView, heightOfRow _: Int) -> CGFloat { + 25 + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + guard let item = tree.item(at: row) else { + return LKTableBlankRowView() + } + let view: LKOutlineRowView + if let reused = tableView.makeView(withIdentifier: Self.rowIdentifier, owner: self) as? LKOutlineRowView { + view = reused + } else { + view = LKOutlineRowView() + view.titleLabel.textColor = .secondaryLabelColor + view.titleLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .regular) + view.titleLabel.isSelectable = true + view.subtitleLabel.textColor = .labelColor + view.subtitleLabel.font = .monospacedDigitSystemFont(ofSize: 14, weight: .regular) + view.subtitleLabel.isSelectable = true + view.disclosureButton.target = self + view.disclosureButton.action = #selector(handleExpand(_:)) + view.identifier = Self.rowIdentifier + } + LKJSONAttributeTree.configure(view, row: row, with: item) + return view + } + + @objc private func handleExpand(_ button: NSButton) { + guard tree.toggle(row: button.tag) else { return } + tableView.reloadData() + } +} + +/// The window of `LKJSONAttributeViewController`. +@objc(LKJSONAttributeWindowController) +final class LKJSONAttributeWindowController: LKWindowController { + @objc convenience init() { + let window = LKWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 320), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: true + ) + window.isMovableByWindowBackground = true + window.titleVisibility = .hidden + window.minSize = CGSize(width: 200, height: 200) + window.center() + + self.init(window: window) + + let viewController = LKJSONAttributeViewController() + window.contentView = viewController.view + contentViewController = viewController + } +} diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.h b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.h deleted file mode 100644 index 6d6bb29b..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKJSONAttributeWindowController.h -// LookinClient -// -// Created by likai.123 on 2023/12/4. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKWindowController.h" - -@interface LKJSONAttributeWindowController : LKWindowController - -@end diff --git a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.m b/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.m deleted file mode 100644 index 6c985b0c..00000000 --- a/LookInside/Dashboard/AttributeView/JSON/LKJSONAttributeWindowController.m +++ /dev/null @@ -1,34 +0,0 @@ -// -// LKJSONAttributeWindowController.m -// LookinClient -// -// Created by likai.123 on 2023/12/4. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKJSONAttributeWindowController.h" -#import "LKJSONAttributeViewController.h" -#import "LKWindow.h" - -@interface LKJSONAttributeWindowController () - -@end - -@implementation LKJSONAttributeWindowController - -- (instancetype)init { - LKWindow *window = [[LKWindow alloc] initWithContentRect:NSMakeRect(0, 0, 600, 320) styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskMiniaturizable|NSWindowStyleMaskResizable|NSWindowStyleMaskFullSizeContentView backing:NSBackingStoreBuffered defer:YES]; - window.movableByWindowBackground = YES; - window.titleVisibility = NSWindowTitleHidden; - window.minSize = CGSizeMake(200, 200); - [window center]; - - if (self = [self initWithWindow:window]) { - LKJSONAttributeViewController *vc = [LKJSONAttributeViewController new]; - window.contentView = vc.view; - self.contentViewController = vc; - } - return self; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeClassView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeClassView.h deleted file mode 100644 index b54bd00c..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeClassView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeClassView.h -// Lookin -// -// Created by Li Kai on 2019/6/14. -// https://lookin.work -// - -#import "LKDashboardAttributeStringArrayView.h" - -@interface LKDashboardAttributeClassView : LKDashboardAttributeStringArrayView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeClassView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeClassView.m deleted file mode 100644 index a3fff7eb..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeClassView.m +++ /dev/null @@ -1,25 +0,0 @@ -// -// LKDashboardAttributeClassView.m -// Lookin -// -// Created by Li Kai on 2019/6/14. -// https://lookin.work -// - -#import "LKDashboardAttributeClassView.h" -#import "LookInside-Swift.h" - -@implementation LKDashboardAttributeClassView - -- (NSArray *)stringListWithAttribute:(LookinAttribute *)attribute { - NSArray *> *lists = attribute.value; - NSArray *result = [lists lookin_map:^id(NSUInteger idx, NSArray *rawClassList) { - NSArray *demangled = [rawClassList lookin_map:^id(NSUInteger idx, NSString *rawClass) { - return [LKSwiftDemangler completedParseWithInput:rawClass]; - }]; - return [demangled componentsJoinedByString:@"\n"]; - }]; - return result; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.h deleted file mode 100644 index 50701e9e..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeColorView.h -// Lookin -// -// Created by Li Kai on 2019/2/21. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeColorView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.m deleted file mode 100644 index 1ffb293c..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.m +++ /dev/null @@ -1,235 +0,0 @@ -// -// LKDashboardAttributeColorView.m -// Lookin -// -// Created by Li Kai on 2019/2/21. -// https://lookin.work -// - -#import "LKDashboardAttributeColorView.h" -#import "LKColorIndicatorLayer.h" -#import "LKDashboardCardView.h" -#import "LKPreferenceManager.h" -#import "LKDashboardViewController.h" -#import "LKHierarchyDataSource.h" - -@interface LKDashboardAttributeColorContainerView : LKBaseView - -@property(nonatomic, weak) id clickTarget; -@property(nonatomic, assign) SEL clickAction; - -@end - -@implementation LKDashboardAttributeColorContainerView - -- (void)mouseDown:(NSEvent *)event { - [super mouseDown:event]; - if (self.clickTarget && self.clickAction) { - [NSApp sendAction:self.clickAction to:self.clickTarget from:event]; - } -} - -@end - -@interface LKDashboardAttributeColorView () - -@property(nonatomic, strong) LKColorIndicatorLayer *indicatorLayer; -@property(nonatomic, strong) LKLabel *descLabel; -@property(nonatomic, strong) LKDashboardAttributeColorContainerView *containerView; -@property(nonatomic, strong) NSImageView *iconImageView; -@property(nonatomic, strong) LKLabel *aliasLabel; - -@property(nonatomic, copy) NSArray *identifiersToHideAlias; - -@end - -@implementation LKDashboardAttributeColorView { - CGFloat _mainContainerHeight; - CGFloat _aliasLabelMarginTop; - CGFloat _labelX; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { -// [self setBackgroundColor:[NSColor blueColor]]; - - self.identifiersToHideAlias = @[LookinAttr_ViewLayer_Border_Color, LookinAttr_ViewLayer_Shadow_Color]; - - _mainContainerHeight = 30; - _aliasLabelMarginTop = 1; - _labelX = 28; - - self.containerView = [LKDashboardAttributeColorContainerView new]; - self.containerView.layer.cornerRadius = DashboardCardControlCornerRadius; - self.containerView.clickTarget = self; - self.containerView.clickAction = @selector(_handleClick:); - [self addSubview:self.containerView]; - - self.indicatorLayer = [LKColorIndicatorLayer new]; - [self.containerView.layer addSublayer:self.indicatorLayer]; - - self.iconImageView = [NSImageView new]; - self.iconImageView.image = NSImageMake(@"Icon_ArrowUpDown"); - [self.containerView addSubview:self.iconImageView]; - - self.descLabel = [LKLabel new]; - self.descLabel.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - self.descLabel.font = NSFontMake(13); - [self.containerView addSubview:self.descLabel]; - - @weakify(self); - [[RACObserve([LKPreferenceManager mainManager], rgbaFormat) skip:1] subscribeNext:^(NSNumber *bool_rgbaFormat) { - @strongify(self); - [self renderWithAttribute]; - }]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.containerView).fullWidth.height(_mainContainerHeight).y(0); - $(self.indicatorLayer).width(16).height(16).x(8).verAlign; - $(self.descLabel).x(_labelX).toRight(20).heightToFit.verAlign.offsetY(-1); - $(self.iconImageView).sizeToFit.verAlign.right(9); - if (self.aliasLabel.isVisible) { - $(self.aliasLabel).x(_labelX).toRight(0).y(self.containerView.$maxY + _aliasLabelMarginTop).heightToFit; - } -} - -- (void)renderWithAttribute { - self.iconImageView.hidden = ![self canEdit]; - - NSColor *color = [NSColor lk_colorFromRGBAComponents:self.attribute.value]; - self.indicatorLayer.color = color; - - if (color) { - self.descLabel.stringValue = [LKPreferenceManager mainManager].rgbaFormat ? color.rgbaString : color.hexString; - } else { - self.descLabel.stringValue = @"nil"; - } - - LKHierarchyDataSource *dataSource = self.dashboardViewController.currentDataSource; - NSArray *alias = [dataSource aliasForColor:color]; - if (alias && ![self.identifiersToHideAlias containsObject:self.attribute.identifier]) { - if (!self.aliasLabel) { - self.aliasLabel = [LKLabel new]; - self.aliasLabel.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - [self addSubview:self.aliasLabel]; - } - self.aliasLabel.hidden = NO; - self.aliasLabel.attributedStringValue = $([alias componentsJoinedByString:@"\n"]).font(NSFontMake(11)).lineHeight(18).attrString; - } else { - self.aliasLabel.hidden = YES; - } - [self setNeedsLayout:YES]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = _mainContainerHeight; - if (self.aliasLabel.isVisible) { - CGFloat width = limitedSize.width - _labelX; - height += _aliasLabelMarginTop + [self.aliasLabel sizeThatFits:NSMakeSize(width, CGFLOAT_MAX)].height; - } - limitedSize.height = height; - return limitedSize; -} - - -- (void)setDashboardViewController:(LKDashboardViewController *)dashboardViewController { - [super setDashboardViewController:dashboardViewController]; - self.containerView.backgroundColorName = @"DashboardCardValueBGColor"; -} - -#pragma mark - - -- (void)_handleClick:(NSEvent *)event { - if (!self.canEdit) { - return; - } - - LKHierarchyDataSource *dataSource = self.dashboardViewController.currentDataSource; - NSMenu *menu = dataSource.selectColorMenu; - menu.delegate = self; - [NSMenu popUpContextMenu:menu withEvent:event forView:self.containerView]; -} - -- (void)menuNeedsUpdate:(NSMenu *)menu { - [menu.itemArray enumerateObjectsUsingBlock:^(NSMenuItem * _Nonnull menuItem, NSUInteger idx, BOOL * _Nonnull stop) { - if (menuItem.hasSubmenu) { - [menuItem.submenu.itemArray enumerateObjectsUsingBlock:^(NSMenuItem * _Nonnull subMenuItem, NSUInteger idx, BOOL * _Nonnull stop) { - [self _updateMenuItem:subMenuItem]; - }]; - } else { - [self _updateMenuItem:menuItem]; - } - }]; -} - -- (void)_updateMenuItem:(NSMenuItem *)menuItem { - if (menuItem.tag == self.dashboardViewController.currentDataSource.customColorMenuItemTag) { - // "自定义颜色" - menuItem.state = NSControlStateValueOff; - menuItem.target = self; - menuItem.action = @selector(_handleCustomColorMenuItem); - return; - } - - if (menuItem.tag == self.dashboardViewController.currentDataSource.toggleColorFormatMenuItemTag) { - menuItem.state = NSControlStateValueOff; - menuItem.target = self; - menuItem.action = @selector(_handleToggleColorFormatMenuItem); - return; - } - - menuItem.target = self; - menuItem.action = @selector(_handlePresetMenuItem:); - NSColor *color = menuItem.representedObject; - if ([self.attribute.value isEqual:[color lk_rgbaComponents]] || self.attribute.value == color) { - // if 中后面的 == 是用来判断二者都是 nil 的情况 - menuItem.state = NSControlStateValueOn; - } else { - menuItem.state = NSControlStateValueOff; - } -} - -- (void)_handlePresetMenuItem:(NSMenuItem *)item { - [self _modifyToColor:item.representedObject]; -} - -- (void)_handleCustomColorMenuItem { - NSColor *initialColor = [NSColor lk_colorFromRGBAComponents:self.attribute.value]; - - NSColorPanel *panel = [NSColorPanel sharedColorPanel]; - [panel setShowsAlpha:YES]; - [panel setContinuous:NO]; - [panel setColor:initialColor]; - [panel setTarget:self]; - [panel setAction:@selector(_handleSystemColorPanel:)]; - [panel orderFront:self]; -} - -- (void)_handleToggleColorFormatMenuItem { - BOOL isRGBA = [LKPreferenceManager mainManager].rgbaFormat; - [LKPreferenceManager mainManager].rgbaFormat = !isRGBA; -} - -- (void)_handleSystemColorPanel:(NSColorPanel *)panel { - [self _modifyToColor:panel.color]; -} - -- (void)_modifyToColor:(NSColor *)targetColor { - NSArray *expecetdValue = [(NSColor *)targetColor lk_rgbaComponents]; - if ([expecetdValue isEqual:self.attribute.value]) { - NSLog(@"修改没有变化,不做任何提交"); - return; - } - // 提交修改 - @weakify(self); - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expecetdValue] subscribeNext:^(id _Nullable x) { - @strongify(self); - [self renderWithAttribute]; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.swift new file mode 100644 index 00000000..f9c582d5 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeColorView.swift @@ -0,0 +1,209 @@ +// +// LKDashboardAttributeColorView.swift +// LookInside +// +// Created by Li Kai on 2019/2/21. +// https://lookin.work +// + +import AppKit + +/// The rounded box of a colour attribute; a click opens the colour menu. +private final class LKDashboardAttributeColorContainerView: LKBaseView { + var onMouseDown: ((NSEvent) -> Void)? + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + onMouseDown?(event) + } +} + +/// A colour: swatch, hex or RGBA text, and the colour's alias names from +/// the app's colour configuration. +@objc(LKDashboardAttributeColorView) +final class LKDashboardAttributeColorView: LKDashboardAttributeView, NSMenuDelegate { + private let mainContainerHeight: CGFloat = 30 + private let aliasLabelMarginTop: CGFloat = 1 + private let labelX: CGFloat = 28 + + /// Border and shadow colours show no alias. + private let identifiersToHideAlias: Set = [ + LookinAttr_ViewLayer_Border_Color, + LookinAttr_ViewLayer_Shadow_Color, + ] + + private let containerView = LKDashboardAttributeColorContainerView() + private let indicatorLayer = LKColorIndicatorLayer() + private let iconImageView = NSImageView() + private let descLabel = LKLabel() + private var aliasLabel: LKLabel? + private var rgbaFormatObservation: NSKeyValueObservation? + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + containerView.layer?.cornerRadius = LKDashboardMetrics.cardControlCornerRadius + containerView.onMouseDown = { [weak self] event in + self?.handleClick(event) + } + addSubview(containerView) + + containerView.layer?.addSublayer(indicatorLayer) + + iconImageView.image = LKDashboardStyle.image("Icon_ArrowUpDown") + containerView.addSubview(iconImageView) + + descLabel.textColor = NSColor(named: "DashboardCardValueColor") + descLabel.font = LKDashboardStyle.font(13) + containerView.addSubview(descLabel) + + // Re-render when the user switches between hex and RGBA. + rgbaFormatObservation = LKPreferenceManager.shared.observe(\.rgbaFormat, options: [.new]) { [weak self] _, _ in + lkRunOnMain { self?.renderWithAttribute() } + } + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + containerView.dashboardLayout.fullWidth().height(mainContainerHeight).y(0) + indicatorLayer.dashboardLayout.width(16).height(16).x(8).verAlign() + descLabel.dashboardLayout.x(labelX).toRight(20).heightToFit().verAlign().offsetY(-1) + iconImageView.dashboardLayout.sizeToFit().verAlign().right(9) + if let aliasLabel, aliasLabel.isVisible { + aliasLabel.dashboardLayout.x(labelX).toRight(0).y(containerView.frame.maxY + aliasLabelMarginTop).heightToFit() + } + } + + override func renderWithAttribute() { + iconImageView.isHidden = !canEdit() + + let color = NSColor.lk_color(fromRGBAComponents: attribute?.value as? [NSNumber]) + indicatorLayer.color = color + + if let color { + descLabel.stringValue = LKPreferenceManager.shared.rgbaFormat ? color.rgbaString() : color.hexString() + } else { + descLabel.stringValue = "nil" + } + + let dataSource = dashboardViewController?.currentDataSource() + let alias = dataSource?.alias(for: color) + if let alias, !identifiersToHideAlias.contains(attribute?.identifier ?? "") { + let label: LKLabel + if let aliasLabel { + label = aliasLabel + } else { + label = LKLabel() + label.textColor = NSColor(named: "DashboardCardValueColor") + addSubview(label) + aliasLabel = label + } + label.isHidden = false + label.attributedStringValue = LKDashboardText.attributed(alias.joined(separator: "\n"), font: LKDashboardStyle.font(11), lineHeight: 18) + } else { + aliasLabel?.isHidden = true + } + needsLayout = true + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var height = mainContainerHeight + if let aliasLabel, aliasLabel.isVisible { + let width = limitedSize.width - labelX + height += aliasLabelMarginTop + aliasLabel.sizeThatFits(NSSize(width: width, height: .greatestFiniteMagnitude)).height + } + var size = limitedSize + size.height = height + return size + } + + override func dashboardViewControllerDidChange() { + containerView.backgroundColorName = "DashboardCardValueBGColor" + } + + // MARK: - Menu + + private func handleClick(_ event: NSEvent) { + guard canEdit(), let menu = dashboardViewController?.currentDataSource()?.selectColorMenu else { return } + menu.delegate = self + NSMenu.popUpContextMenu(menu, with: event, for: containerView) + } + + func menuNeedsUpdate(_ menu: NSMenu) { + for menuItem in menu.items { + if menuItem.hasSubmenu { + menuItem.submenu?.items.forEach(updateMenuItem) + } else { + updateMenuItem(menuItem) + } + } + } + + private func updateMenuItem(_ menuItem: NSMenuItem) { + let dataSource = dashboardViewController?.currentDataSource() + if menuItem.tag == dataSource?.customColorMenuItemTag { + // "Custom colour…" + menuItem.state = .off + menuItem.target = self + menuItem.action = #selector(handleCustomColorMenuItem) + return + } + if menuItem.tag == dataSource?.toggleColorFormatMenuItemTag { + menuItem.state = .off + menuItem.target = self + menuItem.action = #selector(handleToggleColorFormatMenuItem) + return + } + + menuItem.target = self + menuItem.action = #selector(handlePresetMenuItem(_:)) + let color = menuItem.representedObject as? NSColor + let currentValue = attribute?.value as? NSObject + let isCurrent: Bool + if let color { + isCurrent = currentValue?.isEqual(color.lk_rgbaComponents()) ?? false + } else { + // Both nil: the item for "no colour" while the value is nil. + isCurrent = currentValue == nil + } + menuItem.state = isCurrent ? .on : .off + } + + @objc private func handlePresetMenuItem(_ item: NSMenuItem) { + modify(to: item.representedObject as? NSColor) + } + + @objc private func handleCustomColorMenuItem() { + let initialColor = NSColor.lk_color(fromRGBAComponents: attribute?.value as? [NSNumber]) + let panel = NSColorPanel.shared + panel.showsAlpha = true + panel.isContinuous = false + if let initialColor { + panel.color = initialColor + } + panel.setTarget(self) + panel.setAction(#selector(handleSystemColorPanel(_:))) + panel.orderFront(self) + } + + @objc private func handleToggleColorFormatMenuItem() { + let manager = LKPreferenceManager.shared + manager.rgbaFormat = !manager.rgbaFormat + } + + @objc private func handleSystemColorPanel(_ panel: NSColorPanel) { + modify(to: panel.color) + } + + private func modify(to targetColor: NSColor?) { + let expectedValue = targetColor?.lk_rgbaComponents() + if let currentValue = attribute?.value as? NSObject, let expectedValue, currentValue.isEqual(expectedValue) { + NSLog("修改没有变化,不做任何提交") + return + } + submitRenderingOnSuccess(expectedValue) + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.h deleted file mode 100644 index 785dc969..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeEnumsView.h -// Lookin -// -// Created by Li Kai on 2019/2/21. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeEnumsView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.m deleted file mode 100644 index 2afe0a6d..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.m +++ /dev/null @@ -1,199 +0,0 @@ -// -// LKDashboardAttributeEnumsView.m -// Lookin -// -// Created by Li Kai on 2019/2/21. -// https://lookin.work -// - -#import "LKDashboardAttributeEnumsView.h" -#import "LKEnumListRegistry.h" -#import "LKDashboardCardView.h" -#import "LKDashboardViewController.h" -#import "LKHierarchyDataSource.h" -#import "LookinHierarchyInfo.h" -#import "LookinAppInfo.h" -#import "LookinDashboardBlueprint.h" - -@interface LKDashboardAttributeEnumsView () - -@property(nonatomic, strong) NSImageView *iconImageView; -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKLabel *textLabel; - -@end - -@implementation LKDashboardAttributeEnumsView { - CGFloat _labelX; - CGFloat _labelRight; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _labelX = 5; - _labelRight = 20; - - self.layer.cornerRadius = DashboardCardControlCornerRadius; - - self.titleLabel = [LKLabel new]; - self.titleLabel.textColor = [NSColor colorNamed:@"DashboardInputAccessoryColor"]; - self.titleLabel.font = NSFontMake(10); - self.titleLabel.maximumNumberOfLines = 1; - [self addSubview:self.titleLabel]; - - self.textLabel = [LKLabel new]; - self.textLabel.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - self.textLabel.maximumNumberOfLines = 0; - self.textLabel.font = NSFontMake(12); - - [self addSubview:self.textLabel]; - - self.iconImageView = [NSImageView new]; - self.iconImageView.image = NSImageMake(@"Icon_ArrowUpDown"); - [self addSubview:self.iconImageView]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.iconImageView).sizeToFit.verAlign.right(9); - if (self.titleLabel.isHidden) { - $(self.textLabel).x(_labelX).toRight(_labelRight).heightToFit.verAlign; - } else { - CGFloat titleHeight = [self.titleLabel sizeThatFits:NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX)].height; - CGFloat titleY = 3; - $(self.titleLabel).x(_labelX).toRight(_labelRight).height(titleHeight).y(titleY); - CGFloat valueY = titleY + titleHeight + 1; - $(self.textLabel).x(_labelX).toRight(_labelRight).heightToFit.y(valueY); - } -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat contentWidth = limitedSize.width - _labelRight - _labelX; - CGFloat textHeight = [self.textLabel sizeThatFits:NSMakeSize(contentWidth, CGFLOAT_MAX)].height; - if (self.titleLabel.isHidden) { - limitedSize.height = textHeight + 10; - } else { - CGFloat titleHeight = [self.titleLabel sizeThatFits:NSMakeSize(contentWidth, CGFLOAT_MAX)].height; - limitedSize.height = 3 + titleHeight + 1 + textHeight + 4; - } - return limitedSize; -} - -- (void)renderWithAttribute { - // Show title label for non-custom attributes - if (!self.attribute.isUserCustom) { - NSString *briefTitle = [LookinDashboardBlueprint briefTitleWithAttrID:self.attribute.identifier]; - if (briefTitle.length > 0) { - self.titleLabel.stringValue = briefTitle; - self.titleLabel.hidden = NO; - } else { - self.titleLabel.hidden = YES; - } - } else { - self.titleLabel.hidden = YES; - } - - if (self.attribute.attrType == LookinAttrTypeEnumString) { - NSString *text = self.attribute.value; - if (![text isKindOfClass:[NSString class]]) { - NSAssert(NO, @""); - return; - } - self.textLabel.stringValue = text; - - } else { - NSInteger enumValue = [self.attribute.value integerValue]; - NSString *enumListName = [LookinDashboardBlueprint enumListNameWithAttrID:self.attribute.identifier]; - NSString *enumString = [[LKEnumListRegistry sharedInstance] descForEnumName:enumListName value:enumValue]; - self.textLabel.stringValue = enumString; - } -} - -- (void)mouseDown:(NSEvent *)event { - NSMenu *menu; - if (self.attribute.isUserCustom) { - menu = [self createMenuForUserCustom]; - } else { - menu = [self createMenuForPreset]; - } - [NSMenu popUpContextMenu:menu withEvent:event forView:self]; -} - -- (void)setDashboardViewController:(LKDashboardViewController *)dashboardViewController { - [super setDashboardViewController:dashboardViewController]; - self.backgroundColorName = @"DashboardCardValueBGColor"; -} - -#pragma mark - Private - -- (NSMenu *)createMenuForPreset { - NSInteger currentOSVersion = self.dashboardViewController.currentDataSource.rawHierarchyInfo.appInfo.osMainVersion; - - NSMenu *menu = [NSMenu new]; - menu.autoenablesItems = NO; - NSString *enumListName = [LookinDashboardBlueprint enumListNameWithAttrID:self.attribute.identifier]; - NSArray *rawItems = [[LKEnumListRegistry sharedInstance] itemsForEnumName:enumListName]; - [rawItems enumerateObjectsUsingBlock:^(LKEnumListRegistryKeyValueItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL validOSVersion = currentOSVersion >= obj.availableOSVersion; - - NSMenuItem *item = [NSMenuItem new]; - item.image = [[NSImage alloc] initWithSize:NSMakeSize(1, 22)]; - item.title = validOSVersion ? obj.desc : [NSString stringWithFormat:@"%@ (iOS %@)", obj.desc, @(obj.availableOSVersion)]; - item.representedObject = @(obj.value); - item.enabled = [self canEdit] && validOSVersion; - item.target = self; - item.action = @selector(_handleMenuItem:); - if (obj.value == [self.attribute.value longValue]) { - item.state = NSControlStateValueOn; - } else { - item.state = NSControlStateValueOff; - } - [menu addItem:item]; - }]; - - return menu; -} - -- (NSMenu *)createMenuForUserCustom { - NSMenu *menu = [NSMenu new]; - - NSArray *cases = self.attribute.extraValue; - if (!cases || ![cases isKindOfClass:[NSArray class]] || cases.count == 0) { - return menu; - } - - for (NSString *text in cases) { - if (![text isKindOfClass:[NSString class]]) { - NSAssert(NO, @""); - continue; - } - NSMenuItem *item = [NSMenuItem new]; - item.image = [[NSImage alloc] initWithSize:NSMakeSize(1, 22)]; - item.title = text; - item.representedObject = text; - item.enabled = [self canEdit]; - item.target = self; - item.action = @selector(_handleMenuItem:); - item.state = ([text isEqualTo:self.attribute.value] ? NSControlStateValueOn : NSControlStateValueOff); - [menu addItem:item]; - } - - return menu; -} - -- (void)_handleMenuItem:(NSMenuItem *)item { - NSNumber *expectedValue = item.representedObject; - if ([expectedValue isEqual:self.attribute.value]) { - NSLog(@"修改没有变化,不做任何提交"); - return; - } - @weakify(self); - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expectedValue] subscribeNext:^(id _Nullable x) { - @strongify(self); - [self renderWithAttribute]; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.swift new file mode 100644 index 00000000..5d7632fe --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeEnumsView.swift @@ -0,0 +1,153 @@ +// +// LKDashboardAttributeEnumsView.swift +// LookInside +// +// Created by Li Kai on 2019/2/21. +// https://lookin.work +// + +import AppKit + +/// An enum attribute: the case name, and a menu of the cases on click. +@objc(LKDashboardAttributeEnumsView) +final class LKDashboardAttributeEnumsView: LKDashboardAttributeView { + private let labelX: CGFloat = 5 + private let labelRight: CGFloat = 20 + + private let iconImageView = NSImageView() + private let titleLabel = LKLabel() + private let textLabel = LKLabel() + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + layer?.cornerRadius = LKDashboardMetrics.cardControlCornerRadius + + titleLabel.textColor = NSColor(named: "DashboardInputAccessoryColor") + titleLabel.font = LKDashboardStyle.font(10) + titleLabel.maximumNumberOfLines = 1 + addSubview(titleLabel) + + textLabel.textColor = NSColor(named: "DashboardCardValueColor") + textLabel.maximumNumberOfLines = 0 + textLabel.font = LKDashboardStyle.font(12) + addSubview(textLabel) + + iconImageView.image = LKDashboardStyle.image("Icon_ArrowUpDown") + addSubview(iconImageView) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + iconImageView.dashboardLayout.sizeToFit().verAlign().right(9) + if titleLabel.isHidden { + textLabel.dashboardLayout.x(labelX).toRight(labelRight).heightToFit().verAlign() + } else { + let titleHeight = titleLabel.sizeThatFits(LKDashboardMetrics.maxSize).height + let titleY: CGFloat = 3 + titleLabel.dashboardLayout.x(labelX).toRight(labelRight).height(titleHeight).y(titleY) + textLabel.dashboardLayout.x(labelX).toRight(labelRight).heightToFit().y(titleY + titleHeight + 1) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + let contentWidth = limitedSize.width - labelRight - labelX + let textHeight = textLabel.sizeThatFits(NSSize(width: contentWidth, height: .greatestFiniteMagnitude)).height + var size = limitedSize + if titleLabel.isHidden { + size.height = textHeight + 10 + } else { + let titleHeight = titleLabel.sizeThatFits(NSSize(width: contentWidth, height: .greatestFiniteMagnitude)).height + size.height = 3 + titleHeight + 1 + textHeight + 4 + } + return size + } + + override func renderWithAttribute() { + guard let attribute else { return } + // Known attributes show their short title above the value. + if !attribute.isUserCustom(), let briefTitle = LookinDashboardBlueprint.briefTitle(withAttrID: attribute.identifier), !briefTitle.isEmpty { + titleLabel.stringValue = briefTitle + titleLabel.isHidden = false + } else { + titleLabel.isHidden = true + } + + if attribute.attrType == .enumString { + guard let text = attribute.value as? String else { + assertionFailure() + return + } + textLabel.stringValue = text + } else { + let enumValue = (attribute.value as? NSNumber)?.intValue ?? 0 + let enumListName = LookinDashboardBlueprint.enumListName(withAttrID: attribute.identifier) + textLabel.stringValue = LKEnumListRegistry.shared.desc(forEnumName: enumListName, value: enumValue) ?? "" + } + } + + override func mouseDown(with event: NSEvent) { + let menu = attribute?.isUserCustom() == true ? makeMenuForUserCustom() : makeMenuForPreset() + NSMenu.popUpContextMenu(menu, with: event, for: self) + } + + override func dashboardViewControllerDidChange() { + backgroundColorName = "DashboardCardValueBGColor" + } + + // MARK: - Menus + + private func makeMenuForPreset() -> NSMenu { + let currentOSVersion = dashboardViewController?.currentDataSource()?.rawHierarchyInfo?.appInfo?.osMainVersion ?? 0 + let menu = NSMenu() + menu.autoenablesItems = false + let enumListName = LookinDashboardBlueprint.enumListName(withAttrID: attribute?.identifier) + let currentValue = (attribute?.value as? NSNumber)?.intValue ?? 0 + let editable = canEdit() + for enumItem in LKEnumListRegistry.shared.items(forEnumName: enumListName) ?? [] { + let validOSVersion = currentOSVersion >= enumItem.availableOSVersion + let item = NSMenuItem() + item.image = NSImage(size: NSSize(width: 1, height: 22)) + item.title = validOSVersion ? enumItem.desc : "\(enumItem.desc) (iOS \(enumItem.availableOSVersion))" + item.representedObject = NSNumber(value: enumItem.value) + item.isEnabled = editable && validOSVersion + item.target = self + item.action = #selector(handleMenuItem(_:)) + item.state = enumItem.value == currentValue ? .on : .off + menu.addItem(item) + } + return menu + } + + private func makeMenuForUserCustom() -> NSMenu { + let menu = NSMenu() + guard let cases = attribute?.extraValue as? [Any], !cases.isEmpty else { + return menu + } + let editable = canEdit() + for case let text as String in cases { + let item = NSMenuItem() + item.image = NSImage(size: NSSize(width: 1, height: 22)) + item.title = text + item.representedObject = text + item.isEnabled = editable + item.target = self + item.action = #selector(handleMenuItem(_:)) + item.state = (attribute?.value as? NSObject)?.isEqual(text) == true ? .on : .off + menu.addItem(item) + } + return menu + } + + @objc private func handleMenuItem(_ item: NSMenuItem) { + let expectedValue = item.representedObject as? NSObject + if let expectedValue, let current = attribute?.value, expectedValue.isEqual(current) { + NSLog("修改没有变化,不做任何提交") + return + } + submitRenderingOnSuccess(expectedValue) + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeGeometryViews.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeGeometryViews.swift new file mode 100644 index 00000000..51365fe6 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeGeometryViews.swift @@ -0,0 +1,268 @@ +// +// LKDashboardAttributeGeometryViews.swift +// LookInside +// +// Created by Li Kai on 2019/6/10. +// https://lookin.work +// +// The rect, insets, point and size attributes: a number field per +// component, two per row, each titled with a letter inside the field. +// + +import AppKit + +/// Number fields, two per row. Subclasses fill them from the attribute's +/// `NSValue` and turn an edited field into the value to submit. +class LKDashboardAttributeFieldsView: LKDashboardAttributeView, NSTextFieldDelegate { + /// The field titles, in field order. + class var fieldTitles: [String] { + [] + } + + private(set) var inputViews: [LKNumberInputView] = [] + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + inputViews = Self.fieldTitles.map { title in + let view = LKNumberInputView() + view.title = title + view.viewStyle = .horizontal + view.textFieldView.textField.delegate = self + view.textFieldView.backgroundColorName = "DashboardCardValueBGColor" + addSubview(view) + return view + } + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + private var rowCount: Int { + (inputViews.count + 1) / 2 + } + + override func layout() { + super.layout() + let horSpace = LKDashboardMetrics.attrItemHorInterspace + let rowHeight = LKDashboardMetrics.numberInputHorizontalHeight + let itemWidth = (frame.width - horSpace) / 2.0 + for (idx, view) in inputViews.enumerated() { + let x = idx % 2 == 0 ? 0 : itemWidth + horSpace + let y = CGFloat(idx / 2) * (rowHeight + LKDashboardMetrics.attrItemVerInterspace) + view.dashboardLayout.width(itemWidth).height(rowHeight).x(x).y(y) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + let rows = CGFloat(rowCount) + size.height = LKDashboardMetrics.numberInputHorizontalHeight * rows + LKDashboardMetrics.attrItemVerInterspace * (rows - 1) + return size + } + + /// The field texts for the attribute's value, in field order. + func fieldStrings(for _: NSValue) -> [String] { + [] + } + + /// The value to submit after field `index` became `number`, or nil + /// when nothing changed. + func editedValue(from _: NSValue, field _: Int, number _: Double) -> NSValue? { + nil + } + + /// Submits `newValue`, the edit of `oldValue`. + func submitEdit(_ newValue: NSValue, oldValue _: NSValue) { + submitRenderingOnFailure(newValue) + } + + override func renderWithAttribute() { + guard let value = attribute?.value as? NSValue else { + assertionFailure() + return + } + let strings = fieldStrings(for: value) + let editable = canEdit() + for (view, string) in zip(inputViews, strings) { + view.textFieldView.textField.isEditable = editable + view.textFieldView.textField.stringValue = string + } + } + + // MARK: - NSTextFieldDelegate + + func control(_: NSControl, textShouldBeginEditing _: NSText) -> Bool { + canEdit() + } + + func controlTextDidEndEditing(_ notification: Notification) { + // A read-only attribute (such as the SwiftUI user-custom rows, + // which have no setter) can still get an end-of-editing + // notification when the field leaves the window or its editable + // flag changes, although textShouldBeginEditing refused input. + guard canEdit() else { return } + if LKDashboardTextControlEditingFlag.shared.shouldIgnoreTextEditingChangeEvent { + NSLog("忽略 controlTextDidEndEditing 事件,驳回") + return + } + guard let editingTextField = notification.object as? NSTextField, + let parsed = LKNumberInputView.parsedValue(with: editingTextField.stringValue, attrType: .double) as? NSNumber + else { + NSLog("输入格式校验不通过,驳回") + renderWithAttribute() + return + } + guard let oldValue = attribute?.value as? NSValue else { return } + let index = inputViews.firstIndex { $0.textFieldView.textField === editingTextField } ?? NSNotFound + guard let newValue = editedValue(from: oldValue, field: index, number: parsed.doubleValue) else { + NSLog("修改没有变化,不做任何提交") + renderWithAttribute() + return + } + submitEdit(newValue, oldValue: oldValue) + } + + override func dashboardViewControllerDidChange() { + for view in inputViews { + view.textFieldView.backgroundColorName = "DashboardCardValueBGColor" + } + } +} + +@objc(LKDashboardAttributeRectView) +final class LKDashboardAttributeRectView: LKDashboardAttributeFieldsView { + override class var fieldTitles: [String] { + ["X", "Y", "W", "H"] + } + + override func renderWithAttribute() { + guard attribute != nil else { + assertionFailure() + return + } + super.renderWithAttribute() + } + + override func fieldStrings(for value: NSValue) -> [String] { + let rect = value.rectValue + return [rect.origin.x, rect.origin.y, rect.size.width, rect.size.height].map { + NSString.lookin_string(from: Double($0), decimal: 3) + } + } + + override func editedValue(from value: NSValue, field index: Int, number: Double) -> NSValue? { + guard let rect = LKDashboardModification.rect(value.rectValue, replacingField: index, with: number) else { + return nil + } + let newValue = NSValue(rect: rect) + return newValue.isEqual(value) ? nil : newValue + } + + override func submitEdit(_ newValue: NSValue, oldValue: NSValue) { + let oldRect = oldValue.rectValue + let expectedRect = newValue.rectValue + Task { @MainActor [weak self] in + do { + guard let self, try await self.submit(newValue) else { return } + let currentRect = (self.attribute?.value as? NSValue)?.rectValue ?? .zero + if LKDashboardModification.rectEditWasReverted(old: oldRect, expected: expectedRect, current: currentRect) { + // The Server applied the new frame, then the app's own + // code (layoutSubviews, for example) set it back. Say so, + // or it looks as if the edit failed. + LKDashboardStyle.alert( + title: NSLocalizedString("The modification seems to have no effect.", comment: ""), + detail: NSLocalizedString("After modifying successfully by LookInside, the value seems to be recovered by the code in your iOS app. For example, modifying \"frame\" of a view may trigger \"layoutSubviews\", and \"layoutSubviews\" may modify the value again.", comment: ""), + window: self.window + ) + } + } catch { + NSLog("修改返回 error") + self?.renderWithAttribute() + } + } + } +} + +@objc(LKDashboardAttributeInsetsView) +final class LKDashboardAttributeInsetsView: LKDashboardAttributeFieldsView { + override class var fieldTitles: [String] { + ["T", "L", "B", "R"] + } + + override func renderWithAttribute() { + guard attribute != nil else { + assertionFailure() + return + } + super.renderWithAttribute() + } + + override func fieldStrings(for value: NSValue) -> [String] { + let insets = value.edgeInsetsValue + return [insets.top, insets.left, insets.bottom, insets.right].map { + NSString.lookin_string(from: Double($0), decimal: 3) + } + } + + override func editedValue(from value: NSValue, field index: Int, number: Double) -> NSValue? { + let oldInsets = value.edgeInsetsValue + guard let insets = LKDashboardModification.insets(oldInsets, replacingField: index, with: number), + LKDashboardModification.insetsDiffer(oldInsets, insets) + else { + return nil + } + return NSValue(edgeInsets: insets) + } +} + +@objc(LKDashboardAttributePointView) +final class LKDashboardAttributePointView: LKDashboardAttributeFieldsView { + override class var fieldTitles: [String] { + ["X", "Y"] + } + + override func fieldStrings(for value: NSValue) -> [String] { + let point = value.pointValue + return [point.x, point.y].map { NSString.lookin_string(from: Double($0), decimal: 3) } + } + + override func editedValue(from value: NSValue, field index: Int, number: Double) -> NSValue? { + guard let point = LKDashboardModification.point(value.pointValue, replacingField: index, with: number) else { + return nil + } + let newValue = NSValue(point: point) + return newValue.isEqual(value) ? nil : newValue + } +} + +@objc(LKDashboardAttributeSizeView) +final class LKDashboardAttributeSizeView: LKDashboardAttributeFieldsView { + override class var fieldTitles: [String] { + ["W", "H"] + } + + override func renderWithAttribute() { + guard attribute != nil else { + assertionFailure() + return + } + super.renderWithAttribute() + } + + override func fieldStrings(for value: NSValue) -> [String] { + let size = value.sizeValue + return [size.width, size.height].map { dimension in + // Extremely large values (CGFLOAT_MAX, for example) read as "Max". + dimension >= 1e15 ? "Max" : NSString.lookin_string(from: Double(dimension), decimal: 3) + } + } + + override func editedValue(from value: NSValue, field index: Int, number: Double) -> NSValue? { + guard let size = LKDashboardModification.size(value.sizeValue, replacingField: index, with: number) else { + return nil + } + let newValue = NSValue(size: size) + return newValue.isEqual(value) ? nil : newValue + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeInsetsView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeInsetsView.h deleted file mode 100644 index 4da9bbb2..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeInsetsView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeRectView.h -// Lookin -// -// Created by Li Kai on 2019/6/10. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeInsetsView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeInsetsView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeInsetsView.m deleted file mode 100644 index 09239097..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeInsetsView.m +++ /dev/null @@ -1,169 +0,0 @@ -// -// LKDashboardAttributeRectView.m -// Lookin -// -// Created by Li Kai on 2019/6/10. -// https://lookin.work -// - -#import "LKDashboardAttributeInsetsView.h" -#import "LKNumberInputView.h" -#import "LKTextFieldView.h" -#import "LKDashboardViewController.h" -#import "LKDashboardTextControlEditingFlag.h" - -@interface LKDashboardAttributeInsetsView () - -@property(nonatomic, copy) NSArray *mainInputsView; - -@end - -@implementation LKDashboardAttributeInsetsView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - // self.layer.borderWidth = 1; - // self.layer.borderColor = [NSColor redColor].CGColor; - - NSArray *titles = @[@"T", @"L", @"B", @"R"]; - self.mainInputsView = [NSArray lookin_arrayWithCount:4 block:^id(NSUInteger idx) { - LKNumberInputView *view = [LKNumberInputView new]; - view.title = titles[idx]; - view.viewStyle = LKNumberInputViewStyleHorizontal; - view.textFieldView.textField.delegate = self; - [self addSubview:view]; - return view; - }]; - } - return self; -} - -- (void)layout { - [super layout]; - CGFloat itemWidth = (self.$width - DashboardAttrItemHorInterspace) / 2.0; - [self.mainInputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat x, y; - if (idx == 0 || idx == 2) { - x = 0; - } else { - x = itemWidth + DashboardAttrItemHorInterspace; - } - if (idx == 0 || idx == 1) { - y = 0; - } else { - y = LKNumberInputHorizontalHeight + DashboardAttrItemVerInterspace; - } - $(view).width(itemWidth).height(LKNumberInputHorizontalHeight).x(x).y(y); - }]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = LKNumberInputHorizontalHeight * 2 + DashboardAttrItemVerInterspace; - limitedSize.height = height; - return limitedSize; -} - -- (void)renderWithAttribute { - if (!self.attribute) { - NSAssert(NO, @""); - return; - } - if (![self.attribute.value isKindOfClass:[NSValue class]]) { - NSAssert(NO, @""); - return; - } - NSEdgeInsets insets = ((NSValue *)self.attribute.value).edgeInsetsValue; - NSArray *mainStrs = @[[NSString lookin_stringFromDouble:insets.top decimal:3], - [NSString lookin_stringFromDouble:insets.left decimal:3], - [NSString lookin_stringFromDouble:insets.bottom decimal:3], - [NSString lookin_stringFromDouble:insets.right decimal:3]]; - - [self.mainInputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textFieldView.textField.editable = [self canEdit]; - obj.textFieldView.textField.stringValue = mainStrs[idx]; - }]; -} - -#pragma mark - - -- (BOOL)control:(NSControl *)control textShouldBeginEditing:(NSText *)fieldEditor { - return self.canEdit; -} - -- (void)controlTextDidEndEditing:(NSNotification *)notification { - if (![self canEdit]) { - return; - } - if (LKDashboardTextControlEditingFlag.sharedInstance.shouldIgnoreTextEditingChangeEvent) { - NSLog(@"忽略 controlTextDidEndEditing 事件,驳回"); - return; - } - NSTextField *editingTextField = notification.object; - NSNumber *inputValue = [LKNumberInputView parsedValueWithString:editingTextField.stringValue attrType:LookinAttrTypeDouble]; - if (inputValue == nil) { - NSLog(@"输入格式校验不通过,驳回"); - [self renderWithAttribute]; - return; - } - - double inputDouble = [inputValue doubleValue]; - NSUInteger editingTextFieldIdx = [[self.mainInputsView lookin_map:^id(NSUInteger idx, LKNumberInputView *value) { - return value.textFieldView.textField; - }] indexOfObject:editingTextField]; - - NSEdgeInsets expectedInsets = ((NSValue *)self.attribute.value).edgeInsetsValue; - switch (editingTextFieldIdx) { - case 0: - // top - expectedInsets.top = inputDouble; - break; - case 1: - // left - expectedInsets.left = inputDouble; - break; - case 2: - // bottom - expectedInsets.bottom = inputDouble; - break; - case 3: - // right - expectedInsets.right = inputDouble; - break; - default: - [self renderWithAttribute]; - NSAssert(NO, @""); - break; - } - - NSEdgeInsets originInsets = ((NSValue *)self.attribute.value).edgeInsetsValue; - // 这里 NSValue 的 isEqual: 方法不准,要手动判断 - BOOL valueDidChange = (originInsets.top != expectedInsets.top || - originInsets.left != expectedInsets.left || - originInsets.bottom != expectedInsets.bottom || - originInsets.right != expectedInsets.right); - if (!valueDidChange) { - NSLog(@"修改没有变化,不做任何提交"); - [self renderWithAttribute]; - return; - } - NSValue *expectedValue = [NSValue valueWithEdgeInsets:expectedInsets]; - - // 提交修改 - @weakify(self); - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expectedValue] subscribeError:^(NSError * _Nullable error) { - @strongify(self); - NSLog(@"修改返回 error"); - [self renderWithAttribute]; - }]; -} - -#pragma mark - Others - -- (void)setDashboardViewController:(LKDashboardViewController *)dashboardViewController { - [super setDashboardViewController:dashboardViewController]; - [self.mainInputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textFieldView.backgroundColorName = @"DashboardCardValueBGColor"; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.h deleted file mode 100644 index c1e8a09c..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKDashboardAttributeNumberInputView.h -// Lookin -// -// Created by Li Kai on 2019/2/21. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@class LKNumberInputView; - -@interface LKDashboardAttributeNumberInputView : LKDashboardAttributeView - -@property(nonatomic, strong, readonly) LKNumberInputView *inputView; - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.m deleted file mode 100644 index 96b92b3d..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.m +++ /dev/null @@ -1,239 +0,0 @@ -// -// LKDashboardAttributeNumberInputView.m -// Lookin -// -// Created by Li Kai on 2019/2/21. -// https://lookin.work -// - -#import "LKDashboardAttributeNumberInputView.h" -#import "LKNumberInputView.h" -#import "LKTextFieldView.h" -#import "LKDashboardCardView.h" -#import "LKDashboardViewController.h" -#import "LookinDashboardBlueprint.h" -#import "LKDashboardTextControlEditingFlag.h" - -@interface LKDashboardAttributeNumberInputView () - -@end - -@implementation LKDashboardAttributeNumberInputView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _inputView = [LKNumberInputView new]; - self.inputView.textFieldView.textField.delegate = self; - [self addSubview:self.inputView]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.inputView).fullFrame; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - if (self.inputView.viewStyle == LKNumberInputViewStyleHorizontal) { - limitedSize.height = LKNumberInputHorizontalHeight; - } else if (self.inputView.viewStyle == LKNumberInputViewStyleVertical) { - limitedSize.height = LKNumberInputVerticalHeight; - } else { - NSAssert(NO, @""); - } - return limitedSize; -} - -- (void)renderWithAttribute { - self.inputView.textFieldView.textField.editable = [self canEdit]; - - if (self.attribute.isUserCustom) { - self.inputView.title = nil; - } else { - NSString *briefTitle = [LookinDashboardBlueprint briefTitleWithAttrID:self.attribute.identifier]; - self.inputView.title = briefTitle; - } - - double doubleValue = ((NSNumber *)self.attribute.value).doubleValue; - - if (self.attribute.isUserCustom) { - self.inputView.viewStyle = LKNumberInputViewStyleHorizontal; - self.inputView.textFieldView.textField.stringValue = [NSString lookin_stringFromDouble:doubleValue decimal:6]; - - } else { - static dispatch_once_t onceToken; - static NSArray *horizontalAttrs = nil; - dispatch_once(&onceToken,^{ - horizontalAttrs = @[LookinAttr_ViewLayer_Visibility_Opacity, - LookinAttr_ViewLayer_Corner_Radius, - LookinAttr_ViewLayer_Tag_Tag, - LookinAttr_UILabel_Font_Size, - LookinAttr_UILabel_NumberOfLines_NumberOfLines, - LookinAttr_UITextView_Font_Size, - LookinAttr_UITextField_Font_Size, - LookinAttr_UITextField_CanAdjustFont_MinSize, - LookinAttr_ViewLayer_Border_Width, - LookinAttr_UITableView_SectionsNumber_Number, - LookinAttr_AutoLayout_Resistance_Ver, - LookinAttr_AutoLayout_Resistance_Hor, - LookinAttr_AutoLayout_Hugging_Ver, - LookinAttr_AutoLayout_Hugging_Hor, - LookinAttr_UIStackView_Spacing_Spacing, - LookinAttr_NSWindow_Appearance_AlphaValue, - LookinAttr_NSControl_Value_IntValue, - LookinAttr_NSControl_Value_IntegerValue, - LookinAttr_NSControl_Value_FloatValue, - LookinAttr_NSControl_Value_DoubleValue, - LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth, - LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines, - LookinAttr_UITraitCollection_Display_DisplayScale, - LookinAttr_UIWindowScene_Traits_DisplayScale, - LookinAttr_UIWindowScene_Windows_WindowCount, - LookinAttr_UIWindowScene_Screen_ScreenScale, - // Three per row, each labelled with a single - // letter inside the field — same shape as the - // inset rows above them in the same card. - LookinAttr_NSScrollView_LineScroll_Horizontal, - LookinAttr_NSScrollView_LineScroll_Vertical, - LookinAttr_NSScrollView_LineScroll_LineScroll, - LookinAttr_NSScrollView_PageScroll_Horizontal, - LookinAttr_NSScrollView_PageScroll_Vertical, - LookinAttr_NSScrollView_PageScroll_PageScroll]; - }); - if ([horizontalAttrs containsObject:self.attribute.identifier]) { - self.inputView.viewStyle = LKNumberInputViewStyleHorizontal; - self.inputView.textFieldView.textField.stringValue = [NSString lookin_stringFromDouble:doubleValue decimal:3]; - } else { - self.inputView.viewStyle = LKNumberInputViewStyleVertical; - self.inputView.textFieldView.textField.stringValue = [NSString lookin_stringFromDouble:doubleValue decimal:2]; - } - } -} - -- (NSUInteger)numberOfColumnsOccupied { - if (self.attribute.isUserCustom) { - return 1; - } - static dispatch_once_t onceToken; - static NSDictionary *rawDict = nil; - dispatch_once(&onceToken,^{ - rawDict = @{ - LookinAttr_ViewLayer_Visibility_Opacity: @1, - LookinAttr_ViewLayer_Corner_Radius: @1, - LookinAttr_ViewLayer_Tag_Tag: @1, - LookinAttr_UITextView_Font_Size: @1, - LookinAttr_UITextField_Font_Size: @1, - LookinAttr_UITextField_CanAdjustFont_MinSize: @1, - LookinAttr_ViewLayer_Border_Width: @1, - LookinAttr_UITableView_SectionsNumber_Number: @1, - LookinAttr_UILabel_NumberOfLines_NumberOfLines: @1, - LookinAttr_UILabel_Font_Size: @1, - LookinAttr_UIStackView_Spacing_Spacing: @1, - LookinAttr_NSWindow_Appearance_AlphaValue: @1, - LookinAttr_NSWindow_Info_WindowNumber: @2, - LookinAttr_NSWindow_Info_BackingScaleFactor: @2, - LookinAttr_NSControl_Value_IntValue: @1, - LookinAttr_NSControl_Value_IntegerValue: @1, - LookinAttr_NSControl_Value_FloatValue: @1, - LookinAttr_NSControl_Value_DoubleValue: @1, - LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth: @1, - LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines: @1, - - LookinAttr_AutoLayout_Resistance_Ver: @2, - LookinAttr_AutoLayout_Resistance_Hor: @2, - LookinAttr_AutoLayout_Hugging_Ver: @2, - LookinAttr_AutoLayout_Hugging_Hor: @2, - - LookinAttr_UIScrollView_Zoom_Scale: @3, - LookinAttr_UIScrollView_Zoom_MinScale: @3, - LookinAttr_UIScrollView_Zoom_MaxScale: @3, - - LookinAttr_NSScrollView_LineScroll_Horizontal: @3, - LookinAttr_NSScrollView_LineScroll_Vertical: @3, - LookinAttr_NSScrollView_LineScroll_LineScroll: @3, - LookinAttr_NSScrollView_PageScroll_Horizontal: @3, - LookinAttr_NSScrollView_PageScroll_Vertical: @3, - LookinAttr_NSScrollView_PageScroll_PageScroll: @3, - - // Kept in the vertical style — "Current" / "Max" / "Min" - // read better as words than as glyphs — but widened to a - // third of the row so the words fit without truncation. - LookinAttr_NSScrollView_Magnification_Magnification: @3, - LookinAttr_NSScrollView_Magnification_Max: @3, - LookinAttr_NSScrollView_Magnification_Min: @3, - - LookinAttr_UITraitCollection_Display_DisplayScale: @1, - LookinAttr_UIWindowScene_Traits_DisplayScale: @1, - LookinAttr_UIWindowScene_Windows_WindowCount: @1, - LookinAttr_UIWindowScene_Screen_ScreenScale: @1, - }; - }); - - NSNumber *num = rawDict[self.attribute.identifier]; - if (num != nil) { - return [num integerValue]; - } - return 4; -} - -#pragma mark - - -- (BOOL)control:(NSControl *)control textShouldBeginEditing:(NSText *)fieldEditor { - return self.canEdit; -} - -- (void)controlTextDidEndEditing:(NSNotification *)notification { - if (![self canEdit]) { - // Read-only attribute (e.g. SwiftUI-injected user-custom rows have no - // setter): AppKit can still post NSControlTextDidEndEditingNotification - // when the field's superview is removed or its editable flag is - // toggled, even though textShouldBeginEditing rejected user input. - return; - } - if (LKDashboardTextControlEditingFlag.sharedInstance.shouldIgnoreTextEditingChangeEvent) { - NSLog(@"忽略 controlTextDidEndEditing 事件,驳回"); - return; - } - id expectedValue = [LKNumberInputView parsedValueWithString:self.inputView.textFieldView.textField.stringValue attrType:self.attribute.attrType]; - if (!expectedValue) { - NSLog(@"输入格式校验不通过,驳回"); - [self renderWithAttribute]; - return; - } - - if ([self.attribute.identifier isEqualToString:LookinAttr_ViewLayer_Visibility_Opacity] || - [self.attribute.identifier isEqualToString:LookinAttr_ViewLayer_Shadow_Opacity]) { - double inputValue = ((NSNumber *)expectedValue).doubleValue; - inputValue = MAX(MIN(inputValue, 1), 0); - expectedValue = @(inputValue); - } - - if ([expectedValue isEqual:self.attribute.value]) { - NSLog(@"修改没有变化,不做任何提交"); - [self renderWithAttribute]; - return; - } -// if (self.attribute.valueType == LookinCodingValueTypeString && self.attribute.value == nil && [expectedValue isEqual:@""]) { -// NSLog(@"特别处理:作为 string 类型,修改之前是 nil,修改之后是空字符串,此时认为用户仍然是想将值保留为 nil,而非想要修改为空字符串"); -// [self renderWithAttribute]; -// return; -// } - - // 提交修改 - @weakify(self); - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expectedValue] subscribeError:^(NSError * _Nullable error) { - @strongify(self); - NSLog(@"修改返回 error"); - [self renderWithAttribute]; - }]; -} - -#pragma mark - Others - -- (void)setDashboardViewController:(LKDashboardViewController *)dashboardViewController { - [super setDashboardViewController:dashboardViewController]; - self.inputView.textFieldView.backgroundColorName = @"DashboardCardValueBGColor"; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.swift new file mode 100644 index 00000000..47a10861 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeNumberInputView.swift @@ -0,0 +1,197 @@ +// +// LKDashboardAttributeNumberInputView.swift +// LookInside +// +// Created by Li Kai on 2019/2/21. +// https://lookin.work +// + +import AppKit + +/// A single number: opacity, corner radius, font size and the like. +@objc(LKDashboardAttributeNumberInputView) +final class LKDashboardAttributeNumberInputView: LKDashboardAttributeView, NSTextFieldDelegate { + @objc let inputView = LKNumberInputView() + + /// Laid out horizontally (title inside the field) with three decimals; + /// everything else is vertical (title below) with two. + private static let horizontalAttrs: Set = [ + LookinAttr_ViewLayer_Visibility_Opacity, + LookinAttr_ViewLayer_Corner_Radius, + LookinAttr_ViewLayer_Tag_Tag, + LookinAttr_UILabel_Font_Size, + LookinAttr_UILabel_NumberOfLines_NumberOfLines, + LookinAttr_UITextView_Font_Size, + LookinAttr_UITextField_Font_Size, + LookinAttr_UITextField_CanAdjustFont_MinSize, + LookinAttr_ViewLayer_Border_Width, + LookinAttr_UITableView_SectionsNumber_Number, + LookinAttr_AutoLayout_Resistance_Ver, + LookinAttr_AutoLayout_Resistance_Hor, + LookinAttr_AutoLayout_Hugging_Ver, + LookinAttr_AutoLayout_Hugging_Hor, + LookinAttr_UIStackView_Spacing_Spacing, + LookinAttr_NSWindow_Appearance_AlphaValue, + LookinAttr_NSControl_Value_IntValue, + LookinAttr_NSControl_Value_IntegerValue, + LookinAttr_NSControl_Value_FloatValue, + LookinAttr_NSControl_Value_DoubleValue, + LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth, + LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines, + LookinAttr_UITraitCollection_Display_DisplayScale, + LookinAttr_UIWindowScene_Traits_DisplayScale, + LookinAttr_UIWindowScene_Windows_WindowCount, + LookinAttr_UIWindowScene_Screen_ScreenScale, + // Three per row, each labelled with a single letter inside the + // field, the same shape as the inset rows above them in the card. + LookinAttr_NSScrollView_LineScroll_Horizontal, + LookinAttr_NSScrollView_LineScroll_Vertical, + LookinAttr_NSScrollView_LineScroll_LineScroll, + LookinAttr_NSScrollView_PageScroll_Horizontal, + LookinAttr_NSScrollView_PageScroll_Vertical, + LookinAttr_NSScrollView_PageScroll_PageScroll, + ] + + /// Columns per attribute; any other known attribute takes a quarter row. + private static let columns: [String: Int] = [ + LookinAttr_ViewLayer_Visibility_Opacity: 1, + LookinAttr_ViewLayer_Corner_Radius: 1, + LookinAttr_ViewLayer_Tag_Tag: 1, + LookinAttr_UITextView_Font_Size: 1, + LookinAttr_UITextField_Font_Size: 1, + LookinAttr_UITextField_CanAdjustFont_MinSize: 1, + LookinAttr_ViewLayer_Border_Width: 1, + LookinAttr_UITableView_SectionsNumber_Number: 1, + LookinAttr_UILabel_NumberOfLines_NumberOfLines: 1, + LookinAttr_UILabel_Font_Size: 1, + LookinAttr_UIStackView_Spacing_Spacing: 1, + LookinAttr_NSWindow_Appearance_AlphaValue: 1, + LookinAttr_NSWindow_Info_WindowNumber: 2, + LookinAttr_NSWindow_Info_BackingScaleFactor: 2, + LookinAttr_NSControl_Value_IntValue: 1, + LookinAttr_NSControl_Value_IntegerValue: 1, + LookinAttr_NSControl_Value_FloatValue: 1, + LookinAttr_NSControl_Value_DoubleValue: 1, + LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth: 1, + LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines: 1, + + LookinAttr_AutoLayout_Resistance_Ver: 2, + LookinAttr_AutoLayout_Resistance_Hor: 2, + LookinAttr_AutoLayout_Hugging_Ver: 2, + LookinAttr_AutoLayout_Hugging_Hor: 2, + + LookinAttr_UIScrollView_Zoom_Scale: 3, + LookinAttr_UIScrollView_Zoom_MinScale: 3, + LookinAttr_UIScrollView_Zoom_MaxScale: 3, + + LookinAttr_NSScrollView_LineScroll_Horizontal: 3, + LookinAttr_NSScrollView_LineScroll_Vertical: 3, + LookinAttr_NSScrollView_LineScroll_LineScroll: 3, + LookinAttr_NSScrollView_PageScroll_Horizontal: 3, + LookinAttr_NSScrollView_PageScroll_Vertical: 3, + LookinAttr_NSScrollView_PageScroll_PageScroll: 3, + + // Kept in the vertical style ("Current" / "Max" / "Min" read + // better as words than as glyphs) but widened to a third of the + // row so the words fit without truncation. + LookinAttr_NSScrollView_Magnification_Magnification: 3, + LookinAttr_NSScrollView_Magnification_Max: 3, + LookinAttr_NSScrollView_Magnification_Min: 3, + + LookinAttr_UITraitCollection_Display_DisplayScale: 1, + LookinAttr_UIWindowScene_Traits_DisplayScale: 1, + LookinAttr_UIWindowScene_Windows_WindowCount: 1, + LookinAttr_UIWindowScene_Screen_ScreenScale: 1, + ] + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + inputView.textFieldView.textField.delegate = self + addSubview(inputView) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + inputView.dashboardLayout.fullFrame() + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + switch inputView.viewStyle { + case .horizontal: + size.height = LKDashboardMetrics.numberInputHorizontalHeight + case .vertical: + size.height = LKDashboardMetrics.numberInputVerticalHeight + @unknown default: + assertionFailure() + } + return size + } + + override func renderWithAttribute() { + guard let attribute else { return } + let textField: NSTextField = inputView.textFieldView.textField + textField.isEditable = canEdit() + + if attribute.isUserCustom() { + inputView.title = nil + } else { + inputView.title = LookinDashboardBlueprint.briefTitle(withAttrID: attribute.identifier) + } + + let doubleValue = (attribute.value as? NSNumber)?.doubleValue ?? 0 + + if attribute.isUserCustom() { + inputView.viewStyle = .horizontal + textField.stringValue = NSString.lookin_string(from: doubleValue, decimal: 6) + } else if Self.horizontalAttrs.contains(attribute.identifier ?? "") { + inputView.viewStyle = .horizontal + textField.stringValue = NSString.lookin_string(from: doubleValue, decimal: 3) + } else { + inputView.viewStyle = .vertical + textField.stringValue = NSString.lookin_string(from: doubleValue, decimal: 2) + } + } + + override func numberOfColumnsOccupied() -> Int { + guard let attribute, !attribute.isUserCustom() else { return 1 } + return Self.columns[attribute.identifier ?? ""] ?? 4 + } + + override func dashboardViewControllerDidChange() { + inputView.textFieldView.backgroundColorName = "DashboardCardValueBGColor" + } + + // MARK: - NSTextFieldDelegate + + func control(_: NSControl, textShouldBeginEditing _: NSText) -> Bool { + canEdit() + } + + func controlTextDidEndEditing(_: Notification) { + // A read-only attribute (such as the SwiftUI user-custom rows, + // which have no setter) can still get an end-of-editing + // notification when the field leaves the window or its editable + // flag changes, although textShouldBeginEditing refused input. + guard canEdit(), let attribute else { return } + if LKDashboardTextControlEditingFlag.shared.shouldIgnoreTextEditingChangeEvent { + NSLog("忽略 controlTextDidEndEditing 事件,驳回") + return + } + guard let parsed = LKNumberInputView.parsedValue(with: inputView.textFieldView.textField.stringValue, attrType: attribute.attrType) as? NSNumber else { + NSLog("输入格式校验不通过,驳回") + renderWithAttribute() + return + } + guard let expectedValue = LKDashboardModification.numberValue(parsed, for: attribute) else { + NSLog("修改没有变化,不做任何提交") + renderWithAttribute() + return + } + submitRenderingOnFailure(expectedValue) + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeOpenImageView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeOpenImageView.h deleted file mode 100644 index 3c72183f..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeOpenImageView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeOpenImageView.h -// Lookin -// -// Created by Li Kai on 2019/10/7. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeOpenImageView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeOpenImageView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeOpenImageView.m deleted file mode 100644 index 2a8c213a..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeOpenImageView.m +++ /dev/null @@ -1,118 +0,0 @@ -// -// LKDashboardAttributeOpenImageView.m -// Lookin -// -// Created by Li Kai on 2019/10/7. -// https://lookin.work -// - -#import "LKDashboardAttributeOpenImageView.h" -#import "LKNumberInputView.h" -#import "LKDashboardViewController.h" -#import "LKInspectableApp.h" -#import "LookinLiveDocument.h" - -@interface LKDashboardAttributeOpenImageView () - -@property(nonatomic, strong) LKTextControl *control; - -@end - -@implementation LKDashboardAttributeOpenImageView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.layer.borderWidth = 1; - self.layer.cornerRadius = DashboardCardControlCornerRadius; - self.borderColors = LKColorsCombine(LookinColorMake(181, 181, 181), LookinColorMake(83, 83, 83)); - - self.control = [LKTextControl new]; - self.control.adjustAlphaWhenClick = YES; - self.control.label.stringValue = NSLocalizedString(@"Open Image with Preview…", nil); - self.control.label.font = NSFontMake(11); - [self.control addTarget:self clickAction:@selector(_handleClick)]; - [self addSubview:self.control]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.control).fullFrame; -} - -- (void)renderWithAttribute { -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - limitedSize.height = LKNumberInputHorizontalHeight; - return limitedSize; -} - -- (void)_handleClick { - // An imported Xcode capture carries the image's own bytes: there is no - // process to fetch them from, and none is needed. - if ([self.attribute.value isKindOfClass:[NSData class]]) { - [self _openImageData:self.attribute.value]; - return; - } - - NSNumber *imageViewOid_num = self.attribute.value; - if (imageViewOid_num == nil) { - AlertError(LookinErr_Inner, self.window); - NSAssert(NO, @""); - return; - } - - unsigned long imageViewOid = [imageViewOid_num unsignedLongValue]; - - LKDashboardViewController *dashController = self.dashboardViewController; - if (!dashController.isStaticMode) { - AlertErrorText(NSLocalizedString(@"The feature is not available in current mode.", nil), NSLocalizedString(@"You must connect LookInside with target iOS app before using this feature.", nil), self.window); - return; - } - - // Phase F: resolve the inspectable app via the Live Doc that owns this - // view's window instead of the deprecated single-slot global. - LKInspectableApp *inspectableApp = [LookinLiveDocument documentInWindow:self.window].inspectableApp; - if (!inspectableApp) { - AlertError(LookinErr_NoConnect, self.window); - return; - } - - @weakify(self); - [[inspectableApp fetchImageWithImageViewOid:imageViewOid] subscribeNext:^(NSData *imageData) { - @strongify(self); - if (!imageData) { - AlertErrorText(NSLocalizedString(@"Operation failed. The image property value of selected UIImageView is nil.", nil), @"", self.window); - return; - } - [self _openImageData:imageData]; - - } error:^(NSError * _Nullable error) { - @strongify(self); - AlertError(error, self.window); - }]; -} - -/// Writes the encoded image to a temporary file and opens it with Preview. -- (void)_openImageData:(NSData *)imageData { - NSString *fileName = [NSString stringWithFormat:@"%.0f", [[NSDate date] timeIntervalSince1970]]; - NSString *filePath = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSString stringWithFormat:@"LookInside_UIImageView_%@.png", fileName]]; - NSError *writeError; - BOOL writeSucc = [imageData writeToFile:filePath options:0 error:&writeError]; - if (!writeSucc) { - NSAssert(NO, @""); - AlertError(writeError, self.window); - return; - } - [[NSWorkspace sharedWorkspace] openURL:[NSURL fileURLWithPath:filePath]]; - - // 记录临时文件地址以在 Lookin 退出时清理 - if (![LKHelper sharedInstance].tempImageFiles) { - [LKHelper sharedInstance].tempImageFiles = [NSMutableArray array]; - } - [[LKHelper sharedInstance].tempImageFiles addObject:filePath]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributePointView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributePointView.h deleted file mode 100644 index 8b342544..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributePointView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributePointView.h -// Lookin -// -// Created by Li Kai on 2019/6/11. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributePointView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributePointView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributePointView.m deleted file mode 100644 index 750b87c7..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributePointView.m +++ /dev/null @@ -1,143 +0,0 @@ -// -// LKDashboardAttributePointView.m -// Lookin -// -// Created by Li Kai on 2019/6/11. -// https://lookin.work -// - -#import "LKDashboardAttributePointView.h" -#import "LKNumberInputView.h" -#import "LKTextFieldView.h" -#import "LKDashboardViewController.h" -#import "LKDashboardTextControlEditingFlag.h" - -@interface LKDashboardAttributePointView () - -@property(nonatomic, copy) NSArray *inputsView; - -@end - -@implementation LKDashboardAttributePointView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - // self.layer.borderWidth = 1; - // self.layer.borderColor = [NSColor redColor].CGColor; - - NSArray *titles = @[@"X", @"Y"]; - self.inputsView = [NSArray lookin_arrayWithCount:2 block:^id(NSUInteger idx) { - LKNumberInputView *view = [LKNumberInputView new]; - view.title = titles[idx]; - view.viewStyle = LKNumberInputViewStyleHorizontal; - view.textFieldView.textField.delegate = self; - [self addSubview:view]; - return view; - }]; - } - return self; -} - -- (void)layout { - [super layout]; - CGFloat itemWidth = (self.$width - DashboardAttrItemHorInterspace) / 2.0; - [self.inputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat x = idx ? (itemWidth + DashboardAttrItemHorInterspace) : 0; - $(view).width(itemWidth).height(LKNumberInputHorizontalHeight).x(x).y(0); - }]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = LKNumberInputHorizontalHeight; - limitedSize.height = height; - return limitedSize; -} - -- (void)renderWithAttribute { - if (![self.attribute.value isKindOfClass:[NSValue class]]) { - NSAssert(NO, @""); - return; - } - NSPoint point = ((NSValue *)self.attribute.value).pointValue; - NSArray *mainStrs = @[[NSString lookin_stringFromDouble:point.x decimal:3], - [NSString lookin_stringFromDouble:point.y decimal:3]]; - - [self.inputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textFieldView.textField.editable = [self canEdit]; - obj.textFieldView.textField.stringValue = mainStrs[idx]; - }]; -} - -#pragma mark - - -- (BOOL)control:(NSControl *)control textShouldBeginEditing:(NSText *)fieldEditor { - return self.canEdit; -} - -- (void)controlTextDidEndEditing:(NSNotification *)notification { - if (![self canEdit]) { - // Read-only attribute (e.g. SwiftUI-injected user-custom rows have no - // setter): AppKit can still post NSControlTextDidEndEditingNotification - // when the field's superview is removed or its editable flag is - // toggled, even though textShouldBeginEditing rejected user input. - return; - } - if (LKDashboardTextControlEditingFlag.sharedInstance.shouldIgnoreTextEditingChangeEvent) { - NSLog(@"忽略 controlTextDidEndEditing 事件,驳回"); - return; - } - NSTextField *editingTextField = notification.object; - NSNumber *inputValue = [LKNumberInputView parsedValueWithString:editingTextField.stringValue attrType:LookinAttrTypeDouble]; - if (inputValue == nil) { - NSLog(@"输入格式校验不通过,驳回"); - [self renderWithAttribute]; - return; - } - - double inputDouble = [inputValue doubleValue]; - NSUInteger editingTextFieldIdx = [[self.inputsView lookin_map:^id(NSUInteger idx, LKNumberInputView *value) { - return value.textFieldView.textField; - }] indexOfObject:editingTextField]; - - NSPoint expectedPoint = ((NSValue *)self.attribute.value).pointValue; - switch (editingTextFieldIdx) { - case 0: - // x - expectedPoint.x = inputDouble; - break; - case 1: - // y - expectedPoint.y = inputDouble; - break; - default: - [self renderWithAttribute]; - NSAssert(NO, @""); - break; - } - - NSValue *expectedValue = [NSValue valueWithPoint:expectedPoint]; - if ([expectedValue isEqual:self.attribute.value]) { - NSLog(@"修改没有变化,不做任何提交"); - [self renderWithAttribute]; - return; - } - - // 提交修改 - @weakify(self); - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expectedValue] subscribeError:^(NSError * _Nullable error) { - @strongify(self); - NSLog(@"修改返回 error"); - [self renderWithAttribute]; - }]; -} - -#pragma mark - Others - -- (void)setDashboardViewController:(LKDashboardViewController *)dashboardViewController { - [super setDashboardViewController:dashboardViewController]; - [self.inputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textFieldView.backgroundColorName = @"DashboardCardValueBGColor"; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.h deleted file mode 100644 index 326ffb28..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.h +++ /dev/null @@ -1,10 +0,0 @@ -// -// LKDashboardAttributePrivateDiscriminatorView.h -// LookInside -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributePrivateDiscriminatorView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.m deleted file mode 100644 index ae4b2bd3..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.m +++ /dev/null @@ -1,601 +0,0 @@ -// -// LKDashboardAttributePrivateDiscriminatorView.m -// LookInside -// - -#import "LKDashboardAttributePrivateDiscriminatorView.h" -#import "LKDashboardViewController.h" -#import "LookinAttribute.h" -#import "LookinDisplayItem.h" -#import "LookInside-Swift.h" -#import "NSButton+LookinClient.h" - -static NSString *const LKPrivateDiscriminatorDashboardStateDidChangeNotification = @"LKPrivateDiscriminatorDashboardStateDidChange"; - -static const CGFloat kPDRowTitleWidth = 64; -static const CGFloat kPDCardPaddingLeft = 8; -static const CGFloat kPDCardPaddingRight = 4; -static const CGFloat kPDCardInnerSpacing = 6; -static const CGFloat kPDCardVerticalPadding = 4; -static const CGFloat kPDCopySize = 20; -static const CGFloat kPDRowMinHeight = 24; -static const CGFloat kPDButtonHeight = 24; - -@interface LKPrivateDiscriminatorCopyLabel : LKLabel - -@property(nonatomic, copy) NSString *fieldName; - -@end - -@implementation LKPrivateDiscriminatorCopyLabel - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.font = NSFontMake(12); - self.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - self.lineBreakMode = NSLineBreakByCharWrapping; - self.maximumNumberOfLines = 0; - self.toolTip = NSLocalizedString(@"Click to copy", nil); - } - return self; -} - -- (void)mouseDown:(NSEvent *)event { - NSString *value = self.stringValue; - if (value.length == 0) { - [super mouseDown:event]; - return; - } - - [NSApp sendAction:self.action to:self.target from:self]; -} - -- (void)resetCursorRects { - [super resetCursorRects]; - [self addCursorRect:self.bounds cursor:NSCursor.pointingHandCursor]; -} - -@end - -@interface LKDashboardAttributePrivateDiscriminatorView () - -@property(nonatomic, strong) LKBaseView *idCard; -@property(nonatomic, strong) LKLabel *idTitleLabel; -@property(nonatomic, strong) LKPrivateDiscriminatorCopyLabel *idValueLabel; -@property(nonatomic, strong) NSButton *idCopyButton; - -@property(nonatomic, strong) LKBaseView *moduleCard; -@property(nonatomic, strong) LKLabel *moduleTitleLabel; -@property(nonatomic, strong) NSTextField *moduleField; -@property(nonatomic, strong) LKPrivateDiscriminatorCopyLabel *moduleValueLabel; -@property(nonatomic, strong) NSButton *moduleCopyButton; - -@property(nonatomic, strong) LKBaseView *filenameCard; -@property(nonatomic, strong) LKLabel *filenameTitleLabel; -@property(nonatomic, strong) NSTextField *filenameField; -@property(nonatomic, strong) LKPrivateDiscriminatorCopyLabel *filenameValueLabel; -@property(nonatomic, strong) NSButton *filenameCopyButton; - -@property(nonatomic, strong) LKLabel *sourceLabel; -@property(nonatomic, strong) LKLabel *messageLabel; - -@property(nonatomic, strong) NSButton *importButton; -@property(nonatomic, strong) NSButton *guessButton; -@property(nonatomic, strong) NSButton *cancelButton; - -@property(nonatomic, strong) LKBaseView *toastView; -@property(nonatomic, strong) LKLabel *toastLabel; - -@property(nonatomic, strong) LKPrivateDiscriminatorDashboardPayload *payload; -@property(nonatomic, copy) NSString *localMessage; -@property(nonatomic, assign) BOOL localMessageIsError; - -@end - -@implementation LKDashboardAttributePrivateDiscriminatorView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.idCard = [self _makeCard]; - self.idTitleLabel = [self _makeTitleLabel:NSLocalizedString(@"ID", nil)]; - self.idValueLabel = [self _makeCopyLabelNamed:NSLocalizedString(@"ID", nil)]; - self.idValueLabel.font = [NSFont monospacedSystemFontOfSize:12 weight:NSFontWeightRegular]; - self.idCopyButton = [self _makeCopyButton]; - [self.idCard addSubview:self.idTitleLabel]; - [self.idCard addSubview:self.idValueLabel]; - [self.idCard addSubview:self.idCopyButton]; - - self.moduleCard = [self _makeCard]; - self.moduleTitleLabel = [self _makeTitleLabel:NSLocalizedString(@"Module", nil)]; - self.moduleField = [self _makeTextFieldWithPlaceholder:NSLocalizedString(@"ModuleName", nil)]; - self.moduleValueLabel = [self _makeCopyLabelNamed:NSLocalizedString(@"Module", nil)]; - self.moduleCopyButton = [self _makeCopyButton]; - [self.moduleCard addSubview:self.moduleTitleLabel]; - [self.moduleCard addSubview:self.moduleField]; - [self.moduleCard addSubview:self.moduleValueLabel]; - [self.moduleCard addSubview:self.moduleCopyButton]; - - self.filenameCard = [self _makeCard]; - self.filenameTitleLabel = [self _makeTitleLabel:NSLocalizedString(@"Filename", nil)]; - self.filenameField = [self _makeTextFieldWithPlaceholder:NSLocalizedString(@"File.swift", nil)]; - self.filenameValueLabel = [self _makeCopyLabelNamed:NSLocalizedString(@"Filename", nil)]; - self.filenameCopyButton = [self _makeCopyButton]; - [self.filenameCard addSubview:self.filenameTitleLabel]; - [self.filenameCard addSubview:self.filenameField]; - [self.filenameCard addSubview:self.filenameValueLabel]; - [self.filenameCard addSubview:self.filenameCopyButton]; - - self.sourceLabel = [LKLabel new]; - self.sourceLabel.font = NSFontMake(11); - self.sourceLabel.textColor = [NSColor colorNamed:@"DashboardInputAccessoryColor"]; - self.sourceLabel.maximumNumberOfLines = 0; - self.sourceLabel.lineBreakMode = NSLineBreakByWordWrapping; - - self.messageLabel = [LKLabel new]; - self.messageLabel.font = NSFontMake(11); - self.messageLabel.maximumNumberOfLines = 0; - self.messageLabel.lineBreakMode = NSLineBreakByWordWrapping; - - self.importButton = [NSButton lk_normalButtonWithTitle:NSLocalizedString(@"Import from your codebase", nil) target:self action:@selector(_handleImportButton:)]; - self.importButton.font = NSFontMake(12); - - self.guessButton = [NSButton lk_normalButtonWithTitle:NSLocalizedString(@"Guess by swift-pd-guess", nil) target:self action:@selector(_handleGuessButton:)]; - self.guessButton.font = NSFontMake(12); - - self.cancelButton = [NSButton lk_normalButtonWithTitle:NSLocalizedString(@"Cancel", nil) target:self action:@selector(_handleCancelButton:)]; - self.cancelButton.font = NSFontMake(12); - - self.toastView = [LKBaseView new]; - self.toastView.layer.cornerRadius = DashboardCardControlCornerRadius; - self.toastView.hidden = YES; - self.toastView.alphaValue = 0; - - self.toastLabel = [LKLabel new]; - self.toastLabel.stringValue = NSLocalizedString(@"Copied", nil); - self.toastLabel.font = [NSFont systemFontOfSize:11 weight:NSFontWeightSemibold]; - self.toastLabel.textColor = NSColor.whiteColor; - self.toastLabel.alignment = NSTextAlignmentCenter; - [self.toastView addSubview:self.toastLabel]; - - NSArray *subviews = @[ - self.idCard, - self.moduleCard, - self.filenameCard, - self.sourceLabel, - self.messageLabel, - self.importButton, - self.guessButton, - self.cancelButton, - self.toastView, - ]; - [subviews enumerateObjectsUsingBlock:^(NSView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - [self addSubview:view]; - }]; - - [self updateColors]; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(_handleDashboardStateDidChange:) - name:LKPrivateDiscriminatorDashboardStateDidChangeNotification - object:nil]; - } - return self; -} - -- (void)dealloc { - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)renderWithAttribute { - [super renderWithAttribute]; - - self.payload = [self.attribute.value isKindOfClass:LKPrivateDiscriminatorDashboardPayload.class] - ? self.attribute.value - : nil; - - self.idValueLabel.stringValue = self.payload.discriminatorID ?: @""; - self.moduleField.stringValue = self.payload.module ?: @""; - self.filenameField.stringValue = self.payload.filename ?: @""; - self.moduleValueLabel.stringValue = self.payload.module ?: @""; - self.filenameValueLabel.stringValue = self.payload.filename ?: @""; - - BOOL canEdit = self.payload && !self.payload.isMatched && !self.payload.isGuessRunning; - self.moduleField.editable = canEdit; - self.filenameField.editable = canEdit; - self.moduleField.enabled = canEdit; - self.filenameField.enabled = canEdit; - self.moduleField.hidden = !canEdit; - self.filenameField.hidden = !canEdit; - self.moduleValueLabel.hidden = canEdit; - self.filenameValueLabel.hidden = canEdit; - self.idCopyButton.hidden = (self.idValueLabel.stringValue.length == 0); - self.moduleCopyButton.hidden = canEdit || self.moduleValueLabel.stringValue.length == 0; - self.filenameCopyButton.hidden = canEdit || self.filenameValueLabel.stringValue.length == 0; - - self.importButton.hidden = self.payload.isMatched || self.payload.isGuessRunning; - self.guessButton.hidden = self.payload.isMatched || self.payload.isGuessRunning; - self.cancelButton.hidden = !self.payload.isGuessRunning; - - if (self.payload.isMatched) { - NSString *source = self.payload.source.length ? self.payload.source : @"matched"; - self.sourceLabel.stringValue = self.payload.isVerified - ? [NSString stringWithFormat:NSLocalizedString(@"Verified from %@.", nil), source] - : [NSString stringWithFormat:NSLocalizedString(@"Matched from %@.", nil), source]; - self.sourceLabel.hidden = NO; - } else { - self.sourceLabel.stringValue = @""; - self.sourceLabel.hidden = YES; - } - - [self _renderMessage]; - [self setNeedsLayout:YES]; -} - -#pragma mark - Layout - -- (CGFloat)_valueWidthForCardWidth:(CGFloat)cardWidth copyHidden:(BOOL)copyHidden { - CGFloat width = cardWidth - kPDCardPaddingLeft - kPDRowTitleWidth - kPDCardInnerSpacing; - if (copyHidden) { - width -= kPDCardPaddingLeft; - } else { - width -= kPDCopySize + kPDCardInnerSpacing + kPDCardPaddingRight; - } - return MAX(width, 1); -} - -- (CGFloat)_cardHeightForLabel:(LKPrivateDiscriminatorCopyLabel *)label - cardWidth:(CGFloat)cardWidth - copyHidden:(BOOL)copyHidden { - CGFloat valueWidth = [self _valueWidthForCardWidth:cardWidth copyHidden:copyHidden]; - CGFloat valueHeight = MAX(18, [label sizeThatFits:NSMakeSize(valueWidth, CGFLOAT_MAX)].height); - return MAX(valueHeight + kPDCardVerticalPadding * 2, kPDRowMinHeight); -} - -- (CGFloat)_moduleCardHeightForCardWidth:(CGFloat)cardWidth { - if (self.moduleField.hidden) { - return [self _cardHeightForLabel:self.moduleValueLabel cardWidth:cardWidth copyHidden:self.moduleCopyButton.hidden]; - } - return kPDRowMinHeight; -} - -- (CGFloat)_filenameCardHeightForCardWidth:(CGFloat)cardWidth { - if (self.filenameField.hidden) { - return [self _cardHeightForLabel:self.filenameValueLabel cardWidth:cardWidth copyHidden:self.filenameCopyButton.hidden]; - } - return kPDRowMinHeight; -} - -- (void)layout { - [super layout]; - - CGFloat width = MAX(self.$width, 1); - CGFloat verSpacing = DashboardAttrItemVerInterspace; - - CGFloat y = 0; - - CGFloat idHeight = [self _cardHeightForLabel:self.idValueLabel cardWidth:width copyHidden:self.idCopyButton.hidden]; - $(self.idCard).x(0).y(y).width(width).height(idHeight); - [self _layoutCard:self.idCard - titleLabel:self.idTitleLabel - field:nil - valueLabel:self.idValueLabel - copyButton:self.idCopyButton]; - y += idHeight + verSpacing; - - CGFloat moduleHeight = [self _moduleCardHeightForCardWidth:width]; - $(self.moduleCard).x(0).y(y).width(width).height(moduleHeight); - [self _layoutCard:self.moduleCard - titleLabel:self.moduleTitleLabel - field:self.moduleField - valueLabel:self.moduleValueLabel - copyButton:self.moduleCopyButton]; - y += moduleHeight + verSpacing; - - CGFloat filenameHeight = [self _filenameCardHeightForCardWidth:width]; - $(self.filenameCard).x(0).y(y).width(width).height(filenameHeight); - [self _layoutCard:self.filenameCard - titleLabel:self.filenameTitleLabel - field:self.filenameField - valueLabel:self.filenameValueLabel - copyButton:self.filenameCopyButton]; - y += filenameHeight + verSpacing; - - if (!self.sourceLabel.hidden) { - $(self.sourceLabel).x(kPDCardPaddingLeft).y(y).width(MAX(width - kPDCardPaddingLeft * 2, 1)).heightToFit; - y = self.sourceLabel.$maxY + verSpacing; - } - - if (!self.messageLabel.hidden) { - $(self.messageLabel).x(kPDCardPaddingLeft).y(y).width(MAX(width - kPDCardPaddingLeft * 2, 1)).heightToFit; - y = self.messageLabel.$maxY + verSpacing; - } - - if (!self.importButton.hidden) { - $(self.importButton).x(0).y(y).width(width).height(kPDButtonHeight); - y = self.importButton.$maxY + 6; - } - - if (!self.guessButton.hidden) { - $(self.guessButton).x(0).y(y).width(width).height(kPDButtonHeight); - y = self.guessButton.$maxY + 2; - } - - if (!self.cancelButton.hidden) { - $(self.cancelButton).x(0).y(y).width(86).height(kPDButtonHeight); - } - - if (!self.toastView.hidden) { - $(self.toastLabel).x(8).toRight(8).heightToFit.verAlign; - } -} - -- (void)_layoutCard:(LKBaseView *)card - titleLabel:(LKLabel *)titleLabel - field:(NSTextField *)field - valueLabel:(LKPrivateDiscriminatorCopyLabel *)valueLabel - copyButton:(NSButton *)copyButton { - CGFloat cardWidth = card.$width; - CGFloat cardHeight = card.$height; - BOOL fieldVisible = field && !field.hidden; - BOOL copyVisible = copyButton && !copyButton.hidden; - - CGFloat titleHeight = MAX(14, [titleLabel sizeThatFits:NSMakeSize(kPDRowTitleWidth, CGFLOAT_MAX)].height); - $(titleLabel).x(kPDCardPaddingLeft).y(MAX(0, (cardHeight - titleHeight) / 2.0)).width(kPDRowTitleWidth).height(titleHeight); - - CGFloat contentX = kPDCardPaddingLeft + kPDRowTitleWidth + kPDCardInnerSpacing; - CGFloat contentRightX = copyVisible ? cardWidth - kPDCardPaddingRight - kPDCopySize - kPDCardInnerSpacing : cardWidth - kPDCardPaddingLeft; - CGFloat contentWidth = MAX(contentRightX - contentX, 1); - - if (fieldVisible) { - valueLabel.hidden = YES; - CGFloat fieldHeight = MAX(18, [field intrinsicContentSize].height); - $(field).x(contentX - 3).y(MAX(0, (cardHeight - fieldHeight) / 2.0)).width(contentWidth + 6).height(fieldHeight); - } else if (field) { - // 即便 fieldVisible 是 NO 也保持 hidden 状态由 renderWithAttribute 控制 - } - - if (!fieldVisible) { - CGFloat valueHeight = MAX(18, [valueLabel sizeThatFits:NSMakeSize(contentWidth, CGFLOAT_MAX)].height); - $(valueLabel).x(contentX).y(MAX(0, (cardHeight - valueHeight) / 2.0)).width(contentWidth).height(valueHeight); - } - - if (copyVisible) { - CGFloat copyY = MAX(0, (cardHeight - kPDCopySize) / 2.0); - $(copyButton).x(cardWidth - kPDCardPaddingRight - kPDCopySize).y(copyY).width(kPDCopySize).height(kPDCopySize); - } -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat width = MAX(limitedSize.width, 1); - CGFloat verSpacing = DashboardAttrItemVerInterspace; - - CGFloat height = 0; - height += [self _cardHeightForLabel:self.idValueLabel cardWidth:width copyHidden:self.idCopyButton.hidden]; - height += verSpacing; - height += [self _moduleCardHeightForCardWidth:width]; - height += verSpacing; - height += [self _filenameCardHeightForCardWidth:width]; - - CGFloat sideTextWidth = MAX(width - kPDCardPaddingLeft * 2, 1); - - if (!self.sourceLabel.hidden) { - height += verSpacing; - height += [self.sourceLabel sizeThatFits:NSMakeSize(sideTextWidth, CGFLOAT_MAX)].height; - } - if (!self.messageLabel.hidden) { - height += verSpacing; - height += [self.messageLabel sizeThatFits:NSMakeSize(sideTextWidth, CGFLOAT_MAX)].height; - } - - if (!self.importButton.hidden) { - height += verSpacing; - height += kPDButtonHeight; - } - if (!self.guessButton.hidden) { - height += self.importButton.hidden ? verSpacing : 6; - height += kPDButtonHeight; - } - if (!self.cancelButton.hidden) { - if (self.importButton.hidden && self.guessButton.hidden) { - height += verSpacing; - } else { - height += 2; - } - height += kPDButtonHeight; - } - - limitedSize.height = height; - return limitedSize; -} - -- (NSUInteger)numberOfColumnsOccupied { - return 1; -} - -#pragma mark - Actions - -- (void)_submitManualValue:(id)sender { - if (!self.payload || self.payload.isMatched) { - return; - } - - NSError *error = nil; - BOOL saved = [[LKPrivateDiscriminatorStore shared] submitPrivateDiscriminatorForDisplayItem:self.attribute.targetDisplayItem - module:self.moduleField.stringValue - filename:self.filenameField.stringValue - error:&error]; - if (saved) { - self.localMessage = NSLocalizedString(@"Saved.", nil); - self.localMessageIsError = NO; - [self.dashboardViewController reloadCurrentDisplayItem]; - } else { - self.localMessage = error.localizedDescription ?: NSLocalizedString(@"Verification failed.", nil); - self.localMessageIsError = YES; - [self _renderMessage]; - [self.window makeFirstResponder:nil]; - [self setNeedsLayout:YES]; - } -} - -- (void)_handleImportButton:(NSButton *)button { - NSError *error = nil; - BOOL imported = [[LKPrivateDiscriminatorStore shared] importPrivateDiscriminatorFromCodebaseForDisplayItem:self.attribute.targetDisplayItem - window:self.window - error:&error]; - if (imported) { - self.localMessage = nil; - [self.dashboardViewController reloadCurrentDisplayItem]; - } else if (error) { - self.localMessage = error.localizedDescription; - self.localMessageIsError = YES; - [self _renderMessage]; - [self setNeedsLayout:YES]; - } -} - -- (void)_handleGuessButton:(NSButton *)button { - [[LKPrivateDiscriminatorStore shared] beginSwiftPDGuessForDisplayItem:self.attribute.targetDisplayItem window:self.window]; - [self.dashboardViewController reloadCurrentDisplayItem]; -} - -- (void)_handleCancelButton:(NSButton *)button { - [[LKPrivateDiscriminatorStore shared] cancelSwiftPDGuessForDisplayItem:self.attribute.targetDisplayItem]; -} - -- (void)_handleCopyLabel:(LKPrivateDiscriminatorCopyLabel *)label { - [self _copyString:label.stringValue sourceView:label]; -} - -- (void)_handleCopyButton:(NSButton *)button { - if (button == self.idCopyButton) { - [self _copyString:self.idValueLabel.stringValue sourceView:button]; - } else if (button == self.moduleCopyButton) { - [self _copyString:self.moduleValueLabel.stringValue sourceView:button]; - } else if (button == self.filenameCopyButton) { - [self _copyString:self.filenameValueLabel.stringValue sourceView:button]; - } -} - -- (void)_handleDashboardStateDidChange:(NSNotification *)notification { - [self.dashboardViewController reloadCurrentDisplayItem]; -} - -- (void)_hideCopyToast { - [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) { - context.duration = 0.12; - self.toastView.animator.alphaValue = 0; - } completionHandler:^{ - self.toastView.hidden = YES; - }]; -} - -#pragma mark - Views - -- (LKBaseView *)_makeCard { - LKBaseView *card = [LKBaseView new]; - card.layer.cornerRadius = DashboardCardControlCornerRadius; - card.backgroundColorName = @"DashboardCardValueBGColor"; - return card; -} - -- (LKLabel *)_makeTitleLabel:(NSString *)title { - LKLabel *label = [LKLabel new]; - label.stringValue = title; - label.font = NSFontMake(11); - label.textColor = [NSColor colorNamed:@"DashboardInputAccessoryColor"]; - return label; -} - -- (LKPrivateDiscriminatorCopyLabel *)_makeCopyLabelNamed:(NSString *)name { - LKPrivateDiscriminatorCopyLabel *label = [LKPrivateDiscriminatorCopyLabel new]; - label.fieldName = name; - label.target = self; - label.action = @selector(_handleCopyLabel:); - return label; -} - -- (NSButton *)_makeCopyButton { - NSImage *image = [NSImage imageWithSystemSymbolName:@"doc.on.doc" accessibilityDescription:NSLocalizedString(@"Copy", nil)]; - NSButton *button = [NSButton lk_buttonWithImage:image ?: NSImage.new target:self action:@selector(_handleCopyButton:)]; - button.imagePosition = NSImageOnly; - button.toolTip = NSLocalizedString(@"Copy", nil); - if (image) { - image.template = YES; - } - return button; -} - -- (NSTextField *)_makeTextFieldWithPlaceholder:(NSString *)placeholder { - NSTextField *field = [NSTextField new]; - field.placeholderString = placeholder; - field.font = NSFontMake(12); - field.bezeled = NO; - field.drawsBackground = NO; - field.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - field.focusRingType = NSFocusRingTypeNone; - field.target = self; - field.action = @selector(_submitManualValue:); - return field; -} - -- (void)_copyString:(NSString *)value sourceView:(NSView *)sourceView { - if (value.length == 0) { - return; - } - NSPasteboard *pasteboard = NSPasteboard.generalPasteboard; - [pasteboard clearContents]; - [pasteboard setString:value forType:NSPasteboardTypeString]; - [self _showCopyToastNearView:sourceView]; -} - -- (void)_showCopyToastNearView:(NSView *)sourceView { - self.toastLabel.stringValue = NSLocalizedString(@"Copied", nil); - [self.toastLabel sizeToFit]; - - CGFloat toastWidth = ceil(self.toastLabel.$width) + 18; - CGFloat toastHeight = 22; - NSRect sourceFrame = [sourceView convertRect:sourceView.bounds toView:self]; - CGFloat toastX = NSMidX(sourceFrame) - toastWidth / 2.0; - toastX = MIN(MAX(toastX, 6), MAX(self.$width - toastWidth - 6, 6)); - CGFloat toastY = NSMinY(sourceFrame) - toastHeight - 4; - if (toastY < 4) { - toastY = NSMaxY(sourceFrame) + 4; - } - - $(self.toastView).x(toastX).y(toastY).width(toastWidth).height(toastHeight); - $(self.toastLabel).x(8).toRight(8).heightToFit.verAlign; - self.toastView.hidden = NO; - self.toastView.alphaValue = 1; - - [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(_hideCopyToast) object:nil]; - [self performSelector:@selector(_hideCopyToast) withObject:nil afterDelay:1.0]; -} - -- (void)updateColors { - [super updateColors]; - - NSColor *iconColor = self.isDarkMode ? NSColor.secondaryLabelColor : NSColor.tertiaryLabelColor; - self.idCopyButton.contentTintColor = iconColor; - self.moduleCopyButton.contentTintColor = iconColor; - self.filenameCopyButton.contentTintColor = iconColor; - self.toastView.backgroundColor = [NSColor.controlAccentColor colorWithAlphaComponent:self.isDarkMode ? 0.9 : 0.85]; -} - -- (void)_renderMessage { - NSString *message = self.localMessage; - BOOL isError = self.localMessageIsError; - - if (self.payload.warningText.length) { - message = self.payload.warningText; - isError = YES; - } else if (self.payload.guessStatusText.length) { - message = self.payload.guessStatusText; - isError = self.payload.isGuessFailed; - } - - self.messageLabel.stringValue = message ?: @""; - self.messageLabel.textColor = isError ? NSColor.systemRedColor : [NSColor colorNamed:@"DashboardInputAccessoryColor"]; - self.messageLabel.hidden = (message.length == 0); -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.swift new file mode 100644 index 00000000..0cbef9b6 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributePrivateDiscriminatorView.swift @@ -0,0 +1,538 @@ +// +// LKDashboardAttributePrivateDiscriminatorView.swift +// LookInside +// +// The Swift private-discriminator card: the hash ID, and its module and +// file name, matched from the imported indexes or typed in, imported from +// a codebase, or guessed by swift-pd-guess. +// + +import AppKit + +private enum LKPrivateDiscriminatorMetrics { + static let rowTitleWidth: CGFloat = 64 + static let cardPaddingLeft: CGFloat = 8 + static let cardPaddingRight: CGFloat = 4 + static let cardInnerSpacing: CGFloat = 6 + static let cardVerticalPadding: CGFloat = 4 + static let copySize: CGFloat = 20 + static let rowMinHeight: CGFloat = 24 + static let buttonHeight: CGFloat = 24 +} + +/// A value that copies itself when clicked. +private final class LKPrivateDiscriminatorCopyLabel: LKLabel { + var fieldName: String? + var onCopy: ((LKPrivateDiscriminatorCopyLabel) -> Void)? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + font = LKDashboardStyle.font(12) + textColor = NSColor(named: "DashboardCardValueColor") + lineBreakMode = .byCharWrapping + maximumNumberOfLines = 0 + toolTip = NSLocalizedString("Click to copy", comment: "") + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func mouseDown(with event: NSEvent) { + if stringValue.isEmpty { + super.mouseDown(with: event) + return + } + onCopy?(self) + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .pointingHand) + } +} + +@objc(LKDashboardAttributePrivateDiscriminatorView) +final class LKDashboardAttributePrivateDiscriminatorView: LKDashboardAttributeView { + private typealias Metrics = LKPrivateDiscriminatorMetrics + + private static let dashboardStateDidChangeNotification = Notification.Name("LKPrivateDiscriminatorDashboardStateDidChange") + + private let idCard = LKBaseView() + private let idTitleLabel = LKLabel() + private let idValueLabel = LKPrivateDiscriminatorCopyLabel() + private var idCopyButton: NSButton! + + private let moduleCard = LKBaseView() + private let moduleTitleLabel = LKLabel() + private let moduleField = NSTextField() + private let moduleValueLabel = LKPrivateDiscriminatorCopyLabel() + private var moduleCopyButton: NSButton! + + private let filenameCard = LKBaseView() + private let filenameTitleLabel = LKLabel() + private let filenameField = NSTextField() + private let filenameValueLabel = LKPrivateDiscriminatorCopyLabel() + private var filenameCopyButton: NSButton! + + private let sourceLabel = LKLabel() + private let messageLabel = LKLabel() + + private var importButton: NSButton! + private var guessButton: NSButton! + private var cancelButton: NSButton! + + private let toastView = LKBaseView() + private let toastLabel = LKLabel() + private var hideToastWorkItem: DispatchWorkItem? + + private var payload: LKPrivateDiscriminatorDashboardPayload? + private var localMessage: String? + private var localMessageIsError = false + private var stateObserver: NSObjectProtocol? + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + setUpCard(idCard, titleLabel: idTitleLabel, title: NSLocalizedString("ID", comment: "")) + setUpCopyLabel(idValueLabel, name: NSLocalizedString("ID", comment: "")) + idValueLabel.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + idCopyButton = makeCopyButton() + idCard.addSubview(idTitleLabel) + idCard.addSubview(idValueLabel) + idCard.addSubview(idCopyButton) + + setUpCard(moduleCard, titleLabel: moduleTitleLabel, title: NSLocalizedString("Module", comment: "")) + setUpTextField(moduleField, placeholder: NSLocalizedString("ModuleName", comment: "")) + setUpCopyLabel(moduleValueLabel, name: NSLocalizedString("Module", comment: "")) + moduleCopyButton = makeCopyButton() + moduleCard.addSubview(moduleTitleLabel) + moduleCard.addSubview(moduleField) + moduleCard.addSubview(moduleValueLabel) + moduleCard.addSubview(moduleCopyButton) + + setUpCard(filenameCard, titleLabel: filenameTitleLabel, title: NSLocalizedString("Filename", comment: "")) + setUpTextField(filenameField, placeholder: NSLocalizedString("File.swift", comment: "")) + setUpCopyLabel(filenameValueLabel, name: NSLocalizedString("Filename", comment: "")) + filenameCopyButton = makeCopyButton() + filenameCard.addSubview(filenameTitleLabel) + filenameCard.addSubview(filenameField) + filenameCard.addSubview(filenameValueLabel) + filenameCard.addSubview(filenameCopyButton) + + sourceLabel.font = LKDashboardStyle.font(11) + sourceLabel.textColor = NSColor(named: "DashboardInputAccessoryColor") + sourceLabel.maximumNumberOfLines = 0 + sourceLabel.lineBreakMode = .byWordWrapping + + messageLabel.font = LKDashboardStyle.font(11) + messageLabel.maximumNumberOfLines = 0 + messageLabel.lineBreakMode = .byWordWrapping + + importButton = NSButton.lk_normalButton(withTitle: NSLocalizedString("Import from your codebase", comment: ""), target: self, action: #selector(handleImportButton(_:))) + importButton.font = LKDashboardStyle.font(12) + + guessButton = NSButton.lk_normalButton(withTitle: NSLocalizedString("Guess by swift-pd-guess", comment: ""), target: self, action: #selector(handleGuessButton(_:))) + guessButton.font = LKDashboardStyle.font(12) + + cancelButton = NSButton.lk_normalButton(withTitle: NSLocalizedString("Cancel", comment: ""), target: self, action: #selector(handleCancelButton(_:))) + cancelButton.font = LKDashboardStyle.font(12) + + toastView.layer?.cornerRadius = LKDashboardMetrics.cardControlCornerRadius + toastView.isHidden = true + toastView.alphaValue = 0 + + toastLabel.stringValue = NSLocalizedString("Copied", comment: "") + toastLabel.font = .systemFont(ofSize: 11, weight: .semibold) + toastLabel.textColor = .white + toastLabel.alignment = .center + toastView.addSubview(toastLabel) + + let subviews: [NSView] = [ + idCard, + moduleCard, + filenameCard, + sourceLabel, + messageLabel, + importButton, + guessButton, + cancelButton, + toastView, + ] + subviews.forEach(addSubview) + + updateColors() + + stateObserver = NotificationCenter.default.addObserver(forName: Self.dashboardStateDidChangeNotification, object: nil, queue: nil) { [weak self] _ in + lkRunOnMain { self?.dashboardViewController?.reloadCurrentDisplayItem() } + } + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + deinit { + if let stateObserver { + NotificationCenter.default.removeObserver(stateObserver) + } + } + + override func renderWithAttribute() { + super.renderWithAttribute() + + payload = attribute?.value as? LKPrivateDiscriminatorDashboardPayload + + idValueLabel.stringValue = payload?.discriminatorID ?? "" + moduleField.stringValue = payload?.module ?? "" + filenameField.stringValue = payload?.filename ?? "" + moduleValueLabel.stringValue = payload?.module ?? "" + filenameValueLabel.stringValue = payload?.filename ?? "" + + let isMatched = payload?.isMatched ?? false + let isGuessRunning = payload?.isGuessRunning ?? false + let canEdit = payload != nil && !isMatched && !isGuessRunning + for field in [moduleField, filenameField] { + field.isEditable = canEdit + field.isEnabled = canEdit + field.isHidden = !canEdit + } + moduleValueLabel.isHidden = canEdit + filenameValueLabel.isHidden = canEdit + idCopyButton.isHidden = idValueLabel.stringValue.isEmpty + moduleCopyButton.isHidden = canEdit || moduleValueLabel.stringValue.isEmpty + filenameCopyButton.isHidden = canEdit || filenameValueLabel.stringValue.isEmpty + + importButton.isHidden = isMatched || isGuessRunning + guessButton.isHidden = isMatched || isGuessRunning + cancelButton.isHidden = !isGuessRunning + + if let payload, isMatched { + let source = payload.source.isEmpty ? "matched" : payload.source + let format = payload.isVerified ? NSLocalizedString("Verified from %@.", comment: "") : NSLocalizedString("Matched from %@.", comment: "") + sourceLabel.stringValue = String(format: format, source) + sourceLabel.isHidden = false + } else { + sourceLabel.stringValue = "" + sourceLabel.isHidden = true + } + + renderMessage() + needsLayout = true + } + + // MARK: - Layout + + private func valueWidth(forCardWidth cardWidth: CGFloat, copyHidden: Bool) -> CGFloat { + var width = cardWidth - Metrics.cardPaddingLeft - Metrics.rowTitleWidth - Metrics.cardInnerSpacing + if copyHidden { + width -= Metrics.cardPaddingLeft + } else { + width -= Metrics.copySize + Metrics.cardInnerSpacing + Metrics.cardPaddingRight + } + return max(width, 1) + } + + private func cardHeight(for label: LKPrivateDiscriminatorCopyLabel, cardWidth: CGFloat, copyHidden: Bool) -> CGFloat { + let width = valueWidth(forCardWidth: cardWidth, copyHidden: copyHidden) + let valueHeight = max(18, label.sizeThatFits(NSSize(width: width, height: .greatestFiniteMagnitude)).height) + return max(valueHeight + Metrics.cardVerticalPadding * 2, Metrics.rowMinHeight) + } + + private func moduleCardHeight(forCardWidth cardWidth: CGFloat) -> CGFloat { + if moduleField.isHidden { + return cardHeight(for: moduleValueLabel, cardWidth: cardWidth, copyHidden: moduleCopyButton.isHidden) + } + return Metrics.rowMinHeight + } + + private func filenameCardHeight(forCardWidth cardWidth: CGFloat) -> CGFloat { + if filenameField.isHidden { + return cardHeight(for: filenameValueLabel, cardWidth: cardWidth, copyHidden: filenameCopyButton.isHidden) + } + return Metrics.rowMinHeight + } + + override func layout() { + super.layout() + + let width = max(frame.width, 1) + let verSpacing = LKDashboardMetrics.attrItemVerInterspace + var y: CGFloat = 0 + + let idHeight = cardHeight(for: idValueLabel, cardWidth: width, copyHidden: idCopyButton.isHidden) + idCard.dashboardLayout.x(0).y(y).width(width).height(idHeight) + layoutCard(idCard, titleLabel: idTitleLabel, field: nil, valueLabel: idValueLabel, copyButton: idCopyButton) + y += idHeight + verSpacing + + let moduleHeight = moduleCardHeight(forCardWidth: width) + moduleCard.dashboardLayout.x(0).y(y).width(width).height(moduleHeight) + layoutCard(moduleCard, titleLabel: moduleTitleLabel, field: moduleField, valueLabel: moduleValueLabel, copyButton: moduleCopyButton) + y += moduleHeight + verSpacing + + let filenameHeight = filenameCardHeight(forCardWidth: width) + filenameCard.dashboardLayout.x(0).y(y).width(width).height(filenameHeight) + layoutCard(filenameCard, titleLabel: filenameTitleLabel, field: filenameField, valueLabel: filenameValueLabel, copyButton: filenameCopyButton) + y += filenameHeight + verSpacing + + let sideTextWidth = max(width - Metrics.cardPaddingLeft * 2, 1) + if !sourceLabel.isHidden { + sourceLabel.dashboardLayout.x(Metrics.cardPaddingLeft).y(y).width(sideTextWidth).heightToFit() + y = sourceLabel.frame.maxY + verSpacing + } + if !messageLabel.isHidden { + messageLabel.dashboardLayout.x(Metrics.cardPaddingLeft).y(y).width(sideTextWidth).heightToFit() + y = messageLabel.frame.maxY + verSpacing + } + if !importButton.isHidden { + importButton.dashboardLayout.x(0).y(y).width(width).height(Metrics.buttonHeight) + y = importButton.frame.maxY + 6 + } + if !guessButton.isHidden { + guessButton.dashboardLayout.x(0).y(y).width(width).height(Metrics.buttonHeight) + y = guessButton.frame.maxY + 2 + } + if !cancelButton.isHidden { + cancelButton.dashboardLayout.x(0).y(y).width(86).height(Metrics.buttonHeight) + } + if !toastView.isHidden { + toastLabel.dashboardLayout.x(8).toRight(8).heightToFit().verAlign() + } + } + + private func layoutCard(_ card: LKBaseView, titleLabel: LKLabel, field: NSTextField?, valueLabel: LKPrivateDiscriminatorCopyLabel, copyButton: NSButton) { + let cardWidth = card.frame.width + let cardHeight = card.frame.height + let fieldVisible = field.map { !$0.isHidden } ?? false + let copyVisible = !copyButton.isHidden + + let titleHeight = max(14, titleLabel.sizeThatFits(NSSize(width: Metrics.rowTitleWidth, height: .greatestFiniteMagnitude)).height) + titleLabel.dashboardLayout.x(Metrics.cardPaddingLeft).y(max(0, (cardHeight - titleHeight) / 2.0)).width(Metrics.rowTitleWidth).height(titleHeight) + + let contentX = Metrics.cardPaddingLeft + Metrics.rowTitleWidth + Metrics.cardInnerSpacing + let contentRightX = copyVisible + ? cardWidth - Metrics.cardPaddingRight - Metrics.copySize - Metrics.cardInnerSpacing + : cardWidth - Metrics.cardPaddingLeft + let contentWidth = max(contentRightX - contentX, 1) + + if let field, fieldVisible { + valueLabel.isHidden = true + let fieldHeight = max(18, field.intrinsicContentSize.height) + field.dashboardLayout.x(contentX - 3).y(max(0, (cardHeight - fieldHeight) / 2.0)).width(contentWidth + 6).height(fieldHeight) + } else { + // A hidden field stays hidden; renderWithAttribute decides. + let valueHeight = max(18, valueLabel.sizeThatFits(NSSize(width: contentWidth, height: .greatestFiniteMagnitude)).height) + valueLabel.dashboardLayout.x(contentX).y(max(0, (cardHeight - valueHeight) / 2.0)).width(contentWidth).height(valueHeight) + } + + if copyVisible { + let copyY = max(0, (cardHeight - Metrics.copySize) / 2.0) + copyButton.dashboardLayout.x(cardWidth - Metrics.cardPaddingRight - Metrics.copySize).y(copyY).width(Metrics.copySize).height(Metrics.copySize) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + let width = max(limitedSize.width, 1) + let verSpacing = LKDashboardMetrics.attrItemVerInterspace + + var height: CGFloat = 0 + height += cardHeight(for: idValueLabel, cardWidth: width, copyHidden: idCopyButton.isHidden) + height += verSpacing + height += moduleCardHeight(forCardWidth: width) + height += verSpacing + height += filenameCardHeight(forCardWidth: width) + + let sideTextWidth = max(width - Metrics.cardPaddingLeft * 2, 1) + if !sourceLabel.isHidden { + height += verSpacing + height += sourceLabel.sizeThatFits(NSSize(width: sideTextWidth, height: .greatestFiniteMagnitude)).height + } + if !messageLabel.isHidden { + height += verSpacing + height += messageLabel.sizeThatFits(NSSize(width: sideTextWidth, height: .greatestFiniteMagnitude)).height + } + if !importButton.isHidden { + height += verSpacing + height += Metrics.buttonHeight + } + if !guessButton.isHidden { + height += importButton.isHidden ? verSpacing : 6 + height += Metrics.buttonHeight + } + if !cancelButton.isHidden { + height += (importButton.isHidden && guessButton.isHidden) ? verSpacing : 2 + height += Metrics.buttonHeight + } + + var size = limitedSize + size.height = height + return size + } + + override func numberOfColumnsOccupied() -> Int { + 1 + } + + // MARK: - Actions + + @objc private func submitManualValue(_: Any?) { + guard let payload, !payload.isMatched, let item = attribute?.targetDisplayItem else { return } + var error: NSError? + let saved = LKPrivateDiscriminatorStore.shared.submitPrivateDiscriminator(for: item, module: moduleField.stringValue, filename: filenameField.stringValue, error: &error) + if saved { + localMessage = NSLocalizedString("Saved.", comment: "") + localMessageIsError = false + dashboardViewController?.reloadCurrentDisplayItem() + } else { + localMessage = error?.localizedDescription ?? NSLocalizedString("Verification failed.", comment: "") + localMessageIsError = true + renderMessage() + window?.makeFirstResponder(nil) + needsLayout = true + } + } + + @objc private func handleImportButton(_: NSButton) { + guard let item = attribute?.targetDisplayItem else { return } + var error: NSError? + let imported = LKPrivateDiscriminatorStore.shared.importPrivateDiscriminatorFromCodebase(for: item, window: window, error: &error) + if imported { + localMessage = nil + dashboardViewController?.reloadCurrentDisplayItem() + } else if let error { + localMessage = error.localizedDescription + localMessageIsError = true + renderMessage() + needsLayout = true + } + } + + @objc private func handleGuessButton(_: NSButton) { + guard let item = attribute?.targetDisplayItem else { return } + LKPrivateDiscriminatorStore.shared.beginSwiftPDGuess(for: item, window: window) + dashboardViewController?.reloadCurrentDisplayItem() + } + + @objc private func handleCancelButton(_: NSButton) { + guard let item = attribute?.targetDisplayItem else { return } + LKPrivateDiscriminatorStore.shared.cancelSwiftPDGuess(for: item) + } + + @objc private func handleCopyButton(_ button: NSButton) { + if button === idCopyButton { + copy(idValueLabel.stringValue, sourceView: button) + } else if button === moduleCopyButton { + copy(moduleValueLabel.stringValue, sourceView: button) + } else if button === filenameCopyButton { + copy(filenameValueLabel.stringValue, sourceView: button) + } + } + + private func hideCopyToast() { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + self.toastView.animator().alphaValue = 0 + } completionHandler: { + self.toastView.isHidden = true + } + } + + // MARK: - Views + + private func setUpCard(_ card: LKBaseView, titleLabel: LKLabel, title: String) { + card.layer?.cornerRadius = LKDashboardMetrics.cardControlCornerRadius + card.backgroundColorName = "DashboardCardValueBGColor" + titleLabel.stringValue = title + titleLabel.font = LKDashboardStyle.font(11) + titleLabel.textColor = NSColor(named: "DashboardInputAccessoryColor") + } + + private func setUpCopyLabel(_ label: LKPrivateDiscriminatorCopyLabel, name: String) { + label.fieldName = name + label.onCopy = { [weak self] label in + self?.copy(label.stringValue, sourceView: label) + } + } + + private func makeCopyButton() -> NSButton { + let image = NSImage(systemSymbolName: "doc.on.doc", accessibilityDescription: NSLocalizedString("Copy", comment: "")) + let button = NSButton.lk_button(with: image ?? NSImage(), target: self, action: #selector(handleCopyButton(_:))) + button.imagePosition = .imageOnly + button.toolTip = NSLocalizedString("Copy", comment: "") + image?.isTemplate = true + return button + } + + private func setUpTextField(_ field: NSTextField, placeholder: String) { + field.placeholderString = placeholder + field.font = LKDashboardStyle.font(12) + field.isBezeled = false + field.drawsBackground = false + field.textColor = NSColor(named: "DashboardCardValueColor") + field.focusRingType = .none + field.target = self + field.action = #selector(submitManualValue(_:)) + } + + private func copy(_ value: String, sourceView: NSView) { + guard !value.isEmpty else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(value, forType: .string) + showCopyToast(near: sourceView) + } + + private func showCopyToast(near sourceView: NSView) { + toastLabel.stringValue = NSLocalizedString("Copied", comment: "") + toastLabel.sizeToFit() + + let toastWidth = ceil(toastLabel.frame.width) + 18 + let toastHeight: CGFloat = 22 + let sourceFrame = sourceView.convert(sourceView.bounds, to: self) + var toastX = sourceFrame.midX - toastWidth / 2.0 + toastX = min(max(toastX, 6), max(frame.width - toastWidth - 6, 6)) + var toastY = sourceFrame.minY - toastHeight - 4 + if toastY < 4 { + toastY = sourceFrame.maxY + 4 + } + + toastView.dashboardLayout.x(toastX).y(toastY).width(toastWidth).height(toastHeight) + toastLabel.dashboardLayout.x(8).toRight(8).heightToFit().verAlign() + toastView.isHidden = false + toastView.alphaValue = 1 + + hideToastWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + self?.hideCopyToast() + } + hideToastWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: workItem) + } + + override func updateColors() { + super.updateColors() + let isDarkMode = isDarkMode() + let iconColor: NSColor = isDarkMode ? .secondaryLabelColor : .tertiaryLabelColor + idCopyButton?.contentTintColor = iconColor + moduleCopyButton?.contentTintColor = iconColor + filenameCopyButton?.contentTintColor = iconColor + toastView.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(isDarkMode ? 0.9 : 0.85) + } + + private func renderMessage() { + var message = localMessage + var isError = localMessageIsError + + if let warningText = payload?.warningText, !warningText.isEmpty { + message = warningText + isError = true + } else if let guessStatusText = payload?.guessStatusText, !guessStatusText.isEmpty { + message = guessStatusText + isError = payload?.isGuessFailed ?? false + } + + messageLabel.stringValue = message ?? "" + messageLabel.textColor = isError ? .systemRed : NSColor(named: "DashboardInputAccessoryColor") + messageLabel.isHidden = (message ?? "").isEmpty + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeReadOnlyViews.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeReadOnlyViews.swift new file mode 100644 index 00000000..e707e863 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeReadOnlyViews.swift @@ -0,0 +1,254 @@ +// +// LKDashboardAttributeReadOnlyViews.swift +// LookInside +// +// Created by Li Kai on 2019/6/12. +// https://lookin.work +// +// Attributes that only display or act: table rows per section, shadow, +// and the "Open Image with Preview" button. +// + +import AppKit + +/// The number of rows in each section of a table view. +@objc(LKDashboardAttributeRowsCountView) +final class LKDashboardAttributeRowsCountView: LKDashboardAttributeView { + private var inputViews: [LKNumberInputView] = [] + + override func layout() { + super.layout() + for (idx, view) in inputViews.enumerated() { + let y = CGFloat(idx) * (LKDashboardMetrics.numberInputHorizontalHeight + LKDashboardMetrics.attrItemVerInterspace) + view.dashboardLayout.fullWidth().height(LKDashboardMetrics.numberInputHorizontalHeight).y(y) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + let count = CGFloat(inputViews.count) + let height = (LKDashboardMetrics.numberInputHorizontalHeight + LKDashboardMetrics.attrItemVerInterspace) * count - LKDashboardMetrics.attrItemVerInterspace + var size = limitedSize + size.height = max(height, 0) + return size + } + + override func renderWithAttribute() { + guard let numbers = attribute?.value as? [Any] else { + assertionFailure() + return + } + while inputViews.count > numbers.count { + inputViews.removeLast().removeFromSuperview() + } + while inputViews.count < numbers.count { + let view = LKNumberInputView() + view.textFieldView.textField.isEditable = false + view.viewStyle = .horizontal + view.textFieldView.backgroundColorName = "DashboardCardValueBGColor" + addSubview(view) + inputViews.append(view) + } + for (idx, view) in inputViews.enumerated() { + view.title = "Section \(idx)" + view.textFieldView.textField.stringValue = "\(numbers[idx])" + } + needsLayout = true + } + + override func dashboardViewControllerDidChange() { + for view in inputViews { + view.textFieldView.backgroundColorName = "DashboardCardValueBGColor" + } + } +} + +/// A layer shadow: colour, then opacity, radius and offset. +@objc(LKDashboardAttributeShadowView) +final class LKDashboardAttributeShadowView: LKDashboardAttributeView { + private let colorContainerView = LKBaseView() + private let colorIndicatorLayer = LKColorIndicatorLayer() + private let colorDescLabel = LKLabel() + private var inputViews: [LKNumberInputView] = [] + private var rgbaFormatObservation: NSKeyValueObservation? + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + colorContainerView.layer?.cornerRadius = LKDashboardMetrics.cardControlCornerRadius + colorContainerView.backgroundColorName = "DashboardCardValueBGColor" + addSubview(colorContainerView) + + colorContainerView.layer?.addSublayer(colorIndicatorLayer) + + colorDescLabel.textColor = NSColor(named: "DashboardCardValueColor") + colorDescLabel.font = LKDashboardStyle.font(13) + colorContainerView.addSubview(colorDescLabel) + + inputViews = ["Opacity", "Radius", "OffsetW", "OffsetH"].map { title in + let view = LKNumberInputView() + view.textFieldView.textField.isEditable = false + view.title = title + view.viewStyle = .vertical + addSubview(view) + return view + } + + // Re-render when the user switches between hex and RGBA. + rgbaFormatObservation = LKPreferenceManager.shared.observe(\.rgbaFormat, options: [.new]) { [weak self] _, _ in + lkRunOnMain { self?.renderWithAttribute() } + } + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + colorContainerView.dashboardLayout.fullWidth().height(30).y(0) + colorIndicatorLayer.dashboardLayout.width(16).height(16).x(8).verAlign() + colorDescLabel.dashboardLayout.x(28).toRight(20).heightToFit().verAlign().offsetY(-1) + + let horSpace = LKDashboardMetrics.attrItemHorInterspace + let itemWidth = (frame.width - horSpace * 3) / 4.0 + let y = colorContainerView.frame.maxY + LKDashboardMetrics.attrItemVerInterspace + var x: CGFloat = 0 + for view in inputViews { + view.dashboardLayout.width(itemWidth).height(LKDashboardMetrics.numberInputVerticalHeight).x(x).y(y) + x += itemWidth + horSpace + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + size.height = 30 + LKDashboardMetrics.attrItemVerInterspace + LKDashboardMetrics.numberInputVerticalHeight + return size + } + + override func renderWithAttribute() { + guard let info = attribute?.value as? [String: Any], + let offsetValue = info["offset"] as? NSValue, + let opacityNumber = info["opacity"] as? NSNumber, + let radiusNumber = info["radius"] as? NSNumber + else { + assertionFailure() + return + } + // The colour may be nil. + let color = NSColor.lk_color(fromRGBAComponents: info["color"] as? [NSNumber]) + let offset = offsetValue.sizeValue + + colorIndicatorLayer.color = color + if let color { + colorDescLabel.stringValue = LKPreferenceManager.shared.rgbaFormat ? color.rgbaString() : color.hexString() + } else { + colorDescLabel.stringValue = "nil" + } + + let strings = [opacityNumber.doubleValue, radiusNumber.doubleValue, Double(offset.width), Double(offset.height)].map { + NSString.lookin_string(from: $0, decimal: 2) ?? "" + } + for (view, string) in zip(inputViews, strings) { + view.textFieldView.textField.stringValue = string + } + } +} + +/// Opens the image of an image view in Preview, fetched from the app (or +/// taken from an imported Xcode capture). +@objc(LKDashboardAttributeOpenImageView) +final class LKDashboardAttributeOpenImageView: LKDashboardAttributeView { + private let control = LKTextControl() + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + layer?.borderWidth = 1 + layer?.cornerRadius = LKDashboardMetrics.cardControlCornerRadius + borderColors = LKTwoColors(colorInLightMode: LKDashboardStyle.rgb(181, 181, 181), colorInDarkMode: LKDashboardStyle.rgb(83, 83, 83)) + + control.adjustAlphaWhenClick = true + control.label.stringValue = NSLocalizedString("Open Image with Preview…", comment: "") + control.label.font = LKDashboardStyle.font(11) + control.addTarget(self, clickAction: #selector(handleClick)) + addSubview(control) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + control.dashboardLayout.fullFrame() + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + size.height = LKDashboardMetrics.numberInputHorizontalHeight + return size + } + + @objc private func handleClick() { + // An imported Xcode capture carries the image's own bytes: there is + // no process to fetch them from, and none is needed. + if let imageData = attribute?.value as? Data { + openImageData(imageData) + return + } + + guard let imageViewOid = (attribute?.value as? NSNumber)?.uintValue else { + LKDashboardStyle.alert(LKConnectionError.inner, window: window) + assertionFailure() + return + } + + guard dashboardViewController?.isStaticMode == true else { + LKDashboardStyle.alert( + title: NSLocalizedString("The feature is not available in current mode.", comment: ""), + detail: NSLocalizedString("You must connect LookInside with target iOS app before using this feature.", comment: ""), + window: window + ) + return + } + + // The inspectable app of the document that owns this view's window. + guard let inspectableApp = LookinLiveDocument.document(in: window)?.inspectableApp else { + LKDashboardStyle.alert(LKConnectionError.noConnect, window: window) + return + } + + Task { @MainActor [weak self] in + do { + let imageData = try await inspectableApp.image(imageViewOid: imageViewOid) + guard let self else { return } + guard let imageData else { + LKDashboardStyle.alert( + title: NSLocalizedString("Operation failed. The image property value of selected UIImageView is nil.", comment: ""), + detail: "", + window: self.window + ) + return + } + self.openImageData(imageData) + } catch { + LKDashboardStyle.alert(error, window: self?.window) + } + } + } + + /// Writes the encoded image to a temporary file and opens it with + /// Preview. The file is deleted when LookInside quits. + private func openImageData(_ imageData: Data) { + let fileName = String(format: "%.0f", Date().timeIntervalSince1970) + let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent("LookInside_UIImageView_\(fileName).png") + do { + try imageData.write(to: URL(fileURLWithPath: filePath)) + } catch { + assertionFailure() + LKDashboardStyle.alert(error, window: window) + return + } + NSWorkspace.shared.open(URL(fileURLWithPath: filePath)) + + LKHelper.sharedInstance().tempImageFiles.append(filePath) + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRectView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeRectView.h deleted file mode 100644 index 39500281..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRectView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeRectView.h -// Lookin -// -// Created by Li Kai on 2019/6/10. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeRectView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRectView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeRectView.m deleted file mode 100644 index ee7c155c..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRectView.m +++ /dev/null @@ -1,189 +0,0 @@ -// -// LKDashboardAttributeRectView.m -// Lookin -// -// Created by Li Kai on 2019/6/10. -// https://lookin.work -// - -#import "LKDashboardAttributeRectView.h" -#import "LKNumberInputView.h" -#import "LKTextFieldView.h" -#import "LKDashboardViewController.h" -#import "LKDashboardTextControlEditingFlag.h" - -@interface LKDashboardAttributeRectView () - -@property(nonatomic, copy) NSArray *mainInputsView; - -@end - -@implementation LKDashboardAttributeRectView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - // self.layer.borderWidth = 1; - // self.layer.borderColor = [NSColor redColor].CGColor; - - NSArray *titles = @[@"X", @"Y", @"W", @"H"]; - self.mainInputsView = [NSArray lookin_arrayWithCount:4 block:^id(NSUInteger idx) { - LKNumberInputView *view = [LKNumberInputView new]; - view.title = titles[idx]; - view.viewStyle = LKNumberInputViewStyleHorizontal; - view.textFieldView.textField.delegate = self; - view.textFieldView.backgroundColorName = @"DashboardCardValueBGColor"; - [self addSubview:view]; - return view; - }]; - } - return self; -} - -- (void)layout { - [super layout]; - CGFloat itemWidth = (self.$width - DashboardAttrItemHorInterspace) / 2.0; - [self.mainInputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat x, y; - if (idx == 0 || idx == 2) { - x = 0; - } else { - x = itemWidth + DashboardAttrItemHorInterspace; - } - if (idx == 0 || idx == 1) { - y = 0; - } else { - y = LKNumberInputHorizontalHeight + DashboardAttrItemVerInterspace; - } - $(view).width(itemWidth).height(LKNumberInputHorizontalHeight).x(x).y(y); - }]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = LKNumberInputHorizontalHeight * 2 + DashboardAttrItemVerInterspace; - limitedSize.height = height; - return limitedSize; -} - -- (void)renderWithAttribute { - if (!self.attribute) { - NSAssert(NO, @""); - return; - } - if (![self.attribute.value isKindOfClass:[NSValue class]]) { - NSAssert(NO, @""); - return; - } - NSRect rect = ((NSValue *)self.attribute.value).rectValue; - NSArray *mainStrs = @[[NSString lookin_stringFromDouble:rect.origin.x decimal:3], - [NSString lookin_stringFromDouble:rect.origin.y decimal:3], - [NSString lookin_stringFromDouble:rect.size.width decimal:3], - [NSString lookin_stringFromDouble:rect.size.height decimal:3]]; - - [self.mainInputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textFieldView.textField.editable = [self canEdit]; - obj.textFieldView.textField.stringValue = mainStrs[idx]; - }]; -} - -#pragma mark - - -- (BOOL)control:(NSControl *)control textShouldBeginEditing:(NSText *)fieldEditor { - return self.canEdit; -} - -- (void)controlTextDidEndEditing:(NSNotification *)notification { - if (![self canEdit]) { - // Read-only attribute (e.g. SwiftUI-injected user-custom rows have no - // setter): AppKit can still post NSControlTextDidEndEditingNotification - // when the field's superview is removed or its editable flag is - // toggled, even though textShouldBeginEditing rejected user input. - return; - } - if (LKDashboardTextControlEditingFlag.sharedInstance.shouldIgnoreTextEditingChangeEvent) { - NSLog(@"忽略 controlTextDidEndEditing 事件,驳回"); - return; - } - - NSTextField *editingTextField = notification.object; - NSNumber *inputValue = [LKNumberInputView parsedValueWithString:editingTextField.stringValue attrType:LookinAttrTypeDouble]; - if (inputValue == nil) { - NSLog(@"输入格式校验不通过,驳回"); - [self renderWithAttribute]; - return; - } - - double inputDouble = [inputValue doubleValue]; - NSUInteger editingTextFieldIdx = [[self.mainInputsView lookin_map:^id(NSUInteger idx, LKNumberInputView *value) { - return value.textFieldView.textField; - }] indexOfObject:editingTextField]; - - CGRect expectedRect = ((NSValue *)self.attribute.value).rectValue; - switch (editingTextFieldIdx) { - case 0: - // x - expectedRect.origin.x = inputDouble; - break; - case 1: - // y - expectedRect.origin.y = inputDouble; - break; - case 2: - // width - expectedRect.size.width = inputDouble; - break; - case 3: - // height - expectedRect.size.height = inputDouble; - break; - default: - [self renderWithAttribute]; - NSAssert(NO, @""); - break; - } - - NSValue *expectedValue = [NSValue valueWithRect:expectedRect]; - if ([expectedValue isEqual:self.attribute.value]) { - NSLog(@"修改没有变化,不做任何提交"); - [self renderWithAttribute]; - return; - } - - // 提交修改 - @weakify(self); - - CGRect oldRect = [self.attribute.value rectValue]; - - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expectedValue] subscribeNext:^(id _Nullable x) { - @strongify(self); - CGRect currentRect = [self.attribute.value rectValue]; - if ([self _rectA:oldRect isAlmostEqualToRectB:currentRect] && ![self _rectA:expectedRect isAlmostEqualToRectB:currentRect]) { - // Lookin 更改了 value 之后,又被 iOS 里的业务改回去了,这个在修改 frame 时经常出现,就像 Lookin 更改失败了一样,为了避免用户迷惑,这里特意弹窗告知一下 - AlertErrorText(NSLocalizedString(@"The modification seems to have no effect.", nil), NSLocalizedString(@"After modifying successfully by LookInside, the value seems to be recovered by the code in your iOS app. For example, modifying \"frame\" of a view may trigger \"layoutSubviews\", and \"layoutSubviews\" may modify the value again.", nil), self.window); - } - - } error:^(NSError * _Nullable error) { - @strongify(self); - NSLog(@"修改返回 error"); - [self renderWithAttribute]; - - }]; -} - -/// 比较 rectA 和 rectB 是否相等 -- (BOOL)_rectA:(CGRect)rectA isAlmostEqualToRectB:(CGRect)rectB { - if (ABS(rectA.origin.x - rectB.origin.x) > 0.1) { - return NO; - } - if (ABS(rectA.origin.y - rectB.origin.y) > 0.1) { - return NO; - } - if (ABS(rectA.size.width - rectB.size.width) > 0.1) { - return NO; - } - if (ABS(rectA.size.height - rectB.size.height) > 0.1) { - return NO; - } - return YES; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRelationView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeRelationView.h deleted file mode 100644 index 8ff6593f..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRelationView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeRelationView.h -// Lookin -// -// Created by Li Kai on 2019/6/14. -// https://lookin.work -// - -#import "LKDashboardAttributeStringArrayView.h" - -@interface LKDashboardAttributeRelationView : LKDashboardAttributeStringArrayView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRelationView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeRelationView.m deleted file mode 100644 index 141dc7f9..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRelationView.m +++ /dev/null @@ -1,67 +0,0 @@ -// -// LKDashboardAttributeRelationView.m -// Lookin -// -// Created by Li Kai on 2019/6/14. -// https://lookin.work -// - -#import "LKDashboardAttributeRelationView.h" -#import "LookInside-Swift.h" - -@implementation LKDashboardAttributeRelationView - -- (NSArray *)stringListWithAttribute:(LookinAttribute *)attribute { - NSArray *cache = [attribute lookin_getBindObjectForKey:@"cachedDemangled"]; - if (cache) { - return cache; - } - NSArray *result = attribute.value; - NSArray *demangled = [result lookin_map:^id(NSUInteger idx, NSString *raw) { - return [self demangle:raw]; - }]; - [attribute lookin_bindObject:demangled forKey:@"cachedDemangled"]; - return demangled; -} - -- (NSString *)demangle:(NSString *)rawText { - { - // 先看看是不是 (AAA : BBB *) - NSString *regexPattern = @"\\(\\s*(\\w+)\\s*:\\s*(\\w+)\\s*\\*\\s*\\)"; - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:regexPattern options:0 error:nil]; - NSTextCheckingResult *match = [regex firstMatchInString:rawText options:0 range:NSMakeRange(0, rawText.length)]; - - if (match) { - assert(match.numberOfRanges == 3); - NSRange range1 = [match rangeAtIndex:1]; - NSRange range2 = [match rangeAtIndex:2]; - NSString *substring1 = [rawText substringWithRange:range1]; - NSString *substring2 = [rawText substringWithRange:range2]; - NSString *demangled1 = [LKSwiftDemangler simpleParseWithInput:substring1]; - NSString *demangled2 = [LKSwiftDemangler simpleParseWithInput:substring2]; - - NSString *newText = [rawText stringByReplacingCharactersInRange:range1 withString:demangled1]; - newText = [newText stringByReplacingOccurrencesOfString:substring2 withString:demangled2]; - return newText; - } - } - - { - // 再看看是不是 (AAA *) - NSString *regexPattern = @"\\(\\s*(\\w+)\\s*\\*\\s*\\)"; - NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:regexPattern options:0 error:nil]; - NSTextCheckingResult *match = [regex firstMatchInString:rawText options:0 range:NSMakeRange(0, rawText.length)]; - - if (match) { - NSRange range = [match rangeAtIndex:1]; - NSString *rawClassName = [rawText substringWithRange:range]; - NSString *demangledText = [LKSwiftDemangler simpleParseWithInput:rawClassName]; - NSString *newText = [rawText stringByReplacingCharactersInRange:range withString:demangledText]; - return newText; - } - } - - return rawText; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRowsCountView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeRowsCountView.h deleted file mode 100644 index a10a5964..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRowsCountView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeRowsCountView.h -// Lookin -// -// Created by Li Kai on 2019/6/12. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeRowsCountView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRowsCountView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeRowsCountView.m deleted file mode 100644 index cf0f5f95..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeRowsCountView.m +++ /dev/null @@ -1,85 +0,0 @@ -// -// LKDashboardAttributeRowsCountView.m -// Lookin -// -// Created by Li Kai on 2019/6/12. -// https://lookin.work -// - -#import "LKDashboardAttributeRowsCountView.h" -#import "LKNumberInputView.h" -#import "LKTextFieldView.h" -#import "LKDashboardViewController.h" - -@interface LKDashboardAttributeRowsCountView () - -@property(nonatomic, copy) NSArray *inputsView; - -@end - -@implementation LKDashboardAttributeRowsCountView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - // self.layer.borderWidth = 1; - // self.layer.borderColor = [NSColor redColor].CGColor; - - self.inputsView = [NSArray array]; - } - return self; -} - -- (void)layout { - [super layout]; - [self.inputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat y = idx * (LKNumberInputHorizontalHeight + DashboardAttrItemVerInterspace); - $(view).fullWidth.height(LKNumberInputHorizontalHeight).y(y); - }]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = (LKNumberInputHorizontalHeight + DashboardAttrItemVerInterspace) * self.inputsView.count - DashboardAttrItemVerInterspace; - limitedSize.height = MAX(height, 0); - return limitedSize; -} - -- (void)renderWithAttribute { - if (!self.attribute) { - NSAssert(NO, @""); - return; - } - if (![self.attribute.value isKindOfClass:[NSArray class]]) { - NSAssert(NO, @""); - return; - } - - NSArray *numbers = self.attribute.value; - self.inputsView = [self.inputsView lookin_resizeWithCount:numbers.count add:^LKNumberInputView *(NSUInteger idx) { - LKNumberInputView *view = [LKNumberInputView new]; - view.textFieldView.textField.editable = NO; - view.viewStyle = LKNumberInputViewStyleHorizontal; - view.textFieldView.backgroundColorName = @"DashboardCardValueBGColor"; - [self addSubview:view]; - return view; - - } remove:^(NSUInteger idx, LKNumberInputView *obj) { - [obj removeFromSuperview]; - - } doNext:^(NSUInteger idx, LKNumberInputView *obj) { - obj.title = [NSString stringWithFormat:@"Section %@", @(idx)]; - obj.textFieldView.textField.stringValue = [NSString stringWithFormat:@"%@", numbers[idx]]; - }]; - - [self setNeedsLayout:YES]; -} - -#pragma mark - Others - -- (void)setDashboardViewController:(LKDashboardViewController *)dashboardViewController { - [super setDashboardViewController:dashboardViewController]; - [self.inputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textFieldView.backgroundColorName = @"DashboardCardValueBGColor"; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeShadowView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeShadowView.h deleted file mode 100644 index d147d1f3..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeShadowView.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKDashboardAttributeShadowView.h -// LookinClient -// -// Created by likai.123 on 2023/11/29. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKDashboardAttributeView.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LKDashboardAttributeShadowView : LKDashboardAttributeView - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeShadowView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeShadowView.m deleted file mode 100644 index 09353428..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeShadowView.m +++ /dev/null @@ -1,136 +0,0 @@ -// -// LKDashboardAttributeShadowView.m -// LookinClient -// -// Created by likai.123 on 2023/11/29. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKDashboardAttributeShadowView.h" -#import "LKColorIndicatorLayer.h" -#import "LKPreferenceManager.h" -#import "LKNumberInputView.h" -#import "LKTextFieldView.h" - -@interface LKDashboardAttributeShadowView () - -@property(nonatomic, strong) LKBaseView *colorContainerView; -@property(nonatomic, strong) LKColorIndicatorLayer *colorIndicatorLayer; -@property(nonatomic, strong) LKLabel *colorDescLabel; -@property(nonatomic, strong) NSArray *inputViews; - -@end - -@implementation LKDashboardAttributeShadowView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { -// self.layer.borderWidth = 1; -// self.layer.borderColor = [NSColor redColor].CGColor; - - self.colorContainerView = [LKBaseView new]; - self.colorContainerView.layer.cornerRadius = DashboardCardControlCornerRadius; - self.colorContainerView.backgroundColorName = @"DashboardCardValueBGColor"; - [self addSubview:self.colorContainerView]; - - self.colorIndicatorLayer = [LKColorIndicatorLayer new]; - [self.colorContainerView.layer addSublayer:self.colorIndicatorLayer]; - - self.colorDescLabel = [LKLabel new]; - self.colorDescLabel.textColor = [NSColor colorNamed:@"DashboardCardValueColor"]; - self.colorDescLabel.font = NSFontMake(13); - [self.colorContainerView addSubview:self.colorDescLabel]; - - NSArray *titles = @[@"Opacity", @"Radius", @"OffsetW", @"OffsetH"]; - self.inputViews = [NSArray lookin_arrayWithCount:4 block:^id(NSUInteger idx) { - LKNumberInputView *view = [LKNumberInputView new]; - view.textFieldView.textField.editable = NO; - view.title = titles[idx]; - view.viewStyle = LKNumberInputViewStyleVertical; - [self addSubview:view]; - return view; - }]; - - @weakify(self); - [[RACObserve([LKPreferenceManager mainManager], rgbaFormat) skip:1] subscribeNext:^(NSNumber *bool_rgbaFormat) { - @strongify(self); - [self renderWithAttribute]; - }]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.colorContainerView).fullWidth.height(30).y(0); - $(self.colorIndicatorLayer).width(16).height(16).x(8).verAlign; - $(self.colorDescLabel).x(28).toRight(20).heightToFit.verAlign.offsetY(-1); - - CGFloat itemWidth = (self.$width - DashboardAttrItemHorInterspace * 3) / 4.0; - CGFloat y = self.colorContainerView.$maxY + DashboardAttrItemVerInterspace; - __block CGFloat x = 0; - [self.inputViews enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - $(view).width(itemWidth).height(LKNumberInputVerticalHeight).x(x).y(y); - x += itemWidth + DashboardAttrItemHorInterspace; - }]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = 30; - height += DashboardAttrItemVerInterspace; - height += LKNumberInputVerticalHeight; - limitedSize.height = height; - return limitedSize; -} - -- (void)renderWithAttribute { - if (!self.attribute) { - NSAssert(NO, @""); - return; - } - NSDictionary *info = self.attribute.value; - if (![info isKindOfClass:[NSDictionary class]]) { - NSAssert(NO, @""); - return; - } - // 可能为 nil - NSColor *color = [NSColor lk_colorFromRGBAComponents:info[@"color"]]; - - NSValue *offsetValue = info[@"offset"]; - if (![offsetValue isKindOfClass:[NSValue class]]) { - NSAssert(NO, @""); - return; - } - CGSize offset = [offsetValue sizeValue]; - - NSNumber *opacityNumber = info[@"opacity"]; - if (![opacityNumber isKindOfClass:[NSNumber class]]) { - NSAssert(NO, @""); - return; - } - CGFloat opacity = [opacityNumber doubleValue]; - - NSNumber *radiusNumber = info[@"radius"]; - if (![radiusNumber isKindOfClass:[NSNumber class]]) { - NSAssert(NO, @""); - return; - } - CGFloat radius = [radiusNumber doubleValue]; - - self.colorIndicatorLayer.color = color; - if (color) { - self.colorDescLabel.stringValue = [LKPreferenceManager mainManager].rgbaFormat ? color.rgbaString : color.hexString; - } else { - self.colorDescLabel.stringValue = @"nil"; - } - - NSArray *strs = @[[NSString lookin_stringFromDouble:opacity decimal:2], - [NSString lookin_stringFromDouble:radius decimal:2], - [NSString lookin_stringFromDouble:offset.width decimal:2], - [NSString lookin_stringFromDouble:offset.height decimal:2]]; - [self.inputViews enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textFieldView.textField.stringValue = strs[idx]; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSizeView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeSizeView.h deleted file mode 100644 index 0db22404..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSizeView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeSizeView.h -// Lookin -// -// Created by Li Kai on 2019/6/11. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeSizeView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSizeView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeSizeView.m deleted file mode 100644 index bcb4a813..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSizeView.m +++ /dev/null @@ -1,153 +0,0 @@ -// -// LKDashboardAttributeSizeView.m -// Lookin -// -// Created by Li Kai on 2019/6/11. -// https://lookin.work -// - -#import "LKDashboardAttributeSizeView.h" -#import "LKNumberInputView.h" -#import "LKTextFieldView.h" -#import "LKDashboardViewController.h" -#import "LKDashboardTextControlEditingFlag.h" - -@interface LKDashboardAttributeSizeView () - -@property(nonatomic, copy) NSArray *inputsView; - -@end - -@implementation LKDashboardAttributeSizeView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - // self.layer.borderWidth = 1; - // self.layer.borderColor = [NSColor redColor].CGColor; - - NSArray *titles = @[@"W", @"H"]; - self.inputsView = [NSArray lookin_arrayWithCount:2 block:^id(NSUInteger idx) { - LKNumberInputView *view = [LKNumberInputView new]; - view.title = titles[idx]; - view.viewStyle = LKNumberInputViewStyleHorizontal; - view.textFieldView.textField.delegate = self; - [self addSubview:view]; - return view; - }]; - } - return self; -} - -- (void)layout { - [super layout]; - CGFloat itemWidth = (self.$width - DashboardAttrItemHorInterspace) / 2.0; - [self.inputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat x = idx ? (itemWidth + DashboardAttrItemHorInterspace) : 0; - $(view).width(itemWidth).height(LKNumberInputHorizontalHeight).x(x).y(0); - }]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat height = LKNumberInputHorizontalHeight; - limitedSize.height = height; - return limitedSize; -} - -- (void)renderWithAttribute { - if (!self.attribute) { - NSAssert(NO, @""); - return; - } - if (![self.attribute.value isKindOfClass:[NSValue class]]) { - NSAssert(NO, @""); - return; - } - NSSize size = ((NSValue *)self.attribute.value).sizeValue; - NSArray *dimensions = @[@(size.width), @(size.height)]; - - BOOL canEdit = [self canEdit]; - [self.inputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat dimension = dimensions[idx].doubleValue; - obj.textFieldView.textField.editable = canEdit; - // Display extremely large values (e.g. CGFLOAT_MAX) as "Max" for readability - if (dimension >= 1e15) { - obj.textFieldView.textField.stringValue = @"Max"; - } else { - obj.textFieldView.textField.stringValue = [NSString lookin_stringFromDouble:dimension decimal:3]; - } - }]; -} - -#pragma mark - - -- (BOOL)control:(NSControl *)control textShouldBeginEditing:(NSText *)fieldEditor { - return self.canEdit; -} - -- (void)controlTextDidEndEditing:(NSNotification *)notification { - if (![self canEdit]) { - // Read-only attribute (e.g. SwiftUI-injected user-custom rows have no - // setter): AppKit can still post NSControlTextDidEndEditingNotification - // when the field's superview is removed or its editable flag is - // toggled, even though textShouldBeginEditing rejected user input. - return; - } - if (LKDashboardTextControlEditingFlag.sharedInstance.shouldIgnoreTextEditingChangeEvent) { - NSLog(@"忽略 controlTextDidEndEditing 事件,驳回"); - return; - } - NSTextField *editingTextField = notification.object; - NSNumber *inputValue = [LKNumberInputView parsedValueWithString:editingTextField.stringValue attrType:LookinAttrTypeDouble]; - if (inputValue == nil) { - NSLog(@"输入格式校验不通过,驳回"); - [self renderWithAttribute]; - return; - } - - double inputDouble = [inputValue doubleValue]; - NSUInteger editingTextFieldIdx = [[self.inputsView lookin_map:^id(NSUInteger idx, LKNumberInputView *value) { - return value.textFieldView.textField; - }] indexOfObject:editingTextField]; - - NSSize expectedSize = ((NSValue *)self.attribute.value).sizeValue; - switch (editingTextFieldIdx) { - case 0: - // width - expectedSize.width = inputDouble; - break; - case 1: - // height - expectedSize.height = inputDouble; - break; - default: - [self renderWithAttribute]; - NSAssert(NO, @""); - break; - } - - NSValue *expectedValue = [NSValue valueWithSize:expectedSize]; - if ([expectedValue isEqual:self.attribute.value]) { - NSLog(@"修改没有变化,不做任何提交"); - [self renderWithAttribute]; - return; - } - - // 提交修改 - @weakify(self); - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expectedValue] subscribeError:^(NSError * _Nullable error) { - @strongify(self); - NSLog(@"修改返回 error"); - [self renderWithAttribute]; - }]; -} - -#pragma mark - Others - -- (void)setDashboardViewController:(LKDashboardViewController *)dashboardViewController { - [super setDashboardViewController:dashboardViewController]; - [self.inputsView enumerateObjectsUsingBlock:^(LKNumberInputView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.textFieldView.backgroundColorName = @"DashboardCardValueBGColor"; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.h deleted file mode 100644 index a82ab7c4..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// LKDashboardAttributeStringArrayView.h -// Lookin -// -// Created by Li Kai on 2019/6/15. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeStringArrayView : LKDashboardAttributeView - -/// 子类必须实现该方法 -- (NSArray *)stringListWithAttribute:(LookinAttribute *)attribute; - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.m deleted file mode 100644 index 18d82ef2..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.m +++ /dev/null @@ -1,154 +0,0 @@ -// -// LKDashboardAttributeStringArrayView.m -// Lookin -// -// Created by Li Kai on 2019/6/15. -// https://lookin.work -// - -#import "LKDashboardAttributeStringArrayView.h" -#import "DanceScriptManager.h" - -@interface LKDashboardAttributeStringArrayView () - -@property(nonatomic, copy) NSArray *labels; -@property(nonatomic, copy) NSArray *sepLayers; -@property(nonatomic, strong) NSButton *danceButton; - -@end - -@implementation LKDashboardAttributeStringArrayView { - CGFloat _labelsVerInterSpace; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _labelsVerInterSpace = 10; - -// [self showDebugBorder]; - - self.labels = [NSArray array]; - self.sepLayers = [NSArray array]; - } - return self; -} - -- (void)layout { - [super layout]; - [self.labels enumerateObjectsUsingBlock:^(LKLabel * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - LKLabel *prevLabel = (idx > 0 ? self.labels[idx - 1] : nil); - $(obj).fullFrame.heightToFit.y(prevLabel ? (prevLabel.$maxY + self -> _labelsVerInterSpace) : 0); - - if (idx > 0) { - if ([self.sepLayers lookin_hasIndex:(idx - 1)]) { - $(self.sepLayers[idx - 1]).fullFrame.height(1).y(obj.$y - self -> _labelsVerInterSpace / 2.0 + 1); - } else { - NSAssert(NO, @""); - } - } - }]; - if (self.danceButton.isVisible) { - $(self.danceButton).width(150).horAlign.height(25).bottom(0); - } -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - limitedSize.height = [self.labels lookin_reduceCGFloat:^CGFloat(CGFloat accumulator, NSUInteger idx, LKLabel *obj) { - CGFloat labelHeight = [obj sizeThatFits:limitedSize].height; - accumulator += labelHeight; - if (idx) { - accumulator += self->_labelsVerInterSpace; - } - return accumulator; - } initialAccumlator:0]; - if (self.danceButton.isVisible) { - limitedSize.height += 35; - } - return limitedSize; -} - -- (NSArray *)stringListWithAttribute:(LookinAttribute *)attribute { - NSAssert(NO, @"should implement by subclass"); - return nil; -} - -- (void)renderWithAttribute { - NSArray *lists = [self stringListWithAttribute:self.attribute]; - self.labels = [self.labels lookin_resizeWithCount:lists.count add:^LKLabel *(NSUInteger idx) { - LKLabel *label = [LKLabel new]; - label.lineBreakStrategy = NSLineBreakStrategyNone; - label.selectable = YES; - label.allowsEditingTextAttributes = YES; - [self addSubview:label]; - return label; - - } remove:^(NSUInteger idx, LKLabel *label) { - [label removeFromSuperview]; - - } doNext:^(NSUInteger idx, LKLabel *label) { - NSString *string = lists[idx]; - label.attributedStringValue = $(string).textColor([NSColor labelColor]).font(NSFontMake(12)).lineHeight(18).attrString; - }]; - - if (lists.count > 1) { - self.sepLayers = [self.sepLayers lookin_resizeWithCount:(lists.count - 1) add:^CALayer *(NSUInteger idx) { - CALayer *layer = [CALayer new]; - [layer lookin_removeImplicitAnimations]; - [self.layer addSublayer:layer]; - return layer; - } remove:^(NSUInteger idx, CALayer *layer) { - [layer removeFromSuperlayer]; - - } doNext:^(NSUInteger idx, CALayer *layer) { - - }]; - [self updateColors]; - - } else { - [self.sepLayers enumerateObjectsUsingBlock:^(CALayer * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj removeFromSuperlayer]; - }]; - self.sepLayers = @[]; - } - - [self.danceButton setHidden:YES]; - if ([self.attribute.identifier isEqualToString:LookinAttr_Class_Class_Class]) { - NSString *danceSource = self.attribute.targetDisplayItem.danceuiSource; - if (!danceSource) { - danceSource = self.attribute.targetDisplayItem.customInfo.danceuiSource; - } - if (danceSource) { - [self addDanceButtonIfNeeded]; - self.danceButton.title = NSLocalizedString(@"Navigate…", nil); - [self.danceButton setHidden:NO]; - } - } - - [self setNeedsLayout:YES]; -} - -- (void)updateColors { - [super updateColors]; - [self.sepLayers enumerateObjectsUsingBlock:^(CALayer * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.backgroundColor = self.isDarkMode ? SeparatorDarkModeColor.CGColor : SeparatorLightModeColor.CGColor; - }]; -} - -- (void)addDanceButtonIfNeeded { - if (self.danceButton) { - return; - } - self.danceButton = [NSButton lk_normalButtonWithTitle:@"" target:self action:@selector(handleDanceButton)]; - self.danceButton.font = NSFontMake(12); - [self addSubview:self.danceButton]; -} - -- (void)handleDanceButton { - NSString *json = self.attribute.targetDisplayItem.danceuiSource; - if (!json) { - json = self.attribute.targetDisplayItem.customInfo.danceuiSource; - } - [[DanceScriptManager shared] handleText:json]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.swift new file mode 100644 index 00000000..5f6e83d9 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeStringArrayView.swift @@ -0,0 +1,187 @@ +// +// LKDashboardAttributeStringArrayView.swift +// LookInside +// +// Created by Li Kai on 2019/6/15. +// https://lookin.work +// + +import AppKit + +/// A list of selectable texts separated by hairlines: the class chain and +/// the relations of an object. +class LKDashboardAttributeStringArrayView: LKDashboardAttributeView { + private let labelsVerInterSpace: CGFloat = 10 + + private var labels: [LKLabel] = [] + private var sepLayers: [CALayer] = [] + private var danceButton: NSButton? + + /// The texts to list. Subclasses must override. + func stringList(with _: LookinAttribute) -> [String] { + assertionFailure("should implement by subclass") + return [] + } + + override func layout() { + super.layout() + for (idx, label) in labels.enumerated() { + let y = idx > 0 ? labels[idx - 1].frame.maxY + labelsVerInterSpace : 0 + label.dashboardLayout.fullFrame().heightToFit().y(y) + if idx > 0 { + guard sepLayers.indices.contains(idx - 1) else { + assertionFailure() + continue + } + sepLayers[idx - 1].dashboardLayout.fullFrame().height(1).y(label.frame.minY - labelsVerInterSpace / 2.0 + 1) + } + } + if let danceButton, danceButton.isVisible { + danceButton.dashboardLayout.width(150).horAlign().height(25).bottom(0) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + var height: CGFloat = 0 + for (idx, label) in labels.enumerated() { + height += label.sizeThatFits(limitedSize).height + if idx > 0 { + height += labelsVerInterSpace + } + } + if let danceButton, danceButton.isVisible { + height += 35 + } + size.height = height + return size + } + + override func renderWithAttribute() { + guard let attribute else { return } + let list = stringList(with: attribute) + + while labels.count > list.count { + labels.removeLast().removeFromSuperview() + } + while labels.count < list.count { + let label = LKLabel() + label.lineBreakStrategy = [] + label.isSelectable = true + label.allowsEditingTextAttributes = true + addSubview(label) + labels.append(label) + } + for (label, string) in zip(labels, list) { + label.attributedStringValue = LKDashboardText.attributed(string, font: LKDashboardStyle.font(12), color: .labelColor, lineHeight: 18) + } + + let separatorCount = list.count > 1 ? list.count - 1 : 0 + while sepLayers.count > separatorCount { + sepLayers.removeLast().removeFromSuperlayer() + } + while sepLayers.count < separatorCount { + let sepLayer = CALayer() + sepLayer.lookin_removeImplicitAnimations() + layer?.addSublayer(sepLayer) + sepLayers.append(sepLayer) + } + if list.count > 1 { + updateColors() + } + + danceButton?.isHidden = true + if attribute.identifier == LookinAttr_Class_Class_Class, danceSource(of: attribute) != nil { + let button = danceButtonIfNeeded() + button.title = NSLocalizedString("Navigate…", comment: "") + button.isHidden = false + } + + needsLayout = true + } + + override func updateColors() { + super.updateColors() + let color = LKDashboardStyle.separatorColor(isDarkMode: isDarkMode()).cgColor + for sepLayer in sepLayers { + sepLayer.backgroundColor = color + } + } + + private func danceSource(of attribute: LookinAttribute?) -> String? { + let item = attribute?.targetDisplayItem + return item?.danceuiSource ?? item?.customInfo?.danceuiSource + } + + private func danceButtonIfNeeded() -> NSButton { + if let danceButton { + return danceButton + } + let button = NSButton.lk_normalButton(withTitle: "", target: self, action: #selector(handleDanceButton)) + button.font = LKDashboardStyle.font(12) + addSubview(button) + danceButton = button + return button + } + + @objc private func handleDanceButton() { + DanceScriptManager.shared().handleText(danceSource(of: attribute)) + } +} + +/// The class chain of the object, one class list per entry, demangled. +@objc(LKDashboardAttributeClassView) +final class LKDashboardAttributeClassView: LKDashboardAttributeStringArrayView { + override func stringList(with attribute: LookinAttribute) -> [String] { + let lists = attribute.value as? [[String]] ?? [] + return lists.map { rawClassList in + rawClassList.map { LKSwiftDemangler.completedParse(input: $0) }.joined(separator: "\n") + } + } +} + +/// The relations of the object ("(AAA : BBB *)" and "(AAA *)" texts), with +/// the Swift class names demangled. Cached on the attribute. +@objc(LKDashboardAttributeRelationView) +final class LKDashboardAttributeRelationView: LKDashboardAttributeStringArrayView { + private static let cacheKey = "cachedDemangled" + + private static let memberRegex = try! NSRegularExpression(pattern: #"\(\s*(\w+)\s*:\s*(\w+)\s*\*\s*\)"#) + private static let objectRegex = try! NSRegularExpression(pattern: #"\(\s*(\w+)\s*\*\s*\)"#) + + override func stringList(with attribute: LookinAttribute) -> [String] { + if let cache = attribute.lookin_getBindObject(forKey: Self.cacheKey) as? [String] { + return cache + } + let raw = attribute.value as? [String] ?? [] + let demangled = raw.map(Self.demangle) + attribute.lookin_bindObject(demangled, forKey: Self.cacheKey) + return demangled + } + + static func demangle(_ rawText: String) -> String { + let text = rawText as NSString + let fullRange = NSRange(location: 0, length: text.length) + + // "(AAA : BBB *)" + if let match = memberRegex.firstMatch(in: rawText, range: fullRange) { + let range1 = match.range(at: 1) + let range2 = match.range(at: 2) + let substring1 = text.substring(with: range1) + let substring2 = text.substring(with: range2) + let demangled1 = LKSwiftDemangler.simpleParse(input: substring1) + let demangled2 = LKSwiftDemangler.simpleParse(input: substring2) + let replaced = text.replacingCharacters(in: range1, with: demangled1) as NSString + return replaced.replacingOccurrences(of: substring2, with: demangled2) + } + + // "(AAA *)" + if let match = objectRegex.firstMatch(in: rawText, range: fullRange) { + let range = match.range(at: 1) + let demangled = LKSwiftDemangler.simpleParse(input: text.substring(with: range)) + return text.replacingCharacters(in: range, with: demangled) + } + + return rawText + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.h deleted file mode 100644 index 295e08f5..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeToggleView.h -// Lookin -// -// Created by Li Kai on 2019/2/21. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeSwitchView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.m deleted file mode 100644 index bc627e81..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.m +++ /dev/null @@ -1,95 +0,0 @@ -// -// LKDashboardAttributeToggleView.m -// Lookin -// -// Created by Li Kai on 2019/2/21. -// https://lookin.work -// - -#import "LKDashboardAttributeSwitchView.h" -#import "LKDashboardCardView.h" -#import "LKDashboardViewController.h" -#import "LookinDashboardBlueprint.h" - -@interface LKDashboardAttributeSwitchView () - -@property(nonatomic, strong) NSButton *button; - -@end - -@implementation LKDashboardAttributeSwitchView - -- (instancetype)initWithFrame:(NSRect)frame { - if (self = [super initWithFrame:frame]) { -// [self setBackgroundColor:[NSColor blueColor]]; - - self.button = [NSButton new]; - self.button.ignoresMultiClick = YES; - [self.button setButtonType:NSButtonTypeSwitch]; - self.button.target = self; - self.button.action = @selector(_handleButton); - self.button.font = NSFontMake(13); - [self addSubview:self.button]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.button).fullFrame; -} - -- (void)renderWithAttribute { - NSString *title; - if (self.attribute.isUserCustom) { - title = self.attribute.displayTitle; - } else { - title = [LookinDashboardBlueprint briefTitleWithAttrID:self.attribute.identifier]; - } - [self.button setAttributedTitle:$(title).textColor([NSColor colorNamed:@"DashboardCardValueColor"]).attrString]; - - BOOL boolValue = ((NSNumber *)self.attribute.value).boolValue; - self.button.state = boolValue ? NSControlStateValueOn : NSControlStateValueOff; - self.button.enabled = [self canEdit]; - - [self setNeedsLayout:YES]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - NSSize size = [self.button sizeThatFits:limitedSize]; - // 不知道为什么,sizeThatFits: 返回的 size 并不够 - return NSMakeSize(size.width + 3, size.height + 2); -} - -- (NSUInteger)numberOfColumnsOccupied { - if (self.attribute.isUserCustom) { - return 1; - } - if ([self.attribute.identifier isEqualToString:LookinAttr_UIScrollView_Zoom_Bounce]) { - return 1; - } - return 0; -} - -#pragma mark - Private - -- (void)_handleButton { - NSValue *expectedValue; - if (self.button.state == NSControlStateValueOff) { - expectedValue = @(NO); - } else if (self.button.state == NSControlStateValueOn) { - expectedValue = @(YES); - } else { - NSAssert(NO, @""); - return; - } - - // 提交修改 - @weakify(self); - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expectedValue] subscribeError:^(NSError * _Nullable error) { - @strongify(self); - [self renderWithAttribute]; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.swift new file mode 100644 index 00000000..406987c6 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeSwitchView.swift @@ -0,0 +1,88 @@ +// +// LKDashboardAttributeSwitchView.swift +// LookInside +// +// Created by Li Kai on 2019/2/21. +// https://lookin.work +// + +import AppKit + +/// A BOOL attribute as a checkbox. +@objc(LKDashboardAttributeSwitchView) +final class LKDashboardAttributeSwitchView: LKDashboardAttributeView { + private let button = NSButton() + + required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + button.ignoresMultiClick = true + button.setButtonType(.switch) + button.target = self + button.action = #selector(handleButton) + button.font = LKDashboardStyle.font(13) + addSubview(button) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + button.dashboardLayout.fullFrame() + } + + override func renderWithAttribute() { + guard let attribute else { return } + let title: String? + if attribute.isUserCustom() { + title = attribute.displayTitle + } else { + title = LookinDashboardBlueprint.briefTitle(withAttrID: attribute.identifier) + } + button.attributedTitle = LKDashboardText.attributed(title ?? "", color: NSColor(named: "DashboardCardValueColor")) + + let isOn = (attribute.value as? NSNumber)?.boolValue ?? false + button.state = isOn ? .on : .off + button.isEnabled = canEdit() + + needsLayout = true + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + let size = button.sizeThatFits(limitedSize) + // The button's fitting size is a little short. + return NSSize(width: size.width + 3, height: size.height + 2) + } + + override func numberOfColumnsOccupied() -> Int { + guard let attribute else { return 0 } + if attribute.isUserCustom() { + return 1 + } + if attribute.identifier == LookinAttr_UIScrollView_Zoom_Bounce { + return 1 + } + return 0 + } + + @objc private func handleButton() { + let expectedValue: NSNumber + switch button.state { + case .off: + expectedValue = LKDashboardModification.boolValue(isOn: false) + case .on: + expectedValue = LKDashboardModification.boolValue(isOn: true) + default: + assertionFailure() + return + } + Task { @MainActor [weak self] in + do { + try await self?.submit(expectedValue) + } catch { + self?.renderWithAttribute() + } + } + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.h deleted file mode 100644 index fbdd5f32..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardAttributeTextView.h -// Lookin -// -// Created by Li Kai on 2019/9/16. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" - -@interface LKDashboardAttributeTextView : LKDashboardAttributeView - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.m deleted file mode 100644 index 126e5850..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.m +++ /dev/null @@ -1,165 +0,0 @@ -// -// LKDashboardAttributeTextView.m -// Lookin -// -// Created by Li Kai on 2019/9/16. -// https://lookin.work -// - -#import "LKDashboardAttributeTextView.h" -#import "LKDashboardViewController.h" -#import "LKHierarchyDataSource.h" -#import "LookinDashboardBlueprint.h" -#import "LookinDisplayItem.h" - -@interface LKDashboardAttributeTextView () - -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) NSScrollView *scrollView; -@property(nonatomic, strong) NSTextView *textView; -@property(nonatomic, strong) NSButton *jumpButton; - -@property(nonatomic, copy) NSString *initialText; - -- (LookinDisplayItem *)_jumpTargetItem; - -@end - -@implementation LKDashboardAttributeTextView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.initialText = @""; - - self.layer.cornerRadius = DashboardCardControlCornerRadius; - - self.titleLabel = [LKLabel new]; - self.titleLabel.textColor = [NSColor colorNamed:@"DashboardInputAccessoryColor"]; - self.titleLabel.font = NSFontMake(10); - self.titleLabel.maximumNumberOfLines = 1; - self.titleLabel.hidden = YES; - [self addSubview:self.titleLabel]; - - self.scrollView = [LKHelper scrollableTextView]; - self.scrollView.drawsBackground = NO; - self.scrollView.hasVerticalScroller = NO; - self.scrollView.hasHorizontalScroller = NO; - self.textView = self.scrollView.documentView; - self.textView.font = NSFontMake(12); - self.textView.drawsBackground = NO; - self.textView.textContainerInset = NSMakeSize(2, 4); - self.textView.delegate = self; - [self addSubview:self.scrollView]; - - self.jumpButton = [NSButton buttonWithImage:NSImageMake(@"Icon_JumpDisclosure") target:self action:@selector(_handleJumpButton:)]; - self.jumpButton.bezelStyle = NSBezelStyleRoundRect; - self.jumpButton.bordered = NO; - self.jumpButton.toolTip = NSLocalizedString(@"Jump in hierarchy", nil); - self.jumpButton.hidden = YES; - [self addSubview:self.jumpButton]; - } - return self; -} - -- (void)layout { - [super layout]; - CGFloat jumpButtonWidth = self.jumpButton.isHidden ? 0 : 24; - if (self.titleLabel.isHidden) { - $(self.scrollView).fullFrame.toRight(jumpButtonWidth); - } else { - CGFloat titleHeight = [self.titleLabel sizeThatFits:NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX)].height; - $(self.titleLabel).x(7).toRight(7).heightToFit.y(4); - CGFloat scrollY = 4 + titleHeight + 1; - $(self.scrollView).x(0).toRight(jumpButtonWidth).y(scrollY).toBottom(0); - } - if (!self.jumpButton.isHidden) { - $(self.jumpButton).width(20).height(20).right(2).midY(self.scrollView.$midY); - } -} - -- (void)renderWithAttribute { - [super renderWithAttribute]; - - // Show title label for non-custom attributes - if (!self.attribute.isUserCustom) { - NSString *briefTitle = [LookinDashboardBlueprint briefTitleWithAttrID:self.attribute.identifier]; - if (briefTitle.length > 0) { - self.titleLabel.stringValue = briefTitle; - self.titleLabel.hidden = NO; - } else { - self.titleLabel.hidden = YES; - } - } else { - self.titleLabel.hidden = YES; - } - - /// nil 居然会 crash - self.initialText = self.attribute.value ? : @""; - self.textView.string = self.initialText; - self.textView.editable = self.canEdit; - self.jumpButton.hidden = ([self _jumpTargetItem] == nil); - [self setNeedsLayout:YES]; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - limitedSize.width -= self.textView.textContainerInset.width * 2; - - NSDictionary *attributes = @{NSFontAttributeName: self.textView.font}; - NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:[self.textView string] attributes:attributes]; - NSRect rect = [attributedString boundingRectWithSize:limitedSize options:NSStringDrawingUsesLineFragmentOrigin context:nil]; - CGFloat contentHeight = rect.size.height + self.textView.textContainerInset.height * 2; - CGFloat textHeight = MIN(MAX(contentHeight, 24), 80); - - if (!self.titleLabel.isHidden) { - CGFloat titleHeight = [self.titleLabel sizeThatFits:NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX)].height; - textHeight += 3 + titleHeight + 1; - } - - limitedSize.height = textHeight; - return limitedSize; -} - -- (void)setDashboardViewController:(LKDashboardViewController *)dashboardViewController { - [super setDashboardViewController:dashboardViewController]; - self.backgroundColorName = @"DashboardCardValueBGColor"; -} - -- (LookinDisplayItem *)_jumpTargetItem { - LKHierarchyDataSource *dataSource = [self.dashboardViewController currentDataSource]; - return [dataSource swiftUIJumpTargetForAttribute:self.attribute]; -} - -- (void)_handleJumpButton:(NSButton *)button { - LookinDisplayItem *targetItem = [self _jumpTargetItem]; - [[self.dashboardViewController currentDataSource] selectAndRevealItem:targetItem]; -} - -#pragma mark - - -- (BOOL)textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector { - if (commandSelector == @selector(insertNewline:)) { - [self.window makeFirstResponder:nil]; - return YES; - } - return NO; -} - -- (void)textDidEndEditing:(NSNotification *)notification { - NSString *expectedValue = self.textView.string; - - if ([expectedValue isEqual:self.initialText]) { - NSLog(@"修改没有变化,不做任何提交"); - [self renderWithAttribute]; - return; - } - - // 提交修改 - @weakify(self); - [[self.dashboardViewController modifyAttribute:self.attribute newValue:expectedValue] subscribeError:^(NSError * _Nullable error) { - @strongify(self); - NSLog(@"修改返回 error"); - [self renderWithAttribute]; - }]; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.swift new file mode 100644 index 00000000..7e79c1ed --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeTextView.swift @@ -0,0 +1,138 @@ +// +// LKDashboardAttributeTextView.swift +// LookInside +// +// Created by Li Kai on 2019/9/16. +// https://lookin.work +// + +import AppKit + +/// A string attribute in an editable text view. SwiftUI attributes that +/// name another node get a button that jumps to it. +@objc(LKDashboardAttributeTextView) +final class LKDashboardAttributeTextView: LKDashboardAttributeView, NSTextViewDelegate { + private let titleLabel = LKLabel() + private let scrollView: NSScrollView + private let textView: NSTextView + private var jumpButton: NSButton! + + private var initialText = "" + + required init(frame frameRect: NSRect) { + scrollView = LKHelper.scrollableTextView() + textView = scrollView.documentView as! NSTextView + super.init(frame: frameRect) + + layer?.cornerRadius = LKDashboardMetrics.cardControlCornerRadius + + titleLabel.textColor = NSColor(named: "DashboardInputAccessoryColor") + titleLabel.font = LKDashboardStyle.font(10) + titleLabel.maximumNumberOfLines = 1 + titleLabel.isHidden = true + addSubview(titleLabel) + + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + textView.font = LKDashboardStyle.font(12) + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 2, height: 4) + textView.delegate = self + addSubview(scrollView) + + jumpButton = NSButton(image: LKDashboardStyle.image("Icon_JumpDisclosure") ?? NSImage(), target: self, action: #selector(handleJumpButton(_:))) + jumpButton.bezelStyle = .roundRect + jumpButton.isBordered = false + jumpButton.toolTip = NSLocalizedString("Jump in hierarchy", comment: "") + jumpButton.isHidden = true + addSubview(jumpButton) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + let jumpButtonWidth: CGFloat = jumpButton.isHidden ? 0 : 24 + if titleLabel.isHidden { + scrollView.dashboardLayout.fullFrame().toRight(jumpButtonWidth) + } else { + let titleHeight = titleLabel.sizeThatFits(LKDashboardMetrics.maxSize).height + titleLabel.dashboardLayout.x(7).toRight(7).heightToFit().y(4) + scrollView.dashboardLayout.x(0).toRight(jumpButtonWidth).y(4 + titleHeight + 1).toBottom(0) + } + if !jumpButton.isHidden { + jumpButton.dashboardLayout.width(20).height(20).right(2).midY(scrollView.frame.midY) + } + } + + override func renderWithAttribute() { + guard let attribute else { return } + // Known attributes show their short title above the value. + if !attribute.isUserCustom(), let briefTitle = LookinDashboardBlueprint.briefTitle(withAttrID: attribute.identifier), !briefTitle.isEmpty { + titleLabel.stringValue = briefTitle + titleLabel.isHidden = false + } else { + titleLabel.isHidden = true + } + + initialText = attribute.value as? String ?? "" + textView.string = initialText + textView.isEditable = canEdit() + jumpButton.isHidden = jumpTargetItem() == nil + needsLayout = true + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + size.width -= textView.textContainerInset.width * 2 + + let attributes: [NSAttributedString.Key: Any] = textView.font.map { [.font: $0] } ?? [:] + let attributedString = NSAttributedString(string: textView.string, attributes: attributes) + let rect = attributedString.boundingRect(with: size, options: .usesLineFragmentOrigin, context: nil) + let contentHeight = rect.size.height + textView.textContainerInset.height * 2 + var textHeight = min(max(contentHeight, 24), 80) + + if !titleLabel.isHidden { + let titleHeight = titleLabel.sizeThatFits(LKDashboardMetrics.maxSize).height + textHeight += 3 + titleHeight + 1 + } + + size.height = textHeight + return size + } + + override func dashboardViewControllerDidChange() { + backgroundColorName = "DashboardCardValueBGColor" + } + + private func jumpTargetItem() -> LookinDisplayItem? { + dashboardViewController?.currentDataSource()?.swiftUIJumpTarget(for: attribute) + } + + @objc private func handleJumpButton(_: NSButton) { + dashboardViewController?.currentDataSource()?.selectAndRevealItem(jumpTargetItem()) + } + + // MARK: - NSTextViewDelegate + + func textView(_: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + window?.makeFirstResponder(nil) + return true + } + return false + } + + func textDidEndEditing(_: Notification) { + let expectedValue = textView.string + if expectedValue == initialText { + NSLog("修改没有变化,不做任何提交") + renderWithAttribute() + return + } + submitRenderingOnFailure(expectedValue) + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.h b/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.h deleted file mode 100644 index 839070f6..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.h +++ /dev/null @@ -1,36 +0,0 @@ -// -// LKDashboardAttributeView.h -// Lookin -// -// Created by Li Kai on 2018/11/18. -// https://lookin.work -// - -#import "LookinAttribute.h" -#import "LookinDefines.h" -#import "LookinAttributesGroup.h" - -@class LookinDisplayItem, LKDashboardAttributeValueView, LKDashboardViewController; - -@interface LKDashboardAttributeView : LKBaseView - -@property(nonatomic, strong) LookinAttribute *attribute; - -@property(nonatomic, strong) id valueView; - -@property(nonatomic, weak) LKDashboardViewController *dashboardViewController; - -- (BOOL)canEdit; - -@end - -/// 除了下面这两个方法外,子类还需要继承重写 sizeThatFits: 方法 -@interface LKDashboardAttributeView (NSSubclassingHooks) - -/// 方法实现应该是读取 self.attribute 并渲染 -- (void)renderWithAttribute; - -/// 每行可以摆放该 AttrView 的数量,返回 1 则表示独占一行,返回 0 表示宽度根据内容的变化而变化。子类不重写则默认为 1 -- (NSUInteger)numberOfColumnsOccupied; - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.m b/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.m deleted file mode 100644 index 7f261272..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.m +++ /dev/null @@ -1,41 +0,0 @@ -// -// LKDashboardAttributeView.m -// Lookin -// -// Created by Li Kai on 2018/11/18. -// https://lookin.work -// - -#import "LKDashboardAttributeView.h" -#import "LookinDisplayItem.h" -#import "LKDashboardViewController.h" -#import "LookinDashboardBlueprint.h" - -@implementation LKDashboardAttributeView - -- (void)setAttribute:(LookinAttribute *)attribute { - _attribute = attribute; - [self renderWithAttribute]; -} - -- (BOOL)canEdit { - if (self.attribute.isUserCustom) { - if (self.attribute.customSetterID.length > 0) { - return YES; - } else { - return NO; - } - } - SEL setter = [LookinDashboardBlueprint setterWithAttrID:self.attribute.identifier]; - return setter && self.dashboardViewController.isStaticMode; -} - -- (void)renderWithAttribute { - // do nothing -} - -- (NSUInteger)numberOfColumnsOccupied { - return 1; -} - -@end diff --git a/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.swift b/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.swift new file mode 100644 index 00000000..71833588 --- /dev/null +++ b/LookInside/Dashboard/AttributeView/LKDashboardAttributeView.swift @@ -0,0 +1,103 @@ +// +// LKDashboardAttributeView.swift +// LookInside +// +// Created by Li Kai on 2018/11/18. +// https://lookin.work +// + +import AppKit + +/// One attribute row on a Dashboard card. Subclasses render `attribute`, +/// size themselves through `sizeThatFits(_:)` and say how many of them fit +/// in a row. +@objc(LKDashboardAttributeView) +class LKDashboardAttributeView: LKBaseView { + /// Setting the attribute renders it. + @objc var attribute: LookinAttribute? { + didSet { renderWithAttribute() } + } + + @objc weak var dashboardViewController: LKDashboardViewController? { + didSet { dashboardViewControllerDidChange() } + } + + /// Required so a section can make a view from its class. + override required init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + /// Whether the user can change the value: a user-custom attribute with + /// a setter, or a known attribute with a setter on a live (not read + /// from file) dashboard. + @objc func canEdit() -> Bool { + guard let attribute else { return false } + if attribute.isUserCustom() { + return !(attribute.customSetterID ?? "").isEmpty + } + let setter = LookinDashboardBlueprint.setter(withAttrID: attribute.identifier) + return setter != nil && (dashboardViewController?.isStaticMode ?? false) + } + + /// Reads `attribute` and renders it. + @objc func renderWithAttribute() {} + + /// How many of this view fit in a row: 1 takes the whole row, 0 sizes + /// the view to its content. + @objc func numberOfColumnsOccupied() -> Int { + 1 + } + + /// Subclasses set colours that depend on being on a dashboard. + func dashboardViewControllerDidChange() {} + + /// Submits `newValue` through the dashboard. Returns whether the + /// attribute was modified; throws, after the dashboard has shown the + /// error, when the modification failed. + @discardableResult + func submit(_ newValue: Any?) async throws -> Bool { + guard let attribute, let dashboardViewController else { return false } + return try await dashboardViewController.modifyAttribute(attribute, newValue: newValue) + } + + /// Submits `newValue`; on failure renders the current value again. + func submitRenderingOnFailure(_ newValue: Any?) { + Task { @MainActor [weak self] in + do { + try await self?.submit(newValue) + } catch { + NSLog("修改返回 error") + self?.renderWithAttribute() + } + } + } + + /// Submits `newValue`; on success renders the new value. + func submitRenderingOnSuccess(_ newValue: Any?) { + Task { @MainActor [weak self] in + if (try? await self?.submit(newValue)) == true { + self?.renderWithAttribute() + } + } + } +} + +/// Set while a modification result reloads the dashboard. The reload +/// removes the editing card, which ends text editing again; the text +/// fields ignore that second end-of-editing so the value is not submitted +/// twice. +@objc(LKDashboardTextControlEditingFlag) +final class LKDashboardTextControlEditingFlag: NSObject { + @objc(sharedInstance) + static let shared = LKDashboardTextControlEditingFlag() + + @objc var shouldIgnoreTextEditingChangeEvent = false + + override private init() { + super.init() + } +} diff --git a/LookInside/Dashboard/AttributeView/LKDashboardTextControlEditingFlag.h b/LookInside/Dashboard/AttributeView/LKDashboardTextControlEditingFlag.h deleted file mode 100644 index d0b911ec..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardTextControlEditingFlag.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LKDashboardTextControlEditingFlag.h -// LookinClient -// -// Created by likaimacbookhome on 2024/3/3. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface LKDashboardTextControlEditingFlag : NSObject - -+ (instancetype)sharedInstance; - -@property(nonatomic, assign) BOOL shouldIgnoreTextEditingChangeEvent; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Dashboard/AttributeView/LKDashboardTextControlEditingFlag.m b/LookInside/Dashboard/AttributeView/LKDashboardTextControlEditingFlag.m deleted file mode 100644 index 01169124..00000000 --- a/LookInside/Dashboard/AttributeView/LKDashboardTextControlEditingFlag.m +++ /dev/null @@ -1,26 +0,0 @@ -// -// LKDashboardTextControlEditingFlag.m -// LookinClient -// -// Created by likaimacbookhome on 2024/3/3. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import "LKDashboardTextControlEditingFlag.h" - -@implementation LKDashboardTextControlEditingFlag - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKDashboardTextControlEditingFlag *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -@end diff --git a/LookInside/Dashboard/LKDashboardAccessoryWindowController.h b/LookInside/Dashboard/LKDashboardAccessoryWindowController.h deleted file mode 100644 index 50f8f9da..00000000 --- a/LookInside/Dashboard/LKDashboardAccessoryWindowController.h +++ /dev/null @@ -1,29 +0,0 @@ -// -// LKDashboardAccessoryWindowController.h -// Lookin -// -// Created by Li Kai on 2019/8/30. -// https://lookin.work -// - -#import "LKWindowController.h" -#import "LookinAttrIdentifiers.h" - -@class LookinAttributesSection, LKDashboardViewController, LKDashboardAccessoryWindowController; - -@protocol LKDashboardAccessoryWindowControllerDelegate - -- (void)dashboardAccessoryWindowControllerWillClose:(LKDashboardAccessoryWindowController *)controller; - -@end - -@interface LKDashboardAccessoryWindowController : LKWindowController - -@property(nonatomic, weak) id delegate; - -/// contentSize 是内容所需的窗口大小,由该方法返回 -- (instancetype)initWithDashboardController:(LKDashboardViewController *)dashboardController attrGroupID:(LookinAttrGroupIdentifier)groupID; - -- (NSSize)renderWithAttrSections:(NSArray *)sections; - -@end diff --git a/LookInside/Dashboard/LKDashboardAccessoryWindowController.m b/LookInside/Dashboard/LKDashboardAccessoryWindowController.m deleted file mode 100644 index c1b47033..00000000 --- a/LookInside/Dashboard/LKDashboardAccessoryWindowController.m +++ /dev/null @@ -1,84 +0,0 @@ -// -// LKDashboardAccessoryWindowController.m -// Lookin -// -// Created by Li Kai on 2019/8/30. -// https://lookin.work -// - -#import "LKDashboardAccessoryWindowController.h" -#import "LKDashboardSectionView.h" -#import "LookinAttributesSection.h" -#import "LookinDashboardBlueprint.h" -#import "LKPopPanel.h" - -@interface LKDashboardAccessoryWindowController () - -@property(nonatomic, copy) LookinAttrGroupIdentifier groupID; -@property(nonatomic, weak) LKDashboardViewController *dashboardController; -/// key 是 LookinAttrSectionIdentifier -@property(nonatomic, strong) NSMutableDictionary *sectionViews; - -@end - -@implementation LKDashboardAccessoryWindowController - -- (instancetype)initWithDashboardController:(LKDashboardViewController *)dashboardController attrGroupID:(LookinAttrGroupIdentifier)groupID { - self.groupID = groupID; - self.dashboardController = dashboardController; - self.sectionViews = [NSMutableDictionary dictionary]; - - LKPopPanel *panel = [[LKPopPanel alloc] initWithSize:NSMakeSize(400, 100)]; - panel.delegate = self; - - return [self initWithWindow:panel]; -} - -- (NSSize)renderWithAttrSections:(NSArray *)sections { - NSMutableArray *needlessViews = [self.sectionViews allValues].mutableCopy; - - [sections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull attrSec, NSUInteger idx, BOOL * _Nonnull stop) { - LKDashboardSectionView *view = self.sectionViews[attrSec.identifier]; - if (view) { - [needlessViews removeObject:view]; - view.hidden = NO; - } else { - view = [LKDashboardSectionView new]; - self.sectionViews[attrSec.identifier] = view; - } - - view.dashboardViewController = self.dashboardController; - view.manageState = LKDashboardSectionManageState_CanAdd; - view.attrSection = attrSec; - view.showTopSeparator = (idx != 0); - [self.window.contentView addSubview:view]; - }]; - - [needlessViews enumerateObjectsUsingBlock:^(LKDashboardSectionView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.hidden = YES; - }]; - - // layout - CGFloat normalSecWidth = DashboardViewWidth - DashboardHorInset * 2; - __block CGFloat y = 8; - [[LookinDashboardBlueprint sectionIDsForGroupID:self.groupID] enumerateObjectsUsingBlock:^(LookinAttrSectionIdentifier _Nonnull secID, NSUInteger idx, BOOL * _Nonnull stop) { - LKDashboardSectionView *view = self.sectionViews[secID]; - if (!view || view.hidden) { - return; - } - $(view).x(DashboardHorInset).width(normalSecWidth - DashboardHorInset * 2).heightToFit.y(y); - y = view.$maxY + DashboardSectionMarginTop; - }]; - - return NSMakeSize(normalSecWidth + 23, y); -} - -#pragma mark - - -- (void)windowWillClose:(NSNotification *)notification { - if ([self.delegate respondsToSelector:@selector(dashboardAccessoryWindowControllerWillClose:)]) { - [self.delegate dashboardAccessoryWindowControllerWillClose:self]; - } -} - -@end diff --git a/LookInside/Dashboard/LKDashboardAccessoryWindowController.swift b/LookInside/Dashboard/LKDashboardAccessoryWindowController.swift new file mode 100644 index 00000000..c8db0760 --- /dev/null +++ b/LookInside/Dashboard/LKDashboardAccessoryWindowController.swift @@ -0,0 +1,77 @@ +// +// LKDashboardAccessoryWindowController.swift +// LookInside +// +// Created by Li Kai on 2019/8/30. +// https://lookin.work +// + +import AppKit + +protocol LKDashboardAccessoryWindowControllerDelegate: AnyObject { + func dashboardAccessoryWindowControllerWillClose(_ controller: LKDashboardAccessoryWindowController) +} + +/// The panel beside the window that lists a card's hidden sections, each +/// with a button that adds it to the card. +final class LKDashboardAccessoryWindowController: LKWindowController, NSWindowDelegate { + private let groupID: String? + private weak var dashboardController: LKDashboardViewController? + /// Keyed by section identifier. + private var sectionViews: [String: LKDashboardSectionView] = [:] + + weak var delegate: LKDashboardAccessoryWindowControllerDelegate? + + init(dashboardController: LKDashboardViewController?, attrGroupID groupID: String?) { + self.groupID = groupID + self.dashboardController = dashboardController + let panel = LKPopPanel(size: NSSize(width: 400, height: 100)) + super.init(window: panel) + panel.delegate = self + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + /// Shows `sections` and returns the window size they need. + func render(attrSections sections: [LookinAttributesSection]) -> NSSize { + var needlessViews = Array(sectionViews.values) + + for (idx, section) in sections.enumerated() { + let identifier = section.identifier ?? "" + let view: LKDashboardSectionView + if let existing = sectionViews[identifier] { + view = existing + needlessViews.removeAll { $0 === existing } + view.isHidden = false + } else { + view = LKDashboardSectionView() + sectionViews[identifier] = view + } + view.dashboardViewController = dashboardController + view.manageState = .canAdd + view.attrSection = section + view.showTopSeparator = idx != 0 + window?.contentView?.addSubview(view) + } + + needlessViews.forEach { $0.isHidden = true } + + let normalSectionWidth = LKDashboardMetrics.viewWidth - LKDashboardMetrics.horInset * 2 + var y: CGFloat = 8 + for sectionID in LookinDashboardBlueprint.sectionIDs(forGroupID: groupID) ?? [] { + guard let view = sectionViews[sectionID], !view.isHidden else { continue } + view.dashboardLayout.x(LKDashboardMetrics.horInset).width(normalSectionWidth - LKDashboardMetrics.horInset * 2).heightToFit().y(y) + y = view.frame.maxY + LKDashboardMetrics.sectionMarginTop + } + + return NSSize(width: normalSectionWidth + 23, height: y) + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_: Notification) { + delegate?.dashboardAccessoryWindowControllerWillClose(self) + } +} diff --git a/LookInside/Dashboard/LKDashboardCardTitleControl.h b/LookInside/Dashboard/LKDashboardCardTitleControl.h deleted file mode 100644 index 1ee30e7d..00000000 --- a/LookInside/Dashboard/LKDashboardCardTitleControl.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// LKDashboardCardTitleControl.h -// Lookin -// -// Created by Li Kai on 2019/6/14. -// https://lookin.work -// - -#import "LKBaseControl.h" - -@interface LKDashboardCardTitleControl : LKBaseControl - -@property(nonatomic, strong, readonly) NSImageView *iconImageView; -@property(nonatomic, strong, readonly) LKLabel *label; -@property(nonatomic, strong, readonly) NSImageView *disclosureImageView; -@property(nonatomic, strong, readonly) NSImageView *accentImageView; - -@end diff --git a/LookInside/Dashboard/LKDashboardCardTitleControl.m b/LookInside/Dashboard/LKDashboardCardTitleControl.m deleted file mode 100644 index d148210c..00000000 --- a/LookInside/Dashboard/LKDashboardCardTitleControl.m +++ /dev/null @@ -1,56 +0,0 @@ -// -// LKDashboardCardTitleControl.m -// Lookin -// -// Created by Li Kai on 2019/4/13. -// https://lookin.work -// - -#import "LKDashboardCardTitleControl.h" -#import "LKHelper.h" - -@interface LKDashboardCardTitleControl () - -@end - -@implementation LKDashboardCardTitleControl - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { -// self.layer.borderWidth = 1; -// self.layer.borderColor = [NSColor redColor].CGColor; - - _iconImageView = [NSImageView new]; - [self addSubview:self.iconImageView]; - - _label = [LKLabel new]; - self.label.textColor = [NSColor labelColor]; - self.label.font = NSFontMake(13); - [self addSubview:self.label]; - - _disclosureImageView = [NSImageView new]; - [self addSubview:self.disclosureImageView]; - - _accentImageView = [NSImageView new]; - self.accentImageView.contentTintColor = [LKHelper accentColor]; - self.accentImageView.hidden = YES; - [self addSubview:self.accentImageView]; - } - return self; -} - -- (void)layout { - [super layout]; - - $(self.iconImageView).sizeToFit.verAlign.x(DashboardHorInset).offsetY(-1); - $(self.label).sizeToFit.verAlign.offsetY(-1).x(self.iconImageView.$maxX + 3); - - CGFloat tailX = self.label.$maxX; - if (self.accentImageView && !self.accentImageView.hidden) { - $(self.accentImageView).sizeToFit.verAlign.x(tailX + 4); - tailX = self.accentImageView.$maxX; - } - $(self.disclosureImageView).sizeToFit.verAlign.x(tailX + 3); -} - -@end diff --git a/LookInside/Dashboard/LKDashboardCardView.h b/LookInside/Dashboard/LKDashboardCardView.h deleted file mode 100644 index b066f786..00000000 --- a/LookInside/Dashboard/LKDashboardCardView.h +++ /dev/null @@ -1,39 +0,0 @@ -// -// LKDashboardCardView.h -// Lookin -// -// Created by Li Kai on 2018/11/18. -// https://lookin.work -// - -#import "LKBaseView.h" -#import "LookinDisplayItem.h" -#import "LKDashboardAttributeView.h" - -@class LKDashboardViewController, LKDashboardCardView, LKDashboardSectionView; - -@protocol LKDashboardCardViewDelegate - -- (void)dashboardCardViewNeedToggleCollapse:(LKDashboardCardView *)view; - -@end - -@interface LKDashboardCardView : LKBaseView - -@property(nonatomic, weak) LKDashboardViewController *dashboardViewController; - -@property(nonatomic, weak) id delegate; - -/// 用来渲染的数据,设置该属性并不会触发任何渲染之类的行为 -@property(nonatomic, strong) LookinAttributesGroup *attrGroup; -/// 使用 attrGroup 属性来渲染 -- (void)render; - -@property(nonatomic, assign) BOOL isCollapsed; - -- (LKDashboardSectionView *)querySectionViewWithSection:(LookinAttributesSection *)sec; - -/// 如果 rect 为 CGRectZero,则会全部变暗 -- (void)playFadeAnimationWithHighlightRect:(CGRect)rect; - -@end diff --git a/LookInside/Dashboard/LKDashboardCardView.m b/LookInside/Dashboard/LKDashboardCardView.m deleted file mode 100644 index dee70959..00000000 --- a/LookInside/Dashboard/LKDashboardCardView.m +++ /dev/null @@ -1,512 +0,0 @@ -// -// LKDashboardCardView.m -// Lookin -// -// Created by Li Kai on 2018/11/18. -// https://lookin.work -// - -#import "LKDashboardCardView.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LKDashboardAttributeNumberInputView.h" -#import "LKDashboardAttributeSwitchView.h" -#import "LKDashboardAttributeColorView.h" -#import "LKDashboardAttributeEnumsView.h" -#import "LKDashboardViewController.h" -#import "LKDashboardSectionView.h" -#import "LookinDashboardBlueprint.h" -#import "LKPreferenceManager.h" -#import "LKDashboardCardTitleControl.h" -#import "LKDashboardAccessoryWindowController.h" -#import "LKHelper.h" -#import "LKUserActionManager.h" -#import "LookinAttributesGroup+LookinClient.h" -#import "LookinAttributesSection+LookinClient.h" -#import "LKDashboardSectionViewPool.h" -#import "LKHierarchyDataSource.h" -#import "LookinHierarchyInfo.h" - -// Wire-compat fallback for v8 servers (no isSwiftUIGroup flag yet). -// LKS_SwiftUIAttrGroupsMaker emits user-custom groups whose userCustomTitle -// always begins with "SwiftUI" (e.g. "SwiftUI Type", "SwiftUI Layout"). The -// upstream user-custom path (lookin_customDebugInfos) never produces those -// titles, so this distinguishes correctly. Remove once v8 support is dropped. -static BOOL LKAttrGroupLooksLikeSwiftUI(LookinAttributesGroup *group) { - return group.isSwiftUIGroup || [group.userCustomTitle hasPrefix:@"SwiftUI"]; -} - -@interface LKDashboardCardView () - -@property(nonatomic, strong) LKVisualEffectView *backgroundEffectView; -@property(nonatomic, strong) LKDashboardCardTitleControl *titleControl; -@property(nonatomic, strong) NSButton *detailButton; -@property(nonatomic, strong) NSButton *relationHelpButton; - -@property(nonatomic, strong) LKBaseView *fadeView; - -@property(nonatomic, strong) LKDashboardSectionViewPool *sectionViewPool; -@property(nonatomic, strong) NSMutableArray *sectionViews; - -@property(nonatomic, strong) LKDashboardAccessoryWindowController *accessoryWC; - - -@end - -@implementation LKDashboardCardView { - CGFloat _titleHeight; - CGFloat _contentsY; - CGFloat _insetBottom; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _titleHeight = 30; - _contentsY = 35; - _insetBottom = 12; - self.sectionViews = [NSMutableArray array]; - - self.layer.cornerRadius = DashboardCardCornerRadius; - - self.backgroundEffectView = [LKVisualEffectView new]; - self.backgroundEffectView.blendingMode = NSVisualEffectBlendingModeWithinWindow; - self.backgroundEffectView.state = NSVisualEffectStateActive; - [self addSubview:self.backgroundEffectView]; - - self.titleControl = [LKDashboardCardTitleControl new]; - [self.titleControl addTarget:self clickAction:@selector(_handleClickTitle)]; - [self addSubview:self.titleControl]; - - self.detailButton = [NSButton buttonWithImage:NSImageMake(@"icon_more") target:self action:@selector(_handleClickDetailButton)]; - self.detailButton.bezelStyle = NSBezelStyleRoundRect; - self.detailButton.bordered = NO; - [self addSubview:self.detailButton]; - - self.sectionViewPool = [LKDashboardSectionViewPool new]; - - [self updateColors]; - - [[LKUserActionManager sharedInstance] addDelegate:self]; - } - return self; -} - -- (void)layout { - [super layout]; - - $(self.backgroundEffectView).fullFrame; - $(self.titleControl).fullWidth.height(_titleHeight).y(0); - if (self.detailButton.isVisible) { - $(self.detailButton).width(50).height(28).right(-3).y(0); - } - if (self.relationHelpButton.isVisible) { - $(self.relationHelpButton).width(30).height(28).right(5).y(0); - } - - if (!self.attrGroup || !self.sectionViews.count) { - return; - } - - __block CGFloat y = _contentsY; - [self.sectionViews enumerateObjectsUsingBlock:^(LKDashboardSectionView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - $(view).x(DashboardHorInset).toRight(DashboardHorInset).heightToFit.y(y); - y = view.$maxY + DashboardSectionMarginTop; - }]; -} - -- (CGSize)sizeThatFits:(NSSize)limitedSize { - if (self.isCollapsed) { - limitedSize.height = _titleHeight; - return limitedSize; - } - limitedSize.width -= DashboardHorInset * 2; - __block CGFloat height = _contentsY; - [self.sectionViews enumerateObjectsUsingBlock:^(LKDashboardSectionView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat sectionHeight = [obj sizeThatFits:limitedSize].height; - height += (sectionHeight + DashboardSectionMarginTop); - }]; - height -= DashboardSectionMarginTop; - height += _insetBottom; - - limitedSize.height = height; - return limitedSize; -} - -- (void)render { - if (!self.attrGroup) { - NSAssert(NO, @""); - return; - } - if ([self.attrGroup.identifier isEqualToString:LookinAttrGroup_Class]) { - _contentsY = 28; - } else if ([self.attrGroup.identifier isEqualToString:LookinAttrGroup_Relation]) { - _contentsY = 30; - } else { - _contentsY = 35; - } - - LookinAppInfo *inspectedAppInfo = self.dashboardViewController.currentDataSource.rawHierarchyInfo.appInfo; - BOOL isMacTarget = [LKHelper appInfoLooksLikeMacTarget:inspectedAppInfo]; - self.titleControl.label.stringValue = [self.attrGroup queryDisplayTitleForMacTarget:isMacTarget]; - self.titleControl.iconImageView.image = [LKDashboardCardView imageWithAttrGroup:self.attrGroup]; - - // Cards are reused via cardViews[uniqueKey] in LKDashboardViewController; clear - // stale accent state when reassigned to a non-SwiftUI group. - BOOL isSwiftUIGroup = LKAttrGroupLooksLikeSwiftUI(self.attrGroup); - NSImageView *accent = self.titleControl.accentImageView; - accent.hidden = !isSwiftUIGroup; - accent.image = isSwiftUIGroup ? [LKDashboardCardView swiftUIAccentImage] : nil; - self.titleControl.toolTip = isSwiftUIGroup - ? NSLocalizedString(@"LookInside Pro · Activated", nil) - : nil; - [self.titleControl setNeedsLayout:YES]; - - self.detailButton.hidden = ![self _shouldShowDetailButtonWithGroupID:self.attrGroup.identifier]; - - [self.sectionViews enumerateObjectsUsingBlock:^(LKDashboardSectionView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj removeFromSuperview]; - }]; - [self.sectionViews removeAllObjects]; - [self.sectionViewPool recycleAll]; - - [self.attrGroup.attrSections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull sec, NSUInteger idx, BOOL * _Nonnull stop) { - if (!sec.isUserCustom && ![[LKPreferenceManager mainManager] isSectionShowing:sec.identifier]) { - return; - } - - LKDashboardSectionView *secView = [self.sectionViewPool dequeViewForSection:sec]; - [self.sectionViews addObject:secView]; - [self addSubview:secView]; - secView.dashboardViewController = self.dashboardViewController; - secView.attrSection = sec; - secView.showTopSeparator = (idx > 0); - - if (self.accessoryWC) { - secView.manageState = LKDashboardSectionManageState_CanRemove; - } else { - secView.manageState = LKDashboardSectionManageState_None; - } - }]; - - if (self.accessoryWC) { - [self _renderAccessoryWindowController]; - } - - if ([self.attrGroup.identifier isEqualToString:LookinAttrGroup_Relation]) { - [self showRelationHelpButton]; - } else { - [self hideRelationHelpButton]; - } - - [self setNeedsLayout:YES]; -} - -- (void)setIsCollapsed:(BOOL)isCollapsed { - _isCollapsed = isCollapsed; - self.titleControl.disclosureImageView.image = isCollapsed ? NSImageMake(@"icon_arrow_right") : NSImageMake(@"icon_arrow_down"); -} - -- (void)playFadeAnimationWithHighlightRect:(CGRect)rect { - if (self.fadeView) { - return; - } - - self.fadeView = [LKBaseView new]; - self.fadeView.backgroundColor = self.isDarkMode ? LookinColorRGBAMake(0, 0, 0, .7) : LookinColorRGBAMake(0, 0, 0, .6); - self.fadeView.alphaValue = 0; - self.fadeView.frame = self.bounds; - [self addSubview:self.fadeView]; - - - if (!CGRectEqualToRect(rect, CGRectZero)) { - CGFloat totalHeight = self.fadeView.$height; - if (totalHeight <= 0) { - NSAssert(NO, @""); - totalHeight = 1; - } - - CAGradientLayer *maskLayer = [CAGradientLayer layer]; - maskLayer.colors = @[(id)[NSColor blackColor].CGColor, - (id)[NSColor blackColor].CGColor, - (id)[[NSColor clearColor] CGColor], - (id)[[NSColor clearColor] CGColor], - (id)[NSColor blackColor].CGColor, - (id)[NSColor blackColor].CGColor]; - maskLayer.startPoint = CGPointMake(0, 0); - maskLayer.endPoint = CGPointMake(0, 1); - maskLayer.locations = @[@0, - @((CGRectGetMinY(rect) - 4) / totalHeight), - @((CGRectGetMinY(rect) + 10) / totalHeight), - @((CGRectGetMaxY(rect) + 2) / totalHeight), - @((CGRectGetMaxY(rect) + 16) / totalHeight), - @1]; - maskLayer.frame = self.fadeView.bounds; - self.fadeView.layer.mask = maskLayer; - } - - [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) { - context.duration = .3; - self.fadeView.animator.alphaValue = 1; - } completionHandler:^{ - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self _removeFadeAnimation]; - }); - }]; -} - -- (void)_removeFadeAnimation { - [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) { - context.duration = .3; - self.fadeView.animator.alphaValue = 0; - } completionHandler:^{ - [self.fadeView removeFromSuperview]; - self.fadeView = nil; - }]; -} - -- (void)_handleClickTitle { - if ([self.delegate respondsToSelector:@selector(dashboardCardViewNeedToggleCollapse:)]) { - [self.delegate dashboardCardViewNeedToggleCollapse:self]; - } -} - -+ (NSImage *)imageWithAttrGroup:(LookinAttributesGroup *)group { - static dispatch_once_t onceToken; - static NSDictionary *dict = nil; - dispatch_once(&onceToken,^{ - dict = @{ - LookinAttrGroup_Class: NSImageMake(@"dashboard_class"), - LookinAttrGroup_Relation: NSImageMake(@"dashboard_relation"), - LookinAttrGroup_Layout: NSImageMake(@"dashboard_layout"), - LookinAttrGroup_AutoLayout: NSImageMake(@"dashboard_autolayout"), - LookinAttrGroup_ViewLayer: NSImageMake(@"dashboard_layer"), - LookinAttrGroup_UIImageView: NSImageMake(@"dashboard_imageview"), - LookinAttrGroup_UILabel: NSImageMake(@"dashboard_label"), - LookinAttrGroup_UIButton: NSImageMake(@"dashboard_button"), - LookinAttrGroup_UIControl: NSImageMake(@"dashboard_control"), - LookinAttrGroup_UIScrollView: NSImageMake(@"dashboard_scrollview"), - LookinAttrGroup_UITableView: NSImageMake(@"dashboard_tableview"), - LookinAttrGroup_UITextView: NSImageMake(@"dashboard_textview"), - LookinAttrGroup_UITextField: NSImageMake(@"dashboard_textfield"), - LookinAttrGroup_UIVisualEffectView: NSImageMake(@"dashboard_effectview"), - LookinAttrGroup_UIStackView: NSImageMake(@"dashboard_stackview"), - LookinAttrGroup_NSImageView: NSImageMake(@"dashboard_imageview"), - LookinAttrGroup_NSControl: NSImageMake(@"dashboard_control"), - LookinAttrGroup_NSButton: NSImageMake(@"dashboard_button"), - LookinAttrGroup_NSScrollView: NSImageMake(@"dashboard_scrollview"), - LookinAttrGroup_NSTableView: NSImageMake(@"dashboard_tableview"), - LookinAttrGroup_NSTextView: NSImageMake(@"dashboard_textview"), - LookinAttrGroup_NSTextField: NSImageMake(@"dashboard_textfield"), - LookinAttrGroup_NSVisualEffectView: NSImageMake(@"dashboard_effectview"), - LookinAttrGroup_NSStackView: NSImageMake(@"dashboard_stackview"), - // NSControl subclasses share the green hue NSControl itself uses. - LookinAttrGroup_NSSlider: NSImageMake(@"dashboard_slider"), - LookinAttrGroup_NSStepper: NSImageMake(@"dashboard_stepper"), - LookinAttrGroup_NSSwitch: NSImageMake(@"dashboard_switch"), - LookinAttrGroup_NSSegmentedControl: NSImageMake(@"dashboard_segmentedcontrol"), - LookinAttrGroup_NSLevelIndicator: NSImageMake(@"dashboard_levelindicator"), - LookinAttrGroup_NSProgressIndicator: NSImageMake(@"dashboard_progressindicator"), - LookinAttrGroup_NSPopUpButton: NSImageMake(@"dashboard_popupbutton"), - LookinAttrGroup_NSComboBox: NSImageMake(@"dashboard_combobox"), - LookinAttrGroup_NSColorWell: NSImageMake(@"dashboard_colorwell"), - LookinAttrGroup_NSDatePicker: NSImageMake(@"dashboard_datepicker"), - // Data containers share the blue hue NSTableView uses. - LookinAttrGroup_NSOutlineView: NSImageMake(@"dashboard_outlineview"), - LookinAttrGroup_NSCollectionView: NSImageMake(@"dashboard_collectionview"), - LookinAttrGroup_NSGridView: NSImageMake(@"dashboard_gridview"), - // Structural containers share the pink hue NSStackView uses. - LookinAttrGroup_NSSplitView: NSImageMake(@"dashboard_splitview"), - LookinAttrGroup_NSTabView: NSImageMake(@"dashboard_tabview"), - LookinAttrGroup_NSBox: NSImageMake(@"dashboard_box"), - // Window chrome gets a graphite hue no content group uses. - LookinAttrGroup_NSWindow: NSImageMake(@"dashboard_window"), - LookinAttrGroup_UIWindowScene: NSImageMake(@"dashboard_windowscene"), - LookinAttrGroup_UITraitCollection: NSImageMake(@"dashboard_traitcollection"), - LookinAttrGroup_LayoutGuide: NSImageMake(@"dashboard_layoutguide"), - LookinAttrGroup_NSCell: NSImageMake(@"dashboard_cell"), - LookinAttrGroup_UserCustom: NSImageMake(@"dashboard_custom") - }; -#if DEBUG - // Fail on the first card rendered rather than only when someone happens - // to select the unregistered kind — that is how 16 AppKit control - // groups shipped falling back to a borrowed icon. Note this covers the - // groups compiled for this platform; the iOS-only groups this macOS - // host also renders still rely on the per-lookup assert below. - for (LookinAttrGroupIdentifier groupID in [LookinDashboardBlueprint groupIDs]) { - NSAssert(dict[groupID], @"missing dashboard icon for group %@", groupID); - } -#endif - }); - NSImage *image = dict[group.identifier]; - // Every blueprint group needs an entry above — registering a new group - // means four host-side spots: this table, the group title, the default - // sections, and (if applicable) the enum lists. The fallback keeps an - // unregistered group rendering with a generic icon instead of crashing - // debug builds / blanking release builds. - NSAssert(image, @"missing dashboard icon for group %@", group.identifier); - return image ?: NSImageMake(@"dashboard_layer"); -} - -+ (NSImage *)swiftUIAccentImage { - static dispatch_once_t onceToken; - static NSImage *image = nil; - dispatch_once(&onceToken, ^{ - image = [NSImage imageWithSystemSymbolName:@"sparkles" accessibilityDescription:nil]; - image.template = YES; - }); - return image; -} - -- (LKDashboardSectionView *)querySectionViewWithSection:(LookinAttributesSection *)sec { - return [self.sectionViews lookin_firstFiltered:^BOOL(LKDashboardSectionView *obj) { - return obj.attrSection == sec; - }]; -} - -- (void)showRelationHelpButton { - if (!self.relationHelpButton) { - self.relationHelpButton = [NSButton buttonWithImage:NSImageMake(@"ic_question") target:self action:@selector(_handleRelationHelpButton)]; - self.relationHelpButton.bezelStyle = NSBezelStyleRoundRect; - self.relationHelpButton.bordered = NO; - [self addSubview:self.detailButton]; - } - [self addSubview:self.relationHelpButton]; -} - -- (void)hideRelationHelpButton { - [self.relationHelpButton removeFromSuperview]; -} - -#pragma mark - Others - -- (void)mouseDown:(NSEvent *)event { - [super mouseDown:event]; - [[LKUserActionManager sharedInstance] sendAction:LKUserActionType_DashboardClick]; -} - -#pragma mark - - -- (void)LKUserActionManager:(LKUserActionManager *)manager didAct:(LKUserActionType)type { - if (!self.accessoryWC) { - return; - } - NSArray *validTypes = @[@(LKUserActionType_PreviewOperation), @(LKUserActionType_DashboardClick), @(LKUserActionType_SelectedItemChange)]; - if (![validTypes containsObject:@(type)]) { - return; - } - [self.accessoryWC close]; -} - -#pragma mark - Accessory - -- (void)_handleClickDetailButton { - if (self.accessoryWC) { - [self.accessoryWC close]; - return; - } - - [[LKUserActionManager sharedInstance] sendAction:LKUserActionType_DashboardClick]; - - if (self.isCollapsed) { - /// 如果当前是折叠状态,则展开 - if ([self.delegate respondsToSelector:@selector(dashboardCardViewNeedToggleCollapse:)]) { - [self.delegate dashboardCardViewNeedToggleCollapse:self]; - } - } - - self.accessoryWC = [[LKDashboardAccessoryWindowController alloc] initWithDashboardController:self.dashboardViewController attrGroupID:self.attrGroup.identifier]; - self.accessoryWC.delegate = self; - - [self _renderAccessoryWindowController]; - - [self.window addChildWindow:self.accessoryWC.window ordered:NSWindowAbove]; -} - -- (void)_renderAccessoryWindowController { - if (!self.accessoryWC) { - NSAssert(NO, @""); - return; - } - - [self.sectionViews enumerateObjectsUsingBlock:^(LKDashboardSectionView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.manageState = LKDashboardSectionManageState_CanRemove; - }]; - - NSArray *allSecIDs = [LookinDashboardBlueprint sectionIDsForGroupID:self.attrGroup.identifier]; - NSArray *hiddenSecIDs = [allSecIDs lookin_filter:^BOOL(LookinAttrSectionIdentifier obj) { - return ![[LKPreferenceManager mainManager] isSectionShowing:obj]; - }]; - if (hiddenSecIDs.count == 0) { - self.accessoryWC.window.contentView.hidden = YES; - return; - } - self.accessoryWC.window.contentView.hidden = NO; - - NSArray *sections = [hiddenSecIDs lookin_map:^id(NSUInteger idx, LookinAttrSectionIdentifier secID) { - LookinAttributesSection *sec = [self.attrGroup.attrSections lookin_firstFiltered:^BOOL(LookinAttributesSection *obj) { - return [obj.identifier isEqualToString:secID]; - }]; - return sec; - }]; - - NSSize contentSize = [self.accessoryWC renderWithAttrSections:sections]; - - // 这里是左上角坐标系,比如 cardView 顶部恰好和窗口顶部重合时,y 为 0 - CGRect selfFrameInWindow = [self.window.contentView convertRect:self.frame fromView:self.superview]; - // 这里是左下角坐标系 - CGRect selfWindowFrame = self.window.frame; - // 做个 MAX 以防止超出下边缘屏幕 - CGFloat panelY = MAX(selfWindowFrame.origin.y + (selfWindowFrame.size.height - selfFrameInWindow.origin.y) - contentSize.height, 0); - CGFloat panelX = CGRectGetMaxX(selfWindowFrame) + 5; - CGFloat panelMaxX = panelX + contentSize.width; - if (panelMaxX > self.window.screen.frame.size.width) { - // 防止超出屏幕右侧 - panelX = selfWindowFrame.origin.x + CGRectGetMinX(selfFrameInWindow) - contentSize.width - 5; - } - CGRect panelFrame = CGRectMake(panelX, panelY, contentSize.width, contentSize.height); - // panelFrame 是左下角坐标系。虽然这里的 controller.window 是 self.window 的 childWindow,但 panelFrame 仍然是相对于 screen 的坐标,换句话说,如果 panelFrame.y 是 0,则 panel 的底部会和屏幕底部重合 - [self.accessoryWC.window setFrame:panelFrame display:YES]; -} - -- (void)dashboardAccessoryWindowControllerWillClose:(LKDashboardAccessoryWindowController *)controller { - [self.sectionViews enumerateObjectsUsingBlock:^(LKDashboardSectionView * _Nonnull secView, NSUInteger idx, BOOL * _Nonnull stop) { - secView.manageState = LKDashboardSectionManageState_None; - }]; - self.accessoryWC = nil; -} - -- (BOOL)_shouldShowDetailButtonWithGroupID:(LookinAttrGroupIdentifier)identifier { - if ([identifier isEqualToString:LookinAttrGroup_UserCustom]) { - return NO; - } - // Use the actual section count from the group rather than the blueprint, - // because some groups (e.g. Layout for NSWindow) are built manually - // with fewer sections than the blueprint defines. - NSUInteger actualCount = self.attrGroup.attrSections.count; - NSUInteger blueprintCount = [LookinDashboardBlueprint sectionIDsForGroupID:identifier].count; - NSUInteger sectionCount = MIN(actualCount, blueprintCount); - if (sectionCount > 1) { - return YES; - } else { - return NO; - } -} - -- (void)_handleRelationHelpButton { - NSMenu *menu = [NSMenu new]; - - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = NSImageMake(@"Icon_Inspiration_small"); - menuItem.title = NSLocalizedString(@"How to display more member variables…", nil); - menuItem.target = self; - menuItem.action = @selector(handleRelationDocument); - [menu addItem:menuItem]; - - [NSMenu popUpContextMenu:menu withEvent:NSApplication.sharedApplication.currentEvent forView:self.relationHelpButton]; -} - -- (void)handleRelationDocument { - [LKHelper showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"Legacy member-variable documentation links are disabled in this community build. See the repository README instead.", nil)]; -} - -@end diff --git a/LookInside/Dashboard/LKDashboardCardView.swift b/LookInside/Dashboard/LKDashboardCardView.swift new file mode 100644 index 00000000..d44ede6b --- /dev/null +++ b/LookInside/Dashboard/LKDashboardCardView.swift @@ -0,0 +1,493 @@ +// +// LKDashboardCardView.swift +// LookInside +// +// Created by Li Kai on 2018/11/18. +// https://lookin.work +// + +import AppKit +import QuartzCore + +/// The clickable title row of a card: icon, title, SwiftUI accent and +/// disclosure arrow. +final class LKDashboardCardTitleControl: LKBaseControl { + let iconImageView = NSImageView() + let label = LKLabel() + let disclosureImageView = NSImageView() + let accentImageView = NSImageView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + addSubview(iconImageView) + + label.textColor = .labelColor + label.font = LKDashboardStyle.font(13) + addSubview(label) + + addSubview(disclosureImageView) + + accentImageView.contentTintColor = LKHelper.accentColor() + accentImageView.isHidden = true + addSubview(accentImageView) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + iconImageView.dashboardLayout.sizeToFit().verAlign().x(LKDashboardMetrics.horInset).offsetY(-1) + label.dashboardLayout.sizeToFit().verAlign().offsetY(-1).x(iconImageView.frame.maxX + 3) + + var tailX = label.frame.maxX + if !accentImageView.isHidden { + accentImageView.dashboardLayout.sizeToFit().verAlign().x(tailX + 4) + tailX = accentImageView.frame.maxX + } + disclosureImageView.dashboardLayout.sizeToFit().verAlign().x(tailX + 3) + } +} + +protocol LKDashboardCardViewDelegate: AnyObject { + func dashboardCardViewNeedToggleCollapse(_ view: LKDashboardCardView) +} + +/// One attribute group as a card: a title that collapses it, the shown +/// sections, and a "more" button that offers the hidden sections in a +/// panel beside the window. +final class LKDashboardCardView: LKBaseView, LKUserActionManagerDelegate, LKDashboardAccessoryWindowControllerDelegate { + private let titleHeight: CGFloat = 30 + private let insetBottom: CGFloat = 12 + private var contentsY: CGFloat = 35 + + private let backgroundEffectView = LKVisualEffectView() + private let titleControl = LKDashboardCardTitleControl() + private var detailButton: NSButton! + private var relationHelpButton: NSButton? + private var fadeView: LKBaseView? + + private let sectionViewPool = LKDashboardSectionViewPool() + private var sectionViews: [LKDashboardSectionView] = [] + private var accessoryWindowController: LKDashboardAccessoryWindowController? + + weak var dashboardViewController: LKDashboardViewController? + weak var delegate: LKDashboardCardViewDelegate? + + /// The group to render; setting it does not render. + var attrGroup: LookinAttributesGroup? + + var isCollapsed = false { + didSet { + titleControl.disclosureImageView.image = LKDashboardStyle.image(isCollapsed ? "icon_arrow_right" : "icon_arrow_down") + } + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + layer?.cornerRadius = LKDashboardMetrics.cardCornerRadius + + backgroundEffectView.blendingMode = .withinWindow + backgroundEffectView.state = .active + addSubview(backgroundEffectView) + + titleControl.addTarget(self, clickAction: #selector(handleClickTitle)) + addSubview(titleControl) + + detailButton = NSButton(image: LKDashboardStyle.image("icon_more") ?? NSImage(), target: self, action: #selector(handleClickDetailButton)) + detailButton.bezelStyle = .roundRect + detailButton.isBordered = false + addSubview(detailButton) + + updateColors() + + LKUserActionManager.sharedInstance().add(self) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + backgroundEffectView.dashboardLayout.fullFrame() + titleControl.dashboardLayout.fullWidth().height(titleHeight).y(0) + if detailButton.isVisible { + detailButton.dashboardLayout.width(50).height(28).right(-3).y(0) + } + if let relationHelpButton, relationHelpButton.isVisible { + relationHelpButton.dashboardLayout.width(30).height(28).right(5).y(0) + } + + guard attrGroup != nil, !sectionViews.isEmpty else { return } + + var y = contentsY + for view in sectionViews { + view.dashboardLayout.x(LKDashboardMetrics.horInset).toRight(LKDashboardMetrics.horInset).heightToFit().y(y) + y = view.frame.maxY + LKDashboardMetrics.sectionMarginTop + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var size = limitedSize + if isCollapsed { + size.height = titleHeight + return size + } + size.width -= LKDashboardMetrics.horInset * 2 + var height = contentsY + for view in sectionViews { + height += view.sizeThatFits(size).height + LKDashboardMetrics.sectionMarginTop + } + height -= LKDashboardMetrics.sectionMarginTop + height += insetBottom + size.height = height + return size + } + + /// Renders `attrGroup`. + func render() { + guard let attrGroup else { + assertionFailure() + return + } + switch attrGroup.identifier { + case LookinAttrGroup_Class: + contentsY = 28 + case LookinAttrGroup_Relation: + contentsY = 30 + default: + contentsY = 35 + } + + let inspectedAppInfo = dashboardViewController?.currentDataSource()?.rawHierarchyInfo?.appInfo + let isMacTarget = LKHelper.appInfoLooksLikeMacTarget(inspectedAppInfo) + titleControl.label.stringValue = attrGroup.queryDisplayTitle(forMacTarget: isMacTarget) + titleControl.iconImageView.image = Self.image(for: attrGroup) + + // Cards are reused per unique key; clear the accent when the card + // now shows a group that is not SwiftUI's. + let isSwiftUIGroup = Self.looksLikeSwiftUI(attrGroup) + titleControl.accentImageView.isHidden = !isSwiftUIGroup + titleControl.accentImageView.image = isSwiftUIGroup ? Self.swiftUIAccentImage : nil + titleControl.toolTip = isSwiftUIGroup ? NSLocalizedString("LookInside Pro · Activated", comment: "") : nil + titleControl.needsLayout = true + + detailButton.isHidden = !shouldShowDetailButton(groupID: attrGroup.identifier) + + sectionViews.forEach { $0.removeFromSuperview() } + sectionViews.removeAll() + sectionViewPool.recycleAll() + + for (idx, section) in (attrGroup.attrSections ?? []).enumerated() { + if !section.isUserCustom() && !LKPreferenceManager.shared.isSectionShowing(section.identifier) { + continue + } + let sectionView = sectionViewPool.dequeueView(for: section) + sectionViews.append(sectionView) + addSubview(sectionView) + sectionView.dashboardViewController = dashboardViewController + sectionView.attrSection = section + sectionView.showTopSeparator = idx > 0 + sectionView.manageState = accessoryWindowController == nil ? .none : .canRemove + } + + if accessoryWindowController != nil { + renderAccessoryWindowController() + } + + if attrGroup.identifier == LookinAttrGroup_Relation { + showRelationHelpButton() + } else { + relationHelpButton?.removeFromSuperview() + } + + needsLayout = true + } + + func querySectionView(with section: LookinAttributesSection) -> LKDashboardSectionView? { + sectionViews.first { $0.attrSection === section } + } + + /// Dims the card, except for `rect` when it is not empty, then fades + /// back after a second. + func playFadeAnimation(highlightRect rect: CGRect) { + if fadeView != nil { + return + } + let fadeView = LKBaseView() + fadeView.backgroundColor = isDarkMode() ? LKDashboardStyle.rgb(0, 0, 0, 0.7) : LKDashboardStyle.rgb(0, 0, 0, 0.6) + fadeView.alphaValue = 0 + fadeView.frame = bounds + addSubview(fadeView) + self.fadeView = fadeView + + if rect != .zero { + var totalHeight = fadeView.frame.height + if totalHeight <= 0 { + assertionFailure() + totalHeight = 1 + } + let maskLayer = CAGradientLayer() + let black = NSColor.black.cgColor + let clear = NSColor.clear.cgColor + maskLayer.colors = [black, black, clear, clear, black, black] + maskLayer.startPoint = CGPoint(x: 0, y: 0) + maskLayer.endPoint = CGPoint(x: 0, y: 1) + maskLayer.locations = [ + 0, + NSNumber(value: Double((rect.minY - 4) / totalHeight)), + NSNumber(value: Double((rect.minY + 10) / totalHeight)), + NSNumber(value: Double((rect.maxY + 2) / totalHeight)), + NSNumber(value: Double((rect.maxY + 16) / totalHeight)), + 1, + ] + maskLayer.frame = fadeView.bounds + fadeView.layer?.mask = maskLayer + } + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + fadeView.animator().alphaValue = 1 + } completionHandler: { + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.removeFadeAnimation() + } + } + } + + private func removeFadeAnimation() { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + self.fadeView?.animator().alphaValue = 0 + } completionHandler: { + self.fadeView?.removeFromSuperview() + self.fadeView = nil + } + } + + @objc private func handleClickTitle() { + delegate?.dashboardCardViewNeedToggleCollapse(self) + } + + // MARK: - Group look + + /// Wire-compatibility fallback for v8 Servers, which do not send + /// `isSwiftUIGroup`: the SwiftUI groups are user-custom groups whose + /// title begins with "SwiftUI" ("SwiftUI Type", "SwiftUI Layout"), which + /// the upstream user-custom path never produces. Remove with v8 support. + static func looksLikeSwiftUI(_ group: LookinAttributesGroup) -> Bool { + group.isSwiftUIGroup || (group.userCustomTitle?.hasPrefix("SwiftUI") ?? false) + } + + private static let groupIconNames: [String: String] = { + let names: [String: String] = [ + LookinAttrGroup_Class: "dashboard_class", + LookinAttrGroup_Relation: "dashboard_relation", + LookinAttrGroup_Layout: "dashboard_layout", + LookinAttrGroup_AutoLayout: "dashboard_autolayout", + LookinAttrGroup_ViewLayer: "dashboard_layer", + LookinAttrGroup_UIImageView: "dashboard_imageview", + LookinAttrGroup_UILabel: "dashboard_label", + LookinAttrGroup_UIButton: "dashboard_button", + LookinAttrGroup_UIControl: "dashboard_control", + LookinAttrGroup_UIScrollView: "dashboard_scrollview", + LookinAttrGroup_UITableView: "dashboard_tableview", + LookinAttrGroup_UITextView: "dashboard_textview", + LookinAttrGroup_UITextField: "dashboard_textfield", + LookinAttrGroup_UIVisualEffectView: "dashboard_effectview", + LookinAttrGroup_UIStackView: "dashboard_stackview", + LookinAttrGroup_NSImageView: "dashboard_imageview", + LookinAttrGroup_NSControl: "dashboard_control", + LookinAttrGroup_NSButton: "dashboard_button", + LookinAttrGroup_NSScrollView: "dashboard_scrollview", + LookinAttrGroup_NSTableView: "dashboard_tableview", + LookinAttrGroup_NSTextView: "dashboard_textview", + LookinAttrGroup_NSTextField: "dashboard_textfield", + LookinAttrGroup_NSVisualEffectView: "dashboard_effectview", + LookinAttrGroup_NSStackView: "dashboard_stackview", + // NSControl subclasses share the green hue NSControl itself uses. + LookinAttrGroup_NSSlider: "dashboard_slider", + LookinAttrGroup_NSStepper: "dashboard_stepper", + LookinAttrGroup_NSSwitch: "dashboard_switch", + LookinAttrGroup_NSSegmentedControl: "dashboard_segmentedcontrol", + LookinAttrGroup_NSLevelIndicator: "dashboard_levelindicator", + LookinAttrGroup_NSProgressIndicator: "dashboard_progressindicator", + LookinAttrGroup_NSPopUpButton: "dashboard_popupbutton", + LookinAttrGroup_NSComboBox: "dashboard_combobox", + LookinAttrGroup_NSColorWell: "dashboard_colorwell", + LookinAttrGroup_NSDatePicker: "dashboard_datepicker", + // Data containers share the blue hue NSTableView uses. + LookinAttrGroup_NSOutlineView: "dashboard_outlineview", + LookinAttrGroup_NSCollectionView: "dashboard_collectionview", + LookinAttrGroup_NSGridView: "dashboard_gridview", + // Structural containers share the pink hue NSStackView uses. + LookinAttrGroup_NSSplitView: "dashboard_splitview", + LookinAttrGroup_NSTabView: "dashboard_tabview", + LookinAttrGroup_NSBox: "dashboard_box", + // Window chrome gets a graphite hue no content group uses. + LookinAttrGroup_NSWindow: "dashboard_window", + LookinAttrGroup_UIWindowScene: "dashboard_windowscene", + LookinAttrGroup_UITraitCollection: "dashboard_traitcollection", + LookinAttrGroup_LayoutGuide: "dashboard_layoutguide", + LookinAttrGroup_NSCell: "dashboard_cell", + LookinAttrGroup_UserCustom: "dashboard_custom", + ] + #if DEBUG + // Fail on the first card rendered rather than only when someone + // selects the unregistered kind; that is how 16 AppKit control + // groups once shipped with a borrowed icon. This covers the groups + // compiled for this platform; the iOS-only groups rely on the + // per-lookup assertion. + for groupID in LookinDashboardBlueprint.groupIDs() ?? [] { + assert(names[groupID] != nil, "missing dashboard icon for group \(groupID)") + } + #endif + return names + }() + + /// The icon of a group. Registering a new group means four host-side + /// places: this table, the group title, the default sections and (when + /// it has enums) the enum lists. An unregistered group falls back to a + /// generic icon. + static func image(for group: LookinAttributesGroup) -> NSImage? { + let name = groupIconNames[group.identifier ?? ""] + assert(name != nil, "missing dashboard icon for group \(group.identifier ?? "nil")") + return LKDashboardStyle.image(name ?? "dashboard_layer") + } + + private static let swiftUIAccentImage: NSImage? = { + let image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) + image?.isTemplate = true + return image + }() + + // MARK: - Relation help + + private func showRelationHelpButton() { + if relationHelpButton == nil { + let button = NSButton(image: LKDashboardStyle.image("ic_question") ?? NSImage(), target: self, action: #selector(handleRelationHelpButton)) + button.bezelStyle = .roundRect + button.isBordered = false + relationHelpButton = button + addSubview(detailButton) + } + if let relationHelpButton { + addSubview(relationHelpButton) + } + } + + @objc private func handleRelationHelpButton() { + let menu = NSMenu() + let menuItem = NSMenuItem() + menuItem.image = LKDashboardStyle.image("Icon_Inspiration_small") + menuItem.title = NSLocalizedString("How to display more member variables…", comment: "") + menuItem.target = self + menuItem.action = #selector(handleRelationDocument) + menu.addItem(menuItem) + if let event = NSApplication.shared.currentEvent, let relationHelpButton { + NSMenu.popUpContextMenu(menu, with: event, for: relationHelpButton) + } + } + + @objc private func handleRelationDocument() { + LKHelper.showDisabledExternalLinkAlert(withMessage: NSLocalizedString("Legacy member-variable documentation links are disabled in this community build. See the repository README instead.", comment: "")) + } + + // MARK: - Others + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + LKUserActionManager.sharedInstance().send(.dashboardClick) + } + + // MARK: - LKUserActionManagerDelegate + + func lkUserActionManager(_: LKUserActionManager, didAct type: LKUserActionType) { + guard let accessoryWindowController else { return } + guard [.previewOperation, .dashboardClick, .selectedItemChange].contains(type) else { return } + accessoryWindowController.close() + } + + // MARK: - Accessory + + @objc private func handleClickDetailButton() { + if let accessoryWindowController { + accessoryWindowController.close() + return + } + + LKUserActionManager.sharedInstance().send(.dashboardClick) + + if isCollapsed { + // Expand a collapsed card first. + delegate?.dashboardCardViewNeedToggleCollapse(self) + } + + guard let attrGroup else { return } + let controller = LKDashboardAccessoryWindowController(dashboardController: dashboardViewController, attrGroupID: attrGroup.identifier) + controller.delegate = self + accessoryWindowController = controller + + renderAccessoryWindowController() + + if let panel = controller.window { + window?.addChildWindow(panel, ordered: .above) + } + } + + private func renderAccessoryWindowController() { + guard let controller = accessoryWindowController, let attrGroup else { + assertionFailure() + return + } + + sectionViews.forEach { $0.manageState = .canRemove } + + let allSectionIDs = LookinDashboardBlueprint.sectionIDs(forGroupID: attrGroup.identifier) ?? [] + let hiddenSectionIDs = allSectionIDs.filter { !LKPreferenceManager.shared.isSectionShowing($0) } + if hiddenSectionIDs.isEmpty { + controller.window?.contentView?.isHidden = true + return + } + controller.window?.contentView?.isHidden = false + + let sections = hiddenSectionIDs.compactMap { sectionID in + (attrGroup.attrSections ?? []).first { $0.identifier == sectionID } + } + let contentSize = controller.render(attrSections: sections) + + guard let window else { return } + // Top-left origin: 0 when the card's top is the window's top. + let selfFrameInWindow = window.contentView?.convert(frame, from: superview) ?? .zero + // Bottom-left origin, in screen coordinates. + let selfWindowFrame = window.frame + // Kept above the bottom of the screen. + let panelY = max(selfWindowFrame.origin.y + (selfWindowFrame.size.height - selfFrameInWindow.origin.y) - contentSize.height, 0) + var panelX = selfWindowFrame.maxX + 5 + if panelX + contentSize.width > (window.screen?.frame.size.width ?? 0) { + // Kept within the right edge of the screen: placed to the left. + panelX = selfWindowFrame.origin.x + selfFrameInWindow.minX - contentSize.width - 5 + } + // Screen coordinates even though the panel is a child window: a + // y of 0 puts the panel's bottom on the screen's bottom. + controller.window?.setFrame(NSRect(x: panelX, y: panelY, width: contentSize.width, height: contentSize.height), display: true) + } + + func dashboardAccessoryWindowControllerWillClose(_: LKDashboardAccessoryWindowController) { + sectionViews.forEach { $0.manageState = .none } + accessoryWindowController = nil + } + + private func shouldShowDetailButton(groupID: String?) -> Bool { + if groupID == LookinAttrGroup_UserCustom { + return false + } + // The group's own section count, not the blueprint's: some groups + // (Layout for NSWindow, for example) are built with fewer sections. + let actualCount = attrGroup?.attrSections?.count ?? 0 + let blueprintCount = LookinDashboardBlueprint.sectionIDs(forGroupID: groupID)?.count ?? 0 + return min(actualCount, blueprintCount) > 1 + } +} diff --git a/LookInside/Dashboard/LKDashboardLayout.swift b/LookInside/Dashboard/LKDashboardLayout.swift new file mode 100644 index 00000000..1cbe5a26 --- /dev/null +++ b/LookInside/Dashboard/LKDashboardLayout.swift @@ -0,0 +1,436 @@ +// +// LKDashboardLayout.swift +// LookInside +// +// Frame arithmetic for the Dashboard's hand-laid-out views. +// +// The Dashboard lays its views out by hand. These helpers reproduce the +// frame rules the Objective-C code used, so the cards keep their exact +// geometry: +// +// - position and size setters round up to the main screen's pixel grid; +// - `sizeToFit()` / `heightToFit()` only measure `LKBaseView` and +// `NSControl` (through `sizeThatFits(_:)`), do not round, and leave any +// other view alone; +// - `right`, `bottom`, the alignments and the `to…` edges are relative to +// the superview (superlayer) bounds. +// + +import AppKit + +/// A view or layer whose frame is being set. Every setter returns the +/// layout again so calls chain in the order they are written. +@MainActor +struct LKDashboardLayout { + private enum Target { + case view(NSView) + case layer(CALayer) + } + + private let target: Target + + init(_ view: NSView) { + target = .view(view) + } + + init(_ layer: CALayer) { + target = .layer(layer) + } + + // MARK: - Pixel snapping + + /// Rounds `value` up to the main screen's pixel grid. The flag values + /// `CGFloat.leastNormalMagnitude` and `.greatestFiniteMagnitude` pass + /// through unchanged. + static func snapToPixel(_ value: CGFloat) -> CGFloat { + if value == .leastNormalMagnitude || value == .greatestFiniteMagnitude { + return value + } + let scale = NSScreen.main?.backingScaleFactor ?? 1 + return ceil(value * scale) / scale + } + + private static func sanitized(_ value: CGFloat) -> CGFloat { + value.isNaN ? 0 : value + } + + // MARK: - Frame access + + private var frame: CGRect { + get { + switch target { + case let .view(view): return view.frame + case let .layer(layer): return layer.frame + } + } + nonmutating set { + switch target { + case let .view(view): view.frame = newValue + case let .layer(layer): layer.frame = newValue + } + } + } + + private var ownBounds: CGRect { + switch target { + case let .view(view): return view.bounds + case let .layer(layer): return layer.bounds + } + } + + private var superBounds: CGRect { + switch target { + case let .view(view): return view.superview?.bounds ?? .zero + case let .layer(layer): return layer.superlayer?.bounds ?? .zero + } + } + + private var superIsFlipped: Bool { + switch target { + case let .view(view): return view.superview?.isFlipped ?? false + case let .layer(layer): return layer.superlayer?.contentsAreFlipped() ?? false + } + } + + // MARK: - Size and position + + @discardableResult + func width(_ value: CGFloat) -> Self { + var rect = frame + rect.size.width = Self.snapToPixel(Self.sanitized(value)) + frame = rect + return self + } + + @discardableResult + func height(_ value: CGFloat) -> Self { + var rect = frame + rect.size.height = Self.snapToPixel(Self.sanitized(value)) + frame = rect + return self + } + + @discardableResult + func size(_ value: CGSize) -> Self { + width(value.width).height(value.height) + } + + @discardableResult + func x(_ value: CGFloat) -> Self { + var rect = frame + rect.origin.x = Self.snapToPixel(Self.sanitized(value)) + frame = rect + return self + } + + @discardableResult + func y(_ value: CGFloat) -> Self { + var rect = frame + rect.origin.y = Self.snapToPixel(Self.sanitized(value)) + frame = rect + return self + } + + @discardableResult + func offsetY(_ value: CGFloat) -> Self { + var rect = frame + rect.origin.x = Self.snapToPixel(rect.minX) + rect.origin.y = Self.snapToPixel(rect.minY + Self.sanitized(value)) + frame = rect + return self + } + + @discardableResult + func midY(_ value: CGFloat) -> Self { + y(Self.sanitized(value) - ownBounds.height / 2) + } + + @discardableResult + func maxX(_ value: CGFloat) -> Self { + x(Self.sanitized(value) - ownBounds.width) + } + + @discardableResult + func maxY(_ value: CGFloat) -> Self { + y(Self.sanitized(value) - ownBounds.height) + } + + /// Places the trailing edge `value` points inside the superview. + @discardableResult + func right(_ value: CGFloat) -> Self { + maxX(superBounds.width - Self.sanitized(value)) + } + + /// Places the bottom edge `value` points inside the superview. + @discardableResult + func bottom(_ value: CGFloat) -> Self { + if superIsFlipped { + return maxY(superBounds.height - Self.sanitized(value)) + } + return y(Self.sanitized(value)) + } + + @discardableResult + func horAlign() -> Self { + x(superBounds.width / 2 - ownBounds.width / 2) + } + + @discardableResult + func verAlign() -> Self { + y(superBounds.height / 2 - ownBounds.height / 2) + } + + @discardableResult + func fullWidth() -> Self { + x(0).toRight(0) + } + + @discardableResult + func fullHeight() -> Self { + height(superBounds.height).y(0) + } + + @discardableResult + func fullFrame() -> Self { + fullWidth().fullHeight() + } + + // MARK: - Edges + + /// Stretches the width so the trailing edge is `value` points inside the + /// superview; never narrower than zero. + @discardableResult + func toRight(_ value: CGFloat) -> Self { + var rect = frame + rect.size.width = max(Self.snapToPixel(superBounds.width - rect.minX - Self.sanitized(value)), 0) + frame = rect + return self + } + + /// Moves the top edge to `value`, keeping the bottom edge. + @discardableResult + func toY(_ value: CGFloat) -> Self { + var rect = frame + let safeValue = min(Self.sanitized(value), rect.maxY) + rect.size.height = Self.snapToPixel(rect.maxY - safeValue) + rect.origin.y = Self.snapToPixel(safeValue) + frame = rect + return self + } + + /// Moves the bottom edge to `value`, keeping the top edge. + @discardableResult + func toMaxY(_ value: CGFloat) -> Self { + var rect = frame + let safeValue = max(Self.sanitized(value), rect.minY) + rect.size.height = Self.snapToPixel(safeValue - rect.minY) + frame = rect + return self + } + + @discardableResult + func toBottom(_ value: CGFloat) -> Self { + if superIsFlipped { + return toMaxY(superBounds.height - Self.sanitized(value)) + } + return toY(Self.sanitized(value)) + } + + @discardableResult + func minWidth(_ value: CGFloat) -> Self { + var rect = frame + if rect.size.width < value { + rect.size.width = value + frame = rect + } + return self + } + + // MARK: - Fitting + + /// The view that can measure itself: an `LKBaseView` or an `NSControl`. + private var measurableView: NSView? { + guard case let .view(view) = target else { return nil } + if view is LKBaseView || view is NSControl { + return view + } + return nil + } + + private static func fittingSize(of view: NSView, in limit: NSSize) -> NSSize { + if let baseView = view as? LKBaseView { + return baseView.sizeThatFits(limit) + } + if let control = view as? NSControl { + var size = control.sizeThatFits(limit) + if size.width.isNaN { + size.width = 0 + } + if size.height.isNaN { + size.height = 0 + } + return size + } + return view.frame.size + } + + @discardableResult + func sizeToFit() -> Self { + guard let view = measurableView else { return self } + var rect = view.frame + rect.size = Self.fittingSize(of: view, in: NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude)) + view.frame = rect + return self + } + + @discardableResult + func heightToFit() -> Self { + guard let view = measurableView else { return self } + var rect = view.frame + let limit = NSSize(width: view.bounds.width, height: .greatestFiniteMagnitude) + if let baseView = view as? LKBaseView { + rect.size.height = baseView.sizeThatFits(limit).height + } else if let control = view as? NSControl { + rect.size.height = control.sizeThatFits(limit).height + } + view.frame = rect + return self + } +} + +extension NSView { + /// Lays the view out with the Dashboard's frame rules. + @MainActor + var dashboardLayout: LKDashboardLayout { + LKDashboardLayout(self) + } +} + +extension CALayer { + /// Lays the layer out with the Dashboard's frame rules. + @MainActor + var dashboardLayout: LKDashboardLayout { + LKDashboardLayout(self) + } +} + +/// The Dashboard's shared metrics, mirrored from `LKHelper.swift`. +enum LKDashboardMetrics { + static let viewWidth = DashboardViewWidth + static let horInset = DashboardHorInset + static let attrItemHorInterspace = DashboardAttrItemHorInterspace + static let attrItemVerInterspace = DashboardAttrItemVerInterspace + static let cardControlCornerRadius = DashboardCardControlCornerRadius + static let sectionMarginTop = DashboardSectionMarginTop + static let cardCornerRadius = DashboardCardCornerRadius + static let searchCardInset = DashboardSearchCardInset + + static let numberInputHorizontalHeight = LKNumberInputHorizontalHeight + static let numberInputVerticalHeight = LKNumberInputVerticalHeight + + static let maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) +} + +/// Colour, image and alert helpers that replace the old `LKHelper.h` macros. +enum LKDashboardStyle { + static func rgb(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = 1) -> NSColor { + NSColor(red: red / 255.0, green: green / 255.0, blue: blue / 255.0, alpha: alpha) + } + + static var separatorLightModeColor: NSColor { + rgb(215, 215, 215) + } + + static var separatorDarkModeColor: NSColor { + rgb(67, 67, 69) + } + + static func separatorColor(isDarkMode: Bool) -> NSColor { + isDarkMode ? separatorDarkModeColor : separatorLightModeColor + } + + /// `NSFontMake`: the system font at `size`. + static func font(_ size: CGFloat) -> NSFont { + NSFont.systemFont(ofSize: size) + } + + /// `NSImageMake`: a named image, possibly missing. + static func image(_ name: String) -> NSImage? { + NSImage(named: name) + } + + /// `AlertError`: shows `error` as a sheet on `window`, except the + /// discarded-request error. + static func alert(_ error: Error, window: NSWindow?) { + let error = error as NSError + guard error.code != LookinErrCode_Discard else { return } + let alert = NSAlert(error: error) + if let window { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + alert.runModal() + } + } + + /// `AlertErrorText`: an error with a title and a recovery suggestion. + static func alert(title: String, detail: String, window: NSWindow?) { + let error = NSError(domain: LookinErrorDomain, code: LookinErrCode_Default, userInfo: [ + NSLocalizedDescriptionKey: title, + NSLocalizedRecoverySuggestionErrorKey: detail, + ]) + alert(error, window: window) + } +} + +/// Builders for the attributed strings the Objective-C code made with +/// ShortCocoa's string helpers. +enum LKDashboardText { + /// `$(string)` with optional `.font`, `.textColor` and `.lineHeight`, + /// each applied to the whole string; an empty string stays plain. + static func attributed(_ string: String, font: NSFont? = nil, color: NSColor? = nil, lineHeight: CGFloat? = nil) -> NSMutableAttributedString { + let result = NSMutableAttributedString(string: string) + guard result.length > 0 else { return result } + let range = NSRange(location: 0, length: result.length) + if let color { + result.addAttribute(.foregroundColor, value: color, range: range) + } + if let font { + result.addAttribute(.font, value: font, range: range) + } + if let lineHeight { + let style = NSMutableParagraphStyle() + style.minimumLineHeight = lineHeight + style.maximumLineHeight = lineHeight + result.addAttribute(.paragraphStyle, value: style.copy(), range: range) + } + return result + } + + /// ShortCocoa's `addImage(name, baselineOffset, marginLeft, marginRight)`: + /// the image as an attachment, with fixed-width blank images for the + /// margins. + static func appendImage(named name: String, baselineOffset: CGFloat, marginLeft: CGFloat, marginRight: CGFloat, to string: NSMutableAttributedString) { + guard let image = NSImage(named: name) else { return } + let imageString = NSMutableAttributedString(attributedString: attachment(image, baselineOffset: baselineOffset)) + if marginLeft > 0 { + imageString.insert(fixedSpace(marginLeft), at: 0) + } + if marginRight > 0 { + imageString.append(fixedSpace(marginRight)) + } + string.append(imageString) + } + + private static func attachment(_ image: NSImage, baselineOffset: CGFloat) -> NSAttributedString { + let attachment = NSTextAttachment() + attachment.image = image + attachment.bounds = CGRect(x: 0, y: 0, width: image.size.width, height: image.size.height) + let string = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment)) + string.addAttribute(.baselineOffset, value: baselineOffset, range: NSRange(location: 0, length: string.length)) + return string + } + + private static func fixedSpace(_ width: CGFloat) -> NSAttributedString { + attachment(NSImage(size: NSSize(width: width, height: 1)), baselineOffset: 0) + } +} diff --git a/LookInside/Dashboard/LKDashboardModification.swift b/LookInside/Dashboard/LKDashboardModification.swift new file mode 100644 index 00000000..ee3cda0b --- /dev/null +++ b/LookInside/Dashboard/LKDashboardModification.swift @@ -0,0 +1,177 @@ +// +// LKDashboardModification.swift +// LookInside +// +// The payloads the Dashboard sends when the user edits an attribute, and +// the edited values the attribute views build. These rules decide what +// goes over the wire, so they live apart from the views and depend only on +// the LookinCore model: the tests compile this file with LookinCore alone. +// + +import Foundation + +enum LKDashboardModification { + /// The request for an attribute the Server knows: the setter of the + /// attribute's identifier, called on the object the attribute belongs + /// to (view, layer, window or cell of the item). Nil when the + /// identifier has no setter. + static func inbuilt(attribute: LookinAttribute, newValue: Any?, clientReadableVersion: String?) -> LookinAttributeModification? { + let modification = LookinAttributeModification() + modification.clientReadableVersion = clientReadableVersion + let item = attribute.targetDisplayItem + switch LookinDashboardBlueprint.targetKind(forAttrID: attribute.identifier) { + case .view: + modification.targetOid = item?.viewObject?.oid ?? 0 + case .window: + modification.targetOid = item?.windowObject?.oid ?? 0 + case .cell: + modification.targetOid = item?.cellObject?.oid ?? 0 + case .layer: + modification.targetOid = item?.layerObject?.oid ?? 0 + @unknown default: + modification.targetOid = item?.layerObject?.oid ?? 0 + } + guard let setter = LookinDashboardBlueprint.setter(withAttrID: attribute.identifier) else { + return nil + } + modification.setterSelector = setter + modification.attrType = attribute.attrType + modification.value = newValue + return modification + } + + /// The request for a user-custom attribute: its setter id with the new + /// value. Nil when the attribute has no setter (read-only rows, such as + /// the SwiftUI attributes). + static func custom(attribute: LookinAttribute, newValue: Any?) -> LookinCustomAttrModification? { + guard let setterID = attribute.customSetterID, !setterID.isEmpty else { + return nil + } + let modification = LookinCustomAttrModification() + modification.customSetterID = setterID + modification.attrType = attribute.attrType + modification.value = newValue + return modification + } + + // MARK: - Edited values + + /// The attributes whose number is an opacity, kept within 0...1. + static let clampedToUnitIdentifiers: Set = [ + LookinAttr_ViewLayer_Visibility_Opacity, + LookinAttr_ViewLayer_Shadow_Opacity, + ] + + /// The number to submit for a number attribute, or nil when it equals + /// the current value (nothing to submit). + static func numberValue(_ parsed: NSNumber, for attribute: LookinAttribute) -> NSNumber? { + var value = parsed + if clampedToUnitIdentifiers.contains(attribute.identifier ?? "") { + value = NSNumber(value: max(min(parsed.doubleValue, 1), 0)) + } + if let current = attribute.value as? NSObject, value.isEqual(current) { + return nil + } + return value + } + + /// The rect with field `index` (x, y, width, height) replaced. + static func rect(_ rect: CGRect, replacingField index: Int, with value: Double) -> CGRect? { + var result = rect + switch index { + case 0: result.origin.x = value + case 1: result.origin.y = value + case 2: result.size.width = value + case 3: result.size.height = value + default: return nil + } + return result + } + + /// The insets with field `index` (top, left, bottom, right) replaced. + static func insets(_ insets: NSEdgeInsets, replacingField index: Int, with value: Double) -> NSEdgeInsets? { + var result = insets + switch index { + case 0: result.top = value + case 1: result.left = value + case 2: result.bottom = value + case 3: result.right = value + default: return nil + } + return result + } + + /// The point with field `index` (x, y) replaced. + static func point(_ point: CGPoint, replacingField index: Int, with value: Double) -> CGPoint? { + var result = point + switch index { + case 0: result.x = value + case 1: result.y = value + default: return nil + } + return result + } + + /// The size with field `index` (width, height) replaced. + static func size(_ size: CGSize, replacingField index: Int, with value: Double) -> CGSize? { + var result = size + switch index { + case 0: result.width = value + case 1: result.height = value + default: return nil + } + return result + } + + /// Whether two insets differ in any edge. `NSValue`'s equality is not + /// reliable for insets, so the edges are compared one by one. + static func insetsDiffer(_ lhs: NSEdgeInsets, _ rhs: NSEdgeInsets) -> Bool { + lhs.top != rhs.top || lhs.left != rhs.left || lhs.bottom != rhs.bottom || lhs.right != rhs.right + } + + /// Whether two rects are equal within 0.1 point on every field. + static func rect(_ lhs: CGRect, isAlmostEqualTo rhs: CGRect) -> Bool { + abs(lhs.origin.x - rhs.origin.x) <= 0.1 + && abs(lhs.origin.y - rhs.origin.y) <= 0.1 + && abs(lhs.size.width - rhs.size.width) <= 0.1 + && abs(lhs.size.height - rhs.size.height) <= 0.1 + } + + /// Whether a frame edit "had no effect": the app set the value back to + /// the old rect after the Server applied the new one. + static func rectEditWasReverted(old: CGRect, expected: CGRect, current: CGRect) -> Bool { + rect(old, isAlmostEqualTo: current) && !rect(expected, isAlmostEqualTo: current) + } + + /// The value of a switch: `@YES` / `@NO`. + static func boolValue(isOn: Bool) -> NSNumber { + NSNumber(value: isOn) + } +} + +// MARK: - Constraints + +enum LKDashboardConstraintOrder { + /// The order the constraints card lists constraints in: effective ones + /// first, then by first item type and first attribute. The sort is + /// stable, as `-sortedArrayUsingComparator:` is. + static func sorted(_ constraints: [LookinAutoLayoutConstraint]) -> [LookinAutoLayoutConstraint] { + let array = constraints as NSArray + let sorted = array.sortedArray(options: .stable) { lhs, rhs in + guard let lhs = lhs as? LookinAutoLayoutConstraint, let rhs = rhs as? LookinAutoLayoutConstraint else { + return .orderedSame + } + if lhs.effective != rhs.effective { + return lhs.effective ? .orderedAscending : .orderedDescending + } + if lhs.firstItemType.rawValue != rhs.firstItemType.rawValue { + return lhs.firstItemType.rawValue > rhs.firstItemType.rawValue ? .orderedDescending : .orderedAscending + } + if lhs.firstAttribute != rhs.firstAttribute { + return lhs.firstAttribute > rhs.firstAttribute ? .orderedDescending : .orderedAscending + } + return .orderedSame + } + return sorted.compactMap { $0 as? LookinAutoLayoutConstraint } + } +} diff --git a/LookInside/Dashboard/LKDashboardSectionView.h b/LookInside/Dashboard/LKDashboardSectionView.h deleted file mode 100644 index 0df6adc8..00000000 --- a/LookInside/Dashboard/LKDashboardSectionView.h +++ /dev/null @@ -1,29 +0,0 @@ -// -// LKDashboardSectionView.h -// Lookin -// -// Created by Li Kai on 2019/6/7. -// https://lookin.work -// - -#import "LKBaseView.h" - -@class LookinAttributesSection, LKDashboardViewController; - -typedef NS_ENUM(NSInteger, LKDashboardSectionManageState) { - LKDashboardSectionManageState_None, - LKDashboardSectionManageState_CanAdd, - LKDashboardSectionManageState_CanRemove -}; - -@interface LKDashboardSectionView : LKBaseView - -@property(nonatomic, strong) LookinAttributesSection *attrSection; - -@property(nonatomic, assign) BOOL showTopSeparator; - -@property(nonatomic, weak) LKDashboardViewController *dashboardViewController; - -@property(nonatomic, assign) LKDashboardSectionManageState manageState; - -@end diff --git a/LookInside/Dashboard/LKDashboardSectionView.m b/LookInside/Dashboard/LKDashboardSectionView.m deleted file mode 100644 index e8d98fc4..00000000 --- a/LookInside/Dashboard/LKDashboardSectionView.m +++ /dev/null @@ -1,418 +0,0 @@ -// -// LKDashboardSectionView.m -// Lookin -// -// Created by Li Kai on 2019/6/7. -// https://lookin.work -// - -#import "LKDashboardSectionView.h" -#import "LKDashboardAttributeRectView.h" -#import "LKDashboardAttributeNumberInputView.h" -#import "LookinAttributesSection.h" -#import "LookinDashboardBlueprint.h" -#import "LKDashboardAttributeInsetsView.h" -#import "LKDashboardAttributeSwitchView.h" -#import "LKDashboardAttributeColorView.h" -#import "LKDashboardAttributeEnumsView.h" -#import "LKDashboardAttributePointView.h" -#import "LKDashboardAttributeSizeView.h" -#import "LKDashboardAttributeRowsCountView.h" -#import "LKDashboardAttributeClassView.h" -#import "LKDashboardAttributeRelationView.h" -#import "LKDashboardAttributeConstraintsView.h" -#import "LKDashboardAttributeTextView.h" -#import "LKDashboardAttributeShadowView.h" -#import "LKPreferenceManager.h" -#import "LKDashboardAttributeOpenImageView.h" -#import "LKDashboardAttributeJsonView.h" -#import "LKDashboardAttributePrivateDiscriminatorView.h" - -@interface LKDashboardSectionView () - -@property(nonatomic, strong) NSMutableArray *attrViews; -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) CALayer *topSepLayer; - -@property(nonatomic, strong) NSButton *manageButton; -@property(nonatomic, strong) NSButton *jsonPopupButton; - -@end - -@implementation LKDashboardSectionView { - CGFloat _titleMarginTop; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _titleMarginTop = 6; -// self.layer.borderWidth = 1; -// self.layer.borderColor = [NSColor blueColor].CGColor; -// self.layer.backgroundColor = LookinColorRGBAMake(255, 0, 0, .2).CGColor; - - /// 使得 topSepLayer 可以从右边延伸出去 - self.layer.masksToBounds = NO; - - self.attrViews = [NSMutableArray array]; - - self.topSepLayer = [CALayer new]; - [self.topSepLayer lookin_removeImplicitAnimations]; - [self.layer addSublayer:self.topSepLayer]; - - [self updateColors]; - } - return self; -} - -- (void)layout { - [super layout]; - - CGFloat contentsX = 0; - CGFloat selfWidth = self.$width; - CGFloat contentsY = 0; - - if (self.manageButton.isVisible) { - CGFloat y; - if (self.topSepLayer.hidden) { - y = 0; - } else if (self.titleLabel.isVisible) { - y = 6; - } else { - y = 9; - } - - $(self.manageButton).sizeToFit.x(0).y(y); - contentsX = self.manageButton.$maxX + 6; - } - - if (self.jsonPopupButton.isVisible) { - $(self.jsonPopupButton).width(30).height(28).right(-5).y(0); - } - - if (!self.topSepLayer.hidden) { - $(self.topSepLayer).x(contentsX).width(selfWidth).height(1).y(0); - contentsY = DashboardAttrItemVerInterspace; - } - - if (self.titleLabel.isVisible) { - CGFloat titleWidth = selfWidth; - if (self.jsonPopupButton.isVisible) { - titleWidth -= 20; - } - $(self.titleLabel).x(contentsX).width(titleWidth).heightToFit.y(self.topSepLayer.hidden ? 0 : _titleMarginTop); - contentsY = self.titleLabel.$maxY + DashboardAttrItemVerInterspace; - } - - NSArray *> *rows = [self _rowsOfVisibleAttrViewsInAvailableWidth:selfWidth]; - __block CGFloat rowTop = contentsY; - [rows enumerateObjectsUsingBlock:^(NSArray *row, NSUInteger rowIdx, BOOL *stop) { - __block CGFloat itemX = contentsX; - __block CGFloat rowHeight = 0; - [row enumerateObjectsUsingBlock:^(LKDashboardAttributeView *view, NSUInteger idx, BOOL *innerStop) { - CGFloat width = [self _widthForAttrView:view inAvailableWidth:selfWidth]; - $(view).width(width).heightToFit.x(itemX).y(rowTop); - // Rows are as tall as their tallest item. Taking the first item's - // height instead is what let a 21pt switch clip the 38pt number - // input sitting next to it. - rowHeight = MAX(rowHeight, view.$height); - itemX = view.$maxX + DashboardAttrItemHorInterspace; - }]; - rowTop += rowHeight + DashboardAttrItemVerInterspace; - }]; -} - -/// The width an attribute view gets for a given container width. Shared by -/// -layout and -sizeThatFits: so the two can never disagree. -- (CGFloat)_widthForAttrView:(LKDashboardAttributeView *)view inAvailableWidth:(CGFloat)availableWidth { - NSUInteger numberOfColumns = view.numberOfColumnsOccupied; - if (numberOfColumns == 0) { - return [view sizeThatFits:NSSizeMax].width; - } - return floor((availableWidth + DashboardAttrItemHorInterspace) / (CGFloat)numberOfColumns) - DashboardAttrItemHorInterspace; -} - -/// Groups the visible attribute views into rows exactly the way -layout places -/// them. Measurement and placement both go through this, so a row can no longer -/// be measured as one height and drawn at another. -- (NSArray *> *)_rowsOfVisibleAttrViewsInAvailableWidth:(CGFloat)availableWidth { - NSArray *attrViews = [self.attrViews lookin_filter:^BOOL(LKDashboardAttributeView *view) { - return view.isVisible; - }]; - - NSMutableArray *> *rows = [NSMutableArray array]; - NSMutableArray *currentRow = nil; - CGFloat currentRowWidth = 0; - - for (LKDashboardAttributeView *view in attrViews) { - CGFloat width = [self _widthForAttrView:view inAvailableWidth:availableWidth]; - BOOL fitsInCurrentRow = (currentRow != nil) && - (currentRowWidth + DashboardAttrItemHorInterspace + width <= availableWidth); - if (fitsInCurrentRow) { - currentRowWidth += DashboardAttrItemHorInterspace + width; - } else { - currentRow = [NSMutableArray array]; - [rows addObject:currentRow]; - currentRowWidth = width; - } - [currentRow addObject:view]; - } - return rows; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - __block CGFloat height = 0; - if (!self.topSepLayer.hidden) { - height += DashboardAttrItemVerInterspace; - } - if (self.titleLabel.isVisible) { - CGFloat titleWidth = limitedSize.width; - if (self.jsonPopupButton.isVisible) { - titleWidth -= 20; - } - height += [self.titleLabel sizeThatFits:NSMakeSize(titleWidth, CGFLOAT_MAX)].height + _titleMarginTop; - } - - NSArray *> *rows = [self _rowsOfVisibleAttrViewsInAvailableWidth:limitedSize.width]; - [rows enumerateObjectsUsingBlock:^(NSArray *row, NSUInteger rowIdx, BOOL *stop) { - __block CGFloat rowHeight = 0; - [row enumerateObjectsUsingBlock:^(LKDashboardAttributeView *view, NSUInteger idx, BOOL *innerStop) { - // Measure at the width the item will actually get, not at the full - // container width — otherwise anything that grows when narrowed - // (wrapping labels) reports too little height and gets clipped. - CGFloat width = [self _widthForAttrView:view inAvailableWidth:limitedSize.width]; - CGFloat itemHeight = [view sizeThatFits:NSMakeSize(width, CGFLOAT_MAX)].height; - rowHeight = MAX(rowHeight, itemHeight); - }]; - if (rowIdx > 0) { - height += DashboardAttrItemVerInterspace; - } - height += rowHeight; - }]; - - limitedSize.height = height; - return limitedSize; -} - -- (void)setAttrSection:(LookinAttributesSection *)attrSection { - _attrSection = attrSection; - if (!attrSection) { - NSAssert(NO, @""); - return; - } - - NSString *title = [self resolveSectionTitle]; - if (title.length) { - if (self.titleLabel) { - self.titleLabel.hidden = NO; - } else { - self.titleLabel = [LKLabel new]; - self.titleLabel.font = [NSFont boldSystemFontOfSize:12]; -// self.titleLabel.backgroundColor = [NSColor greenColor]; - [self addSubview:self.titleLabel]; - } - self.titleLabel.stringValue = title; - } else { - self.titleLabel.hidden = YES; - } - - NSMutableArray *notUsedViews = [self.attrViews mutableCopy]; - - [self.attrSection.attributes enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop) { - Class attrViewClass = [self _targetAttrClassForType:attr.attrType identifier:attr.identifier]; - if (!attrViewClass) { - // Debug builds trip the assert so a missing case in - // _targetAttrClassForType: surfaces immediately. Release builds - // continue to the next attribute (NSAssert is a no-op there) and - // log a breadcrumb so the skipped row is recoverable post-mortem. - NSLog(@"LookInside dashboard: skipping attribute with unsupported attrType=%ld identifier=%@", - (long)attr.attrType, attr.identifier); - NSAssert(NO, @"LookInside dashboard: no attrView class for attrType=%ld identifier=%@", - (long)attr.attrType, attr.identifier); - return; - } - LKDashboardAttributeView *view = [notUsedViews lookin_firstFiltered:^BOOL(LKDashboardAttributeView *obj) { - return [obj isMemberOfClass:attrViewClass]; - }]; - if (view) { - [notUsedViews removeObject:view]; - view.hidden = NO; - } else { - view = [attrViewClass new]; - view.dashboardViewController = self.dashboardViewController; - [self.attrViews addObject:view]; - [self addSubview:view]; - } - view.attribute = attr; - }]; - - [notUsedViews enumerateObjectsUsingBlock:^(LKDashboardAttributeView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.hidden = YES; - }]; - - BOOL hasJSONAttr = [self.attrSection.attributes lookin_any:^BOOL(LookinAttribute *obj) { - return obj.attrType == LookinAttrTypeJson; - }]; - if (hasJSONAttr) { - [self showJSONPopupButton]; - } else { - [self hideJSONPopupButton]; - } - - [self setNeedsLayout:YES]; -} - -- (void)setShowTopSeparator:(BOOL)showTopSeparator { - _showTopSeparator = showTopSeparator; - self.topSepLayer.hidden = !showTopSeparator; -} - -- (void)updateColors { - [super updateColors]; - self.topSepLayer.backgroundColor = self.isDarkMode ? SeparatorDarkModeColor.CGColor : SeparatorLightModeColor.CGColor; -} - -- (Class)_targetAttrClassForType:(LookinAttrType)type identifier:(LookinAttrIdentifier)identifier { - // Pure lookup: returns nil for any unsupported attrType / identifier. - // The single assert lives in the caller (setAttrSection:) so callees that - // only need a class probe don't pay the debug-time crash. - switch (type) { - case LookinAttrTypeCGRect: - return [LKDashboardAttributeRectView class]; - case LookinAttrTypeUIEdgeInsets: - return [LKDashboardAttributeInsetsView class]; - case LookinAttrTypeBOOL: - return [LKDashboardAttributeSwitchView class]; - case LookinAttrTypeFloat: - case LookinAttrTypeDouble: - case LookinAttrTypeLong: - case LookinAttrTypeUnsignedLong: - case LookinAttrTypeInt: - case LookinAttrTypeUnsignedInt: - return [LKDashboardAttributeNumberInputView class]; - case LookinAttrTypeUIColor: - return [LKDashboardAttributeColorView class]; - case LookinAttrTypeEnumInt: - case LookinAttrTypeEnumLong: - case LookinAttrTypeEnumString: - return [LKDashboardAttributeEnumsView class]; - case LookinAttrTypeCGPoint: - return [LKDashboardAttributePointView class]; - case LookinAttrTypeCGSize: - return [LKDashboardAttributeSizeView class]; - case LookinAttrTypeNSString: - return [LKDashboardAttributeTextView class]; - case LookinAttrTypeShadow: - return [LKDashboardAttributeShadowView class]; - case LookinAttrTypeJson: - return [LKDashboardAttributeJsonView class]; - case LookinAttrTypeCustomObj: - if ([identifier isEqualToString:@"lookinside.private_discriminator.field"]) { - return [LKDashboardAttributePrivateDiscriminatorView class]; - } else if ([identifier isEqualToString:LookinAttr_UITableView_RowsNumber_Number]) { - return [LKDashboardAttributeRowsCountView class]; - } else if ([identifier isEqualToString:LookinAttr_Class_Class_Class]) { - return [LKDashboardAttributeClassView class]; - } else if ([identifier isEqualToString:LookinAttr_Relation_Relation_Relation]) { - return [LKDashboardAttributeRelationView class]; - } else if ([identifier isEqualToString:LookinAttr_AutoLayout_Constraints_Constraints]) { - return [LKDashboardAttributeConstraintsView class]; - } else if ([identifier isEqualToString:LookinAttr_UIImageView_Open_Open] - || [identifier isEqualToString:LookinAttr_NSImageView_Open_Open]) { - return [LKDashboardAttributeOpenImageView class]; - } else if ([identifier isEqualToString:LookinAttr_UIVisualEffectView_Style_Style]) { - return [LKDashboardAttributeEnumsView class]; - } else { - return nil; - } - default: - return nil; - } -} - -- (void)showJSONPopupButton { - if (self.jsonPopupButton) { - return; - } - self.jsonPopupButton = [NSButton buttonWithImage:NSImageMake(@"open_newwindow") target:self action:@selector(_handleJSONPopupButton)]; - self.jsonPopupButton.bezelStyle = NSBezelStyleRoundRect; - self.jsonPopupButton.bordered = NO; - [self addSubview:self.jsonPopupButton]; - [self addSubview:self.jsonPopupButton]; -} - -- (void)hideJSONPopupButton { - [self.jsonPopupButton removeFromSuperview]; -} - -- (void)_handleJSONPopupButton { - LKDashboardAttributeJsonView *view = (LKDashboardAttributeJsonView *)[self.attrViews lookin_firstFiltered:^BOOL(LKDashboardAttributeView *obj) { - return obj.attribute.attrType == LookinAttrTypeJson; - }]; - if (![view isKindOfClass:[LKDashboardAttributeJsonView class]]) { - NSAssert(NO, @""); - return; - } - [view showInNewWindow]; -} - -#pragma mark - Manage - -- (void)setManageState:(LKDashboardSectionManageState)manageState { - _manageState = manageState; - if (manageState == LKDashboardSectionManageState_None) { - self.manageButton.hidden = YES; - [self setNeedsLayout:YES]; - return; - } - - NSImage *image = nil; - if (manageState == LKDashboardSectionManageState_CanAdd) { - image = NSImageMake(@"icon_manage_add"); - } else if (manageState == LKDashboardSectionManageState_CanRemove) { - image = NSImageMake(@"icon_manage_remove"); - } else { - NSAssert(NO, @""); - return; - } - - if (self.manageButton) { - self.manageButton.image = image; - self.manageButton.hidden = NO; - } else { - self.manageButton = [NSButton buttonWithImage:image target:self action:@selector(_handleManageButton)]; - self.manageButton.bezelStyle = NSBezelStyleRoundRect; - self.manageButton.bordered = NO; - [self addSubview:self.manageButton]; - } - [self setNeedsLayout:YES]; -} - -- (void)_handleManageButton { - LKPreferenceManager *manager = [LKPreferenceManager mainManager]; - if (self.manageState == LKDashboardSectionManageState_CanAdd) { - [manager showSection:self.attrSection.identifier]; - } else if (self.manageState == LKDashboardSectionManageState_CanRemove) { - [manager hideSection:self.attrSection.identifier]; - } else { - NSAssert(NO, @""); - } -} - -- (NSString *)resolveSectionTitle { - if (!self.attrSection.isUserCustom) { - return [LookinDashboardBlueprint sectionTitleWithSectionID:self.attrSection.identifier]; - } - LookinAttribute *attr = self.attrSection.attributes.firstObject; - if (!attr) { - NSAssert(NO, @""); - return nil; - } - switch (attr.attrType) { - case LookinAttrTypeBOOL: - return nil; - default: - return attr.displayTitle; - } -} - -@end diff --git a/LookInside/Dashboard/LKDashboardSectionView.swift b/LookInside/Dashboard/LKDashboardSectionView.swift new file mode 100644 index 00000000..05be5805 --- /dev/null +++ b/LookInside/Dashboard/LKDashboardSectionView.swift @@ -0,0 +1,393 @@ +// +// LKDashboardSectionView.swift +// LookInside +// +// Created by Li Kai on 2019/6/7. +// https://lookin.work +// + +import AppKit + +/// Whether a section shows a button that adds it to, or removes it from, +/// its card. +enum LKDashboardSectionManageState { + case none + case canAdd + case canRemove +} + +/// One section of a card: an optional title and separator, then the +/// attribute views flowed into rows. +final class LKDashboardSectionView: LKBaseView { + private let titleMarginTop: CGFloat = 6 + + private var attrViews: [LKDashboardAttributeView] = [] + private var titleLabel: LKLabel? + private let topSepLayer = CALayer() + private var manageButton: NSButton? + private var jsonPopupButton: NSButton? + + weak var dashboardViewController: LKDashboardViewController? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + // Lets the separator run past the right edge. + layer?.masksToBounds = false + + topSepLayer.lookin_removeImplicitAnimations() + layer?.addSublayer(topSepLayer) + + updateColors() + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + var attrSection: LookinAttributesSection? { + didSet { renderSection() } + } + + var showTopSeparator = false { + didSet { topSepLayer.isHidden = !showTopSeparator } + } + + var manageState: LKDashboardSectionManageState = .none { + didSet { renderManageState() } + } + + // MARK: - Layout + + override func layout() { + super.layout() + + var contentsX: CGFloat = 0 + let selfWidth = frame.width + var contentsY: CGFloat = 0 + + if let manageButton, manageButton.isVisible { + let y: CGFloat + if topSepLayer.isHidden { + y = 0 + } else if titleLabel?.isVisible == true { + y = 6 + } else { + y = 9 + } + manageButton.dashboardLayout.sizeToFit().x(0).y(y) + contentsX = manageButton.frame.maxX + 6 + } + + if let jsonPopupButton, jsonPopupButton.isVisible { + jsonPopupButton.dashboardLayout.width(30).height(28).right(-5).y(0) + } + + if !topSepLayer.isHidden { + topSepLayer.dashboardLayout.x(contentsX).width(selfWidth).height(1).y(0) + contentsY = LKDashboardMetrics.attrItemVerInterspace + } + + if let titleLabel, titleLabel.isVisible { + var titleWidth = selfWidth + if jsonPopupButton?.isVisible == true { + titleWidth -= 20 + } + titleLabel.dashboardLayout.x(contentsX).width(titleWidth).heightToFit().y(topSepLayer.isHidden ? 0 : titleMarginTop) + contentsY = titleLabel.frame.maxY + LKDashboardMetrics.attrItemVerInterspace + } + + var rowTop = contentsY + for row in rowsOfVisibleAttrViews(inAvailableWidth: selfWidth) { + var itemX = contentsX + var rowHeight: CGFloat = 0 + for view in row { + let width = width(of: view, inAvailableWidth: selfWidth) + view.dashboardLayout.width(width).heightToFit().x(itemX).y(rowTop) + // A row is as tall as its tallest item, so a 21 pt switch + // does not clip the 38 pt number input next to it. + rowHeight = max(rowHeight, view.frame.height) + itemX = view.frame.maxX + LKDashboardMetrics.attrItemHorInterspace + } + rowTop += rowHeight + LKDashboardMetrics.attrItemVerInterspace + } + } + + /// The width an attribute view gets in a container of `availableWidth`. + /// Layout and measuring share it so they never disagree. + private func width(of view: LKDashboardAttributeView, inAvailableWidth availableWidth: CGFloat) -> CGFloat { + let numberOfColumns = view.numberOfColumnsOccupied() + if numberOfColumns == 0 { + return view.sizeThatFits(LKDashboardMetrics.maxSize).width + } + let horSpace = LKDashboardMetrics.attrItemHorInterspace + return floor((availableWidth + horSpace) / CGFloat(numberOfColumns)) - horSpace + } + + /// The visible attribute views grouped into rows the way layout places + /// them, so a row is never measured at one height and drawn at another. + private func rowsOfVisibleAttrViews(inAvailableWidth availableWidth: CGFloat) -> [[LKDashboardAttributeView]] { + var rows: [[LKDashboardAttributeView]] = [] + var currentRowWidth: CGFloat = 0 + let horSpace = LKDashboardMetrics.attrItemHorInterspace + for view in attrViews where view.isVisible { + let width = width(of: view, inAvailableWidth: availableWidth) + if !rows.isEmpty, currentRowWidth + horSpace + width <= availableWidth { + currentRowWidth += horSpace + width + rows[rows.count - 1].append(view) + } else { + rows.append([view]) + currentRowWidth = width + } + } + return rows + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + var height: CGFloat = 0 + if !topSepLayer.isHidden { + height += LKDashboardMetrics.attrItemVerInterspace + } + if let titleLabel, titleLabel.isVisible { + var titleWidth = limitedSize.width + if jsonPopupButton?.isVisible == true { + titleWidth -= 20 + } + height += titleLabel.sizeThatFits(NSSize(width: titleWidth, height: .greatestFiniteMagnitude)).height + titleMarginTop + } + + for (rowIdx, row) in rowsOfVisibleAttrViews(inAvailableWidth: limitedSize.width).enumerated() { + var rowHeight: CGFloat = 0 + for view in row { + // Measured at the width the item gets, not the full width: + // wrapping labels grow when narrowed. + let width = width(of: view, inAvailableWidth: limitedSize.width) + rowHeight = max(rowHeight, view.sizeThatFits(NSSize(width: width, height: .greatestFiniteMagnitude)).height) + } + if rowIdx > 0 { + height += LKDashboardMetrics.attrItemVerInterspace + } + height += rowHeight + } + + var size = limitedSize + size.height = height + return size + } + + override func updateColors() { + super.updateColors() + topSepLayer.backgroundColor = LKDashboardStyle.separatorColor(isDarkMode: isDarkMode()).cgColor + } + + // MARK: - Rendering + + private func renderSection() { + guard let attrSection else { + assertionFailure() + return + } + + if let title = resolveSectionTitle(), !title.isEmpty { + let label: LKLabel + if let titleLabel { + label = titleLabel + label.isHidden = false + } else { + label = LKLabel() + label.font = .boldSystemFont(ofSize: 12) + addSubview(label) + titleLabel = label + } + label.stringValue = title + } else { + titleLabel?.isHidden = true + } + + var notUsedViews = attrViews + for attr in attrSection.attributes ?? [] { + guard let viewClass = Self.attrViewClass(for: attr.attrType, identifier: attr.identifier) else { + // Debug builds stop here so a missing case surfaces at once; + // release builds skip the attribute and log it. + NSLog("LookInside dashboard: skipping attribute with unsupported attrType=%ld identifier=%@", attr.attrType.rawValue, attr.identifier ?? "nil") + assertionFailure("LookInside dashboard: no attrView class for attrType=\(attr.attrType.rawValue) identifier=\(attr.identifier ?? "nil")") + continue + } + let view: LKDashboardAttributeView + if let index = notUsedViews.firstIndex(where: { type(of: $0) == viewClass }) { + view = notUsedViews.remove(at: index) + view.isHidden = false + } else { + view = viewClass.init(frame: .zero) + view.dashboardViewController = dashboardViewController + attrViews.append(view) + addSubview(view) + } + view.attribute = attr + } + notUsedViews.forEach { $0.isHidden = true } + + let hasJSONAttr = (attrSection.attributes ?? []).contains { $0.attrType == .json } + if hasJSONAttr { + showJSONPopupButton() + } else { + jsonPopupButton?.removeFromSuperview() + } + + needsLayout = true + } + + /// The view class for an attribute type (and, for custom objects, its + /// identifier); nil when there is none. + static func attrViewClass(for type: LookinAttrType, identifier: String?) -> LKDashboardAttributeView.Type? { + switch type { + case .cgRect: + return LKDashboardAttributeRectView.self + case .uiEdgeInsets: + return LKDashboardAttributeInsetsView.self + case .BOOL: + return LKDashboardAttributeSwitchView.self + case .float, .double, .long, .unsignedLong, .int, .unsignedInt: + return LKDashboardAttributeNumberInputView.self + case .uiColor: + return LKDashboardAttributeColorView.self + case .enumInt, .enumLong, .enumString: + return LKDashboardAttributeEnumsView.self + case .cgPoint: + return LKDashboardAttributePointView.self + case .cgSize: + return LKDashboardAttributeSizeView.self + case .nsString: + return LKDashboardAttributeTextView.self + case .shadow: + return LKDashboardAttributeShadowView.self + case .json: + return LKDashboardAttributeJsonView.self + case .customObj: + switch identifier ?? "" { + case "lookinside.private_discriminator.field": + return LKDashboardAttributePrivateDiscriminatorView.self + case LookinAttr_UITableView_RowsNumber_Number: + return LKDashboardAttributeRowsCountView.self + case LookinAttr_Class_Class_Class: + return LKDashboardAttributeClassView.self + case LookinAttr_Relation_Relation_Relation: + return LKDashboardAttributeRelationView.self + case LookinAttr_AutoLayout_Constraints_Constraints: + return LKDashboardAttributeConstraintsView.self + case LookinAttr_UIImageView_Open_Open, LookinAttr_NSImageView_Open_Open: + return LKDashboardAttributeOpenImageView.self + case LookinAttr_UIVisualEffectView_Style_Style: + return LKDashboardAttributeEnumsView.self + default: + return nil + } + default: + return nil + } + } + + private func showJSONPopupButton() { + if jsonPopupButton != nil { + return + } + let button = NSButton(image: LKDashboardStyle.image("open_newwindow") ?? NSImage(), target: self, action: #selector(handleJSONPopupButton)) + button.bezelStyle = .roundRect + button.isBordered = false + addSubview(button) + jsonPopupButton = button + } + + @objc private func handleJSONPopupButton() { + guard let view = attrViews.first(where: { $0.attribute?.attrType == .json }) as? LKDashboardAttributeJsonView else { + assertionFailure() + return + } + view.showInNewWindow() + } + + private func resolveSectionTitle() -> String? { + guard let attrSection else { return nil } + if !attrSection.isUserCustom() { + return LookinDashboardBlueprint.sectionTitle(withSectionID: attrSection.identifier) + } + guard let attr = attrSection.attributes?.first else { + assertionFailure() + return nil + } + // A user-custom switch carries its title on the checkbox. + return attr.attrType == .BOOL ? nil : attr.displayTitle + } + + // MARK: - Manage + + private func renderManageState() { + let imageName: String + switch manageState { + case .none: + manageButton?.isHidden = true + needsLayout = true + return + case .canAdd: + imageName = "icon_manage_add" + case .canRemove: + imageName = "icon_manage_remove" + } + let image = LKDashboardStyle.image(imageName) + if let manageButton { + manageButton.image = image + manageButton.isHidden = false + } else { + let button = NSButton(image: image ?? NSImage(), target: self, action: #selector(handleManageButton)) + button.bezelStyle = .roundRect + button.isBordered = false + addSubview(button) + manageButton = button + } + needsLayout = true + } + + @objc private func handleManageButton() { + guard let identifier = attrSection?.identifier else { return } + let manager = LKPreferenceManager.shared + switch manageState { + case .canAdd: + manager.showSection(identifier) + case .canRemove: + manager.hideSection(identifier) + case .none: + assertionFailure() + } + } +} + +/// Reuses section views across renders of a card, keyed by section +/// identifier (by attribute titles for user-custom sections). +final class LKDashboardSectionViewPool { + /// Views handed out since the last `recycleAll()`. + private var dequeuedViews = Set() + /// Every view made, dequeued or not. + private var cache: [String: [LKDashboardSectionView]] = [:] + + func recycleAll() { + dequeuedViews.removeAll() + } + + func dequeueView(for section: LookinAttributesSection) -> LKDashboardSectionView { + let key = Self.cacheKey(for: section) + if let view = cache[key, default: []].first(where: { !dequeuedViews.contains(ObjectIdentifier($0)) }) { + dequeuedViews.insert(ObjectIdentifier(view)) + return view + } + let view = LKDashboardSectionView() + dequeuedViews.insert(ObjectIdentifier(view)) + cache[key, default: []].append(view) + return view + } + + static func cacheKey(for section: LookinAttributesSection) -> String { + if !section.isUserCustom() { + return section.identifier ?? "" + } + return (section.attributes ?? []).map { $0.displayTitle ?? "" }.joined(separator: ",") + } +} diff --git a/LookInside/Dashboard/LKDashboardSectionViewPool.h b/LookInside/Dashboard/LKDashboardSectionViewPool.h deleted file mode 100644 index 00cecd36..00000000 --- a/LookInside/Dashboard/LKDashboardSectionViewPool.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// LKDashboardSectionViewPool.h -// LookinClient -// -// Created by LikaiMacStudioWork on 2023/10/31. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import -#import "LookinAttributesSection.h" -#import "LKDashboardSectionView.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LKDashboardSectionViewPool : NSObject - -- (void)recycleAll; - -- (LKDashboardSectionView *)dequeViewForSection:(LookinAttributesSection *)section; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Dashboard/LKDashboardSectionViewPool.m b/LookInside/Dashboard/LKDashboardSectionViewPool.m deleted file mode 100644 index e3bfc8d7..00000000 --- a/LookInside/Dashboard/LKDashboardSectionViewPool.m +++ /dev/null @@ -1,65 +0,0 @@ -// -// LKDashboardSectionViewPool.m -// LookinClient -// -// Created by LikaiMacStudioWork on 2023/10/31. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKDashboardSectionViewPool.h" -#import "LookinAttributesSection+LookinClient.h" -#import "LookinAttribute.h" - -@interface LKDashboardSectionViewPool () - -/// 已经被使用的 views,不可以再 dequeue 了 -@property(nonatomic, strong) NSMutableSet *dequeuedViews; -/// 无论是否 dequeue,都会保存在这里 -@property(nonatomic, strong) NSMutableDictionary *> *cache; - -@end - -@implementation LKDashboardSectionViewPool - -- (instancetype)init { - self = [super init]; - if (self) { - self.dequeuedViews = [NSMutableSet set]; - self.cache = [NSMutableDictionary dictionary]; - } - return self; -} - -- (void)recycleAll { - [self.dequeuedViews removeAllObjects]; -} - -- (LKDashboardSectionView *)dequeViewForSection:(LookinAttributesSection *)section { - NSString *key = [self cacheKeyForSection:section]; - if (!self.cache[key]) { - self.cache[key] = [NSMutableArray array]; - } - for (LKDashboardSectionView *v in self.cache[key]) { - if ([self.dequeuedViews containsObject:v]) { - continue; - } - [self.dequeuedViews addObject:v]; - return v; - } - LKDashboardSectionView *newView = [LKDashboardSectionView new]; - [self.dequeuedViews addObject:newView]; - [self.cache[key] addObject:newView]; - return newView; -} - -- (NSString *)cacheKeyForSection:(LookinAttributesSection *)section { - if (!section.isUserCustom) { - return [section identifier]; - } - NSString *key = [[section.attributes lookin_map:^id(NSUInteger idx, LookinAttribute *value) { - return value.displayTitle; - }] componentsJoinedByString:@","]; - return key; -} - -@end diff --git a/LookInside/Dashboard/LKDashboardViewController.h b/LookInside/Dashboard/LKDashboardViewController.h deleted file mode 100644 index cc24066c..00000000 --- a/LookInside/Dashboard/LKDashboardViewController.h +++ /dev/null @@ -1,38 +0,0 @@ -// -// LKDashboardViewController.h -// Lookin -// -// Created by Li Kai on 2018/8/6. -// https://lookin.work -// - -#import - -@class LKHierarchyDataSource, LKStaticHierarchyDataSource, LookinAttribute, LKReadHierarchyDataSource, LookinDisplayItem, LKStaticAsyncUpdateManager, LookinLiveDocument; - -@interface LKDashboardViewController : LKBaseViewController - -- (instancetype)initWithStaticDataSource:(LKStaticHierarchyDataSource *)dataSource; - -- (instancetype)initWithReadDataSource:(LKReadHierarchyDataSource *)dataSource; - -/// Phase A 引入:由 owner(LKStaticViewController)注入的 per-instance update manager(weak)。 -/// Live workspace 触发 modification 时优先用本实例。 -@property(nonatomic, weak) LKStaticAsyncUpdateManager *asyncUpdateManager; - -/// Phase F: Live Doc that owns this dashboard. Static-mode RPC paths -/// (modifyCustomAttribute, modifyInbuiltAttribute, search-method invoke) -/// route through `liveDocument.inspectableApp` instead of the deprecated -/// single-slot global. Read-mode dashboards leave this nil. -@property(nonatomic, weak) LookinLiveDocument *liveDocument; - -- (LKHierarchyDataSource *)currentDataSource; - -- (RACSignal *)modifyAttribute:(LookinAttribute *)attribute newValue:(id)newValue; - -- (void)reloadCurrentDisplayItem; - -/// 如果为 YES 则表示当前使用的是 StaticDataSource 而非 ReadDataSource -@property(nonatomic, assign, readonly) BOOL isStaticMode; - -@end diff --git a/LookInside/Dashboard/LKDashboardViewController.m b/LookInside/Dashboard/LKDashboardViewController.m deleted file mode 100644 index 25f15a4d..00000000 --- a/LookInside/Dashboard/LKDashboardViewController.m +++ /dev/null @@ -1,581 +0,0 @@ -// -// LKDashboardViewController.m -// Lookin -// -// Created by Li Kai on 2018/8/6. -// https://lookin.work -// - -#import "LKDashboardViewController.h" -#import "LKStaticHierarchyDataSource.h" -#import "LKDashboardCardView.h" -#import "LookinDefines.h" -#import "LKInspectableApp.h" -#import "LookinLiveDocument.h" -#import "LKPreferenceManager.h" -#import "LKStaticAsyncUpdateManager.h" -#import "LKReadHierarchyDataSource.h" -#import "LookinDashboardBlueprint.h" -#import "LKUserActionManager.h" -#import "LKDashboardHeaderView.h" -#import "LKDashboardSearchPropView.h" -#import "LookinAttributesSection.h" -#import "LKDashboardSectionView.h" -#import "LKDashboardSearchMethodsView.h" -#import "LKDashboardSearchMethodsDataSource.h" -#import "LookinCustomAttrModification.h" -#import "LKDashboardTextControlEditingFlag.h" -#import "LookInside-Swift.h" - -@interface LKDashboardViewController () - -@property(nonatomic, strong) NSScrollView *scrollView; -@property(nonatomic, strong) LKBaseView *documentView; -@property(nonatomic, strong) LKBaseView *cardContainerView; - -@property(nonatomic, copy) NSArray *groupList; -/// key 是 group.uniqueKey -@property(nonatomic, strong) NSMutableDictionary *cardViews; - -@property(nonatomic, strong) LKBaseView *searchContainerView; -@property(nonatomic, strong) LKDashboardHeaderView *headerView; -@property(nonatomic, strong) NSMutableArray *searchPropViews; -@property(nonatomic, strong) LKDashboardSearchMethodsView *searchMethodsView; -@property(nonatomic, strong) LKDashboardSearchMethodsDataSource *methodsDataSource; - -@property(nonatomic, strong) LKStaticHierarchyDataSource *staticDataSource; -@property(nonatomic, strong) LKReadHierarchyDataSource *readDataSource; - -@end - -@implementation LKDashboardViewController - -- (instancetype)initWithStaticDataSource:(LKStaticHierarchyDataSource *)dataSource { - if (self = [self initWithContainerView:nil]) { - self.staticDataSource = dataSource; - _isStaticMode = YES; - [self _didInitialized]; - - } - return self; -} - -- (instancetype)initWithReadDataSource:(LKReadHierarchyDataSource *)dataSource { - if (self = [self initWithContainerView:nil]) { - self.readDataSource = dataSource; - _isStaticMode = NO; - [self _didInitialized]; - } - return self; -} - -- (NSView *)makeContainerView { - LKBaseView *containerView = [LKBaseView new]; - - self.documentView = [LKBaseView new]; - - self.scrollView = [[NSScrollView alloc] init]; - self.scrollView.drawsBackground = NO; - self.scrollView.hasVerticalScroller = YES; - self.scrollView.hasHorizontalScroller = NO; - self.scrollView.autohidesScrollers = YES; - self.scrollView.contentView.documentView = self.documentView; - [containerView addSubview:self.scrollView]; - - self.headerView = [LKDashboardHeaderView new]; - self.headerView.delegate = self; - [self.documentView addSubview:self.headerView]; - - self.cardContainerView = [LKBaseView new]; - [self.documentView addSubview:self.cardContainerView]; - - self.searchContainerView = [LKBaseView new]; - self.searchContainerView.hidden = YES; - [self.documentView addSubview:self.searchContainerView]; - - return containerView; -} - -- (void)_didInitialized { - self.cardViews = [NSMutableDictionary dictionary]; - self.searchPropViews = [NSMutableArray array]; - - @weakify(self); - if (self.staticDataSource) { - [[[RACSignal merge:@[RACObserve(self.staticDataSource, selectedItem)]] deliverOnMainThread] subscribeNext:^(id x) { - @strongify(self); - [self reloadWithGroupList:[self _groupListForDisplayItem:self.staticDataSource.selectedItem]]; - }]; - - [[self.staticDataSource.itemDidChangeAttrGroup deliverOnMainThread] subscribeNext:^(LookinDisplayItem *displayItem) { - @strongify(self); - if (self.staticDataSource.selectedItem == displayItem) { - [self reloadWithGroupList:[self _groupListForDisplayItem:displayItem]]; - } - }]; - - self.methodsDataSource = [LKDashboardSearchMethodsDataSource new]; - // Phase F: forward the owning Live Doc once injected, so dashboard - // method searches route through the per-doc inspectable app. - [RACObserve(self, liveDocument) subscribeNext:^(LookinLiveDocument *doc) { - @strongify(self); - self.methodsDataSource.liveDocument = doc; - }]; - [self.staticDataSource.didReloadHierarchyInfo subscribeNext:^(id _Nullable x) { - @strongify(self); - [self.methodsDataSource clearAllCache]; - }]; - - } else if (self.readDataSource) { - [[[RACSignal merge:@[RACObserve(self.readDataSource, selectedItem)]] deliverOnMainThread] subscribeNext:^(id x) { - @strongify(self); - [self reloadWithGroupList:[self _groupListForDisplayItem:self.readDataSource.selectedItem]]; - }]; - } - - [[NSNotificationCenter defaultCenter] addObserverForName:NotificationName_DidChangeSectionShowing object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) { - @strongify(self); - [self reloadWithGroupList:[self _groupListForDisplayItem:[self currentDataSource].selectedItem]]; - }]; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - - $(self.scrollView).fullFrame; - - CGFloat verMargin = 10; - CGFloat contentWidth = DashboardViewWidth - DashboardHorInset * 2; - - $(self.headerView).width(contentWidth).x(DashboardHorInset).height(23).y(10); - - if (!self.cardContainerView.hidden) { - $(self.cardContainerView).width(contentWidth).x(DashboardHorInset).y(self.headerView.$maxY + verMargin); - - __block CGFloat y = 0; - - [self.groupList enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop) { - LKDashboardCardView *view = self.cardViews[group.uniqueKey]; - if (view && !view.hidden) { - $(view).width(contentWidth).y(y).heightToFit; - y = view.$maxY + verMargin; - } - }]; - - $(self.cardContainerView).height(y); - $(self.documentView).fullWidth.y(0).toMaxY(self.cardContainerView.$maxY); - } - - if (!self.searchContainerView.hidden) { - $(self.searchContainerView).width(contentWidth).x(DashboardHorInset).y(self.headerView.$maxY + verMargin); - - __block CGFloat y = 0; - [self.searchPropViews enumerateObjectsUsingBlock:^(LKDashboardSearchPropView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - if (!view.hidden) { - $(view).width(contentWidth).x(0).heightToFit.y(y); - y = view.$maxY + verMargin; - } - }]; - - if (self.searchMethodsView.isVisible) { - $(self.searchMethodsView).width(contentWidth).y(y).heightToFit; - y = self.searchMethodsView.$maxY; - } - - $(self.searchContainerView).height(y); - $(self.documentView).fullWidth.y(0).toMaxY(self.searchContainerView.$maxY); - } -} - -- (NSArray *)_groupListForDisplayItem:(LookinDisplayItem *)item { - if (!item) { - return @[]; - } - NSArray *groups = [[LKPrivateDiscriminatorStore shared] appendingPrivateDiscriminatorGroupToGroups:[item queryAllAttrGroupList] forDisplayItem:item]; - // Cards are keyed by uniqueKey, so a duplicated key would lay the shared - // card out twice and leave a blank gap. Servers 0.2.8/0.2.9 actually sent - // a duplicate Layout group for UIWindowScene nodes. - return [LookinAttributesGroup groupsByKeepingFirstGroupForEachUniqueKey:groups]; -} - -- (void)reloadWithGroupList:(NSArray *)list { - self.groupList = list; - - if (list.count > 0) { - self.scrollView.hidden = NO; - } else { - self.scrollView.hidden = YES; - return; - } - - NSMutableArray *needlessViews = [self.cardViews allValues].mutableCopy; - - [list enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop) { - LKDashboardCardView *cardView = self.cardViews[group.uniqueKey]; - if (cardView) { - [needlessViews removeObject:cardView]; - } else { - cardView = [LKDashboardCardView new]; - cardView.dashboardViewController = self; - cardView.delegate = self; - self.cardViews[group.uniqueKey] = cardView; - [self.cardContainerView addSubview:cardView]; - } - cardView.hidden = NO; - cardView.attrGroup = group; - cardView.isCollapsed = [[LKPreferenceManager mainManager].collapsedAttrGroups containsObject:group.identifier]; - [cardView render]; - }]; - - [needlessViews enumerateObjectsUsingBlock:^(LKDashboardCardView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.hidden = YES; - }]; - - [self.view setNeedsLayout:YES]; -} - -- (void)reloadCurrentDisplayItem { - [self reloadWithGroupList:[self _groupListForDisplayItem:[self currentDataSource].selectedItem]]; -} - -- (RACSignal *)modifyAttribute:(LookinAttribute *)attribute newValue:(id)newValue { - if (attribute.isUserCustom) { - if (attribute.customSetterID.length == 0) { - // Read-only user-custom attribute (e.g. SwiftUI-injected node attrs - // produced by LKS_SwiftUIAttrGroupsMaker do not register a setter). - // The per-attrView controlTextDidEndEditing: guards already reject - // these via canEdit, but keep this entry-level fallback so any - // future caller cannot trip the customSetterID assertion below. - return [RACSignal empty]; - } - return [self modifyCustomAttribute:attribute newValue:newValue]; - } else { - return [self modifyInbuiltAttribute:attribute newValue:newValue]; - } -} - -- (RACSignal *)modifyCustomAttribute:(LookinAttribute *)attribute newValue:(id)newValue { - @weakify(self); - return [RACSignal createSignal:^RACDisposable * _Nullable(id _Nonnull subscriber) { - @strongify(self); - LookinCustomAttrModification *modification = [LookinCustomAttrModification new]; - modification.customSetterID = attribute.customSetterID; - modification.attrType = attribute.attrType; - modification.value = newValue; - - if (modification.customSetterID.length == 0) { - NSAssert(NO, @""); - AlertError(LookinErr_Inner, self.view.window); - [subscriber sendError:LookinErr_Inner]; - return nil; - } - - // Phase F: Live Doc inspectable app drives custom-attr modifications. - LKInspectableApp *inspectableApp = self.liveDocument.inspectableApp; - if (!inspectableApp) { - AlertError(LookinErr_NoConnect, self.view.window); - [subscriber sendError:LookinErr_NoConnect]; - return nil; - } - - @weakify(self); - [[inspectableApp submitCustomModification:modification] subscribeNext:^(id ret) { - NSLog(@"custom modification - succ"); - attribute.value = newValue; - [subscriber sendNext:nil]; - - } error:^(NSError * _Nullable error) { - @strongify(self); - AlertError(error, self.view.window); - [subscriber sendError:error]; - }]; - - return nil; - }]; -} - -- (RACSignal *)modifyInbuiltAttribute:(LookinAttribute *)attribute newValue:(id)newValue { - @weakify(self); - return [RACSignal createSignal:^RACDisposable * _Nullable(id _Nonnull subscriber) { - @strongify(self); - LookinDisplayItem *modifyingItem = attribute.targetDisplayItem; - - LookinAttributeModification *modification = [LookinAttributeModification new]; - modification.clientReadableVersion = [LKHelper lookinReadableVersion]; - switch ([LookinDashboardBlueprint targetKindForAttrID:attribute.identifier]) { - case LookinAttrTargetKindView: - modification.targetOid = modifyingItem.viewObject.oid; - break; - case LookinAttrTargetKindWindow: - modification.targetOid = modifyingItem.windowObject.oid; - break; - case LookinAttrTargetKindCell: - modification.targetOid = modifyingItem.cellObject.oid; - break; - case LookinAttrTargetKindLayer: - modification.targetOid = modifyingItem.layerObject.oid; - break; - } - modification.setterSelector = [LookinDashboardBlueprint setterWithAttrID:attribute.identifier]; - modification.attrType = attribute.attrType; - modification.value = newValue; - - if (!modification.setterSelector) { - NSAssert(NO, @""); - AlertError(LookinErr_Inner, self.view.window); - [subscriber sendError:LookinErr_Inner]; - } - - // Phase F: Live Doc inspectable app drives inbuilt-attr modifications. - LKInspectableApp *inbuiltInspectableApp = self.liveDocument.inspectableApp; - if (!inbuiltInspectableApp) { - AlertError(LookinErr_NoConnect, self.view.window); - [subscriber sendError:LookinErr_NoConnect]; - } - - @weakify(self); - [[inbuiltInspectableApp submitInbuiltModification:modification] subscribeNext:^(LookinDisplayItemDetail *detail) { - NSLog(@"modification - succ"); - @strongify(self); - if (self.staticDataSource) { - LKDashboardTextControlEditingFlag.sharedInstance.shouldIgnoreTextEditingChangeEvent = YES; - // 用户通过回车键触发 endEditing -> 触发编辑 -> 走到这里 -> 这一句会触发 reload -> reload 触发 cardView removeFromSuperview -> 再次触发 editEditing,因此会导致连续修改两次(多 sync 一次图像数据),所以这里用标记位规避一下,懒得琢磨其它办法了 - [self.staticDataSource modifyWithDisplayItemDetail:detail]; - LKDashboardTextControlEditingFlag.sharedInstance.shouldIgnoreTextEditingChangeEvent = NO; - - if ([LookinDashboardBlueprint needPatchAfterModificationWithAttrID:attribute.identifier]) { - [self.asyncUpdateManager updateAfterModifyingDisplayItem:(LookinStaticDisplayItem *)modifyingItem]; - } - - } else { - NSAssert(NO, @""); - } - [subscriber sendNext:nil]; - - } error:^(NSError * _Nullable error) { - @strongify(self); - AlertError(error, self.view.window); - [subscriber sendError:error]; - }]; - - return nil; - }]; -} - -- (LKHierarchyDataSource *)currentDataSource { - if (self.staticDataSource) { - return self.staticDataSource; - } - if (self.readDataSource) { - return self.readDataSource; - } - NSAssert(NO, @""); - return nil; -} - -#pragma mark - - -- (void)dashboardCardViewNeedToggleCollapse:(LKDashboardCardView *)view { - LKPreferenceManager *manager = self.currentDataSource.preferenceManager; - if ([manager.collapsedAttrGroups containsObject:view.attrGroup.identifier]) { - view.isCollapsed = NO; - manager.collapsedAttrGroups = [manager.collapsedAttrGroups lookin_arrayByRemovingObject:view.attrGroup.identifier]; - } else { - view.isCollapsed = YES; - manager.collapsedAttrGroups = [manager.collapsedAttrGroups arrayByAddingObject:view.attrGroup.identifier];; - } - [self.view setNeedsLayout:YES]; -} - -#pragma mark - - -- (void)dashboardHeaderView:(LKDashboardHeaderView *)view didInputString:(NSString *)searchString { - if (searchString.length < 3) { - self.searchContainerView.hidden = YES; - return; - } - - searchString = searchString.lowercaseString; - - // 以下是在渲染 attrs - - NSMutableArray *resultAttrs = [NSMutableArray array]; - [[self _groupListForDisplayItem:self.currentDataSource.selectedItem] enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop) { - [group.attrSections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop) { - [section.attributes enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop) { - NSString *title; - if (attr.isUserCustom) { - title = attr.displayTitle; - } else { - title = [LookinDashboardBlueprint fullTitleWithAttrID:attr.identifier]; - } - if ([title.lowercaseString containsString:searchString]) { - [resultAttrs addObject:attr]; - } - }]; - }]; - }]; - - [self.searchPropViews lookin_dequeueWithCount:resultAttrs.count add:^LKDashboardSearchPropView *(NSUInteger idx) { - LKDashboardSearchPropView *view = [LKDashboardSearchPropView new]; - view.delegate = self; - [self.searchContainerView addSubview:view]; - return view; - - } notDequeued:^(NSUInteger idx, LKDashboardSearchPropView *view) { - view.hidden = YES; - - } doNext:^(NSUInteger idx, LKDashboardSearchPropView *view) { - LookinAttribute *attribute = resultAttrs[idx]; - [view renderWithAttribute:attribute]; - view.hidden = NO; - }]; - - // 以下是在渲染 methods - - if (self.currentDataSource != self.staticDataSource) { - self.searchContainerView.hidden = NO; - self.searchMethodsView.hidden = YES; - [self.view setNeedsLayout:YES]; - return; - } - - if (!self.searchMethodsView) { - self.searchMethodsView = [LKDashboardSearchMethodsView new]; - self.searchMethodsView.delegate = self; - [self.searchContainerView addSubview:self.searchMethodsView]; - } - - LookinObject *selectedObj = self.currentDataSource.selectedItem.displayingObject; - NSString *selectedClassName = [selectedObj rawClassName]; - @weakify(self); - [[self.methodsDataSource fetchNonArgMethodsListWithClass:selectedClassName] subscribeNext:^(NSArray *methodsList) { - @strongify(self); - if (![searchString isEqualToString:[self.headerView currentInputString]]) { - return; - } - NSArray *searchedMethods = [LKHelper bestMatchesInCandidates:methodsList input:searchString maxResultsCount:5]; - [self.searchMethodsView renderWithMethods:searchedMethods oid:selectedObj.oid]; - self.searchContainerView.hidden = NO; - [self.view setNeedsLayout:YES]; - - } error:^(NSError * _Nullable error) { - @strongify(self); - if (![searchString isEqualToString:[self.headerView currentInputString]]) { - return; - } - [self.searchMethodsView renderWithError:error]; - self.searchContainerView.hidden = NO; - [self.view setNeedsLayout:YES]; - }]; -} - -- (void)dashboardHeaderView:(LKDashboardHeaderView *)view didToggleActive:(BOOL)isActive { - if (isActive) { - self.cardContainerView.animator.hidden = YES; - self.currentDataSource.shouldAvoidChangingPreviewSelectionDueToDashboardSearch = YES; - - } else { - self.cardContainerView.animator.hidden = NO; - self.searchContainerView.animator.hidden = YES; - [self.view setNeedsLayout:YES]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - // 这个 0.25 的延时是关键,想象这个场景:用户想要结束搜索于是点击了空白图像处,此时程序会先走到这里的 resignActive 逻辑,然后再走到 preview 那边的取消图层选中逻辑(这两者时差大概在 0.1s 左右),但此时用户应该是并不想取消图层选中的,所以这里要用一个 flag 保护一下这种情况 - if (!view.isActive) { - self.currentDataSource.shouldAvoidChangingPreviewSelectionDueToDashboardSearch = NO; - } - }); - } -} - -#pragma mark - - -- (void)dashboardSearchMethodsView:(LKDashboardSearchMethodsView *)view requestToInvokeMethod:(NSString *)method oid:(unsigned long)oid { - // Phase F: Live Doc inspectable app drives method invocations from the - // dashboard search panel. - LKInspectableApp *inspectableApp = self.liveDocument.inspectableApp; - RACSignal *signal; - if (oid == 0 || method.length == 0) { - signal = [RACSignal error:LookinErr_Inner]; - } else if (!inspectableApp) { - signal = [RACSignal error:LookinErr_NoConnect]; - } else { - signal = [inspectableApp invokeMethodWithOid:oid text:method]; - } - - [signal subscribeNext:^(NSDictionary *value) { - NSAlert *alert = [NSAlert new]; - alert.messageText = method; - alert.informativeText = value[@"description"]; - alert.alertStyle = NSAlertStyleInformational; - [alert beginSheetModalForWindow:self.view.window completionHandler:^(NSModalResponse returnCode) { - - }]; - - } error:^(NSError * _Nullable error) { - AlertError(error, self.view.window); - }]; - -} - -#pragma mark - - -- (void)dashboardSearchPropView:(LKDashboardSearchPropView *)view didClickRevealAttribute:(LookinAttribute *)clickedAttr { - self.headerView.isActive = NO; - - __block LookinAttributesGroup *targetGroup = nil; - __block LookinAttributesSection *targetSection = nil; - - [[self _groupListForDisplayItem:self.currentDataSource.selectedItem] enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop0) { - [group.attrSections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop1) { - [section.attributes enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop2) { - if (attr == clickedAttr) { - *stop0 = YES; - *stop1 = YES; - *stop2 = YES; - - BOOL isAlreadyAdded = [[LKPreferenceManager mainManager] isSectionShowing:section.identifier]; - if (!isAlreadyAdded) { - // 把这个属性添加到主面板上 - [[LKPreferenceManager mainManager] showSection:section.identifier]; - } - - targetGroup = group; - targetSection = section; - } - }]; - }]; - }]; - - if (!targetGroup || !targetSection) { - NSAssert(NO, @""); - return; - } - LKDashboardCardView *targetCardView = self.cardViews[targetGroup.uniqueKey]; - if (!targetCardView) { - NSAssert(NO, @""); - return; - } - if (targetCardView.isCollapsed) { - // 如果在折叠状态则展开 - [self dashboardCardViewNeedToggleCollapse:targetCardView]; - } - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - LKDashboardSectionView *targetSecView = [targetCardView querySectionViewWithSection:targetSection]; - [self.scrollView.contentView.animator scrollRectToVisible:[self.scrollView.contentView convertRect:targetSecView.frame fromView:targetSecView.superview]]; - - [self.cardViews.allValues enumerateObjectsUsingBlock:^(LKDashboardCardView * _Nonnull cardView, NSUInteger idx, BOOL * _Nonnull stop) { - if (cardView.hidden) { - return; - } - if (cardView == targetCardView) { - [cardView playFadeAnimationWithHighlightRect:[cardView convertRect:targetSecView.frame fromView:targetSecView.superview]]; - } else { - [cardView playFadeAnimationWithHighlightRect:CGRectZero]; - } - }]; - }); -} - -@end diff --git a/LookInside/Dashboard/LKDashboardViewController.swift b/LookInside/Dashboard/LKDashboardViewController.swift new file mode 100644 index 00000000..43581d1c --- /dev/null +++ b/LookInside/Dashboard/LKDashboardViewController.swift @@ -0,0 +1,502 @@ +// +// LKDashboardViewController.swift +// LookInside +// +// Created by Li Kai on 2018/8/6. +// https://lookin.work +// + +import AppKit + +/// The attribute panel on the right of the inspector: one card per +/// attribute group of the selected item, and a search over attributes and +/// methods. Edits are sent to the inspected app on a live document; a +/// document read from a file is read-only. +@objc(LKDashboardViewController) +final class LKDashboardViewController: LKBaseViewController, LKDashboardCardViewDelegate, LKDashboardHeaderViewDelegate, LKDashboardSearchPropViewDelegate, LKDashboardSearchMethodsViewDelegate { + /// Read by key by the DEBUG UI snapshots. + @objc private(set) var scrollView: NSScrollView! + private var documentView: LKBaseView! + private var cardContainerView: LKBaseView! + private var searchContainerView: LKBaseView! + private var headerView: LKDashboardHeaderView! + + private var groupList: [LookinAttributesGroup] = [] + /// Keyed by `LookinAttributesGroup.uniqueKey`. + private var cardViews: [String: LKDashboardCardView] = [:] + + private var searchPropViews: [LKDashboardSearchPropView] = [] + private var searchMethodsView: LKDashboardSearchMethodsView? + private var methodsDataSource: LKDashboardSearchMethodsDataSource? + + private let staticDataSource: LKStaticHierarchyDataSource? + private let readDataSource: LKReadHierarchyDataSource? + + private var selectedItemObservation: NSKeyValueObservation? + private var sectionShowingObserver: NSObjectProtocol? + private var dataSourceSubscriptions: [LKSyncSubscription] = [] + + /// Whether this dashboard shows a live document (rather than one read + /// from a file). + @objc let isStaticMode: Bool + + /// The update manager of the owning inspector, which patches the + /// preview after modifications that need it. + @objc weak var asyncUpdateManager: LKStaticAsyncUpdateManager? + + /// The live document that owns this dashboard; its inspectable app + /// receives attribute modifications and method invocations. Nil on a + /// document read from a file. + @objc weak var liveDocument: LookinLiveDocument? { + didSet { methodsDataSource?.liveDocument = liveDocument } + } + + @objc(initWithStaticDataSource:) + init(staticDataSource dataSource: LKStaticHierarchyDataSource) { + staticDataSource = dataSource + readDataSource = nil + isStaticMode = true + super.init(containerView: nil) + didInitialize() + } + + @objc(initWithReadDataSource:) + init(readDataSource dataSource: LKReadHierarchyDataSource) { + staticDataSource = nil + readDataSource = dataSource + isStaticMode = false + super.init(containerView: nil) + didInitialize() + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + deinit { + if let sectionShowingObserver { + NotificationCenter.default.removeObserver(sectionShowingObserver) + } + dataSourceSubscriptions.forEach { $0.cancel() } + } + + override func makeContainerView() -> NSView { + let containerView = LKBaseView() + + documentView = LKBaseView() + + scrollView = NSScrollView() + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.contentView.documentView = documentView + containerView.addSubview(scrollView) + + headerView = LKDashboardHeaderView() + headerView.delegate = self + documentView.addSubview(headerView) + + cardContainerView = LKBaseView() + documentView.addSubview(cardContainerView) + + searchContainerView = LKBaseView() + searchContainerView.isHidden = true + documentView.addSubview(searchContainerView) + + return containerView + } + + private func didInitialize() { + if let staticDataSource { + observeSelectedItem(of: staticDataSource) + + // Reloads on a later main-queue turn, after the sender is done. + dataSourceSubscriptions.append(staticDataSource.itemDidChangeAttrGroup.observe { [weak self] item in + DispatchQueue.main.async { + MainActor.assumeIsolated { + guard let self, self.staticDataSource?.selectedItem === item else { return } + self.reload(groupList: self.groupList(for: item)) + } + } + }) + + let methodsDataSource = LKDashboardSearchMethodsDataSource() + methodsDataSource.liveDocument = liveDocument + self.methodsDataSource = methodsDataSource + dataSourceSubscriptions.append(staticDataSource.didReloadHierarchyInfo.observe { [weak self] in + self?.methodsDataSource?.clearAllCache() + }) + } else if let readDataSource { + observeSelectedItem(of: readDataSource) + } + + sectionShowingObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name(NotificationName_DidChangeSectionShowing), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.reloadCurrentDisplayItem() + } + } + } + + /// Renders the selected item now and whenever the selection changes, + /// each time on a later turn of the main queue. + private func observeSelectedItem(of dataSource: LKHierarchyDataSource) { + selectedItemObservation = dataSource.observe(\.selectedItem, options: [.initial, .new]) { [weak self] _, _ in + DispatchQueue.main.async { + guard let self else { return } + self.reload(groupList: self.groupList(for: self.currentDataSource()?.selectedItem)) + } + } + } + + // MARK: - Layout + + override func viewDidLayout() { + super.viewDidLayout() + + scrollView.dashboardLayout.fullFrame() + + let verMargin: CGFloat = 10 + let horInset = LKDashboardMetrics.horInset + let contentWidth = LKDashboardMetrics.viewWidth - horInset * 2 + + headerView.dashboardLayout.width(contentWidth).x(horInset).height(23).y(10) + + if !cardContainerView.isHidden { + cardContainerView.dashboardLayout.width(contentWidth).x(horInset).y(headerView.frame.maxY + verMargin) + var y: CGFloat = 0 + for group in groupList { + guard let view = cardViews[group.uniqueKey()], !view.isHidden else { continue } + view.dashboardLayout.width(contentWidth).y(y).heightToFit() + y = view.frame.maxY + verMargin + } + cardContainerView.dashboardLayout.height(y) + documentView.dashboardLayout.fullWidth().y(0).toMaxY(cardContainerView.frame.maxY) + } + + if !searchContainerView.isHidden { + searchContainerView.dashboardLayout.width(contentWidth).x(horInset).y(headerView.frame.maxY + verMargin) + var y: CGFloat = 0 + for view in searchPropViews where !view.isHidden { + view.dashboardLayout.width(contentWidth).x(0).heightToFit().y(y) + y = view.frame.maxY + verMargin + } + if let searchMethodsView, searchMethodsView.isVisible { + searchMethodsView.dashboardLayout.width(contentWidth).y(y).heightToFit() + y = searchMethodsView.frame.maxY + } + searchContainerView.dashboardLayout.height(y) + documentView.dashboardLayout.fullWidth().y(0).toMaxY(searchContainerView.frame.maxY) + } + } + + // MARK: - Rendering + + private func groupList(for item: LookinDisplayItem?) -> [LookinAttributesGroup] { + guard let item else { return [] } + let groups = LKPrivateDiscriminatorStore.shared.appendingPrivateDiscriminatorGroup(to: item.queryAllAttrGroupList() ?? [], for: item) + // Cards are keyed by uniqueKey, so a duplicated key would lay the + // shared card out twice and leave a blank gap. Servers 0.2.8 and + // 0.2.9 sent a duplicate Layout group for UIWindowScene nodes. + return LookinAttributesGroup.groupsByKeepingFirstGroup(forEachUniqueKey: groups) ?? [] + } + + private func reload(groupList list: [LookinAttributesGroup]) { + groupList = list + + if list.isEmpty { + scrollView.isHidden = true + return + } + scrollView.isHidden = false + + var needlessViews = Array(cardViews.values) + for group in list { + let key = group.uniqueKey() ?? "" + let cardView: LKDashboardCardView + if let existing = cardViews[key] { + cardView = existing + needlessViews.removeAll { $0 === existing } + } else { + cardView = LKDashboardCardView() + cardView.dashboardViewController = self + cardView.delegate = self + cardViews[key] = cardView + cardContainerView.addSubview(cardView) + } + cardView.isHidden = false + cardView.attrGroup = group + cardView.isCollapsed = LKPreferenceManager.shared.collapsedAttrGroups.contains(group.identifier ?? "") + cardView.render() + } + needlessViews.forEach { $0.isHidden = true } + + view.needsLayout = true + } + + @objc func reloadCurrentDisplayItem() { + reload(groupList: groupList(for: currentDataSource()?.selectedItem)) + } + + @objc func currentDataSource() -> LKHierarchyDataSource? { + if let staticDataSource { + return staticDataSource + } + if let readDataSource { + return readDataSource + } + assertionFailure() + return nil + } + + // MARK: - Modification + + /// Sends `newValue` for `attribute` to the inspected app. Returns + /// whether a modification was made: a user-custom attribute without a + /// setter (such as the SwiftUI attributes) is left alone. Failures are + /// shown in the window, then thrown. + func modifyAttribute(_ attribute: LookinAttribute, newValue: Any?) async throws -> Bool { + if attribute.isUserCustom() { + // The attribute views already refuse edits of read-only + // attributes; this keeps any other caller from sending one. + guard let setterID = attribute.customSetterID, !setterID.isEmpty else { + return false + } + try await modifyCustomAttribute(attribute, newValue: newValue) + } else { + try await modifyInbuiltAttribute(attribute, newValue: newValue) + } + return true + } + + private func failModification(_ error: Error) -> Error { + LKDashboardStyle.alert(error, window: view.window) + return error + } + + private func modifyCustomAttribute(_ attribute: LookinAttribute, newValue: Any?) async throws { + guard let modification = LKDashboardModification.custom(attribute: attribute, newValue: newValue) else { + assertionFailure() + throw failModification(LKConnectionError.inner) + } + // The live document's inspectable app applies the modification. + guard let inspectableApp = liveDocument?.inspectableApp else { + throw failModification(LKConnectionError.noConnect) + } + do { + _ = try await inspectableApp.submit(modification) + } catch { + throw failModification(error) + } + NSLog("custom modification - succ") + attribute.value = newValue + } + + private func modifyInbuiltAttribute(_ attribute: LookinAttribute, newValue: Any?) async throws { + let modifyingItem = attribute.targetDisplayItem + guard let modification = LKDashboardModification.inbuilt(attribute: attribute, newValue: newValue, clientReadableVersion: LKHelper.lookinReadableVersion()) else { + assertionFailure() + throw failModification(LKConnectionError.inner) + } + // The live document's inspectable app applies the modification. + guard let inspectableApp = liveDocument?.inspectableApp else { + throw failModification(LKConnectionError.noConnect) + } + let detail: LookinDisplayItemDetail + do { + detail = try await inspectableApp.submit(modification) + } catch { + throw failModification(error) + } + NSLog("modification - succ") + guard let staticDataSource else { + assertionFailure() + return + } + // Pressing Return ends editing, which submits; applying the result + // reloads the cards, which removes the field and ends editing again. + // The flag keeps that second end-of-editing from submitting (and + // syncing the image) a second time. + LKDashboardTextControlEditingFlag.shared.shouldIgnoreTextEditingChangeEvent = true + staticDataSource.modify(with: detail) + LKDashboardTextControlEditingFlag.shared.shouldIgnoreTextEditingChangeEvent = false + + if LookinDashboardBlueprint.needPatchAfterModification(withAttrID: attribute.identifier), let modifyingItem { + _ = asyncUpdateManager?.perform(Selector(("updateAfterModifyingDisplayItem:")), with: modifyingItem) + } + } + + // MARK: - LKDashboardCardViewDelegate + + func dashboardCardViewNeedToggleCollapse(_ view: LKDashboardCardView) { + guard let manager = currentDataSource()?.preferenceManager(), let identifier = view.attrGroup?.identifier else { return } + let collapsed = manager.collapsedAttrGroups + if collapsed.contains(identifier) { + view.isCollapsed = false + manager.collapsedAttrGroups = collapsed.filter { $0 != identifier } + } else { + view.isCollapsed = true + manager.collapsedAttrGroups = collapsed + [identifier] + } + self.view.needsLayout = true + } + + // MARK: - LKDashboardHeaderViewDelegate + + func dashboardHeaderView(_: LKDashboardHeaderView, didInputString string: String) { + guard string.count >= 3 else { + searchContainerView.isHidden = true + return + } + let searchString = string.lowercased() + + // Attributes + var resultAttrs: [LookinAttribute] = [] + for group in groupList(for: currentDataSource()?.selectedItem) { + for section in group.attrSections ?? [] { + for attr in section.attributes ?? [] { + let title = attr.isUserCustom() ? attr.displayTitle : LookinDashboardBlueprint.fullTitle(withAttrID: attr.identifier) + if (title ?? "").lowercased().contains(searchString) { + resultAttrs.append(attr) + } + } + } + } + + while searchPropViews.count < resultAttrs.count { + let propView = LKDashboardSearchPropView() + propView.delegate = self + searchContainerView.addSubview(propView) + searchPropViews.append(propView) + } + for (idx, propView) in searchPropViews.enumerated() { + if idx < resultAttrs.count { + propView.render(attribute: resultAttrs[idx]) + propView.isHidden = false + } else { + propView.isHidden = true + } + } + + // Methods, on a live document only + guard let staticDataSource, currentDataSource() === staticDataSource, let methodsDataSource else { + searchContainerView.isHidden = false + searchMethodsView?.isHidden = true + view.needsLayout = true + return + } + + let methodsView: LKDashboardSearchMethodsView + if let searchMethodsView { + methodsView = searchMethodsView + } else { + methodsView = LKDashboardSearchMethodsView() + methodsView.delegate = self + searchContainerView.addSubview(methodsView) + searchMethodsView = methodsView + } + + let selectedObject = staticDataSource.selectedItem?.displayingObject() + let selectedClassName = selectedObject?.rawClassName() + Task { @MainActor [weak self] in + do { + let methodsList = try await methodsDataSource.nonArgMethods(ofClass: selectedClassName) + guard let self, searchString == self.headerView.currentInputString else { return } + let searchedMethods = LKHelper.bestMatches(inCandidates: methodsList, input: searchString, maxResultsCount: 5) + methodsView.render(methods: searchedMethods, oid: selectedObject?.oid ?? 0) + self.searchContainerView.isHidden = false + self.view.needsLayout = true + } catch { + guard let self, searchString == self.headerView.currentInputString else { return } + methodsView.render(error: error) + self.searchContainerView.isHidden = false + self.view.needsLayout = true + } + } + } + + func dashboardHeaderView(_ view: LKDashboardHeaderView, didToggleActive isActive: Bool) { + if isActive { + cardContainerView.animator().isHidden = true + currentDataSource()?.shouldAvoidChangingPreviewSelectionDueToDashboardSearch = true + } else { + cardContainerView.animator().isHidden = false + searchContainerView.animator().isHidden = true + self.view.needsLayout = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + // Ending the search by clicking the preview first resigns + // here, then (about 0.1 s later) deselects the layer in the + // preview. The user did not mean to deselect, so the flag + // stays on a little longer. + if !view.isActive { + self?.currentDataSource()?.shouldAvoidChangingPreviewSelectionDueToDashboardSearch = false + } + } + } + } + + // MARK: - LKDashboardSearchMethodsViewDelegate + + func dashboardSearchMethodsView(_: LKDashboardSearchMethodsView, requestToInvokeMethod method: String, oid: UInt) { + // The live document's inspectable app invokes the method. + let inspectableApp = liveDocument?.inspectableApp + Task { @MainActor [weak self] in + do { + guard oid != 0, !method.isEmpty else { throw LKConnectionError.inner } + guard let inspectableApp else { throw LKConnectionError.noConnect } + let value = try await inspectableApp.invokeMethod(oid: oid, text: method) + let alert = NSAlert() + alert.messageText = method + alert.informativeText = value["description"] as? String ?? "" + alert.alertStyle = .informational + if let window = self?.view.window { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + alert.runModal() + } + } catch { + LKDashboardStyle.alert(error, window: self?.view.window) + } + } + } + + // MARK: - LKDashboardSearchPropViewDelegate + + func dashboardSearchPropView(_: LKDashboardSearchPropView, didClickRevealAttribute clickedAttribute: LookinAttribute) { + headerView.isActive = false + + var target: (group: LookinAttributesGroup, section: LookinAttributesSection)? + search: for group in groupList(for: currentDataSource()?.selectedItem) { + for section in group.attrSections ?? [] where (section.attributes ?? []).contains(where: { $0 === clickedAttribute }) { + if !LKPreferenceManager.shared.isSectionShowing(section.identifier) { + // Adds the section to its card. + LKPreferenceManager.shared.showSection(section.identifier) + } + target = (group, section) + break search + } + } + + guard let target, let targetCardView = cardViews[target.group.uniqueKey() ?? ""] else { + assertionFailure() + return + } + if targetCardView.isCollapsed { + dashboardCardViewNeedToggleCollapse(targetCardView) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self, let targetSectionView = targetCardView.querySectionView(with: target.section) else { return } + let sectionRect = self.scrollView.contentView.convert(targetSectionView.frame, from: targetSectionView.superview) + self.scrollView.contentView.animator().scrollToVisible(sectionRect) + + for cardView in self.cardViews.values where !cardView.isHidden { + if cardView === targetCardView { + cardView.playFadeAnimation(highlightRect: cardView.convert(targetSectionView.frame, from: targetSectionView.superview)) + } else { + cardView.playFadeAnimation(highlightRect: .zero) + } + } + } + } +} diff --git a/LookInside/Dashboard/LKEnumListRegistry.h b/LookInside/Dashboard/LKEnumListRegistry.h deleted file mode 100644 index cd70973c..00000000 --- a/LookInside/Dashboard/LKEnumListRegistry.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// LKEnumListRegistry.h -// Lookin -// -// Created by Li Kai on 2018/11/21. -// https://lookin.work -// - -#import - -@interface LKEnumListRegistryKeyValueItem : NSObject - -@property(nonatomic, copy) NSString *desc; -@property(nonatomic, assign) long value; -@property(nonatomic, assign) NSInteger availableOSVersion; - -@end - -@interface LKEnumListRegistry : NSObject - -+ (instancetype)sharedInstance; - -- (NSArray *)itemsForEnumName:(NSString *)enumName; - -- (NSString *)descForEnumName:(NSString *)enumName value:(long)value; - -@end diff --git a/LookInside/Dashboard/LKEnumListRegistry.m b/LookInside/Dashboard/LKEnumListRegistry.m deleted file mode 100644 index f87d62af..00000000 --- a/LookInside/Dashboard/LKEnumListRegistry.m +++ /dev/null @@ -1,689 +0,0 @@ -// -// LKEnumListRegistry.m -// Lookin -// -// Created by Li Kai on 2018/11/21. -// https://lookin.work -// - -#import "LKEnumListRegistry.h" - -#define MakeItemWithVersion(descArg, valueArg, availableMinOSVersion) [LKEnumListRegistryKeyValueItem itemWithDesc:descArg value:valueArg availableOSVersion:availableMinOSVersion] -#define MakeItem(descArg, valueArg) MakeItemWithVersion(descArg, valueArg, 0) - -@implementation LKEnumListRegistryKeyValueItem - -+ (instancetype)itemWithDesc:(NSString *)desc value:(long)value availableOSVersion:(NSInteger)osVersion { - LKEnumListRegistryKeyValueItem *MakeItem = [LKEnumListRegistryKeyValueItem new]; - MakeItem.desc = desc; - MakeItem.value = value; - MakeItem.availableOSVersion = osVersion; - return MakeItem; -} - -@end; - -@interface LKEnumListRegistry () - -@property(nonatomic, copy) NSDictionary *> *data; - -@end - -@implementation LKEnumListRegistry - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKEnumListRegistry *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -- (instancetype)init { - if (self = [super init]) { - NSMutableDictionary *mData = [NSMutableDictionary dictionary]; - - mData[@"UIControlContentVerticalAlignment"] = @[MakeItem(@"UIControlContentVerticalAlignmentCenter", 0), - MakeItem(@"UIControlContentVerticalAlignmentTop", 1), - MakeItem(@"UIControlContentVerticalAlignmentBottom", 2), - MakeItem(@"UIControlContentVerticalAlignmentFill", 3)]; - - mData[@"UIControlContentHorizontalAlignment"] = @[MakeItem(@"UIControlContentHorizontalAlignmentCenter", 0), - MakeItem(@"UIControlContentHorizontalAlignmentLeft", 1), - MakeItem(@"UIControlContentHorizontalAlignmentRight", 2), - MakeItem(@"UIControlContentHorizontalAlignmentFill", 3), - MakeItemWithVersion(@"UIControlContentHorizontalAlignmentLeading", 4, 11), - MakeItemWithVersion(@"UIControlContentHorizontalAlignmentTrailing", 5, 11)]; - - mData[@"UIViewContentMode"] = @[MakeItem(@"UIViewContentModeScaleToFill", 0), - MakeItem(@"UIViewContentModeScaleAspectFit", 1), - MakeItem(@"UIViewContentModeScaleAspectFill", 2), - MakeItem(@"UIViewContentModeRedraw", 3), - MakeItem(@"UIViewContentModeCenter", 4), - MakeItem(@"UIViewContentModeTop", 5), - MakeItem(@"UIViewContentModeBottom", 6), - MakeItem(@"UIViewContentModeLeft", 7), - MakeItem(@"UIViewContentModeRight", 8), - MakeItem(@"UIViewContentModeTopLeft", 9), - MakeItem(@"UIViewContentModeTopRight", 10), - MakeItem(@"UIViewContentModeBottomLeft", 11), - MakeItem(@"UIViewContentModeBottomRight", 12)]; - - mData[@"UIViewTintAdjustmentMode"] = @[MakeItem(@"UIViewTintAdjustmentModeAutomatic", 0), - MakeItem(@"UIViewTintAdjustmentModeNormal", 1), - MakeItem(@"UIViewTintAdjustmentModeDimmed", 2)]; - - mData[@"NSTextAlignment"] = @[MakeItem(@"NSTextAlignmentLeft", 0), - MakeItem(@"NSTextAlignmentCenter", 1), - MakeItem(@"NSTextAlignmentRight", 2), - MakeItem(@"NSTextAlignmentJustified", 3), - MakeItem(@"NSTextAlignmentNatural", 4)]; - - mData[@"NSLineBreakMode"] = @[MakeItem(@"NSLineBreakByWordWrapping", 0), - MakeItem(@"NSLineBreakByCharWrapping", 1), - MakeItem(@"NSLineBreakByClipping", 2), - MakeItem(@"NSLineBreakByTruncatingHead", 3), - MakeItem(@"NSLineBreakByTruncatingTail", 4), - MakeItem(@"NSLineBreakByTruncatingMiddle", 5)]; - - mData[@"UIScrollViewContentInsetAdjustmentBehavior"] = @[ - MakeItem(@"UIScrollViewContentInsetAdjustmentAutomatic", 0), - MakeItem(@"UIScrollViewContentInsetAdjustmentScrollableAxes", 1), - MakeItem(@"UIScrollViewContentInsetAdjustmentNever", 2), - MakeItem(@"UIScrollViewContentInsetAdjustmentAlways", 3)]; - - mData[@"UITableViewStyle"] = @[MakeItem(@"UITableViewStylePlain", 0), - MakeItem(@"UITableViewStyleGrouped", 1)]; - - mData[@"UITextFieldViewMode"] = @[MakeItem(@"UITextFieldViewModeNever", 0), - MakeItem(@"UITextFieldViewModeWhileEditing", 1), - MakeItem(@"UITextFieldViewModeUnlessEditing", 2), - MakeItem(@"UITextFieldViewModeAlways", 3)]; - - mData[@"UIAccessibilityNavigationStyle"] = @[ - MakeItem(@"UIAccessibilityNavigationStyleAutomatic", 0), - MakeItem(@"UIAccessibilityNavigationStyleSeparate", 1), - MakeItem(@"UIAccessibilityNavigationStyleCombined", 2)]; - - mData[@"QMUIButtonImagePosition"] = @[ - MakeItem(@"QMUIButtonImagePositionTop", 0), - MakeItem(@"QMUIButtonImagePositionLeft", 1), - MakeItem(@"QMUIButtonImagePositionBottom", 2), - MakeItem(@"QMUIButtonImagePositionRight", 3)]; - - mData[@"UITableViewCellSeparatorStyle"] = @[ - MakeItem(@"UITableViewCellSeparatorStyleNone", 0), - MakeItem(@"UITableViewCellSeparatorStyleSingleLine", 1), - MakeItem(@"UITableViewCellSeparatorStyleSingleLineEtched", 2)]; - - mData[@"UIBlurEffectStyle"] = @[ - MakeItem(@"UIBlurEffectStyleExtraLight", 0), - MakeItem(@"UIBlurEffectStyleLight", 1), - MakeItem(@"UIBlurEffectStyleDark", 2), -// MakeItem(@"UIBlurEffectStyleExtraDark", 3), // 该值被官方标注了 API_UNAVAILABLE(ios),因此这里跳过 - MakeItemWithVersion(@"UIBlurEffectStyleRegular", 4, 10), - MakeItemWithVersion(@"UIBlurEffectStyleProminent", 5, 10), - MakeItemWithVersion(@"UIBlurEffectStyleSystemUltraThinMaterial", 6, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemThinMaterial", 7, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemMaterial", 8, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemThickMaterial", 9, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemChromeMaterial", 10, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemUltraThinMaterialLight", 11, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemThinMaterialLight", 12, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemMaterialLight", 13, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemThickMaterialLight", 14, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemChromeMaterialLight", 15, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemUltraThinMaterialDark", 16, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemThinMaterialDark", 17, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemMaterialDark", 18, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemThickMaterialDark", 19, 13), - MakeItemWithVersion(@"UIBlurEffectStyleSystemChromeMaterialDark", 20, 13), - ]; - - mData[@"UILayoutConstraintAxis"] = @[ - MakeItem(@"UILayoutConstraintAxisHorizontal", 0), - MakeItem(@"UILayoutConstraintAxisVertical", 1), - ]; - - mData[@"UIStackViewDistribution"] = @[ - MakeItem(@"UIStackViewDistributionFill", 0), - MakeItem(@"UIStackViewDistributionFillEqually", 1), - MakeItem(@"UIStackViewDistributionFillProportionally", 2), - MakeItem(@"UIStackViewDistributionEqualSpacing", 3), - MakeItem(@"UIStackViewDistributionEqualCentering", 4) - ]; - - mData[@"UIStackViewAlignment"] = @[ - MakeItem(@"UIStackViewAlignmentFill", 0), - MakeItem(@"UIStackViewAlignmentLeading (Top)", 1), - MakeItem(@"UIStackViewAlignmentFirstBaseline", 2), - MakeItem(@"UIStackViewAlignmentCenter", 3), - MakeItem(@"UIStackViewAlignmentTrailing (Bottom)", 4), - MakeItem(@"UIStackViewAlignmentLastBaseline", 5) - ]; - mData[@"NSWritingDirection"] = @[ - MakeItem(@"NSWritingDirectionNatural", -1), - MakeItem(@"NSWritingDirectionLeftToRight", 0), - MakeItem(@"NSWritingDirectionRightToLeft", 1) - ]; - mData[@"NSTextAlignment_AppKit"] = @[ - MakeItem(@"NSTextAlignmentLeft", 0), - MakeItem(@"NSTextAlignmentRight", 1), - MakeItem(@"NSTextAlignmentCenter", 2), - MakeItem(@"NSTextAlignmentJustified", 3), - MakeItem(@"NSTextAlignmentNatural", 4) - ]; - mData[@"NSCellType"] = @[ - MakeItem(@"NSNullCellType", 0), - MakeItem(@"NSTextCellType", 1), - MakeItem(@"NSImageCellType", 2), - ]; - mData[@"NSTextFieldBezelStyle"] = @[ - MakeItem(@"NSTextFieldSquareBezel", 0), - MakeItem(@"NSTextFieldRoundedBezel", 1), - ]; - mData[@"NSButtonType"] = @[ - MakeItem(@"NSButtonTypeMomentaryLight", 0), - MakeItem(@"NSButtonTypePushOnPushOff", 1), - MakeItem(@"NSButtonTypeToggle", 2), - MakeItem(@"NSButtonTypeSwitch", 3), - MakeItem(@"NSButtonTypeRadio", 4), - MakeItem(@"NSButtonTypeMomentaryChange", 5), - MakeItem(@"NSButtonTypeOnOff", 6), - MakeItem(@"NSButtonTypeMomentaryPushIn", 7), - MakeItem(@"NSButtonTypeAccelerator", 8), - MakeItem(@"NSButtonTypeMultiLevelAccelerator", 9), - ]; - mData[@"NSBezelStyle"] = @[ - MakeItem(@"NSBezelStyleAutomatic", 0), - MakeItem(@"NSBezelStylePush", 1), - MakeItem(@"NSBezelStyleFlexiblePush", 2), - MakeItem(@"NSBezelStyleDisclosure", 5), - MakeItem(@"NSBezelStyleShadowlessSquare", 6), - MakeItem(@"NSBezelStyleCircular", 7), - MakeItem(@"NSBezelStyleTexturedSquare", 8), - MakeItem(@"NSBezelStyleHelpButton", 9), - MakeItem(@"NSBezelStyleSmallSquare", 10), - MakeItem(@"NSBezelStyleToolbar", 11), - MakeItem(@"NSBezelStyleAccessoryBarAction", 12), - MakeItem(@"NSBezelStyleAccessoryBar", 13), - MakeItem(@"NSBezelStylePushDisclosure", 14), - MakeItem(@"NSBezelStyleBadge", 15), - ]; - mData[@"NSTextFieldBezelStyle"] = @[ - MakeItem(@"NSTextFieldSquareBezel", 0), - MakeItem(@"NSTextFieldRoundedBezel", 1), - ]; - mData[@"NSLineBreakStrategy"] = @[ - MakeItem(@"NSLineBreakStrategyNone", 0), - MakeItem(@"NSLineBreakStrategyPushOut", 1), - MakeItem(@"NSLineBreakStrategyHangulWordPriority", 2), - MakeItem(@"NSLineBreakStrategyStandard", 0xFFFF), - ]; - mData[@"NSCellImagePosition"] = @[ - MakeItem(@"NSNoImage", 0), - MakeItem(@"NSImageOnly", 1), - MakeItem(@"NSImageLeft", 2), - MakeItem(@"NSImageRight", 3), - MakeItem(@"NSImageBelow", 4), - MakeItem(@"NSImageAbove", 5), - MakeItem(@"NSImageOverlaps", 6), - MakeItem(@"NSImageLeading", 7), - MakeItem(@"NSImageTrailing", 8), - ]; - mData[@"NSImageScaling"] = @[ - MakeItem(@"NSImageScaleProportionallyDown", 0), - MakeItem(@"NSImageScaleAxesIndependently", 1), - MakeItem(@"NSImageScaleNone", 2), - MakeItem(@"NSImageScaleProportionallyUpOrDown", 3), - ]; - mData[@"NSImageAlignment"] = @[ - MakeItem(@"NSImageAlignCenter", 0), - MakeItem(@"NSImageAlignTop", 1), - MakeItem(@"NSImageAlignTopLeft", 2), - MakeItem(@"NSImageAlignTopRight", 3), - MakeItem(@"NSImageAlignLeft", 4), - MakeItem(@"NSImageAlignBottom", 5), - MakeItem(@"NSImageAlignBottomLeft", 6), - MakeItem(@"NSImageAlignBottomRight", 7), - MakeItem(@"NSImageAlignRight", 8), - ]; - mData[@"NSImageFrameStyle"] = @[ - MakeItem(@"NSImageFrameNone", 0), - MakeItem(@"NSImageFramePhoto", 1), - MakeItem(@"NSImageFrameGrayBezel", 2), - MakeItem(@"NSImageFrameGroove", 3), - MakeItem(@"NSImageFrameButton", 4), - ]; - mData[@"NSControlStateValue"] = @[ - MakeItem(@"NSControlStateValueOff", 0), - MakeItem(@"NSControlStateValueOn", 1), - MakeItem(@"NSControlStateValueMixed", -1), - ]; - - mData[@"NSControlSize"] = @[ - MakeItem(@"NSControlSizeRegular", 0), - MakeItem(@"NSControlSizeSmall", 1), - MakeItem(@"NSControlSizeMini", 2), - MakeItem(@"NSControlSizeLarge", 3), - ]; - mData[@"NSEventModifierFlags"] = @[ - MakeItem(@"NSEventModifierFlagCapsLock", 1 << 16), - MakeItem(@"NSEventModifierFlagShift", 1 << 17), - MakeItem(@"NSEventModifierFlagControl", 1 << 18), - MakeItem(@"NSEventModifierFlagOption", 1 << 19), - MakeItem(@"NSEventModifierFlagCommand", 1 << 20), - MakeItem(@"NSEventModifierFlagNumericPad", 1 << 21), - MakeItem(@"NSEventModifierFlagHelp", 1 << 22), - MakeItem(@"NSEventModifierFlagFunction", 1 << 23), - ]; - mData[@"NSScrollElasticity"] = @[ - MakeItem(@"NSScrollElasticityAutomatic", 0), - MakeItem(@"NSScrollElasticityNone", 1), - MakeItem(@"NSScrollElasticityAllowed", 2), - ]; - mData[@"NSBorderType"] = @[ - MakeItem(@"NSNoBorder", 0), - MakeItem(@"NSLineBorder", 1), - MakeItem(@"NSBezelBorder", 2), - MakeItem(@"NSGrooveBorder", 3), - ]; - mData[@"NSScrollerStyle"] = @[ - MakeItem(@"NSScrollerStyleLegacy", 0), - MakeItem(@"NSScrollerStyleOverlay", 1), - ]; - mData[@"NSScrollerKnobStyle"] = @[ - MakeItem(@"NSScrollerKnobStyleDefault", 0), - MakeItem(@"NSScrollerKnobStyleDark", 1), - MakeItem(@"NSScrollerKnobStyleLight", 2), - ]; - mData[@"NSTableViewColumnAutoresizingStyle"] = @[ - MakeItem(@"NSTableViewNoColumnAutoresizing", 0), - MakeItem(@"NSTableViewUniformColumnAutoresizingStyle", 1), - MakeItem(@"NSTableViewSequentialColumnAutoresizingStyle", 2), - MakeItem(@"NSTableViewReverseSequentialColumnAutoresizingStyle", 3), - MakeItem(@"NSTableViewLastColumnOnlyAutoresizingStyle", 4), - MakeItem(@"NSTableViewFirstColumnOnlyAutoresizingStyle", 5), - ]; - mData[@"NSTableViewGridLineStyle"] = @[ - MakeItem(@"NSTableViewGridNone", 0), - MakeItem(@"NSTableViewSolidVerticalGridLineMask", 1 << 0), - MakeItem(@"NSTableViewSolidHorizontalGridLineMask", 1 << 1), - MakeItem(@"NSTableViewDashedHorizontalGridLineMask", 1 << 3), - ]; - mData[@"NSTableViewRowSizeStyle"] = @[ - MakeItem(@"NSTableViewRowSizeStyleDefault", -1), - MakeItem(@"NSTableViewRowSizeStyleCustom", 0), - MakeItem(@"NSTableViewRowSizeStyleSmall", 1), - MakeItem(@"NSTableViewRowSizeStyleMedium", 2), - MakeItem(@"NSTableViewRowSizeStyleLarge", 3), - ]; - mData[@"NSTableViewStyle"] = @[ - MakeItem(@"NSTableViewStyleAutomatic", 0), - MakeItem(@"NSTableViewStyleFullWidth", 1), - MakeItem(@"NSTableViewStyleInset", 2), - MakeItem(@"NSTableViewStyleSourceList", 3), - MakeItem(@"NSTableViewStylePlain", 4), - ]; - mData[@"NSTableViewSelectionHighlightStyle"] = @[ - MakeItem(@"NSTableViewSelectionHighlightStyleNone", -1), - MakeItem(@"NSTableViewSelectionHighlightStyleRegular", 0), - MakeItem(@"NSTableViewSelectionHighlightStyleSourceList", 1), - ]; - mData[@"NSTableViewDraggingDestinationFeedbackStyle"] = @[ - MakeItem(@"NSTableViewDraggingDestinationFeedbackStyleNone", -1), - MakeItem(@"NSTableViewDraggingDestinationFeedbackStyleRegular", 0), - MakeItem(@"NSTableViewDraggingDestinationFeedbackStyleSourceList", 1), - MakeItem(@"NSTableViewDraggingDestinationFeedbackStyleGap", 2), - ]; - mData[@"NSUserInterfaceLayoutDirection"] = @[ - MakeItem(@"NSUserInterfaceLayoutDirectionLeftToRight", 0), - MakeItem(@"NSUserInterfaceLayoutDirectionRightToLeft", 1), - ]; - mData[@"NSVisualEffectMaterial"] = @[ - MakeItem(@"NSVisualEffectMaterialAppearanceBased", 0), - MakeItem(@"NSVisualEffectMaterialLight", 1), - MakeItem(@"NSVisualEffectMaterialDark", 2), - MakeItem(@"NSVisualEffectMaterialTitlebar", 3), - MakeItem(@"NSVisualEffectMaterialSelection", 4), - MakeItem(@"NSVisualEffectMaterialMenu", 5), - MakeItem(@"NSVisualEffectMaterialPopover", 6), - MakeItem(@"NSVisualEffectMaterialSidebar", 7), - MakeItem(@"NSVisualEffectMaterialMediumLight", 8), - MakeItem(@"NSVisualEffectMaterialUltraDark", 9), - MakeItem(@"NSVisualEffectMaterialHeaderView", 10), - MakeItem(@"NSVisualEffectMaterialSheet", 11), - MakeItem(@"NSVisualEffectMaterialWindowBackground", 12), - MakeItem(@"NSVisualEffectMaterialHUDWindow", 13), - MakeItem(@"NSVisualEffectMaterialFullScreenUI", 15), - MakeItem(@"NSVisualEffectMaterialToolTip", 17), - MakeItem(@"NSVisualEffectMaterialContentBackground", 18), - MakeItem(@"NSVisualEffectMaterialUnderWindowBackground", 21), - MakeItem(@"NSVisualEffectMaterialUnderPageBackground", 22), - ]; - mData[@"NSVisualEffectBlendingMode"] = @[ - MakeItem(@"NSVisualEffectBlendingModeBehindWindow", 0), - MakeItem(@"NSVisualEffectBlendingModeWithinWindow", 1), - ]; - mData[@"NSVisualEffectState"] = @[ - MakeItem(@"NSVisualEffectStateFollowsWindowActiveState", 0), - MakeItem(@"NSVisualEffectStateActive", 1), - MakeItem(@"NSVisualEffectStateInactive", 2), - ]; - mData[@"NSBackgroundStyle"] = @[ - MakeItem(@"NSBackgroundStyleNormal", 0), - MakeItem(@"NSBackgroundStyleEmphasized", 1), - MakeItem(@"NSBackgroundStyleRaised", 2), - MakeItem(@"NSBackgroundStyleLowered", 3), - ]; - mData[@"NSStackViewDistribution"] = @[ - MakeItem(@"NSStackViewDistributionGravityAreas", -1), - MakeItem(@"NSStackViewDistributionFill", 0), - MakeItem(@"NSStackViewDistributionFillEqually", 1), - MakeItem(@"NSStackViewDistributionFillProportionally", 2), - MakeItem(@"NSStackViewDistributionEqualSpacing", 3), - MakeItem(@"NSStackViewDistributionEqualCentering", 4), - ]; - mData[@"NSUserInterfaceLayoutOrientation"] = @[ - MakeItem(@"NSUserInterfaceLayoutOrientationHorizontal", 0), - MakeItem(@"NSUserInterfaceLayoutOrientationVertical", 1), - ]; - mData[@"NSLayoutAttribute"] = @[ - MakeItem(@"NSLayoutAttributeNotAnAttribute", 0), - MakeItem(@"NSLayoutAttributeLeft", 1), - MakeItem(@"NSLayoutAttributeRight", 2), - MakeItem(@"NSLayoutAttributeTop", 3), - MakeItem(@"NSLayoutAttributeBottom", 4), - MakeItem(@"NSLayoutAttributeLeading", 5), - MakeItem(@"NSLayoutAttributeTrailing", 6), - MakeItem(@"NSLayoutAttributeWidth", 7), - MakeItem(@"NSLayoutAttributeHeight", 8), - MakeItem(@"NSLayoutAttributeCenterX", 9), - MakeItem(@"NSLayoutAttributeCenterY", 10), - MakeItem(@"NSLayoutAttributeLastBaseline", 11), - MakeItem(@"NSLayoutAttributeFirstBaseline", 12), - ]; - mData[@"NSWindowTitleVisibility"] = @[ - MakeItem(@"NSWindowTitleVisible", 0), - MakeItem(@"NSWindowTitleHidden", 1), - ]; - mData[@"NSWindowAnimationBehavior"] = @[ - MakeItem(@"NSWindowAnimationBehaviorDefault", 0), - MakeItem(@"NSWindowAnimationBehaviorNone", 2), - MakeItem(@"NSWindowAnimationBehaviorDocumentWindow", 3), - MakeItem(@"NSWindowAnimationBehaviorUtilityWindow", 4), - MakeItem(@"NSWindowAnimationBehaviorAlertPanel", 5), - ]; - mData[@"NSWindowToolbarStyle"] = @[ - MakeItem(@"NSWindowToolbarStyleAutomatic", 0), - MakeItem(@"NSWindowToolbarStyleExpanded", 1), - MakeItem(@"NSWindowToolbarStylePreference", 2), - MakeItem(@"NSWindowToolbarStyleUnified", 3), - MakeItem(@"NSWindowToolbarStyleUnifiedCompact", 4), - ]; - mData[@"NSTitlebarSeparatorStyle"] = @[ - MakeItem(@"NSTitlebarSeparatorStyleAutomatic", 0), - MakeItem(@"NSTitlebarSeparatorStyleNone", 1), - MakeItem(@"NSTitlebarSeparatorStyleLine", 2), - MakeItem(@"NSTitlebarSeparatorStyleShadow", 3), - ]; - mData[@"NSWindowLevel"] = @[ - MakeItem(@"NSNormalWindowLevel", 0), - MakeItem(@"NSFloatingWindowLevel", 3), - MakeItem(@"NSSubmenuWindowLevel", 3), - MakeItem(@"NSTornOffMenuWindowLevel", 3), - MakeItem(@"NSModalPanelWindowLevel", 8), - MakeItem(@"NSMainMenuWindowLevel", 24), - MakeItem(@"NSStatusWindowLevel", 25), - MakeItem(@"NSPopUpMenuWindowLevel", 101), - MakeItem(@"NSScreenSaverWindowLevel", 1000), - ]; - mData[@"NSWindowTabbingMode"] = @[ - MakeItem(@"NSWindowTabbingModeAutomatic", 0), - MakeItem(@"NSWindowTabbingModePreferred", 1), - MakeItem(@"NSWindowTabbingModeDisallowed", 2), - ]; - - // MARK: - UIWindowScene - mData[@"UISceneActivationState"] = @[ - MakeItem(@"UISceneActivationStateUnattached", -1), - MakeItem(@"UISceneActivationStateForegroundActive", 0), - MakeItem(@"UISceneActivationStateForegroundInactive", 1), - MakeItem(@"UISceneActivationStateBackground", 2), - ]; - mData[@"UIInterfaceOrientation"] = @[ - MakeItem(@"UIInterfaceOrientationUnknown", 0), - MakeItem(@"UIInterfaceOrientationPortrait", 1), - MakeItem(@"UIInterfaceOrientationPortraitUpsideDown", 2), - MakeItem(@"UIInterfaceOrientationLandscapeLeft", 3), - MakeItem(@"UIInterfaceOrientationLandscapeRight", 4), - ]; - mData[@"UIStatusBarStyle"] = @[ - MakeItem(@"UIStatusBarStyleDefault", 0), - MakeItem(@"UIStatusBarStyleLightContent", 1), - MakeItem(@"UIStatusBarStyleDarkContent", 3), - ]; - mData[@"UIUserInterfaceStyle"] = @[ - MakeItem(@"UIUserInterfaceStyleUnspecified", 0), - MakeItem(@"UIUserInterfaceStyleLight", 1), - MakeItem(@"UIUserInterfaceStyleDark", 2), - ]; - mData[@"UIUserInterfaceSizeClass"] = @[ - MakeItem(@"UIUserInterfaceSizeClassUnspecified", 0), - MakeItem(@"UIUserInterfaceSizeClassCompact", 1), - MakeItem(@"UIUserInterfaceSizeClassRegular", 2), - ]; - - // MARK: - UITraitCollection - mData[@"UIUserInterfaceIdiom"] = @[ - MakeItem(@"UIUserInterfaceIdiomUnspecified", -1), - MakeItem(@"UIUserInterfaceIdiomPhone", 0), - MakeItem(@"UIUserInterfaceIdiomPad", 1), - MakeItem(@"UIUserInterfaceIdiomTV", 2), - MakeItem(@"UIUserInterfaceIdiomCarPlay", 3), - MakeItemWithVersion(@"UIUserInterfaceIdiomMac", 5, 14), - MakeItemWithVersion(@"UIUserInterfaceIdiomVision", 6, 17), - ]; - mData[@"UIUserInterfaceLevel"] = @[ - MakeItem(@"UIUserInterfaceLevelUnspecified", -1), - MakeItem(@"UIUserInterfaceLevelBase", 0), - MakeItem(@"UIUserInterfaceLevelElevated", 1), - ]; - mData[@"UIUserInterfaceActiveAppearance"] = @[ - MakeItem(@"UIUserInterfaceActiveAppearanceUnspecified", -1), - MakeItem(@"UIUserInterfaceActiveAppearanceInactive", 0), - MakeItem(@"UIUserInterfaceActiveAppearanceActive", 1), - ]; - mData[@"UIAccessibilityContrast"] = @[ - MakeItem(@"UIAccessibilityContrastUnspecified", -1), - MakeItem(@"UIAccessibilityContrastNormal", 0), - MakeItem(@"UIAccessibilityContrastHigh", 1), - ]; - mData[@"UILegibilityWeight"] = @[ - MakeItem(@"UILegibilityWeightUnspecified", -1), - MakeItem(@"UILegibilityWeightRegular", 0), - MakeItem(@"UILegibilityWeightBold", 1), - ]; - mData[@"UIForceTouchCapability"] = @[ - MakeItem(@"UIForceTouchCapabilityUnknown", 0), - MakeItem(@"UIForceTouchCapabilityUnavailable", 1), - MakeItem(@"UIForceTouchCapabilityAvailable", 2), - ]; - mData[@"UIDisplayGamut"] = @[ - MakeItem(@"UIDisplayGamutUnspecified", -1), - MakeItem(@"UIDisplayGamutSRGB", 0), - MakeItem(@"UIDisplayGamutP3", 1), - ]; - mData[@"UITraitEnvironmentLayoutDirection"] = @[ - MakeItem(@"UITraitEnvironmentLayoutDirectionUnspecified", -1), - MakeItem(@"UITraitEnvironmentLayoutDirectionLeftToRight", 0), - MakeItem(@"UITraitEnvironmentLayoutDirectionRightToLeft", 1), - ]; - mData[@"UIImageDynamicRange"] = @[ - MakeItem(@"UIImageDynamicRangeUnspecified", -1), - MakeItem(@"UIImageDynamicRangeStandard", 0), - MakeItem(@"UIImageDynamicRangeConstrainedHigh", 1), - MakeItem(@"UIImageDynamicRangeHigh", 2), - ]; - mData[@"UISceneCaptureState"] = @[ - MakeItem(@"UISceneCaptureStateUnspecified", -1), - MakeItem(@"UISceneCaptureStateInactive", 0), - MakeItem(@"UISceneCaptureStateActive", 1), - ]; - - // MARK: - NSSlider - mData[@"NSSliderType"] = @[ - MakeItem(@"NSSliderTypeLinear", 0), - MakeItem(@"NSSliderTypeCircular", 1), - ]; - mData[@"NSTickMarkPosition"] = @[ - MakeItem(@"NSTickMarkPositionBelow", 0), - MakeItem(@"NSTickMarkPositionAbove", 1), - MakeItem(@"NSTickMarkPositionLeading", 0), - MakeItem(@"NSTickMarkPositionTrailing", 1), - ]; - - // MARK: - NSProgressIndicator - mData[@"NSProgressIndicatorStyle"] = @[ - MakeItem(@"NSProgressIndicatorStyleBar", 0), - MakeItem(@"NSProgressIndicatorStyleSpinning", 1), - ]; - - // MARK: - NSSegmentedControl - mData[@"NSSegmentStyle"] = @[ - MakeItem(@"NSSegmentStyleAutomatic", 0), - MakeItem(@"NSSegmentStyleRounded", 1), - MakeItem(@"NSSegmentStyleRoundRect", 3), - MakeItem(@"NSSegmentStyleTexturedSquare", 4), - MakeItem(@"NSSegmentStyleSmallSquare", 6), - MakeItem(@"NSSegmentStyleSeparated", 8), - MakeItem(@"NSSegmentStyleTexturedRounded", 2), - MakeItem(@"NSSegmentStyleCapsule", 5), - ]; - mData[@"NSSegmentSwitchTracking"] = @[ - MakeItem(@"NSSegmentSwitchTrackingSelectOne", 0), - MakeItem(@"NSSegmentSwitchTrackingSelectAny", 1), - MakeItem(@"NSSegmentSwitchTrackingMomentary", 2), - MakeItem(@"NSSegmentSwitchTrackingMomentaryAccelerator", 3), - ]; - - // MARK: - NSPopUpButton - mData[@"NSRectEdge"] = @[ - MakeItem(@"NSRectEdgeMinX", 0), - MakeItem(@"NSRectEdgeMinY", 1), - MakeItem(@"NSRectEdgeMaxX", 2), - MakeItem(@"NSRectEdgeMaxY", 3), - ]; - - // MARK: - NSColorWell - mData[@"NSColorWellStyle"] = @[ - MakeItem(@"NSColorWellStyleDefault", 0), - MakeItem(@"NSColorWellStyleMinimal", 1), - MakeItem(@"NSColorWellStyleExpanded", 2), - ]; - - // MARK: - NSDatePicker - mData[@"NSDatePickerStyle"] = @[ - MakeItem(@"NSDatePickerStyleTextFieldAndStepper", 0), - MakeItem(@"NSDatePickerStyleClockAndCalendar", 1), - MakeItem(@"NSDatePickerStyleTextField", 2), - ]; - mData[@"NSDatePickerMode"] = @[ - MakeItem(@"NSDatePickerModeSingle", 0), - MakeItem(@"NSDatePickerModeRange", 1), - ]; - - // MARK: - NSLevelIndicator - mData[@"NSLevelIndicatorStyle"] = @[ - MakeItem(@"NSLevelIndicatorStyleRelevancy", 0), - MakeItem(@"NSLevelIndicatorStyleContinuousCapacity", 1), - MakeItem(@"NSLevelIndicatorStyleDiscreteCapacity", 2), - MakeItem(@"NSLevelIndicatorStyleRating", 3), - ]; - - // MARK: - NSBox - mData[@"NSBoxType"] = @[ - MakeItem(@"NSBoxPrimary", 0), - MakeItem(@"NSBoxSeparator", 2), - MakeItem(@"NSBoxCustom", 4), - ]; - mData[@"NSTitlePosition"] = @[ - MakeItem(@"NSNoTitle", 0), - MakeItem(@"NSAboveTop", 1), - MakeItem(@"NSAtTop", 2), - MakeItem(@"NSBelowTop", 3), - MakeItem(@"NSAboveBottom", 4), - MakeItem(@"NSAtBottom", 5), - MakeItem(@"NSBelowBottom", 6), - ]; - - // MARK: - NSSplitView - mData[@"NSSplitViewDividerStyle"] = @[ - MakeItem(@"NSSplitViewDividerStyleThick", 1), - MakeItem(@"NSSplitViewDividerStyleThin", 2), - MakeItem(@"NSSplitViewDividerStylePaneSplitter", 3), - ]; - - // MARK: - NSTabView - mData[@"NSTabViewType"] = @[ - MakeItem(@"NSTopTabsBezelBorder", 0), - MakeItem(@"NSLeftTabsBezelBorder", 1), - MakeItem(@"NSBottomTabsBezelBorder", 2), - MakeItem(@"NSRightTabsBezelBorder", 3), - MakeItem(@"NSNoTabsBezelBorder", 4), - MakeItem(@"NSNoTabsLineBorder", 5), - MakeItem(@"NSNoTabsNoBorder", 6), - ]; - mData[@"NSTabPosition"] = @[ - MakeItem(@"NSTabPositionNone", 0), - MakeItem(@"NSTabPositionTop", 1), - MakeItem(@"NSTabPositionLeft", 2), - MakeItem(@"NSTabPositionBottom", 3), - MakeItem(@"NSTabPositionRight", 4), - ]; - mData[@"NSTabViewBorderType"] = @[ - MakeItem(@"NSTabViewBorderTypeNone", 0), - MakeItem(@"NSTabViewBorderTypeLine", 1), - MakeItem(@"NSTabViewBorderTypeBezel", 2), - ]; - - // MARK: - NSGridView - mData[@"NSGridCellPlacement"] = @[ - MakeItem(@"NSGridCellPlacementInherited", 0), - MakeItem(@"NSGridCellPlacementNone", 1), - MakeItem(@"NSGridCellPlacementLeading", 2), - MakeItem(@"NSGridCellPlacementTop", 2), - MakeItem(@"NSGridCellPlacementTrailing", 3), - MakeItem(@"NSGridCellPlacementBottom", 3), - MakeItem(@"NSGridCellPlacementCenter", 4), - MakeItem(@"NSGridCellPlacementFill", 5), - ]; - - self.data = mData; - } - return self; -} - -- (NSArray *)itemsForEnumName:(NSString *)enumName { - NSArray *items = self.data[enumName]; - return items; -} - -- (NSString *)descForEnumName:(NSString *)enumName value:(long)value { - NSArray *items = [self itemsForEnumName:enumName]; - if (!items) { - NSAssert(NO, @""); - return nil; - } - LKEnumListRegistryKeyValueItem *MakeItem = [items lookin_firstFiltered:^BOOL(LKEnumListRegistryKeyValueItem *obj) { - return (obj.value == value); - }]; - return MakeItem.desc; -} - -@end diff --git a/LookInside/Dashboard/LKEnumListRegistry.swift b/LookInside/Dashboard/LKEnumListRegistry.swift new file mode 100644 index 00000000..ca7e4940 --- /dev/null +++ b/LookInside/Dashboard/LKEnumListRegistry.swift @@ -0,0 +1,708 @@ +// +// LKEnumListRegistry.swift +// LookInside +// +// Created by Li Kai on 2018/11/21. +// https://lookin.work +// +// The values and names of every enum the Dashboard can show and edit, +// keyed by the enum list name `LookinDashboardBlueprint` gives an +// attribute. Foundation only, so the tests can compile it on its own. +// + +import Foundation + +/// One case of an enum: its name, raw value, and the first OS major +/// version that has it (0 for every version). +@objc(LKEnumListRegistryKeyValueItem) +final class LKEnumListRegistryKeyValueItem: NSObject { + @objc let desc: String + @objc let value: Int + @objc let availableOSVersion: Int + + init(desc: String, value: Int, availableOSVersion: Int) { + self.desc = desc + self.value = value + self.availableOSVersion = availableOSVersion + } +} + +@objc(LKEnumListRegistry) +final class LKEnumListRegistry: NSObject { + @objc(sharedInstance) + static let shared = LKEnumListRegistry() + + private let data: [String: [LKEnumListRegistryKeyValueItem]] + + override private init() { + data = Self.makeData() + super.init() + } + + /// The cases of `enumName` in menu order, or nil for an unknown list. + @objc(itemsForEnumName:) + func items(forEnumName enumName: String?) -> [LKEnumListRegistryKeyValueItem]? { + guard let enumName else { return nil } + return data[enumName] + } + + /// The name of the first case of `enumName` whose value is `value`. + @objc(descForEnumName:value:) + func desc(forEnumName enumName: String?, value: Int) -> String? { + guard let items = items(forEnumName: enumName) else { + assertionFailure("unknown enum list \(enumName ?? "nil")") + return nil + } + return items.first { $0.value == value }?.desc + } + + /// Every list, for the tests. + var allEnumNames: [String] { + Array(data.keys) + } + + private static func item(_ desc: String, _ value: Int, _ availableOSVersion: Int = 0) -> LKEnumListRegistryKeyValueItem { + LKEnumListRegistryKeyValueItem(desc: desc, value: value, availableOSVersion: availableOSVersion) + } + + /// Lists are assigned in order; a later assignment of the same name + /// replaces the earlier one, as `NSTextFieldBezelStyle` does. + private static func makeData() -> [String: [LKEnumListRegistryKeyValueItem]] { + var data: [String: [LKEnumListRegistryKeyValueItem]] = [:] + + data["UIControlContentVerticalAlignment"] = [item("UIControlContentVerticalAlignmentCenter", 0), + item("UIControlContentVerticalAlignmentTop", 1), + item("UIControlContentVerticalAlignmentBottom", 2), + item("UIControlContentVerticalAlignmentFill", 3)] + + data["UIControlContentHorizontalAlignment"] = [item("UIControlContentHorizontalAlignmentCenter", 0), + item("UIControlContentHorizontalAlignmentLeft", 1), + item("UIControlContentHorizontalAlignmentRight", 2), + item("UIControlContentHorizontalAlignmentFill", 3), + item("UIControlContentHorizontalAlignmentLeading", 4, 11), + item("UIControlContentHorizontalAlignmentTrailing", 5, 11)] + + data["UIViewContentMode"] = [item("UIViewContentModeScaleToFill", 0), + item("UIViewContentModeScaleAspectFit", 1), + item("UIViewContentModeScaleAspectFill", 2), + item("UIViewContentModeRedraw", 3), + item("UIViewContentModeCenter", 4), + item("UIViewContentModeTop", 5), + item("UIViewContentModeBottom", 6), + item("UIViewContentModeLeft", 7), + item("UIViewContentModeRight", 8), + item("UIViewContentModeTopLeft", 9), + item("UIViewContentModeTopRight", 10), + item("UIViewContentModeBottomLeft", 11), + item("UIViewContentModeBottomRight", 12)] + + data["UIViewTintAdjustmentMode"] = [item("UIViewTintAdjustmentModeAutomatic", 0), + item("UIViewTintAdjustmentModeNormal", 1), + item("UIViewTintAdjustmentModeDimmed", 2)] + + data["NSTextAlignment"] = [item("NSTextAlignmentLeft", 0), + item("NSTextAlignmentCenter", 1), + item("NSTextAlignmentRight", 2), + item("NSTextAlignmentJustified", 3), + item("NSTextAlignmentNatural", 4)] + + data["NSLineBreakMode"] = [item("NSLineBreakByWordWrapping", 0), + item("NSLineBreakByCharWrapping", 1), + item("NSLineBreakByClipping", 2), + item("NSLineBreakByTruncatingHead", 3), + item("NSLineBreakByTruncatingTail", 4), + item("NSLineBreakByTruncatingMiddle", 5)] + + data["UIScrollViewContentInsetAdjustmentBehavior"] = [ + item("UIScrollViewContentInsetAdjustmentAutomatic", 0), + item("UIScrollViewContentInsetAdjustmentScrollableAxes", 1), + item("UIScrollViewContentInsetAdjustmentNever", 2), + item("UIScrollViewContentInsetAdjustmentAlways", 3), + ] + + data["UITableViewStyle"] = [item("UITableViewStylePlain", 0), + item("UITableViewStyleGrouped", 1)] + + data["UITextFieldViewMode"] = [item("UITextFieldViewModeNever", 0), + item("UITextFieldViewModeWhileEditing", 1), + item("UITextFieldViewModeUnlessEditing", 2), + item("UITextFieldViewModeAlways", 3)] + + data["UIAccessibilityNavigationStyle"] = [ + item("UIAccessibilityNavigationStyleAutomatic", 0), + item("UIAccessibilityNavigationStyleSeparate", 1), + item("UIAccessibilityNavigationStyleCombined", 2), + ] + + data["QMUIButtonImagePosition"] = [ + item("QMUIButtonImagePositionTop", 0), + item("QMUIButtonImagePositionLeft", 1), + item("QMUIButtonImagePositionBottom", 2), + item("QMUIButtonImagePositionRight", 3), + ] + + data["UITableViewCellSeparatorStyle"] = [ + item("UITableViewCellSeparatorStyleNone", 0), + item("UITableViewCellSeparatorStyleSingleLine", 1), + item("UITableViewCellSeparatorStyleSingleLineEtched", 2), + ] + + data["UIBlurEffectStyle"] = [ + item("UIBlurEffectStyleExtraLight", 0), + item("UIBlurEffectStyleLight", 1), + item("UIBlurEffectStyleDark", 2), + // item("UIBlurEffectStyleExtraDark", 3), // 该值被官方标注了 API_UNAVAILABLE(ios),因此这里跳过 + item("UIBlurEffectStyleRegular", 4, 10), + item("UIBlurEffectStyleProminent", 5, 10), + item("UIBlurEffectStyleSystemUltraThinMaterial", 6, 13), + item("UIBlurEffectStyleSystemThinMaterial", 7, 13), + item("UIBlurEffectStyleSystemMaterial", 8, 13), + item("UIBlurEffectStyleSystemThickMaterial", 9, 13), + item("UIBlurEffectStyleSystemChromeMaterial", 10, 13), + item("UIBlurEffectStyleSystemUltraThinMaterialLight", 11, 13), + item("UIBlurEffectStyleSystemThinMaterialLight", 12, 13), + item("UIBlurEffectStyleSystemMaterialLight", 13, 13), + item("UIBlurEffectStyleSystemThickMaterialLight", 14, 13), + item("UIBlurEffectStyleSystemChromeMaterialLight", 15, 13), + item("UIBlurEffectStyleSystemUltraThinMaterialDark", 16, 13), + item("UIBlurEffectStyleSystemThinMaterialDark", 17, 13), + item("UIBlurEffectStyleSystemMaterialDark", 18, 13), + item("UIBlurEffectStyleSystemThickMaterialDark", 19, 13), + item("UIBlurEffectStyleSystemChromeMaterialDark", 20, 13), + ] + + data["UILayoutConstraintAxis"] = [ + item("UILayoutConstraintAxisHorizontal", 0), + item("UILayoutConstraintAxisVertical", 1), + ] + + data["UIStackViewDistribution"] = [ + item("UIStackViewDistributionFill", 0), + item("UIStackViewDistributionFillEqually", 1), + item("UIStackViewDistributionFillProportionally", 2), + item("UIStackViewDistributionEqualSpacing", 3), + item("UIStackViewDistributionEqualCentering", 4), + ] + + data["UIStackViewAlignment"] = [ + item("UIStackViewAlignmentFill", 0), + item("UIStackViewAlignmentLeading (Top)", 1), + item("UIStackViewAlignmentFirstBaseline", 2), + item("UIStackViewAlignmentCenter", 3), + item("UIStackViewAlignmentTrailing (Bottom)", 4), + item("UIStackViewAlignmentLastBaseline", 5), + ] + data["NSWritingDirection"] = [ + item("NSWritingDirectionNatural", -1), + item("NSWritingDirectionLeftToRight", 0), + item("NSWritingDirectionRightToLeft", 1), + ] + data["NSTextAlignment_AppKit"] = [ + item("NSTextAlignmentLeft", 0), + item("NSTextAlignmentRight", 1), + item("NSTextAlignmentCenter", 2), + item("NSTextAlignmentJustified", 3), + item("NSTextAlignmentNatural", 4), + ] + data["NSCellType"] = [ + item("NSNullCellType", 0), + item("NSTextCellType", 1), + item("NSImageCellType", 2), + ] + data["NSTextFieldBezelStyle"] = [ + item("NSTextFieldSquareBezel", 0), + item("NSTextFieldRoundedBezel", 1), + ] + data["NSButtonType"] = [ + item("NSButtonTypeMomentaryLight", 0), + item("NSButtonTypePushOnPushOff", 1), + item("NSButtonTypeToggle", 2), + item("NSButtonTypeSwitch", 3), + item("NSButtonTypeRadio", 4), + item("NSButtonTypeMomentaryChange", 5), + item("NSButtonTypeOnOff", 6), + item("NSButtonTypeMomentaryPushIn", 7), + item("NSButtonTypeAccelerator", 8), + item("NSButtonTypeMultiLevelAccelerator", 9), + ] + data["NSBezelStyle"] = [ + item("NSBezelStyleAutomatic", 0), + item("NSBezelStylePush", 1), + item("NSBezelStyleFlexiblePush", 2), + item("NSBezelStyleDisclosure", 5), + item("NSBezelStyleShadowlessSquare", 6), + item("NSBezelStyleCircular", 7), + item("NSBezelStyleTexturedSquare", 8), + item("NSBezelStyleHelpButton", 9), + item("NSBezelStyleSmallSquare", 10), + item("NSBezelStyleToolbar", 11), + item("NSBezelStyleAccessoryBarAction", 12), + item("NSBezelStyleAccessoryBar", 13), + item("NSBezelStylePushDisclosure", 14), + item("NSBezelStyleBadge", 15), + ] + data["NSTextFieldBezelStyle"] = [ + item("NSTextFieldSquareBezel", 0), + item("NSTextFieldRoundedBezel", 1), + ] + data["NSLineBreakStrategy"] = [ + item("NSLineBreakStrategyNone", 0), + item("NSLineBreakStrategyPushOut", 1), + item("NSLineBreakStrategyHangulWordPriority", 2), + item("NSLineBreakStrategyStandard", 0xFFFF), + ] + data["NSCellImagePosition"] = [ + item("NSNoImage", 0), + item("NSImageOnly", 1), + item("NSImageLeft", 2), + item("NSImageRight", 3), + item("NSImageBelow", 4), + item("NSImageAbove", 5), + item("NSImageOverlaps", 6), + item("NSImageLeading", 7), + item("NSImageTrailing", 8), + ] + data["NSImageScaling"] = [ + item("NSImageScaleProportionallyDown", 0), + item("NSImageScaleAxesIndependently", 1), + item("NSImageScaleNone", 2), + item("NSImageScaleProportionallyUpOrDown", 3), + ] + data["NSImageAlignment"] = [ + item("NSImageAlignCenter", 0), + item("NSImageAlignTop", 1), + item("NSImageAlignTopLeft", 2), + item("NSImageAlignTopRight", 3), + item("NSImageAlignLeft", 4), + item("NSImageAlignBottom", 5), + item("NSImageAlignBottomLeft", 6), + item("NSImageAlignBottomRight", 7), + item("NSImageAlignRight", 8), + ] + data["NSImageFrameStyle"] = [ + item("NSImageFrameNone", 0), + item("NSImageFramePhoto", 1), + item("NSImageFrameGrayBezel", 2), + item("NSImageFrameGroove", 3), + item("NSImageFrameButton", 4), + ] + data["NSControlStateValue"] = [ + item("NSControlStateValueOff", 0), + item("NSControlStateValueOn", 1), + item("NSControlStateValueMixed", -1), + ] + + data["NSControlSize"] = [ + item("NSControlSizeRegular", 0), + item("NSControlSizeSmall", 1), + item("NSControlSizeMini", 2), + item("NSControlSizeLarge", 3), + ] + data["NSEventModifierFlags"] = [ + item("NSEventModifierFlagCapsLock", 1 << 16), + item("NSEventModifierFlagShift", 1 << 17), + item("NSEventModifierFlagControl", 1 << 18), + item("NSEventModifierFlagOption", 1 << 19), + item("NSEventModifierFlagCommand", 1 << 20), + item("NSEventModifierFlagNumericPad", 1 << 21), + item("NSEventModifierFlagHelp", 1 << 22), + item("NSEventModifierFlagFunction", 1 << 23), + ] + data["NSScrollElasticity"] = [ + item("NSScrollElasticityAutomatic", 0), + item("NSScrollElasticityNone", 1), + item("NSScrollElasticityAllowed", 2), + ] + data["NSBorderType"] = [ + item("NSNoBorder", 0), + item("NSLineBorder", 1), + item("NSBezelBorder", 2), + item("NSGrooveBorder", 3), + ] + data["NSScrollerStyle"] = [ + item("NSScrollerStyleLegacy", 0), + item("NSScrollerStyleOverlay", 1), + ] + data["NSScrollerKnobStyle"] = [ + item("NSScrollerKnobStyleDefault", 0), + item("NSScrollerKnobStyleDark", 1), + item("NSScrollerKnobStyleLight", 2), + ] + data["NSTableViewColumnAutoresizingStyle"] = [ + item("NSTableViewNoColumnAutoresizing", 0), + item("NSTableViewUniformColumnAutoresizingStyle", 1), + item("NSTableViewSequentialColumnAutoresizingStyle", 2), + item("NSTableViewReverseSequentialColumnAutoresizingStyle", 3), + item("NSTableViewLastColumnOnlyAutoresizingStyle", 4), + item("NSTableViewFirstColumnOnlyAutoresizingStyle", 5), + ] + data["NSTableViewGridLineStyle"] = [ + item("NSTableViewGridNone", 0), + item("NSTableViewSolidVerticalGridLineMask", 1 << 0), + item("NSTableViewSolidHorizontalGridLineMask", 1 << 1), + item("NSTableViewDashedHorizontalGridLineMask", 1 << 3), + ] + data["NSTableViewRowSizeStyle"] = [ + item("NSTableViewRowSizeStyleDefault", -1), + item("NSTableViewRowSizeStyleCustom", 0), + item("NSTableViewRowSizeStyleSmall", 1), + item("NSTableViewRowSizeStyleMedium", 2), + item("NSTableViewRowSizeStyleLarge", 3), + ] + data["NSTableViewStyle"] = [ + item("NSTableViewStyleAutomatic", 0), + item("NSTableViewStyleFullWidth", 1), + item("NSTableViewStyleInset", 2), + item("NSTableViewStyleSourceList", 3), + item("NSTableViewStylePlain", 4), + ] + data["NSTableViewSelectionHighlightStyle"] = [ + item("NSTableViewSelectionHighlightStyleNone", -1), + item("NSTableViewSelectionHighlightStyleRegular", 0), + item("NSTableViewSelectionHighlightStyleSourceList", 1), + ] + data["NSTableViewDraggingDestinationFeedbackStyle"] = [ + item("NSTableViewDraggingDestinationFeedbackStyleNone", -1), + item("NSTableViewDraggingDestinationFeedbackStyleRegular", 0), + item("NSTableViewDraggingDestinationFeedbackStyleSourceList", 1), + item("NSTableViewDraggingDestinationFeedbackStyleGap", 2), + ] + data["NSUserInterfaceLayoutDirection"] = [ + item("NSUserInterfaceLayoutDirectionLeftToRight", 0), + item("NSUserInterfaceLayoutDirectionRightToLeft", 1), + ] + data["NSVisualEffectMaterial"] = [ + item("NSVisualEffectMaterialAppearanceBased", 0), + item("NSVisualEffectMaterialLight", 1), + item("NSVisualEffectMaterialDark", 2), + item("NSVisualEffectMaterialTitlebar", 3), + item("NSVisualEffectMaterialSelection", 4), + item("NSVisualEffectMaterialMenu", 5), + item("NSVisualEffectMaterialPopover", 6), + item("NSVisualEffectMaterialSidebar", 7), + item("NSVisualEffectMaterialMediumLight", 8), + item("NSVisualEffectMaterialUltraDark", 9), + item("NSVisualEffectMaterialHeaderView", 10), + item("NSVisualEffectMaterialSheet", 11), + item("NSVisualEffectMaterialWindowBackground", 12), + item("NSVisualEffectMaterialHUDWindow", 13), + item("NSVisualEffectMaterialFullScreenUI", 15), + item("NSVisualEffectMaterialToolTip", 17), + item("NSVisualEffectMaterialContentBackground", 18), + item("NSVisualEffectMaterialUnderWindowBackground", 21), + item("NSVisualEffectMaterialUnderPageBackground", 22), + ] + data["NSVisualEffectBlendingMode"] = [ + item("NSVisualEffectBlendingModeBehindWindow", 0), + item("NSVisualEffectBlendingModeWithinWindow", 1), + ] + data["NSVisualEffectState"] = [ + item("NSVisualEffectStateFollowsWindowActiveState", 0), + item("NSVisualEffectStateActive", 1), + item("NSVisualEffectStateInactive", 2), + ] + data["NSBackgroundStyle"] = [ + item("NSBackgroundStyleNormal", 0), + item("NSBackgroundStyleEmphasized", 1), + item("NSBackgroundStyleRaised", 2), + item("NSBackgroundStyleLowered", 3), + ] + data["NSStackViewDistribution"] = [ + item("NSStackViewDistributionGravityAreas", -1), + item("NSStackViewDistributionFill", 0), + item("NSStackViewDistributionFillEqually", 1), + item("NSStackViewDistributionFillProportionally", 2), + item("NSStackViewDistributionEqualSpacing", 3), + item("NSStackViewDistributionEqualCentering", 4), + ] + data["NSUserInterfaceLayoutOrientation"] = [ + item("NSUserInterfaceLayoutOrientationHorizontal", 0), + item("NSUserInterfaceLayoutOrientationVertical", 1), + ] + data["NSLayoutAttribute"] = [ + item("NSLayoutAttributeNotAnAttribute", 0), + item("NSLayoutAttributeLeft", 1), + item("NSLayoutAttributeRight", 2), + item("NSLayoutAttributeTop", 3), + item("NSLayoutAttributeBottom", 4), + item("NSLayoutAttributeLeading", 5), + item("NSLayoutAttributeTrailing", 6), + item("NSLayoutAttributeWidth", 7), + item("NSLayoutAttributeHeight", 8), + item("NSLayoutAttributeCenterX", 9), + item("NSLayoutAttributeCenterY", 10), + item("NSLayoutAttributeLastBaseline", 11), + item("NSLayoutAttributeFirstBaseline", 12), + ] + data["NSWindowTitleVisibility"] = [ + item("NSWindowTitleVisible", 0), + item("NSWindowTitleHidden", 1), + ] + data["NSWindowAnimationBehavior"] = [ + item("NSWindowAnimationBehaviorDefault", 0), + item("NSWindowAnimationBehaviorNone", 2), + item("NSWindowAnimationBehaviorDocumentWindow", 3), + item("NSWindowAnimationBehaviorUtilityWindow", 4), + item("NSWindowAnimationBehaviorAlertPanel", 5), + ] + data["NSWindowToolbarStyle"] = [ + item("NSWindowToolbarStyleAutomatic", 0), + item("NSWindowToolbarStyleExpanded", 1), + item("NSWindowToolbarStylePreference", 2), + item("NSWindowToolbarStyleUnified", 3), + item("NSWindowToolbarStyleUnifiedCompact", 4), + ] + data["NSTitlebarSeparatorStyle"] = [ + item("NSTitlebarSeparatorStyleAutomatic", 0), + item("NSTitlebarSeparatorStyleNone", 1), + item("NSTitlebarSeparatorStyleLine", 2), + item("NSTitlebarSeparatorStyleShadow", 3), + ] + data["NSWindowLevel"] = [ + item("NSNormalWindowLevel", 0), + item("NSFloatingWindowLevel", 3), + item("NSSubmenuWindowLevel", 3), + item("NSTornOffMenuWindowLevel", 3), + item("NSModalPanelWindowLevel", 8), + item("NSMainMenuWindowLevel", 24), + item("NSStatusWindowLevel", 25), + item("NSPopUpMenuWindowLevel", 101), + item("NSScreenSaverWindowLevel", 1000), + ] + data["NSWindowTabbingMode"] = [ + item("NSWindowTabbingModeAutomatic", 0), + item("NSWindowTabbingModePreferred", 1), + item("NSWindowTabbingModeDisallowed", 2), + ] + + // MARK: - UIWindowScene + + data["UISceneActivationState"] = [ + item("UISceneActivationStateUnattached", -1), + item("UISceneActivationStateForegroundActive", 0), + item("UISceneActivationStateForegroundInactive", 1), + item("UISceneActivationStateBackground", 2), + ] + data["UIInterfaceOrientation"] = [ + item("UIInterfaceOrientationUnknown", 0), + item("UIInterfaceOrientationPortrait", 1), + item("UIInterfaceOrientationPortraitUpsideDown", 2), + item("UIInterfaceOrientationLandscapeLeft", 3), + item("UIInterfaceOrientationLandscapeRight", 4), + ] + data["UIStatusBarStyle"] = [ + item("UIStatusBarStyleDefault", 0), + item("UIStatusBarStyleLightContent", 1), + item("UIStatusBarStyleDarkContent", 3), + ] + data["UIUserInterfaceStyle"] = [ + item("UIUserInterfaceStyleUnspecified", 0), + item("UIUserInterfaceStyleLight", 1), + item("UIUserInterfaceStyleDark", 2), + ] + data["UIUserInterfaceSizeClass"] = [ + item("UIUserInterfaceSizeClassUnspecified", 0), + item("UIUserInterfaceSizeClassCompact", 1), + item("UIUserInterfaceSizeClassRegular", 2), + ] + + // MARK: - UITraitCollection + + data["UIUserInterfaceIdiom"] = [ + item("UIUserInterfaceIdiomUnspecified", -1), + item("UIUserInterfaceIdiomPhone", 0), + item("UIUserInterfaceIdiomPad", 1), + item("UIUserInterfaceIdiomTV", 2), + item("UIUserInterfaceIdiomCarPlay", 3), + item("UIUserInterfaceIdiomMac", 5, 14), + item("UIUserInterfaceIdiomVision", 6, 17), + ] + data["UIUserInterfaceLevel"] = [ + item("UIUserInterfaceLevelUnspecified", -1), + item("UIUserInterfaceLevelBase", 0), + item("UIUserInterfaceLevelElevated", 1), + ] + data["UIUserInterfaceActiveAppearance"] = [ + item("UIUserInterfaceActiveAppearanceUnspecified", -1), + item("UIUserInterfaceActiveAppearanceInactive", 0), + item("UIUserInterfaceActiveAppearanceActive", 1), + ] + data["UIAccessibilityContrast"] = [ + item("UIAccessibilityContrastUnspecified", -1), + item("UIAccessibilityContrastNormal", 0), + item("UIAccessibilityContrastHigh", 1), + ] + data["UILegibilityWeight"] = [ + item("UILegibilityWeightUnspecified", -1), + item("UILegibilityWeightRegular", 0), + item("UILegibilityWeightBold", 1), + ] + data["UIForceTouchCapability"] = [ + item("UIForceTouchCapabilityUnknown", 0), + item("UIForceTouchCapabilityUnavailable", 1), + item("UIForceTouchCapabilityAvailable", 2), + ] + data["UIDisplayGamut"] = [ + item("UIDisplayGamutUnspecified", -1), + item("UIDisplayGamutSRGB", 0), + item("UIDisplayGamutP3", 1), + ] + data["UITraitEnvironmentLayoutDirection"] = [ + item("UITraitEnvironmentLayoutDirectionUnspecified", -1), + item("UITraitEnvironmentLayoutDirectionLeftToRight", 0), + item("UITraitEnvironmentLayoutDirectionRightToLeft", 1), + ] + data["UIImageDynamicRange"] = [ + item("UIImageDynamicRangeUnspecified", -1), + item("UIImageDynamicRangeStandard", 0), + item("UIImageDynamicRangeConstrainedHigh", 1), + item("UIImageDynamicRangeHigh", 2), + ] + data["UISceneCaptureState"] = [ + item("UISceneCaptureStateUnspecified", -1), + item("UISceneCaptureStateInactive", 0), + item("UISceneCaptureStateActive", 1), + ] + + // MARK: - NSSlider + + data["NSSliderType"] = [ + item("NSSliderTypeLinear", 0), + item("NSSliderTypeCircular", 1), + ] + data["NSTickMarkPosition"] = [ + item("NSTickMarkPositionBelow", 0), + item("NSTickMarkPositionAbove", 1), + item("NSTickMarkPositionLeading", 0), + item("NSTickMarkPositionTrailing", 1), + ] + + // MARK: - NSProgressIndicator + + data["NSProgressIndicatorStyle"] = [ + item("NSProgressIndicatorStyleBar", 0), + item("NSProgressIndicatorStyleSpinning", 1), + ] + + // MARK: - NSSegmentedControl + + data["NSSegmentStyle"] = [ + item("NSSegmentStyleAutomatic", 0), + item("NSSegmentStyleRounded", 1), + item("NSSegmentStyleRoundRect", 3), + item("NSSegmentStyleTexturedSquare", 4), + item("NSSegmentStyleSmallSquare", 6), + item("NSSegmentStyleSeparated", 8), + item("NSSegmentStyleTexturedRounded", 2), + item("NSSegmentStyleCapsule", 5), + ] + data["NSSegmentSwitchTracking"] = [ + item("NSSegmentSwitchTrackingSelectOne", 0), + item("NSSegmentSwitchTrackingSelectAny", 1), + item("NSSegmentSwitchTrackingMomentary", 2), + item("NSSegmentSwitchTrackingMomentaryAccelerator", 3), + ] + + // MARK: - NSPopUpButton + + data["NSRectEdge"] = [ + item("NSRectEdgeMinX", 0), + item("NSRectEdgeMinY", 1), + item("NSRectEdgeMaxX", 2), + item("NSRectEdgeMaxY", 3), + ] + + // MARK: - NSColorWell + + data["NSColorWellStyle"] = [ + item("NSColorWellStyleDefault", 0), + item("NSColorWellStyleMinimal", 1), + item("NSColorWellStyleExpanded", 2), + ] + + // MARK: - NSDatePicker + + data["NSDatePickerStyle"] = [ + item("NSDatePickerStyleTextFieldAndStepper", 0), + item("NSDatePickerStyleClockAndCalendar", 1), + item("NSDatePickerStyleTextField", 2), + ] + data["NSDatePickerMode"] = [ + item("NSDatePickerModeSingle", 0), + item("NSDatePickerModeRange", 1), + ] + + // MARK: - NSLevelIndicator + + data["NSLevelIndicatorStyle"] = [ + item("NSLevelIndicatorStyleRelevancy", 0), + item("NSLevelIndicatorStyleContinuousCapacity", 1), + item("NSLevelIndicatorStyleDiscreteCapacity", 2), + item("NSLevelIndicatorStyleRating", 3), + ] + + // MARK: - NSBox + + data["NSBoxType"] = [ + item("NSBoxPrimary", 0), + item("NSBoxSeparator", 2), + item("NSBoxCustom", 4), + ] + data["NSTitlePosition"] = [ + item("NSNoTitle", 0), + item("NSAboveTop", 1), + item("NSAtTop", 2), + item("NSBelowTop", 3), + item("NSAboveBottom", 4), + item("NSAtBottom", 5), + item("NSBelowBottom", 6), + ] + + // MARK: - NSSplitView + + data["NSSplitViewDividerStyle"] = [ + item("NSSplitViewDividerStyleThick", 1), + item("NSSplitViewDividerStyleThin", 2), + item("NSSplitViewDividerStylePaneSplitter", 3), + ] + + // MARK: - NSTabView + + data["NSTabViewType"] = [ + item("NSTopTabsBezelBorder", 0), + item("NSLeftTabsBezelBorder", 1), + item("NSBottomTabsBezelBorder", 2), + item("NSRightTabsBezelBorder", 3), + item("NSNoTabsBezelBorder", 4), + item("NSNoTabsLineBorder", 5), + item("NSNoTabsNoBorder", 6), + ] + data["NSTabPosition"] = [ + item("NSTabPositionNone", 0), + item("NSTabPositionTop", 1), + item("NSTabPositionLeft", 2), + item("NSTabPositionBottom", 3), + item("NSTabPositionRight", 4), + ] + data["NSTabViewBorderType"] = [ + item("NSTabViewBorderTypeNone", 0), + item("NSTabViewBorderTypeLine", 1), + item("NSTabViewBorderTypeBezel", 2), + ] + + // MARK: - NSGridView + + data["NSGridCellPlacement"] = [ + item("NSGridCellPlacementInherited", 0), + item("NSGridCellPlacementNone", 1), + item("NSGridCellPlacementLeading", 2), + item("NSGridCellPlacementTop", 2), + item("NSGridCellPlacementTrailing", 3), + item("NSGridCellPlacementBottom", 3), + item("NSGridCellPlacementCenter", 4), + item("NSGridCellPlacementFill", 5), + ] + + return data + } +} diff --git a/LookInside/Dashboard/Search/LKDashboardHeaderView.h b/LookInside/Dashboard/Search/LKDashboardHeaderView.h deleted file mode 100644 index 2f319ca2..00000000 --- a/LookInside/Dashboard/Search/LKDashboardHeaderView.h +++ /dev/null @@ -1,29 +0,0 @@ -// -// LKDashboardHeaderView.h -// Lookin -// -// Created by Li Kai on 2019/9/5. -// https://lookin.work -// - -#import "LKBaseView.h" - -@class LKDashboardHeaderView; - -@protocol LKDashboardHeaderViewDelegate - -- (void)dashboardHeaderView:(LKDashboardHeaderView *)view didToggleActive:(BOOL)isActive; - -- (void)dashboardHeaderView:(LKDashboardHeaderView *)view didInputString:(NSString *)string; - -@end - -@interface LKDashboardHeaderView : LKBaseView - -@property(nonatomic, assign) BOOL isActive; - -- (NSString *)currentInputString; - -@property(nonatomic, weak) id delegate; - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardHeaderView.m b/LookInside/Dashboard/Search/LKDashboardHeaderView.m deleted file mode 100644 index 02620a17..00000000 --- a/LookInside/Dashboard/Search/LKDashboardHeaderView.m +++ /dev/null @@ -1,171 +0,0 @@ -// -// LKDashboardHeaderView.m -// Lookin -// -// Created by Li Kai on 2019/9/5. -// https://lookin.work -// - -#import "LKDashboardHeaderView.h" -#import "LKHelper.h" -#import "LKPreferenceManager.h" - -@interface LKDashboardHeaderView () - -@property(nonatomic, strong) NSView *inputBorderView; -@property(nonatomic, strong) NSImageView *iconImageView; -@property(nonatomic, strong) NSTextField *textField; -@property(nonatomic, strong) NSButton *addButton; - -@end - -@implementation LKDashboardHeaderView { - CGFloat _iconXWhenActive; - CGFloat _iconXWhenInactive; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _iconXWhenActive = 11; - _iconXWhenInactive = 89; - - self.inputBorderView = [NSView new]; - self.inputBorderView.wantsLayer = YES; - self.inputBorderView.layer.cornerRadius = DashboardCardCornerRadius; - self.inputBorderView.layer.borderWidth = 1; - [self addSubview:self.inputBorderView]; - - self.iconImageView = [NSImageView new]; - self.iconImageView.image = NSImageMake(@"icon_search"); - [self addSubview:self.iconImageView]; - - self.textField = [NSTextField new]; - self.textField.placeholderString = NSLocalizedString(@"properties or methods", nil); - self.textField.delegate = self; - self.textField.focusRingType = NSFocusRingTypeNone; - self.textField.editable = YES; - self.textField.bordered = NO; - self.textField.bezeled = NO; - self.textField.usesSingleLineMode = YES; - self.textField.backgroundColor = [NSColor clearColor]; - self.textField.lineBreakMode = NSLineBreakByTruncatingTail; - self.textField.font = NSFontMake(13); - self.textField.hidden = YES; - [self addSubview:self.textField]; - - self.addButton = [NSButton new]; - self.addButton.image = [NSImage imageNamed:NSImageNameAddTemplate]; - self.addButton.bezelStyle = NSBezelStyleRounded; - self.addButton.target = self; - self.addButton.action = @selector(handleAddButton); - self.addButton.frame = NSMakeRect(0, 0, 84, 40); - [self addSubview:self.addButton]; - - @weakify(self); - [[self.textField.rac_textSignal throttle:0.3] subscribeNext:^(NSString * _Nullable x) { - @strongify(self); - if ([self.delegate respondsToSelector:@selector(dashboardHeaderView:didInputString:)]) { - [self.delegate dashboardHeaderView:self didInputString:x]; - } - }]; - - [self updateColors]; - } - return self; -} - -- (void)layout { - [super layout]; - if (self.isActive) { - $(self.inputBorderView).fullFrame; - $(self.iconImageView).sizeToFit.x(_iconXWhenActive); - } else { - $(self.addButton).width(50).fullHeight.right(-6).offsetY(1); - $(self.inputBorderView).x(0).toRight(48).fullHeight; - $(self.iconImageView).sizeToFit.verAlign.x(_iconXWhenInactive); - } - $(self.textField).x(30).toRight(2).heightToFit.verAlign; -} - -- (void)updateColors { - [super updateColors]; - if (self.isActive) { - self.inputBorderView.layer.borderColor = self.isDarkMode ? LookinColorMake(70, 71, 72).CGColor : LookinColorMake(198, 199, 200).CGColor; - } else { - self.inputBorderView.layer.borderColor = self.isDarkMode ? LookinColorMake(47, 48, 49).CGColor : LookinColorMake(220, 221, 222).CGColor; - } -} - -- (void)mouseDown:(NSEvent *)event { - [super mouseDown:event]; - self.isActive = YES; -} - -- (NSString *)currentInputString { - return self.textField.stringValue; -} - -- (void)setIsActive:(BOOL)isActive { - if (_isActive == isActive) { - return; - } - _isActive = isActive; - - [self updateColors]; - - if (isActive) { - [self.addButton.animator setAlphaValue:0]; - [self.inputBorderView.animator setFrameSize:self.$size]; - [self.iconImageView.animator setFrameOrigin:NSMakePoint(_iconXWhenActive, self.iconImageView.$y)]; - - self.textField.animator.hidden = NO; - [self.textField becomeFirstResponder]; - - } else { - [self.addButton.animator setAlphaValue:1]; - [self.inputBorderView.animator setFrameSize:NSMakeSize(self.$width - 48, self.$height)]; - [self.iconImageView.animator setFrameOrigin:NSMakePoint(_iconXWhenInactive, self.iconImageView.$y)]; - - self.textField.animator.hidden = YES; - self.textField.stringValue = @""; - } - - if ([self.delegate respondsToSelector:@selector(dashboardHeaderView:didToggleActive:)]) { - [self.delegate dashboardHeaderView:self didToggleActive:isActive]; - } -} - -- (void)handleAddButton { - NSMenu *menu = [NSMenu new]; - - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = NSImageMake(@"Icon_Inspiration_small"); //[[NSImage alloc] initWithSize:NSMakeSize(1, 22)]; - menuItem.title = NSLocalizedString(@"How to add custom properties…", nil); - menuItem.target = self; - menuItem.action = @selector(handleAddCustomAttr); - [menu addItem:menuItem]; - - [NSMenu popUpContextMenu:menu withEvent:NSApplication.sharedApplication.currentEvent forView:self.addButton]; -} - -- (void)handleAddCustomAttr { - [LKHelper showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"Legacy custom property guides are disabled in this community build. See the repository README instead.", nil)]; -} - -#pragma mark - - -- (void)controlTextDidEndEditing:(NSNotification *)obj { - self.isActive = NO; -} - -- (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector { - if (commandSelector == NSSelectorFromString(@"cancelOperation:")) { - // 按下了 esc 键 - self.isActive = NO; - return YES; - } - return NO; -} - - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardHeaderView.swift b/LookInside/Dashboard/Search/LKDashboardHeaderView.swift new file mode 100644 index 00000000..59ab7fc6 --- /dev/null +++ b/LookInside/Dashboard/Search/LKDashboardHeaderView.swift @@ -0,0 +1,172 @@ +// +// LKDashboardHeaderView.swift +// LookInside +// +// Created by Li Kai on 2019/9/5. +// https://lookin.work +// + +import AppKit + +protocol LKDashboardHeaderViewDelegate: AnyObject { + func dashboardHeaderView(_ view: LKDashboardHeaderView, didToggleActive isActive: Bool) + func dashboardHeaderView(_ view: LKDashboardHeaderView, didInputString string: String) +} + +/// The search field above the cards, with the "add custom property" button. +/// Clicking it activates the search; typing reports the text 0.3 s after +/// the last keystroke. +final class LKDashboardHeaderView: LKBaseView, NSTextFieldDelegate { + private static let inputDebounceInterval: TimeInterval = 0.3 + + private let iconXWhenActive: CGFloat = 11 + private let iconXWhenInactive: CGFloat = 89 + + private let inputBorderView = NSView() + private let iconImageView = NSImageView() + private let textField = NSTextField() + private let addButton = NSButton() + private var pendingInput: DispatchWorkItem? + + weak var delegate: LKDashboardHeaderViewDelegate? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + inputBorderView.wantsLayer = true + inputBorderView.layer?.cornerRadius = LKDashboardMetrics.cardCornerRadius + inputBorderView.layer?.borderWidth = 1 + addSubview(inputBorderView) + + iconImageView.image = LKDashboardStyle.image("icon_search") + addSubview(iconImageView) + + textField.placeholderString = NSLocalizedString("properties or methods", comment: "") + textField.delegate = self + textField.focusRingType = .none + textField.isEditable = true + textField.isBordered = false + textField.isBezeled = false + textField.usesSingleLineMode = true + textField.backgroundColor = .clear + textField.lineBreakMode = .byTruncatingTail + textField.font = LKDashboardStyle.font(13) + textField.isHidden = true + addSubview(textField) + + addButton.image = NSImage(named: NSImage.addTemplateName) + addButton.bezelStyle = .rounded + addButton.target = self + addButton.action = #selector(handleAddButton) + addButton.frame = NSRect(x: 0, y: 0, width: 84, height: 40) + addSubview(addButton) + + updateColors() + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + var isActive = false { + didSet { + guard isActive != oldValue else { return } + updateColors() + + if isActive { + addButton.animator().alphaValue = 0 + inputBorderView.animator().setFrameSize(frame.size) + iconImageView.animator().setFrameOrigin(NSPoint(x: iconXWhenActive, y: iconImageView.frame.minY)) + textField.animator().isHidden = false + textField.becomeFirstResponder() + } else { + addButton.animator().alphaValue = 1 + inputBorderView.animator().setFrameSize(NSSize(width: frame.width - 48, height: frame.height)) + iconImageView.animator().setFrameOrigin(NSPoint(x: iconXWhenInactive, y: iconImageView.frame.minY)) + textField.animator().isHidden = true + textField.stringValue = "" + } + + delegate?.dashboardHeaderView(self, didToggleActive: isActive) + } + } + + var currentInputString: String { + textField.stringValue + } + + override func layout() { + super.layout() + if isActive { + inputBorderView.dashboardLayout.fullFrame() + iconImageView.dashboardLayout.sizeToFit().x(iconXWhenActive) + } else { + addButton.dashboardLayout.width(50).fullHeight().right(-6).offsetY(1) + inputBorderView.dashboardLayout.x(0).toRight(48).fullHeight() + iconImageView.dashboardLayout.sizeToFit().verAlign().x(iconXWhenInactive) + } + textField.dashboardLayout.x(30).toRight(2).heightToFit().verAlign() + } + + override func updateColors() { + super.updateColors() + let isDarkMode = isDarkMode() + let color: NSColor + if isActive { + color = isDarkMode ? LKDashboardStyle.rgb(70, 71, 72) : LKDashboardStyle.rgb(198, 199, 200) + } else { + color = isDarkMode ? LKDashboardStyle.rgb(47, 48, 49) : LKDashboardStyle.rgb(220, 221, 222) + } + inputBorderView.layer?.borderColor = color.cgColor + } + + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + isActive = true + } + + @objc private func handleAddButton() { + let menu = NSMenu() + let menuItem = NSMenuItem() + menuItem.image = LKDashboardStyle.image("Icon_Inspiration_small") + menuItem.title = NSLocalizedString("How to add custom properties…", comment: "") + menuItem.target = self + menuItem.action = #selector(handleAddCustomAttr) + menu.addItem(menuItem) + if let event = NSApplication.shared.currentEvent { + NSMenu.popUpContextMenu(menu, with: event, for: addButton) + } + } + + @objc private func handleAddCustomAttr() { + LKHelper.showDisabledExternalLinkAlert(withMessage: NSLocalizedString("Legacy custom property guides are disabled in this community build. See the repository README instead.", comment: "")) + } + + // MARK: - NSTextFieldDelegate + + func controlTextDidChange(_: Notification) { + // The text as typed now; only the last one within the interval is + // reported. + let text = textField.stringValue + pendingInput?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.delegate?.dashboardHeaderView(self, didInputString: text) + } + pendingInput = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + Self.inputDebounceInterval, execute: workItem) + } + + func controlTextDidEndEditing(_: Notification) { + isActive = false + } + + func control(_: NSControl, textView _: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + // Escape + isActive = false + return true + } + return false + } +} diff --git a/LookInside/Dashboard/Search/LKDashboardSearchCardView.h b/LookInside/Dashboard/Search/LKDashboardSearchCardView.h deleted file mode 100644 index ce71c9d5..00000000 --- a/LookInside/Dashboard/Search/LKDashboardSearchCardView.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKDashboardSearchCardView.h -// Lookin -// -// Created by Li Kai on 2019/9/5. -// https://lookin.work -// - -#import "LKBaseView.h" - -@interface LKDashboardSearchCardView : LKBaseView - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardSearchCardView.m b/LookInside/Dashboard/Search/LKDashboardSearchCardView.m deleted file mode 100644 index 97bda68f..00000000 --- a/LookInside/Dashboard/Search/LKDashboardSearchCardView.m +++ /dev/null @@ -1,36 +0,0 @@ -// -// LKDashboardSearchCardView.m -// Lookin -// -// Created by Li Kai on 2019/9/5. -// https://lookin.work -// - -#import "LKDashboardSearchCardView.h" - -@interface LKDashboardSearchCardView () - -@property(nonatomic, strong) LKVisualEffectView *backgroundEffectView; - -@end - -@implementation LKDashboardSearchCardView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.layer.cornerRadius = DashboardCardCornerRadius; - - self.backgroundEffectView = [LKVisualEffectView new]; - self.backgroundEffectView.blendingMode = NSVisualEffectBlendingModeWithinWindow; - self.backgroundEffectView.state = NSVisualEffectStateActive; - [self addSubview:self.backgroundEffectView]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.backgroundEffectView).fullFrame; -} - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardSearchMethodsDataSource.h b/LookInside/Dashboard/Search/LKDashboardSearchMethodsDataSource.h deleted file mode 100644 index ec0ec472..00000000 --- a/LookInside/Dashboard/Search/LKDashboardSearchMethodsDataSource.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// LKDashboardSearchMethodsDataSource.h -// Lookin -// -// Created by Li Kai on 2019/9/6. -// https://lookin.work -// - -#import - -@class LookinLiveDocument; - -@interface LKDashboardSearchMethodsDataSource : NSObject - -/// Phase F: Live Doc that owns this dashboard search session. The single -/// caller (`LKDashboardViewController`) injects it after constructing the -/// data source so RPCs route through the per-doc inspectable app instead -/// of the deprecated single-slot global. -@property(nonatomic, weak) LookinLiveDocument *liveDocument; - -- (RACSignal *)fetchNonArgMethodsListWithClass:(NSString *)className; - -- (void)clearAllCache; - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardSearchMethodsDataSource.m b/LookInside/Dashboard/Search/LKDashboardSearchMethodsDataSource.m deleted file mode 100644 index 2c3d4f43..00000000 --- a/LookInside/Dashboard/Search/LKDashboardSearchMethodsDataSource.m +++ /dev/null @@ -1,58 +0,0 @@ -// -// LKDashboardSearchMethodsDataSource.m -// Lookin -// -// Created by Li Kai on 2019/9/6. -// https://lookin.work -// - -#import "LKDashboardSearchMethodsDataSource.h" -#import "LKInspectableApp.h" -#import "LookinLiveDocument.h" - -@interface LKDashboardSearchMethodsDataSource () - -/** - @{ - @"UIView": @[@"layoutSubviews", @"addSubview:", ...], - @"UIViewController": @[@"viewDidAppear:", ...], - ... - }; - */ -@property(nonatomic, strong) NSMutableDictionary *> *classesToSelsDict; - -@end - -@implementation LKDashboardSearchMethodsDataSource - -- (instancetype)init { - if (self = [super init]) { - self.classesToSelsDict = [NSMutableDictionary dictionary]; - } - return self; -} - -- (RACSignal *)fetchNonArgMethodsListWithClass:(NSString *)className { - if (!className.length) { - return [RACSignal error:LookinErr_Inner]; - } - LKInspectableApp *inspectableApp = self.liveDocument.inspectableApp; - if (!inspectableApp) { - return [RACSignal error:LookinErr_NoConnect]; - } - if ([self.classesToSelsDict objectForKey:className]) { - return [RACSignal return:self.classesToSelsDict[className]]; - } - - @weakify(self); - return [[inspectableApp fetchSelectorNamesWithClass:className hasArg:NO] doNext:^(NSArray *sels) { - @strongify(self); - self.classesToSelsDict[className] = sels; - }]; -} - -- (void)clearAllCache { - [self.classesToSelsDict removeAllObjects]; -} - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardSearchMethodsView.h b/LookInside/Dashboard/Search/LKDashboardSearchMethodsView.h deleted file mode 100644 index 3eb09ada..00000000 --- a/LookInside/Dashboard/Search/LKDashboardSearchMethodsView.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// LKDashboardSearchMethodsView.h -// Lookin -// -// Created by Li Kai on 2019/9/6. -// https://lookin.work -// - -#import "LKDashboardSearchCardView.h" - -@class LKDashboardSearchMethodsView; - -@protocol LKDashboardSearchMethodsViewDelegate - -- (void)dashboardSearchMethodsView:(LKDashboardSearchMethodsView *)view requestToInvokeMethod:(NSString *)method oid:(unsigned long)oid; - -@end - -@interface LKDashboardSearchMethodsView : LKDashboardSearchCardView - -@property(nonatomic, weak) id delegate; - -- (void)renderWithMethods:(NSArray *)methods oid:(unsigned long)oid; - -- (void)renderWithError:(NSError *)error; - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardSearchMethodsView.m b/LookInside/Dashboard/Search/LKDashboardSearchMethodsView.m deleted file mode 100644 index bef88614..00000000 --- a/LookInside/Dashboard/Search/LKDashboardSearchMethodsView.m +++ /dev/null @@ -1,136 +0,0 @@ -// -// LKDashboardSearchMethodsView.m -// Lookin -// -// Created by Li Kai on 2019/9/6. -// https://lookin.work -// - -#import "LKDashboardSearchMethodsView.h" - -@interface LKDashboardSearchMethodsView () - -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKLabel *errorLabel; -@property(nonatomic, strong) NSMutableArray *itemViews; -@property(nonatomic, assign) unsigned long oid; - -@end - -@implementation LKDashboardSearchMethodsView { - CGFloat _insetTop; - CGFloat _contentMarginTop; - CGFloat _itemInterspace; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _insetTop = 5; - _contentMarginTop = 10; - _itemInterspace = 8; - self.itemViews = [NSMutableArray array]; - - self.titleLabel = [LKLabel new]; - self.titleLabel.font = NSFontMake(12); - self.titleLabel.textColor = [NSColor secondaryLabelColor]; - self.titleLabel.stringValue = NSLocalizedString(@"Click to invoke methods below and get the return value.", nil); - [self addSubview:self.titleLabel]; - } - return self; -} - -- (void)layout { - [super layout]; - - CGFloat width = self.$width - DashboardSearchCardInset * 2; - if (self.titleLabel.isVisible) { - $(self.titleLabel).x(DashboardSearchCardInset).width(width).heightToFit.y(_insetTop); - - __block CGFloat y = self.titleLabel.$maxY + _contentMarginTop; - [self.itemViews enumerateObjectsUsingBlock:^(LKTextControl * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - if (view.hidden) { - return; - } - NSSize methodControlSize = [view sizeThatFits:NSMakeSize(width, CGFLOAT_MAX)]; - $(view).size(methodControlSize).x(DashboardSearchCardInset).y(y); - y = view.$maxY + self->_itemInterspace; - }]; - - } else if (self.errorLabel.isVisible) { - $(self.errorLabel).x(DashboardSearchCardInset).width(width).heightToFit.y(_insetTop); - } -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat contentWidth = limitedSize.width - DashboardSearchCardInset * 2; - if (self.titleLabel.isVisible) { - CGFloat height = [self.titleLabel heightForWidth:contentWidth] + _insetTop + _contentMarginTop; - limitedSize.height = [self.itemViews lookin_reduceCGFloat:^CGFloat(CGFloat accumulator, NSUInteger idx, LKTextControl *view) { - accumulator += [view heightForWidth:contentWidth] + self->_itemInterspace; - return accumulator; - } initialAccumlator:height]; - } else { - limitedSize.height = [self.errorLabel heightForWidth:contentWidth] + _insetTop * 2; - } - - return limitedSize; -} - -- (void)renderWithMethods:(NSArray *)methods oid:(unsigned long)oid { - self.oid = oid; - - self.titleLabel.hidden = NO; - self.errorLabel.hidden = YES; - - [self.itemViews lookin_dequeueWithCount:methods.count add:^LKTextControl *(NSUInteger idx) { - LKTextControl *control = [LKTextControl new]; - control.label.alignment = NSTextAlignmentLeft; - control.label.maximumNumberOfLines = 0; - control.adjustAlphaWhenClick = YES; - [control addTarget:self clickAction:@selector(_handleMethodControl:)]; - [self addSubview:control]; - - return control; - - } notDequeued:^(NSUInteger idx, LKTextControl *view) { - view.hidden = YES; - - } doNext:^(NSUInteger idx, LKTextControl *view) { - view.hidden = NO; - [view lookin_bindObject:methods[idx] forKey:@"methodName"]; - view.label.attributedStringValue = $(methods[idx]).font(@13).textColor(@"74, 144, 226").addImage(@"icon_arrowRight_blue", -1, 2, 0).attrString; - [view setNeedsLayout:YES]; - }]; - - [self setNeedsLayout:YES]; -} - -- (void)renderWithError:(NSError *)error { - NSLog(@"%@", error); - [self.itemViews enumerateObjectsUsingBlock:^(LKTextControl * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.hidden = YES; - }]; - self.titleLabel.hidden = YES; - if (!self.errorLabel) { - self.errorLabel = [LKLabel new]; - self.errorLabel.textColor = [NSColor labelColor]; - self.errorLabel.font = NSFontMake(12); - [self addSubview:self.errorLabel]; - } - self.errorLabel.hidden = NO; - self.errorLabel.stringValue = [NSString stringWithFormat:@"%@%@", NSLocalizedString(@"Failed to search related methods: ", nil), error.localizedDescription]; - [self setNeedsLayout:YES]; -} - -- (void)_handleMethodControl:(NSControl *)control { - NSString *methodName = [control lookin_getBindObjectForKey:@"methodName"]; - if (methodName.length == 0) { - NSAssert(NO, @""); - return; - } - if ([self.delegate respondsToSelector:@selector(dashboardSearchMethodsView:requestToInvokeMethod:oid:)]) { - [self.delegate dashboardSearchMethodsView:self requestToInvokeMethod:methodName oid:self.oid]; - } -} - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardSearchPropView.h b/LookInside/Dashboard/Search/LKDashboardSearchPropView.h deleted file mode 100644 index af609f23..00000000 --- a/LookInside/Dashboard/Search/LKDashboardSearchPropView.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// LKDashboardSearchPropView.h -// Lookin -// -// Created by Li Kai on 2019/9/5. -// https://lookin.work -// - -#import "LKDashboardSearchCardView.h" -#import "LookinAttrIdentifiers.h" - -@class LookinAttribute, LKDashboardSearchPropView; - -@protocol LKDashboardSearchPropViewDelegate - -- (void)dashboardSearchPropView:(LKDashboardSearchPropView *)view didClickRevealAttribute:(LookinAttribute *)attr; - -@end - -@interface LKDashboardSearchPropView : LKDashboardSearchCardView - -@property(nonatomic, weak) id delegate; - -- (void)renderWithAttribute:(LookinAttribute *)attribute; - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardSearchPropView.m b/LookInside/Dashboard/Search/LKDashboardSearchPropView.m deleted file mode 100644 index b2827a0a..00000000 --- a/LookInside/Dashboard/Search/LKDashboardSearchPropView.m +++ /dev/null @@ -1,161 +0,0 @@ -// -// LKDashboardSearchPropView.m -// Lookin -// -// Created by Li Kai on 2019/9/5. -// https://lookin.work -// - -#import "LKDashboardSearchPropView.h" -#import "LookinDashboardBlueprint.h" -#import "LookinAttribute.h" -#import "LKPreferenceManager.h" -#import "LKEnumListRegistry.h" - -@interface LKDashboardSearchPropView () - -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKLabel *contentLabel; -@property(nonatomic, strong) LKTextControl *revealControl; - -@property(nonatomic, strong) LookinAttribute *attribute; - -@end - -@implementation LKDashboardSearchPropView { - CGFloat _contentLabelY; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _contentLabelY = 21; - - self.titleLabel = [LKLabel new]; - self.titleLabel.font = NSFontMake(12); - self.titleLabel.textColor = [NSColor secondaryLabelColor]; - [self addSubview:self.titleLabel]; - - self.contentLabel = [LKLabel new]; - self.contentLabel.font = NSFontMake(15); - [self addSubview:self.contentLabel]; - - self.revealControl = [LKTextControl new]; - [self.revealControl addTarget:self clickAction:@selector(_handleRevealButton)]; - self.revealControl.adjustAlphaWhenClick = YES; - [self addSubview:self.revealControl]; - - [self updateColors]; - } - return self; -} - -- (void)layout { - [super layout]; - - CGFloat width = self.$width - DashboardSearchCardInset * 2; - - $(self.titleLabel).x(DashboardSearchCardInset).width(width).heightToFit.y(5); - $(self.contentLabel).x(DashboardSearchCardInset).width(width).heightToFit.y(_contentLabelY); - $(self.revealControl).sizeToFit.x(DashboardSearchCardInset).bottom(DashboardSearchCardInset); -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat width = limitedSize.width - DashboardSearchCardInset * 2; - CGFloat height = _contentLabelY; - height += [self.contentLabel heightForWidth:width] + DashboardSearchCardInset + 25; - limitedSize.height = height; - return limitedSize; -} - -- (void)updateColors { - [super updateColors]; - self.contentLabel.textColor = self.isDarkMode ? LookinColorMake(250, 251, 252) : LookinColorMake(56, 57, 58); - - NSString *text = NSLocalizedString(@"Reveal in panel…", nil); - self.revealControl.label.attributedStringValue = $(text).textColor(self.isDarkMode ? @"245, 166, 30" : @"229, 135, 67").font(@11).addImage(@"icon_arrowRight_orange", 0, 2, 0).attrString; -} - -- (void)renderWithAttribute:(LookinAttribute *)attribute { - self.attribute = attribute; - self.titleLabel.stringValue = attribute.displayTitle ? : [LookinDashboardBlueprint fullTitleWithAttrID:attribute.identifier]; - self.contentLabel.stringValue = [self _stringValueFromAttribute:attribute]; - [self setNeedsLayout:YES]; -} - -- (void)_handleRevealButton { - if ([self.delegate respondsToSelector:@selector(dashboardSearchPropView:didClickRevealAttribute:)]) { - [self.delegate dashboardSearchPropView:self didClickRevealAttribute:self.attribute]; - } -} - -- (NSString *)_stringValueFromAttribute:(LookinAttribute *)attribute { - switch (attribute.attrType) { - case LookinAttrTypeNone: - case LookinAttrTypeVoid: - case LookinAttrTypeCustomObj: - NSAssert(NO, @""); - return @""; - - case LookinAttrTypeChar: - case LookinAttrTypeInt: - case LookinAttrTypeShort: - case LookinAttrTypeLong: - case LookinAttrTypeLongLong: - case LookinAttrTypeUnsignedChar: - case LookinAttrTypeUnsignedInt: - case LookinAttrTypeUnsignedShort: - case LookinAttrTypeUnsignedLong: - case LookinAttrTypeUnsignedLongLong: - case LookinAttrTypeFloat: - case LookinAttrTypeDouble: - case LookinAttrTypeSel: - case LookinAttrTypeClass: - case LookinAttrTypeCGVector: - case LookinAttrTypeCGAffineTransform: - case LookinAttrTypeUIOffset: - return [attribute.value description]; - - case LookinAttrTypeBOOL: { - BOOL boolValue = [(NSNumber *)attribute.value boolValue]; - return boolValue ? @"YES" : @"NO"; - } - - case LookinAttrTypeCGPoint: - return [NSString lookin_stringFromPoint:[(NSValue *)attribute.value pointValue]]; - case LookinAttrTypeCGSize: - return [NSString lookin_stringFromSize:[(NSValue *)attribute.value sizeValue]]; - case LookinAttrTypeCGRect: - return [NSString lookin_stringFromRect:[(NSValue *)attribute.value rectValue]]; - case LookinAttrTypeUIEdgeInsets: - return [NSString lookin_stringFromInset:[(NSValue *)attribute.value edgeInsetsValue]]; - - case LookinAttrTypeNSString: - case LookinAttrTypeEnumString: - return attribute.value; - - case LookinAttrTypeEnumInt: - case LookinAttrTypeEnumLong: { - NSInteger enumValue = [attribute.value integerValue]; - NSString *enumListName = [LookinDashboardBlueprint enumListNameWithAttrID:attribute.identifier]; - NSString *enumString = [[LKEnumListRegistry sharedInstance] descForEnumName:enumListName value:enumValue]; - return enumString; - } - - case LookinAttrTypeUIColor: { - NSColor *color = [NSColor lk_colorFromRGBAComponents:attribute.value]; - if (color) { - return [LKPreferenceManager mainManager].rgbaFormat ? color.rgbaString : color.hexString; - } else { - return @"nil"; - } - } - case LookinAttrTypeShadow: - case LookinAttrTypeJson: - return @"……"; - } - - NSAssert(NO, @""); - return @""; -} - -@end diff --git a/LookInside/Dashboard/Search/LKDashboardSearchViews.swift b/LookInside/Dashboard/Search/LKDashboardSearchViews.swift new file mode 100644 index 00000000..c038665b --- /dev/null +++ b/LookInside/Dashboard/Search/LKDashboardSearchViews.swift @@ -0,0 +1,315 @@ +// +// LKDashboardSearchViews.swift +// LookInside +// +// Created by Li Kai on 2019/9/5. +// https://lookin.work +// +// The search results that replace the cards while searching: one card per +// matching attribute, and a card of matching methods to invoke. +// + +import AppKit + +/// A search result card on a blurred background. +class LKDashboardSearchCardView: LKBaseView { + private let backgroundEffectView = LKVisualEffectView() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + layer?.cornerRadius = LKDashboardMetrics.cardCornerRadius + backgroundEffectView.blendingMode = .withinWindow + backgroundEffectView.state = .active + addSubview(backgroundEffectView) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + backgroundEffectView.dashboardLayout.fullFrame() + } +} + +protocol LKDashboardSearchPropViewDelegate: AnyObject { + func dashboardSearchPropView(_ view: LKDashboardSearchPropView, didClickRevealAttribute attribute: LookinAttribute) +} + +/// One matching attribute: its title, its value as text, and a button that +/// reveals it on its card. +final class LKDashboardSearchPropView: LKDashboardSearchCardView { + private let contentLabelY: CGFloat = 21 + + private let titleLabel = LKLabel() + private let contentLabel = LKLabel() + private let revealControl = LKTextControl() + private var attribute: LookinAttribute? + + weak var delegate: LKDashboardSearchPropViewDelegate? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + titleLabel.font = LKDashboardStyle.font(12) + titleLabel.textColor = .secondaryLabelColor + addSubview(titleLabel) + + contentLabel.font = LKDashboardStyle.font(15) + addSubview(contentLabel) + + revealControl.addTarget(self, clickAction: #selector(handleRevealButton)) + revealControl.adjustAlphaWhenClick = true + addSubview(revealControl) + + updateColors() + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + let inset = LKDashboardMetrics.searchCardInset + let width = frame.width - inset * 2 + titleLabel.dashboardLayout.x(inset).width(width).heightToFit().y(5) + contentLabel.dashboardLayout.x(inset).width(width).heightToFit().y(contentLabelY) + revealControl.dashboardLayout.sizeToFit().x(inset).bottom(inset) + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + let inset = LKDashboardMetrics.searchCardInset + let width = limitedSize.width - inset * 2 + var size = limitedSize + size.height = contentLabelY + contentLabel.height(forWidth: width) + inset + 25 + return size + } + + override func updateColors() { + super.updateColors() + let isDarkMode = isDarkMode() + contentLabel.textColor = isDarkMode ? LKDashboardStyle.rgb(250, 251, 252) : LKDashboardStyle.rgb(56, 57, 58) + + let text = LKDashboardText.attributed( + NSLocalizedString("Reveal in panel…", comment: ""), + font: LKDashboardStyle.font(11), + color: isDarkMode ? LKDashboardStyle.rgb(245, 166, 30) : LKDashboardStyle.rgb(229, 135, 67) + ) + LKDashboardText.appendImage(named: "icon_arrowRight_orange", baselineOffset: 0, marginLeft: 2, marginRight: 0, to: text) + revealControl.label.attributedStringValue = text + } + + func render(attribute: LookinAttribute) { + self.attribute = attribute + titleLabel.stringValue = attribute.displayTitle ?? LookinDashboardBlueprint.fullTitle(withAttrID: attribute.identifier) ?? "" + contentLabel.stringValue = Self.stringValue(of: attribute) + needsLayout = true + } + + @objc private func handleRevealButton() { + guard let attribute else { return } + delegate?.dashboardSearchPropView(self, didClickRevealAttribute: attribute) + } + + /// The attribute's value as one line of text. + static func stringValue(of attribute: LookinAttribute) -> String { + switch attribute.attrType { + case .none, .void, .customObj: + assertionFailure() + return "" + + case .char, .int, .short, .long, .longLong, .unsignedChar, .unsignedInt, .unsignedShort, .unsignedLong, .unsignedLongLong, + .float, .double, .sel, .class, .cgVector, .cgAffineTransform, .uiOffset: + return (attribute.value as? NSObject)?.description ?? "" + + case .BOOL: + return (attribute.value as? NSNumber)?.boolValue == true ? "YES" : "NO" + + case .cgPoint: + return NSString.lookin_string(from: (attribute.value as? NSValue)?.pointValue ?? .zero) + + case .cgSize: + return NSString.lookin_string(from: (attribute.value as? NSValue)?.sizeValue ?? .zero) + + case .cgRect: + return NSString.lookin_string(from: (attribute.value as? NSValue)?.rectValue ?? .zero) + + case .uiEdgeInsets: + return NSString.lookin_string(fromInset: (attribute.value as? NSValue)?.edgeInsetsValue ?? NSEdgeInsetsZero) + + case .nsString, .enumString: + return attribute.value as? String ?? "" + + case .enumInt, .enumLong: + let enumValue = (attribute.value as? NSNumber)?.intValue ?? 0 + let enumListName = LookinDashboardBlueprint.enumListName(withAttrID: attribute.identifier) + return LKEnumListRegistry.shared.desc(forEnumName: enumListName, value: enumValue) ?? "" + + case .uiColor: + guard let color = NSColor.lk_color(fromRGBAComponents: attribute.value as? [NSNumber]) else { + return "nil" + } + return LKPreferenceManager.shared.rgbaFormat ? color.rgbaString() : color.hexString() + + case .shadow, .json: + return "……" + + @unknown default: + assertionFailure() + return "" + } + } +} + +protocol LKDashboardSearchMethodsViewDelegate: AnyObject { + func dashboardSearchMethodsView(_ view: LKDashboardSearchMethodsView, requestToInvokeMethod method: String, oid: UInt) +} + +/// The methods without arguments that match the search; a click invokes +/// one. +final class LKDashboardSearchMethodsView: LKDashboardSearchCardView { + private let insetTop: CGFloat = 5 + private let contentMarginTop: CGFloat = 10 + private let itemInterspace: CGFloat = 8 + + private let titleLabel = LKLabel() + private var errorLabel: LKLabel? + private var itemViews: [LKTextControl] = [] + private var methodNames: [ObjectIdentifier: String] = [:] + private var oid: UInt = 0 + + weak var delegate: LKDashboardSearchMethodsViewDelegate? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + titleLabel.font = LKDashboardStyle.font(12) + titleLabel.textColor = .secondaryLabelColor + titleLabel.stringValue = NSLocalizedString("Click to invoke methods below and get the return value.", comment: "") + addSubview(titleLabel) + } + + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + let inset = LKDashboardMetrics.searchCardInset + let width = frame.width - inset * 2 + if titleLabel.isVisible { + titleLabel.dashboardLayout.x(inset).width(width).heightToFit().y(insetTop) + var y = titleLabel.frame.maxY + contentMarginTop + for view in itemViews where !view.isHidden { + let size = view.sizeThatFits(NSSize(width: width, height: .greatestFiniteMagnitude)) + view.dashboardLayout.size(size).x(inset).y(y) + y = view.frame.maxY + itemInterspace + } + } else if let errorLabel, errorLabel.isVisible { + errorLabel.dashboardLayout.x(inset).width(width).heightToFit().y(insetTop) + } + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + let contentWidth = limitedSize.width - LKDashboardMetrics.searchCardInset * 2 + var size = limitedSize + if titleLabel.isVisible { + var height = titleLabel.height(forWidth: contentWidth) + insetTop + contentMarginTop + // Hidden items count too, as they always have. + for view in itemViews { + height += view.height(forWidth: contentWidth) + itemInterspace + } + size.height = height + } else { + size.height = (errorLabel?.height(forWidth: contentWidth) ?? 0) + insetTop * 2 + } + return size + } + + func render(methods: [String], oid: UInt) { + self.oid = oid + titleLabel.isHidden = false + errorLabel?.isHidden = true + + while itemViews.count < methods.count { + let control = LKTextControl() + control.label.alignment = .left + control.label.maximumNumberOfLines = 0 + control.adjustAlphaWhenClick = true + control.addTarget(self, clickAction: #selector(handleMethodControl(_:))) + addSubview(control) + itemViews.append(control) + } + for (idx, view) in itemViews.enumerated() { + guard idx < methods.count else { + view.isHidden = true + continue + } + view.isHidden = false + methodNames[ObjectIdentifier(view)] = methods[idx] + let text = LKDashboardText.attributed(methods[idx], font: LKDashboardStyle.font(13), color: LKDashboardStyle.rgb(74, 144, 226)) + LKDashboardText.appendImage(named: "icon_arrowRight_blue", baselineOffset: -1, marginLeft: 2, marginRight: 0, to: text) + view.label.attributedStringValue = text + view.needsLayout = true + } + needsLayout = true + } + + func render(error: Error) { + NSLog("%@", String(describing: error)) + itemViews.forEach { $0.isHidden = true } + titleLabel.isHidden = true + let label: LKLabel + if let errorLabel { + label = errorLabel + } else { + label = LKLabel() + label.textColor = .labelColor + label.font = LKDashboardStyle.font(12) + addSubview(label) + errorLabel = label + } + label.isHidden = false + label.stringValue = NSLocalizedString("Failed to search related methods: ", comment: "") + error.localizedDescription + needsLayout = true + } + + @objc private func handleMethodControl(_ control: NSControl) { + guard let methodName = methodNames[ObjectIdentifier(control)], !methodName.isEmpty else { + assertionFailure() + return + } + delegate?.dashboardSearchMethodsView(self, requestToInvokeMethod: methodName, oid: oid) + } +} + +/// The selectors without arguments of each class, fetched once per class +/// until the hierarchy reloads. +final class LKDashboardSearchMethodsDataSource { + /// The live document whose app answers; nil for a document read from a + /// file. + weak var liveDocument: LookinLiveDocument? + + /// Class name to selector names. + private var classesToSelectors: [String: [String]] = [:] + + @MainActor + func nonArgMethods(ofClass className: String?) async throws -> [String] { + guard let className, !className.isEmpty else { + throw LKConnectionError.inner + } + guard let inspectableApp = liveDocument?.inspectableApp else { + throw LKConnectionError.noConnect + } + if let cached = classesToSelectors[className] { + return cached + } + let selectors = try await inspectableApp.selectorNames(className: className, hasArg: false) + classesToSelectors[className] = selectors + return selectors + } + + func clearAllCache() { + classesToSelectors.removeAll() + } +} diff --git a/LookInside/DebugE2E/LKDebugE2EDump.swift b/LookInside/DebugE2E/LKDebugE2EDump.swift new file mode 100644 index 00000000..eaf8d6e6 --- /dev/null +++ b/LookInside/DebugE2E/LKDebugE2EDump.swift @@ -0,0 +1,405 @@ +#if DEBUG + import AppKit + import Foundation + + /// DEBUG-only end-to-end dump. When LOOKINSIDE_DEBUG_E2E_DUMP_DIR is set, + /// the Host waits for the first inspectable app after launch (optionally + /// the one named by LOOKINSIDE_DEBUG_E2E_BUNDLE_ID), opens it through the + /// same calls the launch window makes, waits for the hierarchy and every + /// detail the async update manager requests, then writes into the directory: + /// + /// snapshot.json hierarchy + merged details, normalized like + /// lookin-probe's snapshot (LookinSnapshotNormalizer) + /// host-window.png the Host's own document window, drawn in process + /// + /// and exits 0. A step that runs past LOOKINSIDE_DEBUG_E2E_TIMEOUT seconds + /// (default 240) exits non-zero with a message on stderr. The driver is + /// Scripts/reborn-e2e/host-dump.sh; Release builds compile this file out. + /// + /// Licensed runs (host-dump.sh --licensed) use two more switches: + /// + /// LOOKINSIDE_DEBUG_E2E_PREPARE_LICENSE_CSR= + /// Instead of dumping, create the license key in the test file + /// keychain (LOOKINSIDE_ACTIVATION_KEYCHAIN_PATH) through the + /// activation runtime, write a certificate request for it to + /// and exit. The driver issues the intermediate certificate under a + /// throwaway root, so the key the Host later signs with is one the + /// Host created and uses without a keychain prompt. + /// LOOKINSIDE_DEBUG_E2E_EXPECT_LICENSED=1 + /// Open the app only after the 220/221 license handshake succeeded on + /// its channel, so the hierarchy is fetched licensed. The Host runs + /// behind other apps, so its window never becomes key; once one of its + /// windows is visible the dump satisfies the activation runtime's + /// first-window gate itself (debugNoteFirstWindowShown()). + /// + /// UI snapshots (Scripts/reborn-e2e/ui-snapshots.sh) add: + /// + /// LOOKINSIDE_DEBUG_E2E_START_FILE= + /// Look for the app only once exists. The Host is launched + /// before the inspected app, so the launch window is captured with no + /// app; the driver then launches the app and creates the file. + /// LOOKINSIDE_DEBUG_UI_SNAPSHOT_DIR= + /// After writing the dump, run the UI scenes of LKDebugUISnapshots + /// against the loaded document instead of exiting; the Host exits + /// when they are done. + @MainActor + final class LKDebugE2EDump: NSObject { + private static let directoryKey = "LOOKINSIDE_DEBUG_E2E_DUMP_DIR" + private static let bundleIDKey = "LOOKINSIDE_DEBUG_E2E_BUNDLE_ID" + private static let timeoutKey = "LOOKINSIDE_DEBUG_E2E_TIMEOUT" + private static let prepareLicenseCSRKey = "LOOKINSIDE_DEBUG_E2E_PREPARE_LICENSE_CSR" + private static let expectLicensedKey = "LOOKINSIDE_DEBUG_E2E_EXPECT_LICENSED" + private static let startFileKey = "LOOKINSIDE_DEBUG_E2E_START_FILE" + + /// Exit statuses, so the driver script can tell the failing step. + private enum ExitStatus: Int32 { + case success = 0 + case timeout = 3 + case openFailed = 4 + case writeFailed = 5 + case licenseKeyFailed = 6 + } + + private enum Phase { + case waitingForApp + case opening + case loadingDetails + case settling + case done + } + + /// How often the driver checks its state, in seconds. + private static let tickInterval: TimeInterval = 0.25 + /// How often the app list is fetched while waiting for the target. + private static let appPollInterval: TimeInterval = 1 + /// The update manager must stay idle this long before details count as + /// loaded: one detail reply can make the data source start another request. + private static let idleInterval: TimeInterval = 2 + /// Wait after the details for the preview and outline to draw them. + private static let settleInterval: TimeInterval = 1.5 + + /// The running dump; it lives until the process exits. + private static var current: LKDebugE2EDump? + + private let directory: String + private let bundleID: String? + private let startFile: String? + private let timeout: TimeInterval + private let expectsLicensed: Bool + private var loggedStartWait = false + private var loggedLicenseWait = false + private var notedFirstWindow = false + + private var phase: Phase = .waitingForApp + private var deadline = Date() + private var timer: Timer? + private var fetchingApps = false + private var nextAppPoll: Date? + private var lastAppListSummary: String? + + private var app: LKInspectableApp? + private var document: LookinLiveDocument? + private var sawUpdating = false + private var idleSince: Date? + private var settleUntil = Date() + + private init(directory: String, environment: [String: String]) { + self.directory = directory + bundleID = environment[Self.bundleIDKey] + startFile = environment[Self.startFileKey].map { ($0 as NSString).standardizingPath } + let timeout = (environment[Self.timeoutKey] as NSString?)?.doubleValue ?? 0 + self.timeout = timeout > 0 ? timeout : 240 + expectsLicensed = environment[Self.expectLicensedKey] == "1" + super.init() + } + + /// Called at the very start of the app's `main()`, before the app + /// delegate is set, so the dump sees NSApplicationDidFinishLaunching + /// before the delegate does. + static func installIfRequested() { + let environment = ProcessInfo.processInfo.environment + if let csrPath = environment[prepareLicenseCSRKey], !csrPath.isEmpty { + NotificationCenter.default.addObserver( + forName: NSApplication.didFinishLaunchingNotification, + object: nil, + queue: nil + ) { _ in + MainActor.assumeIsolated { + prepareLicenseKeyWritingRequest(to: (csrPath as NSString).standardizingPath) + } + } + return + } + guard let directory = environment[directoryKey], !directory.isEmpty else { + return + } + let dump = LKDebugE2EDump(directory: (directory as NSString).standardizingPath, environment: environment) + current = dump + NotificationCenter.default.addObserver( + dump, + selector: #selector(applicationDidFinishLaunching(_:)), + name: NSApplication.didFinishLaunchingNotification, + object: nil + ) + } + + /// LOOKINSIDE_DEBUG_E2E_PREPARE_LICENSE_CSR: see the type comment. + private static func prepareLicenseKeyWritingRequest(to path: String) { + do { + let pem = try LKSwiftUISupportGatekeeper.sharedInstance() + .debugMakeTestKeyCertificateSigningRequestPEM(commonName: "LookInside Host E2E Test Device") + try pem.write(toFile: path, atomically: true, encoding: .utf8) + } catch { + fputs("[host-e2e-dump] FAIL: preparing the license key failed: \(error as NSError)\n", stderr) + exit(ExitStatus.licenseKeyFailed.rawValue) + } + fputs("[host-e2e-dump] license key ready in the test keychain; wrote \(path)\n", stderr) + exit(ExitStatus.success.rawValue) + } + + @objc private func applicationDidFinishLaunching(_: Notification) { + let target = (bundleID?.isEmpty == false) ? bundleID! : "the first inspectable app" + log("waiting for \(target)\(expectsLicensed ? " on a licensed channel" : "") (timeout \(String(format: "%.0f", timeout))s per step)") + enterPhase(.waitingForApp) + if LKDebugUISnapshots.isEnabled { + LKDebugUISnapshots.captureLaunchScene() + } + let timer = Timer.scheduledTimer( + timeInterval: Self.tickInterval, + target: self, + selector: #selector(tick), + userInfo: nil, + repeats: true + ) + self.timer = timer + // Keep ticking while a menu or a modal sheet runs the loop. + RunLoop.main.add(timer, forMode: .common) + } + + // MARK: - Steps + + private func enterPhase(_ phase: Phase) { + self.phase = phase + deadline = Date(timeIntervalSinceNow: timeout) + } + + @objc private func tick() { + if phase == .done { + return + } + if deadline.timeIntervalSinceNow < 0 { + fail(.timeout, timeoutMessage()) + return + } + switch phase { + case .waitingForApp: + noteFirstWindowIfNeeded() + if let startFile, !startFile.isEmpty, !FileManager.default.fileExists(atPath: startFile) { + if !loggedStartWait { + loggedStartWait = true + log("waiting for \(startFile) before looking for the app") + } + // The step deadline starts once the driver lets the dump go. + enterPhase(.waitingForApp) + break + } + pollApps() + case .opening: + break + case .loadingDetails: + checkDetails() + case .settling: + if settleUntil.timeIntervalSinceNow <= 0 { + writeAndExit() + } + case .done: + break + } + } + + private func timeoutMessage() -> String { + switch phase { + case .waitingForApp: + if loggedLicenseWait { + let state = LKSwiftUISupportGatekeeper.sharedInstance().activationState.rawValue + return "timed out waiting for the license handshake on \(bundleID ?? "the app") (activation state \(state))" + } + return "timed out waiting for an inspectable app; last app list: \(lastAppListSummary ?? "none received")" + case .opening: + return "timed out fetching the hierarchy and opening the live document" + case .loadingDetails: + let updating = document?.asyncUpdateManager?.isUpdating() == true + return "timed out waiting for the display-item details (update manager \(updating ? "still updating" : "never started"))" + default: + return "timed out" + } + } + + private func noteFirstWindowIfNeeded() { + guard expectsLicensed, !notedFirstWindow else { + return + } + if NSApp.windows.contains(where: \.isVisible) { + notedFirstWindow = true + LKSwiftUISupportGatekeeper.sharedInstance().debugNoteFirstWindowShown() + log("a Host window is visible; first-window signing gate satisfied") + } + } + + /// Same list the launch window shows: LKDebugE2EDumpWriter.fetchApps(completion:). + private func pollApps() { + if fetchingApps { + return + } + if let nextAppPoll, nextAppPoll.timeIntervalSinceNow > 0 { + return + } + fetchingApps = true + nextAppPoll = Date(timeIntervalSinceNow: Self.appPollInterval) + LKDebugE2EDumpWriter.fetchApps { [self] apps in + fetchingApps = false + guard phase == .waitingForApp else { + return + } + var summary: [String] = [] + var target: LKInspectableApp? + for app in apps ?? [] { + let bundleID = app.appInfo?.appBundleIdentifier ?? "?" + if let error = app.serverVersionError { + summary.append("\(bundleID) (server version error \(error.code))") + } else { + summary.append(bundleID) + } + if target != nil || app.serverVersionError != nil || app.appInfo == nil { + continue + } + if self.bundleID?.isEmpty != false || bundleID == self.bundleID { + target = app + } + } + lastAppListSummary = summary.isEmpty ? "empty" : summary.joined(separator: ", ") + guard let target else { + return + } + // Each app-list request runs the license handshake on the channel + // first (once the activation runtime allows signing), so polling + // again is what retries it. + if expectsLicensed, !LKDebugE2EDumpWriter.isChannelLicensed(for: target) { + if !loggedLicenseWait { + loggedLicenseWait = true + log("found \(target.appInfo?.appBundleIdentifier ?? "(null)"); waiting for the license handshake") + } + return + } + open(target) + } + } + + /// Mirrors LKLaunchViewController's app entry: fetch the hierarchy, open + /// a live document through LookinLiveDocumentController, prime its data + /// source with that hierarchy, close the launch window. The SwiftUI + /// activation prompt that follows there is licensing UI and is left out. + private func open(_ app: LKInspectableApp) { + let licensed = LKDebugE2EDumpWriter.isChannelLicensed(for: app) ? "YES" : "NO" + log("opening \(app.appInfo?.appBundleIdentifier ?? "(null)") (channel licensed: \(licensed))") + self.app = app + enterPhase(.opening) + LKDebugE2EDumpWriter.fetchHierarchy(for: app) { [self] info, error in + if let error { + fail(.openFailed, "the hierarchy request failed: \(error as NSError)") + return + } + guard let info else { + fail(.openFailed, "the hierarchy request returned nothing") + return + } + let (document, alreadyOpen) = LookinLiveDocumentController.shared.openLiveDocument(for: app) + self.document = document + enterPhase(.loadingDetails) + if !alreadyOpen { + document.hierarchyDataSource?.reload(with: info, keepState: false) + } + LKNavigationManager.shared.closeLaunch() + log("hierarchy loaded, \(document.hierarchyDataSource?.flatItems?.count ?? 0) items; waiting for details") + } + } + + /// Details count as loaded once the document's update manager, which + /// requests every detail the UI shows, has worked and then stayed idle for + /// idleInterval. + private func checkDetails() { + guard let manager = document?.asyncUpdateManager else { + return + } + if manager.isUpdating() { + sawUpdating = true + idleSince = nil + return + } + guard let idleSince else { + idleSince = Date() + return + } + if !sawUpdating || -idleSince.timeIntervalSinceNow < Self.idleInterval { + return + } + log("details loaded") + settleUntil = Date(timeIntervalSinceNow: Self.settleInterval) + enterPhase(.settling) + } + + private func writeAndExit() { + phase = .done + // AppKit swallows an exception raised in a timer callback, which + // would leave the dump waiting forever. Run the write from the main + // queue instead, where an exception (from JSONSerialization, say) + // is uncaught and ends the process with its reason on stderr. + DispatchQueue.main.async { [self] in + write() + } + } + + private func write() { + guard let document, let dataSource = document.hierarchyDataSource else { + fail(.writeFailed, "writing snapshot.json failed: the document has no data source") + return + } + let directoryURL = URL(fileURLWithPath: directory, isDirectory: true) + let platform = LKHelper.appInfoLooksLikeMacTarget(app?.appInfo) ? "macos" : "ios" + do { + try LKDebugE2EDumpWriter.writeSnapshot(for: dataSource, app: document.inspectableApp, platform: platform, to: directoryURL) + } catch { + fail(.writeFailed, "writing snapshot.json failed: \(error as NSError)") + return + } + let window = document.windowControllers.first?.window + let pngURL = directoryURL.appendingPathComponent("host-window.png") + guard let png = LKDebugWindowImage.pngData(of: window), !png.isEmpty, (try? png.write(to: pngURL, options: .atomic)) != nil else { + fail(.writeFailed, "capturing the Host window failed") + return + } + log("wrote \(directory)") + if LKDebugUISnapshots.isEnabled { + timer?.invalidate() + LKDebugUISnapshots.runScenes(for: document) { status in + exit(status) + } + return + } + exit(ExitStatus.success.rawValue) + } + + // MARK: - Output + + private func log(_ message: String) { + fputs("[host-e2e-dump] \(message)\n", stderr) + fflush(stderr) + } + + private func fail(_ status: ExitStatus, _ message: String) { + phase = .done + log("FAIL: \(message)") + exit(status.rawValue) + } + } +#endif diff --git a/LookInside/DebugE2E/LKDebugE2EDumpWriter.swift b/LookInside/DebugE2E/LKDebugE2EDumpWriter.swift new file mode 100644 index 00000000..c0c610a0 --- /dev/null +++ b/LookInside/DebugE2E/LKDebugE2EDumpWriter.swift @@ -0,0 +1,174 @@ +#if DEBUG + import AppKit + import Foundation + import LookInsideHostCore + + /// Writes what the Host loaded for one live document as a normalized + /// `snapshot.json`, in the same format lookin-probe writes (shared + /// `LookinSnapshotNormalizer`), so the diff tool of + /// Scripts/reborn-e2e/LookinProbe compares both. + /// + /// The Host merges each detail into its display item, so a node has one + /// `item` record (hierarchy plus merged details) and no separate `detail`. + /// The Host decodes screenshots into images it does not archive, so a + /// node's `host` record names their pixel sizes; the pixels themselves are + /// covered by lookin-probe. + @objc(LKDebugE2EDumpWriter) + final class LKDebugE2EDumpWriter: NSObject { + @objc(writeSnapshotForDataSource:app:platform:toDirectory:error:) + static func writeSnapshot( + for dataSource: LKStaticHierarchyDataSource, + app: LKInspectableApp, + platform: String, + to directory: URL + ) throws { + guard let hierarchy = dataSource.rawHierarchyInfo else { + throw LookinSnapshotError("the data source has no hierarchy") + } + guard let appInfo = app.appInfo ?? hierarchy.appInfo else { + throw LookinSnapshotError("the inspected app has no app info") + } + let nodes = LookinSnapshotNormalizer.collectNodes(roots: hierarchy.displayItems ?? []) + let childrenByParent = LookinSnapshotNormalizer.childrenByParent(nodes) + let idsByItem = Dictionary( + nodes.map { (ObjectIdentifier($0.item), $0.id) }, + uniquingKeysWith: { first, _ in first } + ) + let displayingItems = dataSource.displayingFlatItems ?? [] + let displayingSet = Set(displayingItems.compactMap { idsByItem[ObjectIdentifier($0)] }) + // The outline shows its items in tree order; taking that order + // from `nodes` applies the canonical sibling order to it too. + let displayingIDs = nodes.map(\.id).filter { displayingSet.contains($0) } + + var flatNodes: [[String: Any]] = [] + var nodesWithAttributes = 0 + for node in nodes { + let item = node.item + let attributesLoaded = (item.attributesGroupList?.isEmpty == false) + if attributesLoaded { + nodesWithAttributes += 1 + } + var host: [String: Any] = [ + "title": item.title() ?? NSNull(), + "subtitle": normalizedSubtitle(item.subtitle()), + "indentLevel": item.indentLevel(), + "isExpandable": item.isExpandable, + "isExpanded": item.isExpandable ? item.isExpanded : NSNull(), + "displayed": displayingSet.contains(node.id), + "doNotFetchScreenshotReason": item.doNotFetchScreenshotReason.rawValue, + "attributesLoaded": attributesLoaded, + ] + host["soloScreenshot"] = imageSize(item.soloScreenshot) + host["groupScreenshot"] = imageSize(item.groupScreenshot) + flatNodes.append([ + "id": node.id, + "parent": node.parentID.isEmpty ? NSNull() : node.parentID, + "depth": node.depth, + "children": childrenByParent[node.id] ?? [], + "item": LookinSnapshotNormalizer.nodeObjectJSON(item, imageSink: sizeOnlyImage), + "host": host, + ]) + } + + let session = LookinSnapshotNormalizer.sessionStateJSON( + hostAppearance: NSApp.effectiveAppearance.name.rawValue + ) + let raw: [String: Any] = [ + "format": 1, + "host": [ + "transport": "host-ui", + "platform": platform, + "activationState": activationStateName(), + "channelLicensed": isChannelLicensed(for: app), + "nodeCount": nodes.count, + "displayedNodeCount": displayingIDs.count, + "nodesWithAttributes": nodesWithAttributes, + // Informational here; the diff checks session-state.json. + "session": session, + ], + "appInfo": LookinSnapshotNormalizer.appInfoJSON(appInfo, imageSink: sizeOnlyImage), + "hierarchy": LookinSnapshotNormalizer.hierarchyJSON(hierarchy), + "roots": childrenByParent[""] ?? [], + "displayed": displayingIDs, + "nodes": flatNodes, + ] + guard let snapshot = LookinSnapshotNormalizer.normalize(raw) as? [String: Any] else { + throw LookinSnapshotError("normalization did not return an object") + } + let data = try LookinSnapshotNormalizer.serialize(snapshot) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent("snapshot.json"), options: .atomic) + try LookinSnapshotNormalizer.writeSessionState(session, to: directory) + } + + /// The outline subtitle can list the ivars that reference the object + /// (`-[LookinDisplayItem subtitle]`), joined in hash-set order. They are + /// sorted, and the timing-dependent ones dropped, as in the item JSON. + private static func normalizedSubtitle(_ subtitle: String?) -> Any { + guard let subtitle, !subtitle.isEmpty else { return NSNull() } + let separator = " " + return subtitle.components(separatedBy: separator) + .filter { !LookinSnapshotNormalizer.volatileIvarNames.contains($0) } + .sorted() + .joined(separator: separator) + } + + /// Images stand in as their pixel size only; see the type comment. + private static func sizeOnlyImage(_ image: CGImage, _: [String]) -> Any { + ["$imageSize": [image.width, image.height]] + } + + private static func imageSize(_ image: NSImage?) -> Any { + guard let image else { return NSNull() } + var rect = CGRect(origin: .zero, size: image.size) + guard let cgImage = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) else { + return ["$imageSize": NSNull()] + } + return sizeOnlyImage(cgImage, []) + } + + /// The list the launch window shows, from + /// `LKAppsManager.scanApps(needImages: true, localInfos: nil)`: + /// nil when no channel is connected. `completion` runs on the main + /// thread, after this method returns. + @objc(fetchAppsWithCompletion:) + @MainActor + static func fetchApps(completion: @escaping ([LKInspectableApp]?) -> Void) { + Task { @MainActor in + let result = await LKAppsManager.shared.scanApps(needImages: true, localInfos: nil) + completion(result.channelCount == 0 ? nil : result.apps) + } + } + + /// `app`'s hierarchy, or the error the request failed with. + /// `completion` runs on the main thread, after this method returns. + @objc(fetchHierarchyForApp:completion:) + @MainActor + static func fetchHierarchy(for app: LKInspectableApp, completion: @escaping (LookinHierarchyInfo?, Error?) -> Void) { + Task { @MainActor in + do { + try completion(await app.hierarchy(), nil) + } catch { + completion(nil, error) + } + } + } + + /// Whether the 220/221 license handshake succeeded on `app`'s channel, + /// as the Host's connection layer records it. + @objc(isChannelLicensedForApp:) + static func isChannelLicensed(for app: LKInspectableApp) -> Bool { + MainActor.assumeIsolated { + app.channel?.connectionState.licenseHandshake.isVerified ?? false + } + } + + private static func activationStateName() -> String { + switch LKSwiftUISupportGatekeeper.sharedInstance().activationState { + case .unknown: return "unknown" + case .notActivated: return "notActivated" + case .activated: return "activated" + } + } + } +#endif diff --git a/LookInside/DebugE2E/LKDebugUISnapshots.swift b/LookInside/DebugE2E/LKDebugUISnapshots.swift new file mode 100644 index 00000000..bd728208 --- /dev/null +++ b/LookInside/DebugE2E/LKDebugUISnapshots.swift @@ -0,0 +1,541 @@ +#if DEBUG + import AppKit + import Foundation + + /// DEBUG-only UI self-screenshots. When LOOKINSIDE_DEBUG_UI_SNAPSHOT_DIR + /// is set (together with the end-to-end dump's LOOKINSIDE_DEBUG_E2E_*), + /// the Host drives its own UI through a fixed list of scenes and writes + /// one PNG per scene, drawn in process (LKDebugWindowImage: no + /// Accessibility or Screen Recording permission), plus: + /// + /// scenes.json every scene: file, status (captured / skipped), + /// skip reason, the session state at its capture + /// session-state.json the run's session state (the first scene with + /// the inspected app loaded), for + /// LookinProbe/session-state.sh check + /// .launch-captured marker: the no-app scene is done and the driver + /// may launch the inspected app + /// + /// Flow (driver: Scripts/reborn-e2e/ui-snapshots.sh): + /// 1. At launch, before any app is inspectable, the launch window is + /// captured ("launch-no-app"); then the marker is written. The dump + /// waits for LOOKINSIDE_DEBUG_E2E_START_FILE before it looks for the + /// app, so the driver launches the Example in between. + /// 2. LKDebugE2EDump opens the app, waits for every detail and writes its + /// snapshot, then hands the live document to `runScenes`. + /// 3. The scenes run one after another in this launch, and the Host + /// exits with 0 (all scenes captured or skipped), or non-zero. + /// + /// Determinism: windows get fixed sizes (split views laid out again with + /// their own first-layout rule), nodes are chosen by their canonical + /// snapshot id, indeterminate spinners are stopped before a capture, and + /// every Host window ignores mouse events, so someone using the Mac + /// cannot hover or scroll what is captured. The Host never activates + /// itself; the activation window is shown without `present()`, which + /// would. Appearance, scroll bars, language and locale come from the + /// driver's launch arguments. + /// + /// Session state: each scene records the state it was captured in + /// (screen lock, console, display sleep, the inspected Example's + /// appearance and active / key state, the Host's appearance, whether the + /// Host was active and the window key, the window's backing scale). The + /// comparison (`lookin-probe ui-diff`) skips a scene whose state differs + /// from the baseline's, and never loosens a threshold for it. + @objc(LKDebugUISnapshots) + @MainActor + final class LKDebugUISnapshots: NSObject { + static let directoryVariable = "LOOKINSIDE_DEBUG_UI_SNAPSHOT_DIR" + static let launchMarkerName = ".launch-captured" + + /// Content size of the inspector and reader windows, in points. + static let documentContentSize = NSSize(width: 1280, height: 800) + /// Canonical snapshot ids (LookinSnapshotNormalizer, as in + /// host-dump-golden/macos) of the nodes the scenes select in + /// LookInsideExampleAppKit's main window. + static let contentStackID = "/NSWindow[0]/NSThemeFrame[0]/NSView[0]/NSSplitView[0]" + + "/_NSSplitViewItemViewWrapper[0]/NSView[0]/NSView[0]/NSScrollView[0]/NSClipView[0]" + + "/LookInsideExampleAppKit.FlippedView[0]/NSStackView[0]" + /// `trackTitleLabel`, an NSTextField. + static let labelID = contentStackID + "/NSStackView[0]/NSStackView[0]/NSTextField[0]" + /// The artwork's `symbolImageView`, an NSImageView. + static let imageViewID = contentStackID + "/NSStackView[0]/LookInsideExampleAppKit.AlbumArtworkView[0]/NSStackView[0]/NSImageView[0]" + /// `contentStackView`, an NSStackView. + static let stackViewID = contentStackID + static let filterText = "TextField" + + private static var shared: LKDebugUISnapshots? + + private let directory: URL + private var entries: [[String: Any]] = [] + private var runState: [String: Any]? + + private init(directory: URL) { + self.directory = directory + } + + @objc static var isEnabled: Bool { + !(ProcessInfo.processInfo.environment[directoryVariable] ?? "").isEmpty + } + + private static func instance() -> LKDebugUISnapshots? { + if let shared { + return shared + } + guard let path = ProcessInfo.processInfo.environment[directoryVariable], !path.isEmpty else { + return nil + } + let snapshots = LKDebugUISnapshots(directory: URL(fileURLWithPath: (path as NSString).standardizingPath, isDirectory: true)) + shared = snapshots + return snapshots + } + + // MARK: - Entry points + + /// Step 1: captures the launch window before any app is inspectable, + /// then writes the marker. Called once the app finished launching. + @objc static func captureLaunchScene() { + guard let snapshots = instance() else { return } + Task { @MainActor in + do { + try FileManager.default.createDirectory(at: snapshots.directory, withIntermediateDirectories: true) + try await snapshots.launchScene() + try snapshots.writeIndex() + try Data().write(to: snapshots.directory.appendingPathComponent(launchMarkerName)) + } catch { + log("FAIL: launch scene: \(error)") + exit(7) + } + } + } + + /// Steps 2 and 3: runs every scene against the loaded live document, + /// then calls `completion` with the exit status. + @objc(runScenesForDocument:completion:) + static func runScenes(for document: LookinLiveDocument, completion: @escaping (Int32) -> Void) { + guard let snapshots = instance() else { + completion(0) + return + } + Task { @MainActor in + do { + try await snapshots.documentScenes(document) + try snapshots.writeIndex() + log("wrote \(snapshots.entries.count) scenes to \(snapshots.directory.path)") + completion(0) + } catch { + try? snapshots.writeIndex() + log("FAIL: \(error)") + completion(7) + } + } + } + + // MARK: - Scenes + + private func launchScene() async throws { + let window = try await waitFor("the launch window") { + LKNavigationManager.shared.launchWindowController?.window.flatMap { $0.isVisible ? $0 : nil } + } + quietWindows() + // Two app-list rounds of the launch window (every 1.5 s), so its + // "searching" state and progress bar have settled. + await pause(3.5) + let apps = await LKAppsManager.shared.fetchAppInfos(needImages: false, localInfos: nil) + if !apps.isEmpty { + let names = apps.map { $0.appInfo?.appBundleIdentifier ?? "?" }.joined(separator: ", ") + skip("launch-no-app", reason: "\(apps.count) inspectable app(s) already running: \(names)") + return + } + try await capture("launch-no-app", window: window, includesInspectedApp: false) + } + + private func documentScenes(_ document: LookinLiveDocument) async throws { + guard let windowController = document.windowControllers.first as? LKStaticWindowController, + let window = windowController.window, + let viewController = windowController.viewController, + let dataSource = document.hierarchyDataSource + else { throw LookinSnapshotError("the live document has no inspector window") } + let preferences = LKPreferenceManager.shared + quietWindows() + pinSize(of: window, to: Self.documentContentSize) + logLayout(of: window) + + let nodes = LookinSnapshotNormalizer.collectNodes(roots: dataSource.rawHierarchyInfo?.displayItems ?? []) + let label = try node(Self.labelID, in: nodes) + let imageView = try node(Self.imageViewID, in: nodes) + let stackView = try node(Self.stackViewID, in: nodes) + + // Main window: hierarchy, preview and dashboard with the default + // attribute groups (Class collapsed). + select(label, in: dataSource) + try await capture("main-selected-label", window: window, document: document) + + // Dashboard with every attribute group expanded, for a label, an + // image view and a stack view: the window, and the dashboard's + // whole scrollable content. + preferences.collapsedAttrGroups = [] + for (name, item) in [("label", label), ("imageview", imageView), ("stackview", stackView)] { + select(item, in: dataSource) + try await capture("dashboard-\(name)", window: window, document: document) + try await captureView("dashboard-\(name)-content", window: window, + view: dashboardContentView(of: viewController)) + } + + // Measure: label selected, image view hovered, measuring locked. + select(label, in: dataSource) + preferences.measureState.setIntegerValue(LookinMeasureState.locked.rawValue, ignoreSubscriber: nil) + dataSource.hoveredItem = imageView + try await capture("measure", window: window, document: document) + dataSource.hoveredItem = nil + preferences.measureState.setIntegerValue(LookinMeasureState.no.rawValue, ignoreSubscriber: nil) + + // Console panel under the preview, label selected. + viewController.showConsole = true + try await capture("console", window: window, document: document) + viewController.showConsole = false + + // Hierarchy filter active. + try await filterHierarchy(viewController, text: Self.filterText) + try await capture("hierarchy-filter", window: window, document: document) + try await filterHierarchy(viewController, text: nil) + + // Export: the save panel runs out of process, so its accessory + // view is captured on its own; the exported data is then opened + // as a .lookin file (Export -> Read round trip). + guard let hierarchyInfo = dataSource.rawHierarchyInfo else { + throw LookinSnapshotError("the data source has no hierarchy") + } + // Full image quality: below 1 the export scales every screenshot + // down by drawing it, and that resampling is not pixel-stable + // from run to run (measured: two variants of the reader's + // preview at 50%). At 1 the images keep their size. + var fileName: NSString? + guard let exported = LKExportManager.sharedInstance().data( + from: hierarchyInfo, imageCompression: 1, fileName: &fileName + ) + else { throw LookinSnapshotError("exporting the hierarchy returned no data") } + try await captureExportAccessory() + try await readRoundTrip(exported) + + // Windows of their own. + LKNavigationManager.shared.showPreference() + try await captureStandaloneWindow("preferences", controllerKey: "preferenceWindowController") + LKNavigationManager.shared.showAbout() + try await captureStandaloneWindow("about", controllerKey: "aboutWindowController") + try await captureActivationWindow() + } + + private func readRoundTrip(_ data: Data) async throws { + let work = directory.appendingPathComponent("work", isDirectory: true) + try FileManager.default.createDirectory(at: work, withIntermediateDirectories: true) + let url = work.appendingPathComponent("roundtrip.lookin") + try data.write(to: url, options: .atomic) + let document: NSDocument = try await withCheckedThrowingContinuation { continuation in + NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { document, _, error in + if let document { + continuation.resume(returning: document) + } else { + continuation.resume(throwing: error ?? LookinSnapshotError("opening \(url.path) failed")) + } + } + } + let readViewController = try await waitFor("the reader to load the file") { + (document.windowControllers.first?.contentViewController as? LKReadViewController) + } + guard let window = document.windowControllers.first?.window, + let dataSource = readViewController.hierarchyDataSource + else { throw LookinSnapshotError("the reader has no window") } + quietWindows() + pinSize(of: window, to: Self.documentContentSize) + let nodes = LookinSnapshotNormalizer.collectNodes(roots: dataSource.rawHierarchyInfo?.displayItems ?? []) + try select(node(Self.labelID, in: nodes), in: dataSource) + try await capture("read-roundtrip", window: window) + document.close() + } + + /// The export size the accessory shows for the snapshot. The real + /// size changes from run to run (the screenshots are JPEG-encoded, + /// and the encoder's output is not byte-stable), so a fixed size + /// stands in; the exported data itself is covered by the reader + /// round trip. + static let exportAccessoryDataSize: UInt = 46_200_000 + + private func captureExportAccessory() async throws { + let accessory = LKExportAccessoryView() + accessory.dataSize = Self.exportAccessoryDataSize + accessory.setFrameSize(accessory.sizeThatFits(NSSize(width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude))) + // Never shown: the view only needs a window for its appearance + // and backing scale. + let window = NSWindow(contentRect: NSRect(origin: .zero, size: accessory.frame.size), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = accessory + await pause(0.5) + try await captureView("export-accessory", window: window, view: accessory) + window.close() + } + + private func captureStandaloneWindow(_ name: String, controllerKey: String) async throws { + let navigation = LKNavigationManager.shared + let window = try await waitFor("the \(name) window") { + (navigation.value(forKey: controllerKey) as? NSWindowController)?.window.flatMap { $0.isVisible ? $0 : nil } + } + quietWindows() + window.center() + try await capture(name, window: window) + window.close() + } + + private func captureActivationWindow() async throws { + let window = await LKSwiftUISupportGatekeeper.sharedInstance().debugPreparedActivationWindow() + window.center() + window.orderFront(nil) + quietWindows() + try await capture("activation", window: window) + window.close() + } + + // MARK: - Driving the UI + + private func node(_ id: String, in nodes: [LookinSnapshotNormalizer.Node]) throws -> LookinDisplayItem { + guard let node = nodes.first(where: { $0.id == id }) else { + throw LookinSnapshotError("no node \(id)") + } + return node.item + } + + private func select(_ item: LookinDisplayItem, in dataSource: LKHierarchyDataSource) { + // A fresh selection, so the dashboard renders its cards again + // with the current collapsed-group preference. + dataSource.selectedItem = nil + dataSource.selectAndRevealItem(item) + } + + /// Types into the hierarchy's filter field as the user would; nil + /// ends the filter through its close button path. + private func filterHierarchy(_ viewController: LKStaticViewController, text: String?) async throws { + guard let hierarchyView = viewController.currentHierarchyView(), + let field = hierarchyView.value(forKey: "searchTextFieldView") as? LKTextFieldView + else { throw LookinSnapshotError("the hierarchy view has no filter field") } + field.textField.stringValue = text ?? "" + hierarchyView.delegate?.hierarchyView(hierarchyView, didInputSearch: text) + } + + private func dashboardContentView(of viewController: LKStaticViewController) throws -> NSView { + guard let dashboard = viewController.value(forKey: "dashboardController") as? NSViewController, + let scrollView = dashboard.value(forKey: "scrollView") as? NSScrollView, + let content = scrollView.documentView + else { throw LookinSnapshotError("the dashboard has no scroll view") } + return content + } + + /// Sets the window's content size and lays its split views out again + /// with their own first-layout rule, which otherwise ran for the + /// size the window was created with (a share of the screen). + private func pinSize(of window: NSWindow, to size: NSSize) { + window.setContentSize(size) + window.contentView?.layoutSubtreeIfNeeded() + var splitViews: [LKSplitView] = [] + func collect(_ view: NSView) { + if let split = view as? LKSplitView { + splitViews.append(split) + } + view.subviews.forEach(collect) + } + if let content = window.contentView { + collect(content) + } + for split in splitViews { + split.didFinishFirstLayout?(split) + } + } + + /// Logs where the split views, scroll views and SceneKit views sit, + /// so a capture that looks wrong can be told from a layout change. + private func logLayout(of window: NSWindow) { + func walk(_ view: NSView, depth: Int) { + if view is NSSplitView || view is NSScrollView || String(describing: type(of: view)).contains("SCNView") { + Self.log("layout \(String(repeating: " ", count: depth))\(type(of: view)) \(NSStringFromRect(view.convert(view.bounds, to: nil)))") + } + for subview in view.subviews where !subview.isHidden { + walk(subview, depth: depth + 1) + } + } + if let frameView = window.contentView?.superview { + walk(frameView, depth: 0) + } + } + + /// Every Host window ignores the mouse, so a cursor over it cannot + /// hover, scroll or click what is being captured. + private func quietWindows() { + for window in NSApp.windows { + window.ignoresMouseEvents = true + } + } + + // MARK: - Capture + + private func capture(_ name: String, window: NSWindow, document: LookinLiveDocument? = nil, + includesInspectedApp: Bool = true) async throws + { + try await settle(window, document: document) + let restore = maskAddresses(in: window.contentView?.superview) + defer { restore() } + guard let png = LKDebugWindowImage.pngData(of: window) else { + throw LookinSnapshotError("capturing \(name) failed") + } + try write(png, scene: name, window: window, includesInspectedApp: includesInspectedApp) + } + + private func captureView(_ name: String, window: NSWindow, view: NSView) async throws { + try await settle(window, document: nil) + let restore = maskAddresses(in: view) + defer { restore() } + guard let png = LKDebugWindowImage.pngData(of: view) else { + throw LookinSnapshotError("capturing \(name) failed") + } + try write(png, scene: name, window: window, includesInspectedApp: true) + } + + private func settle(_ window: NSWindow, document: LookinLiveDocument?) async throws { + if let manager = document?.asyncUpdateManager { + _ = try await waitFor("detail updates to finish") { manager.isUpdating() ? nil : true } + } + await pause(1.5) + let frameView = window.contentView?.superview ?? window.contentView + stopSpinners(in: frameView) + // One full layout pass of every view, toolbar included: the + // reader's app item otherwise keeps whichever layout it got + // before its labels and icon were sized. + invalidateLayout(in: frameView) + frameView?.layoutSubtreeIfNeeded() + window.displayIfNeeded() + } + + /// Object addresses of the inspected app ("" in the + /// dashboard, the console's target) change with every launch. Each + /// hex digit of an address shown in a text field or text view is drawn as 0 for + /// the capture (same length, so the layout stays), the way the + /// snapshot normalizer drops oids. Returns the undo. + private func maskAddresses(in root: NSView?) -> () -> Void { + guard let root, + let pattern = try? NSRegularExpression(pattern: "0x[0-9a-fA-F]{6,}") + else { return {} } + var undo: [() -> Void] = [] + // Replaces the hex digits of every address in `string` with 0, + // keeping its attributes; false when it holds none. + func mask(_ string: NSMutableAttributedString) -> Bool { + let matches = pattern.matches(in: string.string, range: NSRange(location: 0, length: string.length)) + for match in matches { + let digits = NSRange(location: match.range.location + 2, length: match.range.length - 2) + string.replaceCharacters(in: digits, with: String(repeating: "0", count: digits.length)) + } + return !matches.isEmpty + } + func walk(_ view: NSView) { + if let field = view as? NSTextField { + let original = field.attributedStringValue + let masked = NSMutableAttributedString(attributedString: original) + if mask(masked) { + field.attributedStringValue = masked + undo.append { field.attributedStringValue = original } + } + } else if let textView = view as? NSTextView, let storage = textView.textStorage { + let original = NSAttributedString(attributedString: storage) + let masked = NSMutableAttributedString(attributedString: original) + if mask(masked) { + storage.setAttributedString(masked) + undo.append { storage.setAttributedString(original) } + } + } + view.subviews.forEach(walk) + } + walk(root) + if !undo.isEmpty { + root.layoutSubtreeIfNeeded() + root.displayIfNeeded() + } + return { + undo.forEach { $0() } + } + } + + private func invalidateLayout(in view: NSView?) { + guard let view else { return } + view.needsLayout = true + view.subviews.forEach(invalidateLayout) + } + + /// An indeterminate spinner draws a different frame each time; a + /// stopped one draws a fixed one. + private func stopSpinners(in view: NSView?) { + guard let view else { return } + if let indicator = view as? NSProgressIndicator, indicator.isIndeterminate { + indicator.stopAnimation(nil) + } + view.subviews.forEach(stopSpinners) + } + + private func write(_ png: Data, scene name: String, window: NSWindow, includesInspectedApp: Bool) throws { + let file = "\(name).png" + try png.write(to: directory.appendingPathComponent(file), options: .atomic) + let state = sessionState(window: window, includesInspectedApp: includesInspectedApp) + if includesInspectedApp, runState == nil { + runState = state + try LookinSnapshotNormalizer.writeSessionState(state, to: directory) + } + entries.append(["name": name, "file": file, "status": "captured", "state": state]) + Self.log("captured \(name)") + } + + private func skip(_ name: String, reason: String) { + entries.append(["name": name, "status": "skipped", "reason": reason]) + Self.log("SKIP \(name): \(reason)") + } + + /// The session state now (LookinSnapshotNormalizer.sessionStateJSON) + /// plus what only the Host knows: whether it is active, whether the + /// captured window is key, and the window's backing scale. + private func sessionState(window: NSWindow, includesInspectedApp: Bool) -> [String: Any] { + var state = includesInspectedApp + ? LookinSnapshotNormalizer.sessionStateJSON(hostAppearance: NSApp.effectiveAppearance.name.rawValue) + : LookinSnapshotNormalizer.systemSessionStateJSON() + state["hostAppearance"] = NSApp.effectiveAppearance.name.rawValue + state["hostAppActive"] = NSApp.isActive + state["hostWindowKey"] = window.isKeyWindow + state["hostBackingScale"] = Double(window.backingScaleFactor) + return state + } + + private func writeIndex() throws { + let index: [String: Any] = ["format": 1, "scenes": entries] + let data = try JSONSerialization.data(withJSONObject: index, options: [.prettyPrinted, .sortedKeys]) + try (data + Data("\n".utf8)).write(to: directory.appendingPathComponent("scenes.json"), options: .atomic) + } + + // MARK: - Waiting + + private func pause(_ seconds: Double) async { + try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + } + + private func waitFor(_ what: String, timeout: Double = 60, _ probe: () -> T?) async throws -> T { + let deadline = Date().addingTimeInterval(timeout) + while true { + if let value = probe() { + return value + } + if Date() > deadline { + throw LookinSnapshotError("timed out waiting for \(what)") + } + await pause(0.25) + } + } + + private static func log(_ message: String) { + FileHandle.standardError.write(Data("[host-ui-snapshots] \(message)\n".utf8)) + } + } +#endif diff --git a/LookInside/DebugE2E/LKDebugWindowImage.swift b/LookInside/DebugE2E/LKDebugWindowImage.swift new file mode 100644 index 00000000..fcb6fb9d --- /dev/null +++ b/LookInside/DebugE2E/LKDebugWindowImage.swift @@ -0,0 +1,108 @@ +#if DEBUG + import AppKit + import SceneKit + + /// Draws a Host window or view into a bitmap in process, so no + /// screen-recording permission is involved. Shared by the end-to-end + /// dump (LKDebugE2EDump.swift, host-window.png) and the UI snapshots + /// (LKDebugUISnapshots.swift). + /// + /// SceneKit views render through Metal, which `cacheDisplay(in:to:)` does + /// not capture, so each one's `SCNView.snapshot()` is drawn over its area, + /// clipped away from the views that sit on top of it (outline, dashboard, + /// toolbar). + @objc(LKDebugWindowImage) + final class LKDebugWindowImage: NSObject { + /// PNG of `window`'s frame view: title bar and toolbar included. + @objc(pngDataOfWindow:) + static func pngData(of window: NSWindow?) -> Data? { + guard let window, let frameView = window.contentView?.superview ?? window.contentView else { + return nil + } + return pngData(of: frameView) + } + + /// PNG of `view`'s whole bounds, including parts scrolled out of + /// sight. + static func pngData(of view: NSView) -> Data? { + let bounds = view.bounds + guard bounds.width > 0, bounds.height > 0, + let rep = view.bitmapImageRepForCachingDisplay(in: bounds) + else { return nil } + view.cacheDisplay(in: bounds, to: rep) + + var sceneViews: [SCNView] = [] + collectSceneViews(in: view, into: &sceneViews) + if !sceneViews.isEmpty, let context = NSGraphicsContext(bitmapImageRep: rep) { + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + for sceneView in sceneViews { + let sceneRect = rect(sceneView.bounds, of: sceneView, in: view) + // Only the part its ancestors show: the preview's scene + // view reaches under the hierarchy outline, which the + // window clips away. + let clip = NSBezierPath(rect: rect(sceneView.visibleRect, of: sceneView, in: view)) + clip.windingRule = .evenOdd + for cover in views(above: sceneView, upTo: view) { + clip.appendRect(rect(cover.bounds, of: cover, in: view)) + } + NSGraphicsContext.saveGraphicsState() + clip.addClip() + sceneView.snapshot().draw(in: sceneRect) + NSGraphicsContext.restoreGraphicsState() + } + context.flushGraphics() + NSGraphicsContext.restoreGraphicsState() + } + guard let png = rep.representation(using: .png, properties: [:]), !png.isEmpty else { + return nil + } + return png + } + + /// `rect` of `view` in the bitmap's (unflipped) coordinates. + private static func rect(_ rect: NSRect, of view: NSView, in root: NSView) -> NSRect { + var converted = view.convert(rect, to: root) + if root.isFlipped { + converted.origin.y = root.bounds.height - converted.maxY + } + return converted + } + + /// Visible views drawn after `view`'s branch at each level up to + /// `root`, i.e. the ones that cover it. + private static func views(above view: NSView, upTo root: NSView) -> [NSView] { + var covers: [NSView] = [] + let target = view.convert(view.bounds, to: nil) + var branch = view + while let superview = branch.superview, branch !== root { + let siblings = superview.subviews + if let index = siblings.firstIndex(where: { $0 === branch }) { + for sibling in siblings[(index + 1)...] { + if sibling.isHiddenOrHasHiddenAncestor || sibling.alphaValue == 0 { + continue + } + if sibling.convert(sibling.bounds, to: nil).intersects(target) { + covers.append(sibling) + } + } + } + branch = superview + } + return covers + } + + private static func collectSceneViews(in view: NSView, into sceneViews: inout [SCNView]) { + if view.isHiddenOrHasHiddenAncestor { + return + } + if let sceneView = view as? SCNView { + sceneViews.append(sceneView) + return + } + for subview in view.subviews { + collectSceneViews(in: subview, into: &sceneViews) + } + } + } +#endif diff --git a/LookInside/DebugE2E/LookinSnapshotNormalizer.swift b/LookInside/DebugE2E/LookinSnapshotNormalizer.swift new file mode 100644 index 00000000..c54f7736 --- /dev/null +++ b/LookInside/DebugE2E/LookinSnapshotNormalizer.swift @@ -0,0 +1,672 @@ +// Shared by two regression tools so their outputs stay comparable: +// - lookin-probe (Scripts/reborn-e2e/LookinProbe), which links this file +// through a symlink and defines LOOKIN_PROBE; +// - the Host's DEBUG end-to-end dump (LKDebugE2EDump.swift). +// It needs only Foundation, AppKit and the LookinCore model classes, which +// the probe imports as a module and the Host sees through its bridging +// header. Release Host builds compile it out. +#if DEBUG || LOOKIN_PROBE + import AppKit + import CryptoKit + import Foundation + #if canImport(LookinCore) + import LookinCore + import LookinCoreImpl + #endif + + struct LookinSnapshotError: Error, CustomStringConvertible { + let description: String + + init(_ description: String) { + self.description = description + } + } + + /// Normalization rules for inspection snapshots: stable node ids, + /// volatile-value masking, ordering of hash-table arrays, and the + /// one-line-per-node JSON layout. + enum LookinSnapshotNormalizer { + // MARK: - Nodes + + struct Node { + let id: String + let parentID: String + let depth: Int + let item: LookinDisplayItem + } + + static func className(of item: LookinDisplayItem) -> String { + for object in [item.viewObject, item.layerObject, item.windowObject, item.kindObject, item.cellObject] { + if let object, let name = object.rawClassName(), !name.isEmpty { + return name + } + } + if let title = item.customInfo?.title, !title.isEmpty { + return "custom:" + title + } + return "node" + } + + /// Depth-first nodes with stable ids: the class path from the root, + /// each step suffixed with its index among same-class siblings. The + /// ids survive oid / address churn. + static func collectNodes(roots: [LookinDisplayItem]) -> [Node] { + var nodes: [Node] = [] + for (index, root) in roots.enumerated() { + collect(root, parentID: "", siblingIndex: index, siblings: roots, depth: 0, into: &nodes) + } + return nodes + } + + private static func collect( + _ item: LookinDisplayItem, + parentID: String, + siblingIndex: Int, + siblings: [LookinDisplayItem], + depth: Int, + into nodes: inout [Node] + ) { + let name = className(of: item) + let sameClassBefore = siblings.prefix(siblingIndex).filter { className(of: $0) == name }.count + let id = parentID + "/" + name + "[\(sameClassBefore)]" + nodes.append(Node(id: id, parentID: parentID, depth: depth, item: item)) + let children = canonicalSiblingOrder(item.subitems ?? []) + for (index, child) in children.enumerated() { + collect(child, parentID: id, siblingIndex: index, siblings: children, depth: depth + 1, into: &nodes) + } + } + + /// `items` with SwiftUI siblings of the same class ordered by their + /// frame in the window (x, y, width, height; a wrapper without a + /// frame uses its first descendant's), in the slots they held; every + /// other sibling keeps its place. The Server does not keep the order + /// of identical SwiftUI views stable from launch to launch (a + /// ZStack's ForEach circles come back in either direction). + static func canonicalSiblingOrder(_ items: [LookinDisplayItem]) -> [LookinDisplayItem] { + func frameKey(_ item: LookinDisplayItem) -> [Double] { + if let rect = item.customInfo?.frameInWindow?.rectValue { + return [rect.origin.x, rect.origin.y, rect.size.width, rect.size.height].map(Double.init) + } + for child in item.subitems ?? [] { + let key = frameKey(child) + if !key.isEmpty { + return key + } + } + return [] + } + var slotsByClass: [String: [Int]] = [:] + for (index, item) in items.enumerated() where item.customInfo?.isSwiftUI == true { + slotsByClass[className(of: item), default: []].append(index) + } + var result = items + for slots in slotsByClass.values where slots.count > 1 { + let ordered = slots.map { items[$0] }.sorted { frameKey($0).lexicographicallyPrecedes(frameKey($1)) } + for (slot, item) in zip(slots, ordered) { + result[slot] = item + } + } + return result + } + + // MARK: - Session state + + /// The session state a snapshot was taken in, written into the + /// snapshot header (`host.session` / `probe.session`) and next to it + /// as `session-state.json`. AppKit renders differently with the screen + /// locked (blank window-server captures, window captures at 0.9x, + /// opaque scroll pockets, no key window), with the display asleep, in + /// another appearance, or with the inspected window key, so a golden + /// only compares with a run taken in the same state: `lookin-probe + /// diff` reports SKIP when the candidate's state differs from the + /// golden's `session-state.json` and never loosens the comparison + /// instead (SnapshotSessionState in LookinProbe). + /// + /// - `screenLocked`, `onConsole`: CGSessionCopyCurrentDictionary. + /// - `displayAwake`: the main display is not asleep. + /// - `appearance`, `inspectedAppActive`, `inspectedWindowKey`: what + /// the inspected app reports about itself through the file named by + /// `LOOKIN_E2E_INSPECTED_STATE_FILE` (written by + /// LookinProbe/support/pin-launch-state.m in the macOS Example); + /// "unavailable" when the variable is set but the file cannot be + /// read. Absent on iOS, where nothing sets the variable. + /// - `hostAppearance`: the Host's own effective appearance (Host dump + /// only). + static func sessionStateJSON(hostAppearance: String? = nil) -> [String: Any] { + var state = systemSessionStateJSON() + if let path = ProcessInfo.processInfo.environment[inspectedStateFileVariable], !path.isEmpty { + let inspected = (try? Data(contentsOf: URL(fileURLWithPath: path))) + .flatMap { try? JSONSerialization.jsonObject(with: $0) as? [String: Any] } + for key in ["appearance", "inspectedAppActive", "inspectedWindowKey"] { + switch inspected?[key] { + case let flag as NSNumber where CFGetTypeID(flag) == CFBooleanGetTypeID(): + // A Swift Bool, so the header normalization keeps it a boolean. + state[key] = flag.boolValue + case let value?: + state[key] = value + case nil: + state[key] = "unavailable" + } + } + } + if let hostAppearance { + state["hostAppearance"] = hostAppearance + } + return state + } + + /// The part of the session state that does not depend on the + /// inspected app; known before anything is launched. + static func systemSessionStateJSON() -> [String: Any] { + let session = (CGSessionCopyCurrentDictionary() as? [String: Any]) ?? [:] + func flag(_ key: String) -> Bool { + (session[key] as? NSNumber)?.boolValue ?? false + } + return [ + "onConsole": flag(kCGSessionOnConsoleKey), + // Present (and true) only while the screen is locked. + "screenLocked": flag("CGSSessionScreenIsLocked"), + "displayAwake": CGDisplayIsAsleep(CGMainDisplayID()) == 0, + ] + } + + static let inspectedStateFileVariable = "LOOKIN_E2E_INSPECTED_STATE_FILE" + static let sessionStateFileName = "session-state.json" + + /// Writes `state` as `session-state.json` into `directory`. + static func writeSessionState(_ state: [String: Any], to directory: URL) throws { + let data = try JSONSerialization.data(withJSONObject: state, options: [.prettyPrinted, .sortedKeys]) + try (data + Data("\n".utf8)).write(to: directory.appendingPathComponent(sessionStateFileName), options: .atomic) + } + + /// Child ids per parent id ("" holds the roots), in tree order. + static func childrenByParent(_ nodes: [Node]) -> [String: [String]] { + var childrenByParent: [String: [String]] = [:] + for node in nodes { + childrenByParent[node.parentID, default: []].append(node.id) + } + return childrenByParent + } + + /// The oid the Host puts in the detail task for this node; mirrors + /// -[LKStaticAsyncUpdateManager _taskFromDisplayItem:type:] with the + /// backing-layer toggle off. + static func taskOid(for item: LookinDisplayItem, macTarget: Bool) -> UInt { + switch item.resolvedNodeKind() { + case .window, .windowScene: + return item.windowObject?.oid ?? 0 + case .layoutGuide, .cell: + return item.kindObject?.oid ?? 0 + case .custom: + return 0 + default: + let viewOid = item.viewObject?.oid ?? 0 + let layerOid = item.layerObject?.oid ?? 0 + if macTarget, viewOid != 0 { + return viewOid + } + if layerOid != 0 { + return layerOid + } + return viewOid + } + } + + /// Windows always get a group screenshot: that is the picture of the + /// whole UI. iOS reports a UIWindow as a plain view node under its scene. + static func isWindow(_ item: LookinDisplayItem) -> Bool { + if item.resolvedNodeKind() == .window { + return true + } + let chain = item.viewObject?.classChainList ?? [] + return chain.contains("UIWindow") || chain.contains("NSWindow") + } + + // MARK: - Records + + /// LookinAppInfo encodes its images under the short keys "1" (icon) + /// and "2" (screenshot). + static let appImageNames = ["1": "appIcon", "2": "screenshot"] + + /// `appInfo` as JSON with the machine-identifying fields masked. + static func appInfoJSON(_ appInfo: LookinAppInfo, imageSink: @escaping LookinSnapshotWireJSON.ImageSink) -> [String: Any] { + var json = LookinSnapshotWireJSON(imageSink: imageSink).record(appInfo, keyPath: []) + // "3" = deviceDescription: on a Mac that is the computer name, and + // the model identifier names the hardware. Neither belongs in a + // checked-in fixture; keep only whether they were sent. + for key in ["3", "deviceModelIdentifier"] where json[key] is String { + json[key] = "" + } + return json + } + + /// `hierarchy` as JSON without its display items ("1") and app info + /// ("2"), which are recorded separately (LookinHierarchyInfo.m). + static func hierarchyJSON(_ hierarchy: LookinHierarchyInfo) -> [String: Any] { + LookinSnapshotWireJSON(skippedKeys: ["LookinHierarchyInfo": ["1", "2"]]) { _, _ in + ["$image": ""] + }.record(hierarchy, keyPath: []) + } + + /// A display item or detail as JSON without its `subitems`; the tree + /// is recorded through the node ids instead. + static func nodeObjectJSON(_ object: NSObject, imageSink: @escaping LookinSnapshotWireJSON.ImageSink) -> [String: Any] { + LookinSnapshotWireJSON( + skippedKeys: [ + "LookinDisplayItem": ["subitems"], + "LookinDisplayItemDetail": ["subitems"], + ], + imageSink: imageSink + ).record(object, keyPath: []) + } + + static func imageBaseName(for nodeID: String) -> String { + let digest = Insecure.SHA1.hash(data: Data(nodeID.utf8)).map { String(format: "%02x", $0) }.joined() + let leaf = nodeID.split(separator: "/").last.map(String.init) ?? "node" + let safeLeaf = leaf.replacingOccurrences(of: "[^A-Za-z0-9_]+", with: "_", options: .regularExpression) + return String(safeLeaf.prefix(40)) + "-" + digest.prefix(10) + } + + // MARK: - Normalization + + /// Keys whose values change from launch to launch and carry no meaning + /// for a regression diff. + static let volatileKeys: Set = [ + "oid", "memoryAddress", "displayItemOid", "recognizerOid", + "appInfoIdentifier", "cachedTimestamp", "shouldUseCache", + "constraintOid", + ] + + /// Dashboard attributes whose value is a per-launch counter. + static let volatileAttributeIdentifiers: Set = [ + "NSWindow_Info_WindowNumber", + ] + + /// AppKit caches that may or may not point at an object depending on + /// when the last layer-geometry update ran; an ivar trace through them + /// is timing, not structure. + static let volatileIvarNames: Set = [ + "_ancestorWithLayerForLastLayerGeometryUpdate", + ] + + /// Arrays stored under these keys are read from unordered tables on the + /// Server (a UIControl's target-action set), so they are sorted. + static let unorderedArrayKeys: Set = [ + "targetActions", + ] + + /// Arrays of these classes come out in enumeration order of a hash + /// table on the Server (constraints, ivar references), so their order is + /// not semantic: they are sorted after normalization. + static let unorderedElementClasses: Set = [ + "LookinAutoLayoutConstraint", "LookinIvarTrace", + ] + + private static let addressPattern = try! NSRegularExpression(pattern: "0x[0-9a-fA-F]{5,16}") + + /// SwiftUI node ids are `swiftui::` + /// (LKS_SwiftUIDisplayItemsMaker). The hash is per launch, and the + /// index follows the Server's creation order, which is not stable for + /// identical views (see `canonicalSiblingOrder`). + private static let swiftUIDisplayItemIDPattern = try! NSRegularExpression(pattern: "^swiftui:-?[0-9]+:[0-9]+$") + + /// Drops volatile keys, rounds doubles, and masks heap addresses inside + /// strings (descriptions like ``). + static func normalize(_ value: Any) -> Any { + switch value { + case let dictionary as [String: Any]: + var out: [String: Any] = [:] + for (key, element) in dictionary where !volatileKeys.contains(key) { + var normalized = normalize(element) + if key == "swiftUIDisplayItemID", let id = normalized as? String { + let range = NSRange(id.startIndex..., in: id) + normalized = swiftUIDisplayItemIDPattern.stringByReplacingMatches( + in: id, range: range, withTemplate: "swiftui::" + ) + } + if unorderedArrayKeys.contains(key), let array = normalized as? [Any] { + out[key] = array.sorted { LookinSnapshotWireJSON.sortKey($0) < LookinSnapshotWireJSON.sortKey($1) } + } else { + out[key] = normalized + } + } + // NSStackView keeps redundant edge / alignment + // constraints and AppKit picks which ones are active per launch, + // so `effective` on them is not reproducible. + if dictionary["$class"] as? String == "LookinAutoLayoutConstraint", + (dictionary["identifier"] as? String)?.hasPrefix("NSStackView.") == true, + out["effective"] != nil + { + out["effective"] = "" + } + if dictionary["$class"] as? String == "LookinAttribute", + let identifier = dictionary["identifier"] as? String, + volatileAttributeIdentifiers.contains(identifier) + { + out["value"] = "" + } + return out + case let array as [Any]: + let normalized = array.filter { element in + guard let trace = element as? [String: Any], trace["$class"] as? String == "LookinIvarTrace", + let name = trace["ivarName"] as? String else { return true } + return !volatileIvarNames.contains(name) + }.map(normalize) + let classes = Set(normalized.compactMap { ($0 as? [String: Any])?["$class"] as? String }) + if classes.count == 1, normalized.count > 1, unorderedElementClasses.isSuperset(of: classes), + normalized.allSatisfy({ $0 is [String: Any] }) + { + return normalized.sorted { LookinSnapshotWireJSON.sortKey($0) < LookinSnapshotWireJSON.sortKey($1) } + } + return normalized + case let double as Double: + guard double.isFinite else { return "\(double)" } + // Near CGFLOAT_MAX (unbounded SwiftUI sizes) the scaling + // overflows to infinity, which JSON cannot hold. + guard abs(double) < 1e300 else { return double } + let rounded = (double * 10000).rounded() / 10000 + return rounded == 0 ? 0.0 : rounded + case let string as String: + let range = NSRange(string.startIndex..., in: string) + return addressPattern.stringByReplacingMatches(in: string, range: range, withTemplate: "0x") + default: + return value + } + } + + /// The key path of the first value JSONSerialization cannot write + /// (a non-finite number or a non-JSON type), or nil. + static func firstUnwritablePath(_ value: Any, path: String) -> String? { + switch value { + case let dictionary as [String: Any]: + for (key, element) in dictionary { + if let found = firstUnwritablePath(element, path: path + "." + key) { + return found + } + } + return nil + case let array as [Any]: + for (index, element) in array.enumerated() { + if let found = firstUnwritablePath(element, path: path + "[\(index)]") { + return found + } + } + return nil + case is String, is NSNull: + return nil + default: + return JSONSerialization.isValidJSONObject([value]) ? nil : path + " (\(type(of: value)) \(value))" + } + } + + /// Pretty-printed header, then one compact line per node: keeps the + /// fixture small and makes a textual diff point at the changed node. + static func serialize(_ snapshot: [String: Any]) throws -> Data { + // JSONSerialization raises an Objective-C exception on a value it + // cannot write; name the value instead. + if let path = firstUnwritablePath(snapshot, path: "$") { + throw LookinSnapshotError("cannot write \(path) as JSON") + } + var header = snapshot + let nodes = header.removeValue(forKey: "nodes") as? [Any] ?? [] + header["nodes"] = [] + var text = try String(decoding: JSONSerialization.data(withJSONObject: header, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]), as: UTF8.self) + let lines = try nodes.map { + try String(decoding: JSONSerialization.data(withJSONObject: $0, options: [.sortedKeys, .withoutEscapingSlashes]), as: UTF8.self) + } + guard let range = text.range(of: "\"nodes\" : [\n\n ]") ?? text.range(of: "\"nodes\" : [\n ]") ?? text.range(of: "\"nodes\" : []") else { + throw LookinSnapshotError("unexpected header layout") + } + text.replaceSubrange(range, with: "\"nodes\" : [\n" + lines.joined(separator: ",\n") + "\n ]") + return Data((text + "\n").utf8) + } + } + + /// Turns a decoded wire object into plain JSON by replaying the object's own + /// `-encodeWithCoder:` into a recording coder. The output therefore lists + /// exactly the keys that cross the wire, under their wire names, for every + /// model class — no per-class field list to keep in sync with LookinCore, + /// before or after the Swift rewrite. + /// + /// Images (NSImage, or NSData holding PNG / TIFF / JPEG bytes) are handed to + /// `imageSink`, which returns the JSON that stands in for them. + final class LookinSnapshotWireJSON { + typealias ImageSink = (_ image: CGImage, _ keyPath: [String]) -> Any + + private let imageSink: ImageSink + /// Keys the caller handles itself (e.g. `subitems` on display items, so + /// the tree walk can attach details and stable node ids). + private let skippedKeys: [String: Set] + + init(skippedKeys: [String: Set] = [:], imageSink: @escaping ImageSink) { + self.skippedKeys = skippedKeys + self.imageSink = imageSink + } + + func convert(_ value: Any?, keyPath: [String] = []) -> Any { + guard let value else { return NSNull() } + + if value is NSNull { + return NSNull() + } + if let number = value as? NSNumber { + return Self.json(number) + } + if let string = value as? String { + return string + } + if let data = value as? Data { + if let image = Self.cgImage(fromEncoded: data) { + return imageSink(image, keyPath) + } + return ["$data": ["length": data.count]] + } + if let image = value as? NSImage { + var rect = CGRect(origin: .zero, size: image.size) + if let cgImage = image.cgImage(forProposedRect: &rect, context: nil, hints: nil) { + return imageSink(cgImage, keyPath) + } + return ["$image": NSNull()] + } + if let nsValue = value as? NSValue { + return Self.json(nsValue) + } + if value is Date { + return ["$date": ""] + } + if let error = value as? NSError { + return ["$error": ["domain": error.domain, "code": error.code]] + } + if let array = value as? [Any] { + return array.enumerated().map { convert($0.element, keyPath: keyPath + ["\($0.offset)"]) } + } + if let set = value as? Set { + let items = set.map { convert($0, keyPath: keyPath) } + return items.sorted { Self.sortKey($0) < Self.sortKey($1) } + } + if let dictionary = value as? [AnyHashable: Any] { + var out: [String: Any] = [:] + for (key, element) in dictionary { + let name = "\(key)" + out[name] = convert(element, keyPath: keyPath + [name]) + } + return out + } + if let object = value as? NSObject, object.conforms(to: NSCoding.self) { + return record(object, keyPath: keyPath) + } + return ["$unsupported": String(describing: type(of: value))] + } + + func record(_ object: NSObject, keyPath: [String]) -> [String: Any] { + let className = NSStringFromClass(object.classForKeyedArchiver ?? type(of: object)) + let coder = LookinSnapshotRecordingCoder(owner: self, keyPath: keyPath, skipped: skippedKeys[className] ?? []) + (object as? NSCoding)?.encode(with: coder) + var out = coder.fields + out["$class"] = className + return out + } + + // MARK: - Leaf conversions + + static func json(_ number: NSNumber) -> Any { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + switch String(cString: number.objCType) { + case "f", "d": + return number.doubleValue + default: + return number.int64Value + } + } + + /// Structs are written as their type encoding plus their scalar fields, + /// e.g. `{"$value": "{CGRect={CGPoint=dd}{CGSize=dd}}", "v": [0,0,10,10]}`. + static func json(_ value: NSValue) -> Any { + let type = String(cString: value.objCType) + var size = 0 + NSGetSizeAndAlignment(value.objCType, &size, nil) + var bytes = [UInt8](repeating: 0, count: size) + value.getValue(&bytes, size: size) + + let scalars = scalarLetters(of: type) + if let scalars, scalars.allSatisfy({ $0 == "d" }), scalars.count * 8 == size { + let doubles = bytes.withUnsafeBytes { Array($0.bindMemory(to: Double.self)) } + return ["$value": type, "v": doubles] + } + if let scalars, scalars.allSatisfy({ $0 == "f" }), scalars.count * 4 == size { + let floats = bytes.withUnsafeBytes { Array($0.bindMemory(to: Float.self)) } + return ["$value": type, "v": floats.map(Double.init)] + } + if let scalars, scalars.allSatisfy({ "qQlLiI".contains($0) }), scalars.count * 8 == size { + let ints = bytes.withUnsafeBytes { Array($0.bindMemory(to: Int64.self)) } + return ["$value": type, "v": ints] + } + return ["$value": type, "hex": bytes.map { String(format: "%02x", $0) }.joined()] + } + + /// The scalar type letters of a struct encoding with the struct names + /// removed, or nil when it holds pointers or other non-scalar members. + private static func scalarLetters(of encoding: String) -> [Character]? { + var letters: [Character] = [] + var insideName = false + for character in encoding { + switch character { + case "{": + insideName = true + case "=": + insideName = false + case "}": + insideName = false + default: + if insideName { + continue + } + guard "dfqQlLiIcCsSB".contains(character) else { return nil } + letters.append(character) + } + } + return letters.isEmpty ? nil : letters + } + + static func cgImage(fromEncoded data: Data) -> CGImage? { + let magic = [UInt8](data.prefix(4)) + let isPNG = magic.starts(with: [0x89, 0x50, 0x4E, 0x47]) + let isTIFF = magic.starts(with: [0x49, 0x49, 0x2A, 0x00]) || magic.starts(with: [0x4D, 0x4D, 0x00, 0x2A]) + let isJPEG = magic.starts(with: [0xFF, 0xD8, 0xFF]) + guard isPNG || isTIFF || isJPEG, + let source = CGImageSourceCreateWithData(data as CFData, nil) + else { return nil } + return CGImageSourceCreateImageAtIndex(source, 0, nil) + } + + static func sortKey(_ value: Any) -> String { + if let data = try? JSONSerialization.data(withJSONObject: ["k": value], options: [.sortedKeys]) { + return String(decoding: data, as: UTF8.self) + } + return "\(value)" + } + } + + /// A keyed NSCoder that only records. Scalars keep their JSON type; objects + /// recurse through `LookinSnapshotWireJSON.convert`. + private final class LookinSnapshotRecordingCoder: NSCoder { + private(set) var fields: [String: Any] = [:] + private let owner: LookinSnapshotWireJSON + private let keyPath: [String] + private let skipped: Set + + init(owner: LookinSnapshotWireJSON, keyPath: [String], skipped: Set) { + self.owner = owner + self.keyPath = keyPath + self.skipped = skipped + super.init() + } + + override var allowsKeyedCoding: Bool { + true + } + + override var requiresSecureCoding: Bool { + false + } + + private func store(_ value: @autoclosure () -> Any, _ key: String) { + guard !skipped.contains(key) else { return } + fields[key] = value() + } + + override func encode(_ object: Any?, forKey key: String) { + store(owner.convert(object, keyPath: keyPath + [key]), key) + } + + override func encodeConditionalObject(_ object: Any?, forKey key: String) { + encode(object, forKey: key) + } + + override func encode(_ value: Bool, forKey key: String) { + store(value, key) + } + + override func encodeCInt(_ value: Int32, forKey key: String) { + store(Int64(value), key) + } + + override func encode(_ value: Int32, forKey key: String) { + store(Int64(value), key) + } + + override func encode(_ value: Int64, forKey key: String) { + store(value, key) + } + + override func encode(_ value: Int, forKey key: String) { + store(Int64(value), key) + } + + override func encode(_ value: Float, forKey key: String) { + store(Double(value), key) + } + + override func encode(_ value: Double, forKey key: String) { + store(value, key) + } + + override func encodeBytes(_: UnsafePointer?, length: Int, forKey key: String) { + store(["$bytes": length], key) + } + + override func encode(_ rect: NSRect, forKey key: String) { + store(["$rect": [rect.origin.x, rect.origin.y, rect.size.width, rect.size.height]], key) + } + + override func encode(_ point: NSPoint, forKey key: String) { + store(["$point": [point.x, point.y]], key) + } + + override func encode(_ size: NSSize, forKey key: String) { + store(["$size": [size.width, size.height]], key) + } + } +#endif diff --git a/LookInside/DerivedSource/README.md b/LookInside/DerivedSource/README.md index a6d24cab..de63ad89 100644 --- a/LookInside/DerivedSource/README.md +++ b/LookInside/DerivedSource/README.md @@ -2,6 +2,6 @@ This folder is generated. -`Scripts/sync-derived-source.sh` copies `LookinCore` and `LookinServerBase` here before the app builds. +`Scripts/sync-derived-source.sh` copies `LookinCore`, `LookinCoreImpl` and `LookinServerBase` from `/Sources` here before the app builds. The folder is a synchronized group of the app target, so every source file in it is compiled without a project change. -Edit the source files under `/Sources`. +Edit the source files under `/Sources` (which `Scripts/sync-lookin-core.sh` mirrors from LookInside-Server). diff --git a/LookInside/Export/LKExportAccessoryView.h b/LookInside/Export/LKExportAccessoryView.h deleted file mode 100644 index 70f350a2..00000000 --- a/LookInside/Export/LKExportAccessoryView.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// LKExportAccessoryView.h -// Lookin -// -// Created by Li Kai on 2019/5/13. -// https://lookin.work -// - -#import "LKBaseView.h" -#import "LKExportManager.h" - -@interface LKExportAccessoryView : LKBaseView - -@property(nonatomic, assign) NSUInteger dataSize; - -@end diff --git a/LookInside/Export/LKExportAccessoryView.m b/LookInside/Export/LKExportAccessoryView.m deleted file mode 100644 index ef8c1849..00000000 --- a/LookInside/Export/LKExportAccessoryView.m +++ /dev/null @@ -1,103 +0,0 @@ -// -// LKExportAccessoryView.m -// Lookin -// -// Created by Li Kai on 2019/5/13. -// https://lookin.work -// - -#import "LKExportAccessoryView.h" -#import "LKPreferenceManager.h" - -@interface LKExportAccessoryView () - -@property(nonatomic, assign, readwrite) CGFloat selectedCompression; - -@property(nonatomic, strong) LKLabel *compressionLabel; - -@property(nonatomic, strong) NSPopUpButton *compressionButton; - -@property(nonatomic, strong) LKLabel *sizeLabel; - -@property(nonatomic, copy) NSArray *compressionArray; - -@end - -@implementation LKExportAccessoryView { - CGFloat _insetTop; - CGFloat _insetBottom; - CGFloat _buttonWidth; - CGFloat _sizeLabelTop; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _insetTop = 20; - _insetBottom = 10; - _buttonWidth = 150; - _sizeLabelTop = 5; - - self.compressionArray = @[@(.1), @(.3), @(.5), @(.75), @(1)]; - - self.compressionLabel = [LKLabel new]; - self.compressionLabel.stringValue = NSLocalizedString(@"Image Quality:", nil); - self.compressionLabel.font = NSFontMake(15); - self.compressionLabel.alignment = NSTextAlignmentRight; - [self addSubview:self.compressionLabel]; - - self.compressionButton = [NSPopUpButton new]; - self.compressionButton.font = NSFontMake(14); - self.compressionButton.target = self; - self.compressionButton.action = @selector(_handleCompressionButton); - [self.compressionButton addItemsWithTitles:[self.compressionArray lookin_map:^id(NSUInteger idx, NSNumber *value) { - return [NSString stringWithFormat:@"%@%%", @(value.doubleValue * 100)]; - }]]; - [self addSubview:self.compressionButton]; - - self.sizeLabel = [LKLabel new]; - self.sizeLabel.font = NSFontMake(13); - self.sizeLabel.stringValue = NSLocalizedString(@"File Size", nil); - self.sizeLabel.alignment = NSTextAlignmentRight; - [self addSubview:self.sizeLabel]; - - @weakify(self); - [[RACObserve([LKPreferenceManager mainManager], preferredExportCompression) distinctUntilChanged] subscribeNext:^(NSNumber *num) { - @strongify(self); - NSUInteger shouldSelectIdx = [self.compressionArray indexOfObjectPassingTest:^BOOL(NSNumber * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat diff = ABS(num.doubleValue - obj.doubleValue); - return (diff < 0.05); - }]; - - if (self.compressionButton.indexOfSelectedItem != shouldSelectIdx) { - [self.compressionButton selectItemAtIndex:shouldSelectIdx]; - } - }]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.compressionLabel).sizeToFit.y(_insetTop).x(0); - $(self.compressionButton).width(_buttonWidth).heightToFit.x(self.compressionLabel.$maxX).midY(self.compressionLabel.$midY); - - $(self.sizeLabel).fullWidth.heightToFit.y(self.compressionButton.$maxY + _sizeLabelTop); -} - -- (void)_handleCompressionButton { - CGFloat compression = [self.compressionArray[self.compressionButton.indexOfSelectedItem] doubleValue]; - [LKPreferenceManager mainManager].preferredExportCompression = compression; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - limitedSize.width = [self.compressionLabel sizeThatFits:NSSizeMax].width + _buttonWidth; - limitedSize.height = _insetTop + [self.compressionLabel sizeThatFits:NSSizeMax].height + _sizeLabelTop + [self.sizeLabel sizeThatFits:NSSizeMax].height + _insetBottom; - return limitedSize; -} - -- (void)setDataSize:(NSUInteger)dataSize { - _dataSize = dataSize; - self.sizeLabel.stringValue = [NSString stringWithFormat:NSLocalizedString(@"File Size: %.2f M", nil), dataSize / 1000.0 / 1000.0]; -} - -@end diff --git a/LookInside/Export/LKExportAccessoryView.swift b/LookInside/Export/LKExportAccessoryView.swift new file mode 100644 index 00000000..a283e44d --- /dev/null +++ b/LookInside/Export/LKExportAccessoryView.swift @@ -0,0 +1,103 @@ +// +// LKExportAccessoryView.swift +// LookInside +// +// The save panel accessory of the `.lookin` export: an image-quality menu +// bound to the preferred export compression, and the resulting file size. +// + +import AppKit +import LookInsideHostCore + +@objc(LKExportAccessoryView) +final class LKExportAccessoryView: LKBaseView { + private static let insetTop: CGFloat = 20 + private static let insetBottom: CGFloat = 10 + private static let buttonWidth: CGFloat = 150 + private static let sizeLabelTop: CGFloat = 5 + + /// The exported size in bytes, shown in megabytes. + @objc var dataSize: UInt = 0 { + didSet { + sizeLabel.stringValue = String(format: NSLocalizedString("File Size: %.2f M", comment: ""), + ExportNaming.megabytes(dataSize)) + } + } + + private let compressionLabel = LKLabel() + private let compressionButton = NSPopUpButton() + private let sizeLabel = LKLabel() + private var compressionObservation: NSKeyValueObservation? + private var lastObservedCompression: CGFloat? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + compressionLabel.stringValue = NSLocalizedString("Image Quality:", comment: "") + compressionLabel.font = .systemFont(ofSize: 15) + compressionLabel.alignment = .right + addSubview(compressionLabel) + + compressionButton.font = .systemFont(ofSize: 14) + compressionButton.target = self + compressionButton.action = #selector(handleCompressionButton) + compressionButton.addItems(withTitles: ExportNaming.compressionOptions.map(ExportNaming.compressionTitle)) + addSubview(compressionButton) + + sizeLabel.font = .systemFont(ofSize: 13) + sizeLabel.stringValue = NSLocalizedString("File Size", comment: "") + sizeLabel.alignment = .right + addSubview(sizeLabel) + + compressionObservation = LKPreferenceManager.shared.observe(\.preferredExportCompression, options: [.initial, .new]) { + [weak self] manager, _ in + let compression = manager.preferredExportCompression + MainActor.assumeIsolated { + self?.selectCompression(compression) + } + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + private func selectCompression(_ compression: CGFloat) { + guard compression != lastObservedCompression else { return } + lastObservedCompression = compression + let index = ExportNaming.compressionIndex(for: Double(compression)) ?? -1 + if compressionButton.indexOfSelectedItem != index { + compressionButton.selectItem(at: index) + } + } + + override func layout() { + super.layout() + compressionLabel.lkpSizeToFit() + compressionLabel.lkpSetY(Self.insetTop) + compressionLabel.lkpSetX(0) + + compressionButton.lkpSetWidth(Self.buttonWidth) + compressionButton.lkpHeightToFit() + compressionButton.lkpSetX(compressionLabel.frame.maxX) + compressionButton.lkpSetMidY(compressionLabel.frame.midY) + + sizeLabel.lkpFullWidth() + sizeLabel.lkpHeightToFit() + sizeLabel.lkpSetY(compressionButton.frame.maxY + Self.sizeLabelTop) + } + + @objc private func handleCompressionButton() { + let index = compressionButton.indexOfSelectedItem + guard ExportNaming.compressionOptions.indices.contains(index) else { return } + LKPreferenceManager.shared.preferredExportCompression = CGFloat(ExportNaming.compressionOptions[index]) + } + + override func sizeThatFits(_: NSSize) -> NSSize { + let labelSize = compressionLabel.sizeThatFits(lkpMaxSize) + return NSSize(width: labelSize.width + Self.buttonWidth, + height: Self.insetTop + labelSize.height + Self.sizeLabelTop + + sizeLabel.sizeThatFits(lkpMaxSize).height + Self.insetBottom) + } +} diff --git a/LookInside/Export/LKExportManager.h b/LookInside/Export/LKExportManager.h deleted file mode 100644 index 9a7e30e5..00000000 --- a/LookInside/Export/LKExportManager.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LKExportManager.h -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import - -@class LookinHierarchyInfo, LookinDisplayItem; - -@interface LKExportManager : NSObject - -+ (instancetype)sharedInstance; - -- (NSData *)dataFromHierarchyInfo:(LookinHierarchyInfo *)info imageCompression:(CGFloat)compression fileName:(NSString **)fileName; - -+ (void)exportScreenshotWithDisplayItem:(LookinDisplayItem *)displayItem; - -@end diff --git a/LookInside/Export/LKExportManager.m b/LookInside/Export/LKExportManager.m deleted file mode 100644 index 1d7b8535..00000000 --- a/LookInside/Export/LKExportManager.m +++ /dev/null @@ -1,150 +0,0 @@ -// -// LKExportManager.m -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKExportManager.h" -#import "LookinHierarchyInfo.h" -#import "LookinHierarchyFile.h" -#import "LookinAppInfo.h" -#import "LookinDisplayItem.h" -#import "LookinArchiveDocument.h" -#import "LKHelper.h" -#import "LKNavigationManager.h" -#import "LookinDisplayItem.h" -#import "LookinDisplayItem+LookinClient.h" -#import - -@implementation LKExportManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKExportManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -- (NSData *)dataFromHierarchyInfo:(LookinHierarchyInfo *)info imageCompression:(CGFloat)compression fileName:(NSString **)fileName { - LookinHierarchyFile *file = [LookinHierarchyFile new]; - file.serverVersion = info.serverVersion; - file.hierarchyInfo = info; - - NSMutableDictionary *soloScreenshots = [NSMutableDictionary dictionary]; - NSMutableDictionary *groupScreenshots = [NSMutableDictionary dictionary]; - BOOL prefersViewOID = [LKHelper appInfoLooksLikeMacTarget:info.appInfo]; - - NSArray *allItems = [LookinDisplayItem flatItemsFromHierarchicalItems:info.displayItems]; - [allItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull displayItem, NSUInteger idx, BOOL * _Nonnull stop) { - unsigned long oid = [displayItem bestObjectOidPreferView:prefersViewOID]; - if (!oid) { - return; - } - displayItem.screenshotEncodeType = LookinDisplayItemImageEncodeTypeNone; - soloScreenshots[@(oid)] = [self _compressedDataFromImage:displayItem.soloScreenshot compression:compression]; - groupScreenshots[@(oid)] = [self _compressedDataFromImage:displayItem.groupScreenshot compression:compression]; - }]; - file.soloScreenshots = soloScreenshots.copy; - file.groupScreenshots = groupScreenshots.copy; - - LookinArchiveDocument *document = [[LookinArchiveDocument alloc] init]; - document.hierarchyFile = file; - NSError *error; - NSData *exportedData = [document dataOfType:@"com.lookin.lookin" error:&error]; - if (error) { - NSAssert(NO, @""); - } - - if (fileName) { - NSString *timeString = ({ - NSDate *date = [NSDate date]; - NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; - [formatter setDateFormat:@"MMddHHmm"]; - [formatter stringFromDate:date]; - }); - NSString *iOSVersion = ({ - NSString *str = info.appInfo.osDescription; - NSUInteger dotIdx = [str rangeOfString:@"."].location; - if (dotIdx != NSNotFound) { - str = [str substringToIndex:dotIdx]; - } - str; - }); - *fileName = [NSString stringWithFormat:@"%@_ios%@_%@.lookin", info.appInfo.appName, iOSVersion, timeString]; - - } - - return exportedData; -} - -/// compression 范围从 0.01 ~ 1 -- (NSData *)_compressedDataFromImage:(LookinImage *)sourceImage compression:(CGFloat)compression { - if (!sourceImage) { - return nil; - } - -#if TARGET_OS_IPHONE - return nil; - -#elif TARGET_OS_MAC - - compression = MAX(MIN(compression, 1), 0.01); - - NSSize targetSize = NSMakeSize(sourceImage.size.width * compression, sourceImage.size.height * compression); - NSRect targetFrame = NSMakeRect(0, 0, targetSize.width, targetSize.height); - NSImageRep *sourceImageRep = [sourceImage bestRepresentationForRect:targetFrame context:nil hints:nil]; - - NSImage *resizedImage = [[NSImage alloc] initWithSize:targetSize]; - [resizedImage lockFocus]; - [sourceImageRep drawInRect:targetFrame]; - [resizedImage unlockFocus]; - - NSBitmapImageRep *imageRep = [[NSBitmapImageRep alloc] initWithData:[resizedImage TIFFRepresentation]]; - NSData *compressedData = [imageRep TIFFRepresentationUsingCompression:NSTIFFCompressionLZW factor:1]; - return compressedData; -#endif -} - -+ (void)exportScreenshotWithDisplayItem:(LookinDisplayItem *)displayItem { - NSImage *image = displayItem.groupScreenshot; - if (!image) { - AlertError(LookinErr_Inner, CurrentKeyWindow); - return; - } - - NSData *imageData = [image TIFFRepresentationUsingCompression:NSTIFFCompressionLZW factor:1]; - if (!imageData) { - AlertError(LookinErr_Inner, CurrentKeyWindow); - return; - } - - NSString *fileName = [displayItem title] ? : @"LookInsideImage"; - - NSSavePanel *panel = [NSSavePanel savePanel]; - [panel setNameFieldStringValue:fileName]; - [panel setAllowsOtherFileTypes:NO]; - panel.allowedContentTypes = @[UTTypeTIFF]; - [panel setExtensionHidden:YES]; - [panel setCanCreateDirectories:YES]; - [panel beginSheetModalForWindow:CurrentKeyWindow completionHandler:^(NSModalResponse result) { - if (result == NSModalResponseOK) { - NSString *path = [[panel URL] path]; - NSError *writeError; - BOOL writeSucc = [imageData writeToFile:path options:0 error:&writeError]; - if (!writeSucc) { - AlertError(writeError, CurrentKeyWindow); - NSAssert(NO, @""); - } - } - }]; -} - -@end diff --git a/LookInside/Export/LKExportManager.swift b/LookInside/Export/LKExportManager.swift new file mode 100644 index 00000000..00f549ee --- /dev/null +++ b/LookInside/Export/LKExportManager.swift @@ -0,0 +1,121 @@ +// +// LKExportManager.swift +// LookInside +// +// Writes a hierarchy as a `.lookin` archive (screenshots scaled by the chosen +// image quality and stored as LZW TIFF, keyed by object id) and saves a +// single item's screenshot as a TIFF file. +// + +import AppKit +import LookInsideHostCore +import UniformTypeIdentifiers + +@objc(LKExportManager) +final class LKExportManager: NSObject { + private static let shared = LKExportManager() + + @objc class func sharedInstance() -> LKExportManager { + shared + } + + /// The archive data of `info`, with every screenshot scaled by + /// `compression` (clamped to 0.01...1). When `fileName` is given it + /// receives the suggested file name. + @objc(dataFromHierarchyInfo:imageCompression:fileName:) + func data(from info: LookinHierarchyInfo, + imageCompression compression: CGFloat, + fileName: AutoreleasingUnsafeMutablePointer?) -> Data? + { + let file = LookinHierarchyFile() + file.serverVersion = info.serverVersion + file.hierarchyInfo = info + + var soloScreenshots: [NSNumber: Data] = [:] + var groupScreenshots: [NSNumber: Data] = [:] + let prefersViewOID = LKHelper.appInfoLooksLikeMacTarget(info.appInfo) + let allItems = LookinDisplayItem.flatItems(fromHierarchicalItems: info.displayItems ?? []) ?? [] + for displayItem in allItems { + let oid = displayItem.bestObjectOidPreferView(prefersViewOID) + if oid == 0 { + continue + } + displayItem.screenshotEncodeType = .none + let key = NSNumber(value: oid) + soloScreenshots[key] = Self.compressedData(from: displayItem.soloScreenshot, compression: compression) + groupScreenshots[key] = Self.compressedData(from: displayItem.groupScreenshot, compression: compression) + } + file.soloScreenshots = soloScreenshots + file.groupScreenshots = groupScreenshots + + let document = LookinArchiveDocument() + document.hierarchyFile = file + var exportedData: Data? + do { + exportedData = try document.data(ofType: "com.lookin.lookin") + } catch { + assertionFailure("Encoding the .lookin archive failed: \(error)") + } + + if let fileName { + fileName.pointee = ExportNaming.fileName(appName: info.appInfo?.appName, + osDescription: info.appInfo?.osDescription, + date: Date()) as NSString + } + return exportedData + } + + private static func compressedData(from sourceImage: NSImage?, compression: CGFloat) -> Data? { + guard let sourceImage else { + return nil + } + let compression = max(min(compression, 1), 0.01) + let targetSize = NSSize(width: sourceImage.size.width * compression, height: sourceImage.size.height * compression) + let targetFrame = NSRect(origin: .zero, size: targetSize) + let sourceImageRep = sourceImage.bestRepresentation(for: targetFrame, context: nil, hints: nil) + + let resizedImage = NSImage(size: targetSize) + resizedImage.lockFocus() + sourceImageRep?.draw(in: targetFrame) + resizedImage.unlockFocus() + + guard let tiff = resizedImage.tiffRepresentation, let imageRep = NSBitmapImageRep(data: tiff) else { + return nil + } + return imageRep.tiffRepresentation(using: .lzw, factor: 1) + } + + /// Asks where to save `displayItem`'s group screenshot and writes it as TIFF. + @MainActor + @objc(exportScreenshotWithDisplayItem:) + class func exportScreenshot(with displayItem: LookinDisplayItem) { + guard let image = displayItem.groupScreenshot, + let imageData = image.tiffRepresentation(using: .lzw, factor: 1) + else { + LKPeripheralAlerts.show(LKConnectionError.inner, in: LKPeripheralAlerts.keyWindow) + return + } + + let panel = NSSavePanel() + let title = displayItem.title() + panel.nameFieldStringValue = title.isEmpty ? "LookInsideImage" : title + panel.allowsOtherFileTypes = false + panel.allowedContentTypes = [.tiff] + panel.isExtensionHidden = true + panel.canCreateDirectories = true + let completion: (NSApplication.ModalResponse) -> Void = { result in + guard result == .OK, let url = panel.url else { return } + do { + try imageData.write(to: URL(fileURLWithPath: url.path)) + } catch { + LKPeripheralAlerts.show(error, in: LKPeripheralAlerts.keyWindow) + assertionFailure("Writing the screenshot failed: \(error)") + } + } + if let window = LKPeripheralAlerts.keyWindow { + panel.beginSheetModal(for: window, completionHandler: completion) + } else { + panel.begin(completionHandler: completion) + } + } +} diff --git a/LookInside/GestureDebug/LKGestureDebugSession.swift b/LookInside/GestureDebug/LKGestureDebugSession.swift index d8ad6d4f..a4094ce1 100644 --- a/LookInside/GestureDebug/LKGestureDebugSession.swift +++ b/LookInside/GestureDebug/LKGestureDebugSession.swift @@ -1,8 +1,7 @@ import AppKit import Combine -/// One capture belongs to one inspection window and one Peertalk channel. -/// ObservableObject supports the host's macOS 13 deployment target. +/// One capture belongs to one inspection window and one channel. @MainActor final class LKGestureDebugSession: ObservableObject { @Published private(set) var snapshots: [LKGestureCaptureSnapshot] = [] @@ -29,12 +28,12 @@ final class LKGestureDebugSession: ObservableObject { @Published private(set) var supported = false private var app: LKInspectableApp? - private var channel: Lookin_PTChannel? + private var channel: LKChannel? private var sessionID: String? private var lastSessionID: String? private var lastBatchSequence = 0 private var startTask: Task? - private var subscriptions: [RACDisposable] = [] + private var subscriptions: [Task] = [] var selectedSnapshot: LKGestureCaptureSnapshot? { snapshots.first { $0.id == selectedSnapshotID } @@ -45,28 +44,24 @@ final class LKGestureDebugSession: ObservableObject { } init() { - let manager = LKConnectionManager.sharedInstance() - if let disposable = manager?.didReceivePush?.subscribeNext({ [weak self] value in - guard let tuple = value as? RACTuple, - let channel = tuple.first as? Lookin_PTChannel, - (tuple.second as? NSNumber)?.uint32Value == 306, - let data = tuple.third as? Data else { return } - Task { @MainActor [weak self] in self?.receive(data, from: channel) } - }) { - subscriptions.append(disposable) - } - if let disposable = manager?.channelWillEnd?.subscribeNext({ [weak self] value in - guard let ended = value as? Lookin_PTChannel else { return } - Task { @MainActor [weak self] in - guard let self, self.channel === ended else { return } - self.stop() - self.message = "The target disconnected. Reconnect it, then start a new capture." - self.state = "disconnected" - self.supported = false + let manager = LKConnectionManager.shared + let pushes = manager.pushEvents() + subscriptions.append(Task { [weak self] in + for await push in pushes { + guard push.type == UInt32(LookinPush_GestureDebug), let data = push.data as? Data else { continue } + self?.receive(data, from: push.channel) } - }) { - subscriptions.append(disposable) - } + }) + let endedChannels = manager.channelWillEndEvents() + subscriptions.append(Task { [weak self] in + for await ended in endedChannels { + guard let self, channel === ended else { continue } + stop() + message = "The target disconnected. Reconnect it, then start a new capture." + state = "disconnected" + supported = false + } + }) } func bind(to app: LKInspectableApp?) { @@ -118,9 +113,7 @@ final class LKGestureDebugSession: ObservableObject { message = "Starting gesture capture…" startTask = Task { [weak self] in do { - let response: NSDictionary = try await LKMCPBridgeRACBridge.awaitFirstValue( - of: app.controlGestureDebug(["command": "start", "sessionID": token, "overlay": self?.overlayEnabled ?? true]) - ) + let response = try await app.controlGestureDebug(["command": "start", "sessionID": token, "overlay": self?.overlayEnabled ?? true]) guard let self, sessionID == token, !Task.isCancelled else { return } state = response["state"] as? String ?? "waiting" isStarting = state == "starting" @@ -153,9 +146,7 @@ final class LKGestureDebugSession: ObservableObject { // The stop request must outlive the panel that initiated it. Task { [weak self] in do { - let _: NSDictionary = try await LKMCPBridgeRACBridge.awaitFirstValue( - of: app.controlGestureDebug(["command": "stop", "sessionID": token]) - ) + _ = try await app.controlGestureDebug(["command": "stop", "sessionID": token]) } catch { if self?.sessionID == nil, self?.channel === app.channel { self?.message = "Could not confirm capture stopped: \(error.localizedDescription). Disconnect the target to end capture." @@ -177,9 +168,7 @@ final class LKGestureDebugSession: ObservableObject { let enabled = overlayEnabled Task { [weak self] in do { - let _: NSDictionary = try await LKMCPBridgeRACBridge.awaitFirstValue( - of: app.controlGestureDebug(["command": "overlay", "sessionID": token, "enabled": enabled, "clear": clear]) - ) + _ = try await app.controlGestureDebug(["command": "overlay", "sessionID": token, "enabled": enabled, "clear": clear]) } catch { guard self?.sessionID == token else { return } self?.message = error.localizedDescription @@ -213,12 +202,12 @@ final class LKGestureDebugSession: ObservableObject { func dispose() { stop() for subscription in subscriptions { - subscription.dispose() + subscription.cancel() } subscriptions.removeAll() } - private func receive(_ data: Data, from channel: Lookin_PTChannel) { + private func receive(_ data: Data, from channel: LKChannel) { guard channel === self.channel, let sessionID, data.count <= 4 * 1024 * 1024 else { return } do { let batch = try JSONDecoder().decode(LKGestureCaptureBatch.self, from: data) diff --git a/LookInside/GestureDebug/LKGestureDebugView.swift b/LookInside/GestureDebug/LKGestureDebugView.swift index 48b8f21f..5610b8b0 100644 --- a/LookInside/GestureDebug/LKGestureDebugView.swift +++ b/LookInside/GestureDebug/LKGestureDebugView.swift @@ -43,9 +43,9 @@ struct LKGestureDebugView: View { .frame(minWidth: 620, maxWidth: .infinity, maxHeight: .infinity) } } - .onChange(of: session.overlayEnabled) { _ in session.updateOverlay() } - .onChange(of: session.followsLatest) { _ in session.followLatest() } - .onChange(of: section) { _ in session.selectedNodeID = nil } + .onChange(of: session.overlayEnabled) { session.updateOverlay() } + .onChange(of: session.followsLatest) { session.followLatest() } + .onChange(of: section) { session.selectedNodeID = nil } } private var controls: some View { @@ -164,12 +164,12 @@ struct LKGestureDebugView: View { }.buttonStyle(.plain).id(snapshot.id) } }.listStyle(.plain) - .onChange(of: session.snapshots.last?.id) { id in + .onChange(of: session.snapshots.last?.id) { _, id in if session.followsLatest, let id { proxy.scrollTo(id, anchor: .top) } } - .onChange(of: session.followsLatest) { follows in + .onChange(of: session.followsLatest) { _, follows in if follows, let id = session.snapshots.last?.id { proxy.scrollTo(id, anchor: .top) } diff --git a/LookInside/GestureDebug/LKNativeInteractionDetailView.swift b/LookInside/GestureDebug/LKNativeInteractionDetailView.swift index fe2316fa..811749fa 100644 --- a/LookInside/GestureDebug/LKNativeInteractionDetailView.swift +++ b/LookInside/GestureDebug/LKNativeInteractionDetailView.swift @@ -42,7 +42,7 @@ struct LKNativeInteractionDetailView: View { } } } - .onChange(of: regions.map(\.id)) { ids in + .onChange(of: regions.map(\.id)) { _, ids in if let selection, !ids.contains(selection) { self.selection = nil } diff --git a/LookInside/GestureDebug/LKSuggestionsView.swift b/LookInside/GestureDebug/LKSuggestionsView.swift index bf9f3426..6bf61809 100644 --- a/LookInside/GestureDebug/LKSuggestionsView.swift +++ b/LookInside/GestureDebug/LKSuggestionsView.swift @@ -47,7 +47,7 @@ struct LKSuggestionsView: View { } } .padding(14) - .onChange(of: report.warnings.map(\.id)) { ids in + .onChange(of: report.warnings.map(\.id)) { _, ids in if let selectedID, !ids.contains(selectedID) { self.selectedID = nil } diff --git a/LookInside/Hierarchy/LKDanceUIAttrMaker.h b/LookInside/Hierarchy/LKDanceUIAttrMaker.h deleted file mode 100644 index 2759c970..00000000 --- a/LookInside/Hierarchy/LKDanceUIAttrMaker.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// LKDanceUIAttrMaker.h -// LookinClient -// -// Created by likai.123 on 2023/12/21. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import - -@interface LKDanceUIAttrMaker : NSObject - -/// 给 item 的属性列表里填充上“跳转 DanceUI 文件”相关的信息 -+ (void)makeDanceUIJumpAttribute:(LookinDisplayItem *)item danceSource:(NSString *)source; - -@end diff --git a/LookInside/Hierarchy/LKDanceUIAttrMaker.m b/LookInside/Hierarchy/LKDanceUIAttrMaker.m deleted file mode 100644 index 45971be1..00000000 --- a/LookInside/Hierarchy/LKDanceUIAttrMaker.m +++ /dev/null @@ -1,73 +0,0 @@ -// -// LKDanceUIAttrMaker.m -// LookinClient -// -// Created by likai.123 on 2023/12/21. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKDanceUIAttrMaker.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LookinAttribute.h" - -@implementation LKDanceUIAttrMaker - -+ (void)makeDanceUIJumpAttribute:(LookinDisplayItem *)item danceSource:(NSString *)source { - NSString *className = [self getClassFromSource:source]; - if (!className) { - return; - } - - __block BOOL alreadyHas = NO; - [item.attributesGroupList enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop) { - if ([group.identifier isEqualToString:LookinAttrGroup_Class]) { - alreadyHas = YES; - *stop = YES; - } - }]; - if (alreadyHas) { -// NSAssert(NO, @""); - return; - } - LookinAttribute *attr = [LookinAttribute new]; - attr.identifier = LookinAttr_Class_Class_Class; - attr.attrType = LookinAttrTypeCustomObj; - attr.value = @[@[className]]; - - LookinAttributesSection *sec = [LookinAttributesSection new]; - sec.identifier = LookinAttrSec_Class_Class; - sec.attributes = @[attr]; - - LookinAttributesGroup *group = [LookinAttributesGroup new]; - group.identifier = LookinAttrGroup_Class; - group.attrSections = @[sec]; - - if (item.attributesGroupList) { - item.attributesGroupList = [item.attributesGroupList arrayByAddingObject:group]; - } else { - item.attributesGroupList = @[group]; - } -} - -+ (NSString *)getClassFromSource:(NSString *)json { - NSData *jsonData = [json dataUsingEncoding:NSUTF8StringEncoding]; - NSError *error; - NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:&error]; - if (error) { - NSAssert(NO, @""); - return nil; - } - if (![dict isKindOfClass:[NSDictionary class]]) { - NSAssert(NO, @""); - return nil; - } - NSString *type = dict[@"type"]; - if (!type) { - NSAssert(NO, @""); - return nil; - } - return type; -} - -@end diff --git a/LookInside/Hierarchy/LKDanceUIAttrMaker.swift b/LookInside/Hierarchy/LKDanceUIAttrMaker.swift new file mode 100644 index 00000000..0477ff60 --- /dev/null +++ b/LookInside/Hierarchy/LKDanceUIAttrMaker.swift @@ -0,0 +1,53 @@ +// +// LKDanceUIAttrMaker.swift +// LookInside +// +// Created by likai.123 on 2023/12/21. +// Copyright © 2023 hughkli. All rights reserved. +// + +import Foundation + +/// Adds the "jump to the DanceUI file" class attribute to DanceUI nodes. +enum LKDanceUIAttrMaker { + /// Gives `item` a Class group naming the type in `source` (DanceUI's + /// JSON), unless it already has one. + static func makeDanceUIJumpAttribute(_ item: LookinDisplayItem, danceSource source: String) { + guard let className = className(fromSource: source) else { + return + } + let groups = item.attributesGroupList ?? [] + if groups.contains(where: { $0.identifier == LookinAttrGroup_Class }) { + return + } + let attribute = LookinAttribute() + attribute.identifier = LookinAttr_Class_Class_Class + attribute.attrType = .customObj + attribute.value = [[className]] + + let section = LookinAttributesSection() + section.identifier = LookinAttrSec_Class_Class + section.attributes = [attribute] + + let group = LookinAttributesGroup() + group.identifier = LookinAttrGroup_Class + group.attrSections = [section] + + item.attributesGroupList = groups + [group] + } + + private static func className(fromSource json: String) -> String? { + guard let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data), + let dictionary = object as? [String: Any] + else { + assertionFailure("DanceUI source is not a JSON object") + return nil + } + guard let type = dictionary["type"] as? String else { + assertionFailure("DanceUI source has no type") + return nil + } + return type + } +} diff --git a/LookInside/Hierarchy/LKHierarchyController.h b/LookInside/Hierarchy/LKHierarchyController.h deleted file mode 100644 index f4909922..00000000 --- a/LookInside/Hierarchy/LKHierarchyController.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// LKHierarchyController.h -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKBaseViewController.h" -#import "LKHierarchyView.h" - -@class LKHierarchyDataSource; - -@interface LKHierarchyController : LKBaseViewController - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource; - -@property(nonatomic, strong, readonly) LKHierarchyDataSource *dataSource; - -@property(nonatomic, strong, readonly) LKHierarchyView *hierarchyView; - -- (NSView *)currentSelectedRowView; - -@end diff --git a/LookInside/Hierarchy/LKHierarchyController.m b/LookInside/Hierarchy/LKHierarchyController.m deleted file mode 100644 index 6a68f459..00000000 --- a/LookInside/Hierarchy/LKHierarchyController.m +++ /dev/null @@ -1,123 +0,0 @@ -// -// LKHierarchyController.m -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKHierarchyController.h" -#import "LKHierarchyDataSource.h" -#import "LookinDisplayItem.h" -#import "LKTableView.h" -#import "LKHierarchyDataSource+KeyDown.h" -#import "LKPreferenceManager.h" - -@interface LKHierarchyController () - -@end - -@implementation LKHierarchyController - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource { - LKHierarchyView *hierarchyView = [[LKHierarchyView alloc] initWithDataSource:dataSource]; - hierarchyView.delegate = self; - if (self = [self initWithContainerView:hierarchyView]) { - _dataSource = dataSource; - _hierarchyView = hierarchyView; - } - return self; -} - -- (NSView *)makeContainerView { - LKHierarchyView *hierarchyView = [[LKHierarchyView alloc] init]; - hierarchyView.delegate = self; - _hierarchyView = hierarchyView; - return hierarchyView; -} - -- (NSView *)currentSelectedRowView { - NSInteger row = [self.dataSource.displayingFlatItems indexOfObject:self.dataSource.selectedItem]; - if (row == NSNotFound) { -// NSAssert(NO, @"LKHierarchyController, currentSelectedRowView, NSNotFound"); - return nil; - } - return [self.hierarchyView.tableView.tableView rowViewAtRow:row makeIfNecessary:NO]; -} - -- (BOOL)acceptsFirstResponder { - return true; -} - - -- (void)keyDown:(NSEvent *)event { - if ([self.dataSource keyDown:event]) { - return; - } - - [super keyDown:event]; -} - -#pragma mark - - -- (void)hierarchyView:(LKHierarchyView *)view didSelectItem:(LookinDisplayItem *)item { - self.dataSource.selectedItem = item; -} - -- (void)hierarchyView:(LKHierarchyView *)view didDoubleClickItem:(LookinDisplayItem *)item { - LookinDoubleClickBehavior behavior = [[LKPreferenceManager mainManager] doubleClickBehavior]; - if (behavior == LookinDoubleClickBehaviorCollapse) { - if (!item.isExpandable) { - return; - } - if (item.isExpanded) { - [self.dataSource collapseItem:item]; - } else { - [self.dataSource expandItem:item]; - } - - } else if (behavior == LookinDoubleClickBehaviorFocus) { - [self.dataSource focusDisplayItem:item]; - - } else { - NSAssert(NO, @""); - } -} - -/// 注意这里 item 可能为 nil -- (void)hierarchyView:(LKHierarchyView *)view didHoverAtItem:(LookinDisplayItem *)item { - self.dataSource.hoveredItem = item; -} - -- (void)hierarchyView:(LKHierarchyView *)view needToCollapseItem:(LookinDisplayItem *)item { - [self.dataSource collapseItem:item]; -} - -- (void)hierarchyView:(LKHierarchyView *)view needToCollapseChildrenOfItem:(LookinDisplayItem *)item { - [self.dataSource collapseAllChildrenOfItem:item]; -} - -- (void)hierarchyView:(LKHierarchyView *)view needToExpandItem:(LookinDisplayItem *)item recursively:(BOOL)recursively { - if (recursively) { - [self.dataSource expandItemsRootedByItem:item]; - } else { - [self.dataSource expandItem:item]; - } -} - -- (void)hierarchyView:(LKHierarchyView *)view didInputSearchString:(NSString *)string { - NSLog(@"search string:%@", string); - if (string.length) { - [self.dataSource searchWithString:string]; - } else { - [self.dataSource endSearch]; - if (self.dataSource.selectedItem) { - // 结束搜索,滚动到选中的 item - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self.hierarchyView scrollToMakeItemVisible:self.dataSource.selectedItem]; - }); - } - } -} - -@end diff --git a/LookInside/Hierarchy/LKHierarchyController.swift b/LookInside/Hierarchy/LKHierarchyController.swift new file mode 100644 index 00000000..6746e508 --- /dev/null +++ b/LookInside/Hierarchy/LKHierarchyController.swift @@ -0,0 +1,115 @@ +// +// LKHierarchyController.swift +// LookInside +// +// Created by Li Kai on 2019/5/12. +// https://lookin.work +// + +import AppKit + +@objc(LKHierarchyController) +@objcMembers +class LKHierarchyController: LKBaseViewController, LKHierarchyViewDelegate { + private(set) var dataSource: LKHierarchyDataSource! + private(set) var hierarchyView: LKHierarchyView! + + init!(dataSource: LKHierarchyDataSource!) { + let hierarchyView = LKHierarchyView(dataSource: dataSource) + self.dataSource = dataSource + self.hierarchyView = hierarchyView + super.init(containerView: hierarchyView) + hierarchyView?.delegate = self + } + + required init?(coder _: NSCoder) { + fatalError("LKHierarchyController is not loaded from archives") + } + + func currentSelectedRowView() -> NSView! { + guard let selectedItem = dataSource.selectedItem, + let row = dataSource.displayingFlatItems.firstIndex(where: { $0 === selectedItem }) + else { + return nil + } + return hierarchyView.tableView.tableView.rowView(atRow: row, makeIfNecessary: false) + } + + override var acceptsFirstResponder: Bool { + true + } + + override func keyDown(with event: NSEvent) { + if dataSource.keyDown(event) { + return + } + super.keyDown(with: event) + } + + // MARK: - LKHierarchyViewDelegate + + @objc(hierarchyView:didSelectItem:) + func hierarchyView(_: LKHierarchyView!, didSelect item: LookinDisplayItem!) { + dataSource.selectedItem = item + } + + @objc(hierarchyView:didDoubleClickItem:) + func hierarchyView(_: LKHierarchyView!, didDoubleClick item: LookinDisplayItem!) { + switch LKPreferenceManager.shared.doubleClickBehavior { + case .collapse: + guard item.isExpandable else { + return + } + if item.isExpanded { + dataSource.collapse(item) + } else { + dataSource.expand(item) + } + case .focus: + dataSource.focus(item) + @unknown default: + assertionFailure("unknown double-click behavior") + } + } + + /// `item` is nil when the mouse leaves the rows. + @objc(hierarchyView:didHoverAtItem:) + func hierarchyView(_: LKHierarchyView!, didHoverAt item: LookinDisplayItem!) { + dataSource.hoveredItem = item + } + + @objc(hierarchyView:needToCollapseItem:) + func hierarchyView(_: LKHierarchyView!, needToCollapse item: LookinDisplayItem!) { + dataSource.collapse(item) + } + + @objc(hierarchyView:needToCollapseChildrenOfItem:) + func hierarchyView(_: LKHierarchyView!, needToCollapseChildrenOf item: LookinDisplayItem!) { + dataSource.collapseAllChildren(of: item) + } + + @objc(hierarchyView:needToExpandItem:recursively:) + func hierarchyView(_: LKHierarchyView!, needToExpand item: LookinDisplayItem!, recursively: Bool) { + if recursively { + dataSource.expandItemsRooted(by: item) + } else { + dataSource.expand(item) + } + } + + @objc(hierarchyView:didInputSearchString:) + func hierarchyView(_: LKHierarchyView!, didInputSearch string: String!) { + NSLog("search string:%@", string ?? "(null)") + if let string, !string.isEmpty { + dataSource.search(with: string) + return + } + dataSource.endSearch() + if dataSource.selectedItem != nil { + // Scroll back to the selected item once the rows are restored. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in + hierarchyView.scroll(toMakeItemVisible: dataSource.selectedItem) + } + } + } +} diff --git a/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.h b/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.h deleted file mode 100644 index 1ee2d076..00000000 --- a/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LKHierarchyDataSource+KeyDown.h -// LookinClient -// -// Created by Hares on 7/17/23. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKHierarchyDataSource.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LKHierarchyDataSource (KeyDown) - -- (BOOL)keyDown:(NSEvent *)event; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.m b/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.m deleted file mode 100644 index 3db4189b..00000000 --- a/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.m +++ /dev/null @@ -1,67 +0,0 @@ -// -// LKHierarchyDataSource+KeyDown.m -// LookinClient -// -// Created by Hares on 7/17/23. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKHierarchyDataSource+KeyDown.h" -#import "LookinDisplayItem.h" - -@implementation LKHierarchyDataSource (KeyDown) - -- (BOOL)keyDown:(NSEvent *)event { - LookinDisplayItem *currentItem = self.selectedItem; - NSInteger selectedRowIdx = [self.displayingFlatItems indexOfObject:self.selectedItem]; - if (selectedRowIdx == NSNotFound) { - return false; - } - - switch (event.keyCode) { - case 125: { // down - LookinDisplayItem *willSelectedItem = [self.displayingFlatItems lookin_safeObjectAtIndex:selectedRowIdx + 1]; - if (willSelectedItem) { - self.selectedItem = willSelectedItem; - return true; - } - } break; - case 126: { // up - LookinDisplayItem *willSelectedItem = [self.displayingFlatItems lookin_safeObjectAtIndex:selectedRowIdx - 1]; - if (willSelectedItem) { - self.selectedItem = willSelectedItem; - return true; - } - } break; - case 123: { // left - if (currentItem.isExpandable && currentItem.isExpanded) { - [self collapseItem:currentItem]; - return true; - } else if (currentItem.superItem && [self.displayingFlatItems indexOfObject:currentItem.superItem] != NSNotFound) { - [self collapseItem:currentItem.superItem]; - self.selectedItem = currentItem.superItem; - return true; - } - } break; - case 124: { // right - if (currentItem.isExpandable && !currentItem.isExpanded) { - [self expandItem:self.selectedItem]; - return true; - } else { - NSArray *displayItems = self.displayingFlatItems.copy; - for (NSInteger i = selectedRowIdx + 1; i < displayItems.count; i++) { - LookinDisplayItem *next = displayItems[i]; - if (!next.inHiddenHierarchy) { - self.selectedItem = next; - return true; - } - } - } - } break; - default: - break; - } - - return false; -} -@end diff --git a/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.swift b/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.swift new file mode 100644 index 00000000..a91ec515 --- /dev/null +++ b/LookInside/Hierarchy/LKHierarchyDataSource+KeyDown.swift @@ -0,0 +1,43 @@ +// +// LKHierarchyDataSource+KeyDown.swift +// LookInside +// +// Created by Hares on 7/17/23. +// Copyright © 2023 hughkli. All rights reserved. +// + +import AppKit +import LookInsideHostCore + +extension LKHierarchyDataSource { + /// Arrow keys move the selection and expand or collapse rows. Returns + /// whether the key was handled. + @objc(keyDown:) + func keyDown(_ event: NSEvent) -> Bool { + let rows = displayingFlatItems ?? [] + let selectedRow = selectedItem.flatMap { selected in rows.firstIndex { $0 === selected } } + let action = HierarchyKeyNavigation.action(keyCode: event.keyCode, selectedRow: selectedRow, rowCount: rows.count) { index in + let item = rows[index] + return HierarchyKeyNavigation.Row( + isExpandable: item.isExpandable, + isExpanded: item.isExpanded, + parentRow: item.super.flatMap { parent in rows.firstIndex { $0 === parent } }, + isInHiddenHierarchy: item.inHiddenHierarchy + ) + } + switch action { + case let .select(row): + selectedItem = rows[row] + case let .collapse(row): + collapse(rows[row]) + case let .collapseAndSelect(row): + collapse(rows[row]) + selectedItem = rows[row] + case let .expand(row): + expand(rows[row]) + case nil: + return false + } + return true + } +} diff --git a/LookInside/Hierarchy/LKHierarchyDataSource.h b/LookInside/Hierarchy/LKHierarchyDataSource.h deleted file mode 100644 index 27cd65ab..00000000 --- a/LookInside/Hierarchy/LKHierarchyDataSource.h +++ /dev/null @@ -1,154 +0,0 @@ -// -// LKHierarchyDataSource.h -// Lookin -// -// Created by Li Kai on 2019/5/6. -// https://lookin.work -// - -#import - -@class LookinHierarchyInfo, LookinDisplayItem, LKPreferenceManager, LookinAttribute; - -typedef NS_ENUM(NSUInteger, LKHierarchyDataSourceState) { - LKHierarchyDataSourceStateNormal, - LKHierarchyDataSourceStateSearch, - LKHierarchyDataSourceStateFocus, -}; - -@interface LKHierarchyDataSource : NSObject - -/// 业务可以 observe 该属性 -@property(nonatomic, assign, readonly) LKHierarchyDataSourceState state; - -/** - 如果 keepState 为 YES,则会尽量维持刷新之前的折叠状态和选中态 - */ -- (void)reloadWithHierarchyInfo:(LookinHierarchyInfo *)info keepState:(BOOL)keepState; -@property(nonatomic, strong, readonly) RACSubject *willReloadHierarchyInfo; -@property(nonatomic, strong, readonly) RACSubject *didReloadHierarchyInfo; - -@property(nonatomic, copy) NSArray *rawFlatItems; - -/// 一维数组,包含所有 hierarchy 树中可见和不可见的 displayItems -/// 搜索或聚焦状态下,flatItems 是 rawFlatItems 的子集(normal 状态下,flatItems 和 rawFlatItems 等价) -@property(nonatomic, copy) NSArray *flatItems; - -/// 一维数组,只包括在 hierarchy 树中因为未被折叠而可见的 displayItems -/// 业务可以 observe 该属性 -@property(nonatomic, copy, readonly) NSArray *displayingFlatItems; - -/** - index 范围:0 ~ 4 - referenceDict 的 key 为结构路径(参见 +pathIdentifierForItem:inRootItems:),value 为 @(YES)/@(NO) 即是否展开,它记录了一组 displayItem 的展开状态 - 在调整一个 item 的 expansion 时,如果 referenceDict 中存在这个 item 的记录则会采用 referenceDict 里的数据,否则会重新根据 index 来调整 - */ -- (void)adjustExpansionByIndex:(NSInteger)index referenceDict:(NSDictionary *)referenceDict selectedItem:(LookinDisplayItem **)selectedItem; - -/** - Computes a structural path identifier for `item` using its superItem chain. - Format: "/:/:/..." (left-to-right root → leaf). - Returns nil if any chain segment lacks a class (typically UserCustom-only nodes), if the - sibling index is unresolvable, or if `rootItems` is empty. - */ -+ (NSString *)pathIdentifierForItem:(LookinDisplayItem *)item - inRootItems:(NSArray *)rootItems; - -/// Persists every expandable item's current expand/collapse state to LKPreferenceManager, -/// keyed by the current target app's bundle identifier. Records both YES (expanded) and -/// NO (collapsed) entries so cold reloads can restore user-driven collapses, not just -/// expansions. No-ops when the rememberExpansionState toggle is OFF or the bundle -/// identifier is empty. -- (void)persistExpansionStateToPreferences; - -/// 当前应该被显示的 rows 行数 -- (NSInteger)numberOfRows; - -/// 获取指定行的 item -- (LookinDisplayItem *)itemAtRow:(NSInteger)index; - -/// 获取指定 item 的 row,可能为 NSNotFound -- (NSInteger)rowForItem:(LookinDisplayItem *)item; - -/// 当前选中的 item -@property(nonatomic, weak) LookinDisplayItem *selectedItem; - -/// 当前被鼠标 hover 的 item -@property(nonatomic, weak) LookinDisplayItem *hoveredItem; - -/// 某个颜色的业务别名,如果不存在则返回 nil -- (NSArray *)aliasForColor:(NSColor *)color; -/// 在 dashboard 里选择颜色时弹出的 menu -@property(nonatomic, strong, readonly) NSMenu *selectColorMenu; -/// 该 tag 标示这个 menuItem 是“自定义……”那个选项 -@property(nonatomic, assign, readonly) NSInteger customColorMenuItemTag; -/// The menu tag of "switch color format" -@property(nonatomic, assign, readonly) NSInteger toggleColorFormatMenuItemTag; - -/// 将 item 折叠起来,如果该 item 没有 subitems 或已经被折叠,则该方法不起任何作用 -- (void)collapseItem:(LookinDisplayItem *)item; - -/// 将 item 展开,如果该 item 没有 subitems 或已经被展开,则该方法不起任何作用 -- (void)expandItem:(LookinDisplayItem *)item; - -/// 如果 item 在 hierarchy 中可见则该方法不执行任何操作,否则会将 item 的所有上级元素展开以显示 item -- (void)expandToShowItem:(LookinDisplayItem *)item; -/// 把 item 及所有后代元素全部展开 -- (void)expandItemsRootedByItem:(LookinDisplayItem *)item; -/// 把 item 所有后代元素全部折叠(但是不折叠 item 自身) -- (void)collapseAllChildrenOfItem:(LookinDisplayItem *)item; - -/// 通过 oid 找到对应的 displayItem -- (LookinDisplayItem *)displayItemWithOid:(unsigned long)oid; - -/// 选择目标 item,并确保它的祖先在 hierarchy 中展开可见。 -- (void)selectAndRevealItem:(LookinDisplayItem *)item; - -/// SwiftUI 节点 -> 已匹配的 CALayer 节点。返回空数组表示没有可跳转目标。 -- (NSArray *)swiftUIBackingLayerItemsForItem:(LookinDisplayItem *)item; - -/// CALayer 节点 -> 对应的 SwiftUI 节点。返回 nil 表示没有可跳转目标。 -- (LookinDisplayItem *)swiftUISourceItemForLayerItem:(LookinDisplayItem *)item; - -/// Dashboard row -> 对应的 SwiftUI/CALayer 跳转目标。返回 nil 表示该 row 不支持跳转。 -- (LookinDisplayItem *)swiftUIJumpTargetForAttribute:(LookinAttribute *)attribute; - -@property(nonatomic, strong, readonly) LookinHierarchyInfo *rawHierarchyInfo; - -/// 某个 item 的 isHidden 或 alpha 发生改变 -@property(nonatomic, strong, readonly) RACSubject *itemDidChangeHiddenAlphaValue; -/// 某个 item 的 attrGroup 改变 -@property(nonatomic, strong, readonly) RACSubject *itemDidChangeAttrGroup; - -@property(nonatomic, strong, readonly) RACSubject *itemDidChangeNoPreview; - -/// 子类实现该方法 -- (LKPreferenceManager *)preferenceManager; - -/// 当该属性为 YES 时,表示正处于 dashboard 搜索状态中,此时 preview 界面不应该响应图层点击 -@property(nonatomic, assign) BOOL shouldAvoidChangingPreviewSelectionDueToDashboardSearch; - -@property(nonatomic, assign, readonly) BOOL serverSideIsSwiftProject; - -/// 只读模式下(比如打开一个文件),该方法返回 YES -- (BOOL)isReadOnly; - -- (void)buildDisplayingFlatItems; - -#pragma mark - Search or Focus - -/// 应该在用户输入搜索词时调用该方法,内部会直接更改 flatItems 和 displayingFlatItems 对象 -/// string 不能为 nil 或空字符串 -- (void)searchWithString:(NSString *)string; - - -/// 应该在点击搜索框的关闭按钮时调用该方法,用来恢复搜索前的状态等一系列工作 -- (void)endSearch; - -/// 由于搜索或 Focus 而修改了 flatItems -@property(nonatomic, strong, readonly) RACSubject *didReloadFlatItemsWithSearchOrFocus; - -- (void)focusDisplayItem:(LookinDisplayItem *)item; -- (void)endFocus; - -@end diff --git a/LookInside/Hierarchy/LKHierarchyDataSource.m b/LookInside/Hierarchy/LKHierarchyDataSource.m deleted file mode 100644 index 383b2468..00000000 --- a/LookInside/Hierarchy/LKHierarchyDataSource.m +++ /dev/null @@ -1,1274 +0,0 @@ -// -// LKHierarchyDataSource.m -// Lookin -// -// Created by Li Kai on 2019/5/6. -// https://lookin.work -// - -#import "LKHierarchyDataSource.h" -#import "LookinHierarchyInfo.h" -#import "LookinAppInfo.h" -#import "LookinDisplayItem.h" -#import "LookinAttribute.h" -#import "LKPreferenceManager.h" -#import "LKColorIndicatorLayer.h" -#import "LKUserActionManager.h" -#import "LookinDisplayItem+LookinClient.h" -#import "LKDanceUIAttrMaker.h" -#import "LKStaticAsyncUpdateManager.h" -#include - -@interface LKSelectColorItem : NSObject - -+ (instancetype)itemWithTitle:(NSString *)title color:(LookinColor *)color; -@property(nonatomic, copy) NSString *title; -@property(nonatomic, strong) NSColor *color; - -@end - -@implementation LKSelectColorItem - -+ (instancetype)itemWithTitle:(NSString *)title color:(LookinColor *)color { - LKSelectColorItem *item = [LKSelectColorItem new]; - item.title = title; - item.color = color; - return item; -} - -@end - -@interface LKSelectColorItemsSection : NSObject - -@property(nonatomic, copy) NSString *title; -@property(nonatomic, copy) NSArray *items; - -@end - -@implementation LKSelectColorItemsSection - -- (void)setItems:(NSArray *)items { - _items = [items sortedArrayUsingComparator:^NSComparisonResult(LKSelectColorItem * _Nonnull obj1, LKSelectColorItem * _Nonnull obj2) { - return [obj1.title caseInsensitiveCompare:obj2.title]; - }].copy; -} - -@end - -@interface LookinDisplayItem (LKHierarchyDataSource) - -/// 记录搜索之前的 isExpanded 的值,用来在结束搜索后恢复 -@property(nonatomic, assign) BOOL isExpandedBeforeSearchOrFocus; - -@end - -@implementation LookinDisplayItem (LKHierarchyDataSource) - -- (void)setIsExpandedBeforeSearchOrFocus:(BOOL)isExpandedBeforeSearching { - [self lookin_bindBOOL:isExpandedBeforeSearching forKey:@"isExpandedBeforeSearching"]; -} - -- (BOOL)isExpandedBeforeSearchOrFocus { - return [self lookin_getBindBOOLForKey:@"isExpandedBeforeSearching"]; -} - -@end - -static NSInteger LKSwiftUILayerOrdinalFromAttributeTitle(NSString *title) { - if (![title hasPrefix:@"Layer "]) { - return NSNotFound; - } - NSScanner *scanner = [NSScanner scannerWithString:[title substringFromIndex:@"Layer ".length]]; - NSInteger value = 0; - if (![scanner scanInteger:&value] || value <= 0) { - return NSNotFound; - } - return value - 1; -} - -static void LKAddUniqueDisplayItem(NSMutableArray *items, LookinDisplayItem *item) { - if (item && ![items containsObject:item]) { - [items addObject:item]; - } -} - -static BOOL LKRectsAlmostEqual(CGRect lhs, CGRect rhs) { - CGFloat tolerance = 1.0; - return fabs(lhs.origin.x - rhs.origin.x) <= tolerance && - fabs(lhs.origin.y - rhs.origin.y) <= tolerance && - fabs(lhs.size.width - rhs.size.width) <= tolerance && - fabs(lhs.size.height - rhs.size.height) <= tolerance; -} - -/// Returns the item's leaf class name using the view → layer → window priority order shared -/// with `LookinDisplayItem.title`; returns nil for UserCustom-only nodes (no classChainList). -static NSString *LKDisplayItemPrimaryClassName(LookinDisplayItem *item) { - return item.displayingObject.classChainList.firstObject; -} - -static BOOL LKSwiftUIItemMatchesSourceTypes(LookinDisplayItem *item, NSArray *sourceTypes) { - if (sourceTypes.count == 0) { - return YES; - } - NSArray *itemTypes = [item lk_swiftUITypeNames]; - for (NSString *sourceType in sourceTypes) { - if ([itemTypes containsObject:sourceType]) { - return YES; - } - } - return NO; -} - -@interface LKHierarchyDataSource () -@property(nonatomic, assign) LKHierarchyDataSourceState state; - -@property(nonatomic, strong, readwrite) LookinHierarchyInfo *rawHierarchyInfo; - -/// displayingFlatItems 是 flatItems 的子集,仅包含用户可以看到的 items,而那些被折叠的 items 会被剔除。换句话说,当用户展开或收起 item 时,displayingFlatItems 属性会被 buildDisplayingFlatItems 方法不断更新 -@property(nonatomic, copy, readwrite) NSArray *displayingFlatItems; - -@property(nonatomic, strong, readwrite) NSMenu *selectColorMenu; -@property(nonatomic, copy) NSDictionary *oidToDisplayItemMap; - -/** - key 是 rgba 字符串,value 是 alias 字符串数组,比如: - - @{ - @"(255, 255, 255, 1)": @[@"MyWhite", @"MainThemeWhite"], - @"(255, 0, 0, 0.5)": @[@"BestRed", @"TransparentRed"] - }; - - */ -@property(nonatomic, strong) NSDictionary *> *colorToAliasMap; - -- (LookinDisplayItem *)_layerItemWithMemoryAddress:(NSString *)memoryAddress; -- (LookinDisplayItem *)_layerItemWithDisplayListID:(NSNumber *)displayListID; -- (LookinDisplayItem *)_swiftUIItemWithDisplayListID:(NSNumber *)displayListID; -- (LookinDisplayItem *)_swiftUIItemForLayerItemByFrameAndSource:(LookinDisplayItem *)layerItem; - -@end - -@implementation LKHierarchyDataSource - -- (instancetype)init { - if (self = [super init]) { - _itemDidChangeHiddenAlphaValue = [RACSubject subject]; - _itemDidChangeAttrGroup = [RACSubject subject]; - _itemDidChangeNoPreview = [RACSubject subject]; - _didReloadHierarchyInfo = [RACSubject subject]; - _willReloadHierarchyInfo = [RACSubject subject]; - _didReloadFlatItemsWithSearchOrFocus = [RACSubject subject]; - - @weakify(self); - [[[RACObserve([LKPreferenceManager mainManager], rgbaFormat) skip:1] distinctUntilChanged] subscribeNext:^(id _Nullable x) { - @strongify(self); - [self _setUpColors]; - }]; - - // Toggling the system-layout-guide visibility re-filters the visible - // rows; the tree data itself keeps every node. - [[LKPreferenceManager mainManager].showSystemLayoutGuides subscribe:self action:@selector(_handleShowSystemLayoutGuidesDidChange:) relatedObject:nil]; - } - return self; -} - -- (void)reloadWithHierarchyInfo:(LookinHierarchyInfo *)info keepState:(BOOL)keepState { - // Snapshot the previous root items before overwriting rawHierarchyInfo so that path - // identifiers built from the OLD flatItems' superItem chains still resolve their root - // index against the OLD displayItems array. - NSArray *prevRootItems = self.rawHierarchyInfo.displayItems; - - self.rawHierarchyInfo = info; - - [self.willReloadHierarchyInfo sendNext:nil]; - - if (info.colorAlias.count) { - [LKPreferenceManager mainManager].receivingConfigTime_Color = [[NSDate date] timeIntervalSince1970]; - } - if (info.collapsedClassList.count) { - [LKPreferenceManager mainManager].receivingConfigTime_Class = [[NSDate date] timeIntervalSince1970]; - } - - unsigned long prevSelectedOid = 0; - NSMutableDictionary *prevExpansionMap = nil; - BOOL prefersViewOID = [LKHelper appInfoLooksLikeMacTarget:info.appInfo]; - if (keepState) { - prevSelectedOid = [self.selectedItem bestObjectOidPreferView:prefersViewOID]; - - prevExpansionMap = [NSMutableDictionary dictionary]; - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSString *path = [LKHierarchyDataSource pathIdentifierForItem:obj inRootItems:prevRootItems]; - if (path) { - prevExpansionMap[path] = @(obj.isExpanded); - } - }]; - } - - // 设置 color alias 和 select color menu - [self _setUpColors]; - - // 根据 subitems 属性打平为二维数组,同时给每个 item 设置 indentLevel - self.rawFlatItems = [LookinDisplayItem flatItemsFromHierarchicalItems:info.displayItems]; - NSArray *flatItems = self.rawFlatItems.copy; - - // 设置 preferToBeCollapsed 属性 - NSSet *classesPreferredToCollapse = [NSSet setWithObjects:@"UILabel", @"UIPickerView", @"UIProgressView", @"UIActivityIndicatorView", @"UIAlertView", @"UIActionSheet", @"UISearchBar", @"UIButton", @"UITextView", @"UIDatePicker", @"UIPageControl", @"UISegmentedControl", @"UITextField", @"UISlider", @"UISwitch", @"UIVisualEffectView", @"UIImageView", @"WKCommonWebView", @"UITextEffectsWindow", nil]; - if (info.collapsedClassList.count) { - classesPreferredToCollapse = [classesPreferredToCollapse setByAddingObjectsFromArray:info.collapsedClassList]; - } - // no preview - NSSet *classesWithNoPreview = [NSSet setWithArray:@[@"UITextEffectsWindow", @"UIRemoteKeyboardWindow"]]; - - [flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if ([obj itemIsKindOfClassesWithNames:classesPreferredToCollapse]) { - [obj enumerateSelfAndChildren:^(LookinDisplayItem *item) { - item.preferToBeCollapsed = YES; - }]; - } - - // 在 indentLevel 0(iOS 13 之前的 window 或 macOS)和 indentLevel 1(iOS 13+ scene 下的 window)都检查 - if (obj.indentLevel <= 1) { - if ([obj itemIsKindOfClassesWithNames:classesWithNoPreview]) { - obj.noPreview = YES; - } - } - - if (!obj.isPixelBearing) { - // Guides, cells and a view's outer layer have no pixels of their - // own — fetching a screenshot would draw the owning view's content - // a second time. No screenshot task; they stay in the preview - // (guides and cells on their owner's plane, the outer layer as a - // parallel plane of its own). - obj.doNotFetchScreenshotReason = LookinDoNotFetchScreenshotForNoPixels; - } - if (!obj.isUserCustom && !obj.shouldCaptureImage) { - [obj enumerateSelfAndChildren:^(LookinDisplayItem *item) { - item.noPreview = YES; - item.doNotFetchScreenshotReason = LookinDoNotFetchScreenshotForUserConfig; - }]; - } -// } else if ([LKPreferenceManager mainManager].showHiddenItems.currentBOOLValue == NO && obj.inHiddenHierarchy) { -// [obj enumerateSelfAndChildren:^(LookinDisplayItem *item) { -// item.noPreview = YES; -// item.doNotFetchScreenshotReason = LookinDoNotFetchScreenshotForHidden; -// }]; -// } - - if (!self.serverSideIsSwiftProject) { - if ([obj.displayingObject.lk_completedDemangledClassName containsString:@"."]) { - _serverSideIsSwiftProject = YES; - } - } - - if (obj.customInfo.danceuiSource.length > 0) { - [LKDanceUIAttrMaker makeDanceUIJumpAttribute:obj danceSource:obj.customInfo.danceuiSource]; - } - }]; - - self.flatItems = flatItems; - - // 设置选中 - LookinDisplayItem *shouldSelectedItem = nil; - if (keepState) { - LookinDisplayItem *prevSelectedItem = [self displayItemWithOid:prevSelectedOid]; - if (prevSelectedItem) { - shouldSelectedItem = prevSelectedItem; - } - } - - // 设置展开和折叠 - NSInteger expansionIndex = self.preferenceManager.expansionIndex; - if (self.flatItems.count > 300) { - if (expansionIndex > 2) { - expansionIndex = 2; - } - } - - NSDictionary *referenceDict = nil; - if (keepState) { - referenceDict = prevExpansionMap; - } else { - // Cold reload: seed the reference dict from the persisted state for this bundle id - // so adjustExpansionByIndex: restores the matching items (both expanded and - // user-collapsed). Paths not in the dict keep the index-driven preset behavior. - LKPreferenceManager *prefs = self.preferenceManager; - NSString *bundleId = info.appInfo.appBundleIdentifier; - if (prefs.rememberExpansionState && bundleId.length > 0) { - NSDictionary *storedState = [prefs expansionStateForBundleIdentifier:bundleId]; - if (storedState.count > 0) { - referenceDict = storedState; - } - } - } - [self adjustExpansionByIndex:expansionIndex referenceDict:referenceDict selectedItem:(shouldSelectedItem ? nil : &shouldSelectedItem)]; - - if (!shouldSelectedItem) { - shouldSelectedItem = self.flatItems.firstObject; - } - self.selectedItem = shouldSelectedItem; - - if (self.state != LKHierarchyDataSourceStateNormal) { - // 可能在 search 或 focus 状态,要退出 - self.state = LKHierarchyDataSourceStateNormal; - } - - [self.didReloadHierarchyInfo sendNext:nil]; -} - -- (NSInteger)numberOfRows { - return self.displayingFlatItems.count; -} - -- (LookinDisplayItem *)itemAtRow:(NSInteger)index { - if (index < 0) { - return nil; - } - if ([self.displayingFlatItems lookin_hasIndex:index]) { - return self.displayingFlatItems[index]; - } - return nil; -} - -- (NSInteger)rowForItem:(LookinDisplayItem *)item { - NSInteger row = [self.displayingFlatItems indexOfObject:item]; - return row; -} - -- (void)setSelectedItem:(LookinDisplayItem *)selectedItem { - if (_selectedItem == selectedItem) { - return; - } - LookinDisplayItem *prevItem = _selectedItem; - _selectedItem = selectedItem; - - [prevItem notifySelectionChangeToDelegates]; - [_selectedItem notifySelectionChangeToDelegates]; - - [[LKUserActionManager sharedInstance] sendAction:LKUserActionType_SelectedItemChange]; - - if (NSColorPanel.sharedColorPanelExists) { - [[NSColorPanel sharedColorPanel] close]; - } - - if (!selectedItem && self.preferenceManager.measureState.currentIntegerValue != LookinMeasureState_no) { - // 如果当前在测距,则取消 - [self.preferenceManager.measureState setIntegerValue:LookinMeasureState_no ignoreSubscriber:nil]; - } -} - -- (void)setHoveredItem:(LookinDisplayItem *)hoveredItem { - if (_hoveredItem == hoveredItem) { - return; - } - LookinDisplayItem *prevItem = _hoveredItem; - _hoveredItem = hoveredItem; - [prevItem notifyHoverChangeToDelegates]; - [_hoveredItem notifyHoverChangeToDelegates]; -} - -- (void)adjustExpansionByIndex:(NSInteger)index referenceDict:(NSDictionary *)referenceDict selectedItem:(LookinDisplayItem **)selectedItem { - if (index < 0 || index > 4) { - NSAssert(NO, @"adjustExpansionByIndex, index 为 %@", @(index)); - index = MAX(MIN(index, 4), 0); - } - - self.preferenceManager.expansionIndex = index; - - NSArray *rootItems = self.rawHierarchyInfo.displayItems; - - __block NSUInteger expandedCount = self.flatItems.count; - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.hasDeterminedExpansion = NO; - - if (!obj.isExpandable) { - obj.hasDeterminedExpansion = YES; - expandedCount--; - return; - } - - if (referenceDict) { - NSString *path = [LKHierarchyDataSource pathIdentifierForItem:obj inRootItems:rootItems]; - NSNumber *prevState = path ? referenceDict[path] : nil; - if (prevState != nil) { - // 旧的对象,直接维持之前的状态 - obj.isExpanded = [prevState boolValue]; - obj.hasDeterminedExpansion = YES; - if (!obj.isExpanded) { - expandedCount--; - } - } - } - }]; - - if (index == 0) { - // 全部折叠,只剩下最顶层的 UIWindow - - __block LookinDisplayItem *preferedSelectedItem = nil; - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hasDeterminedExpansion) { - return; - } - obj.isExpanded = NO; - - if (obj.representedAsKeyWindow) { - preferedSelectedItem = obj; - } - }]; - - if (selectedItem) { - *selectedItem = preferedSelectedItem; - } - - } else if (index == 4) { - // 全部展开,包括 UIButton、UITabBar、UINavigationBar 等等,全部展开 - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hasDeterminedExpansion) { - return; - } - if (obj.inNoPreviewHierarchy) { - obj.isExpanded = NO; - return; - } - obj.isExpanded = YES; - }]; - - if (selectedItem) { - __block LookinDisplayItem *preferedSelectedItem = nil; - LookinDisplayItem *keyWindowRootItem = [self.rawHierarchyInfo.displayItems lookin_firstFiltered:^BOOL(LookinDisplayItem *obj) { - return obj.representedAsKeyWindow; - }]; - if (keyWindowRootItem) { - [[LookinDisplayItem flatItemsFromHierarchicalItems:@[keyWindowRootItem]] enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hostViewControllerObject || obj.hostWindowControllerObject) { - preferedSelectedItem = obj; - *stop = YES; - } - }]; - } - *selectedItem = preferedSelectedItem; - } - - } else { - LookinDisplayItem *keyWindowItem = [self.rawHierarchyInfo.displayItems lookin_firstFiltered:^BOOL(LookinDisplayItem *windowItem) { - return windowItem.representedAsKeyWindow; - }]; - if (!keyWindowItem) { - keyWindowItem = self.rawHierarchyInfo.displayItems.firstObject; - } - if (keyWindowItem) { - - // 如果 keyWindowItem 是 scene 容器(只有 windowObject,没有 layer/view), - // 在其子项中找到实际的 key window,用于 UITransitionView 搜索 - LookinDisplayItem *actualKeyWindow = keyWindowItem; - LookinDisplayItemNodeKind keyWindowItemKind = keyWindowItem.resolvedNodeKind; - if (keyWindowItemKind == LookinDisplayItemNodeKindWindow || keyWindowItemKind == LookinDisplayItemNodeKindWindowScene) { - for (LookinDisplayItem *child in keyWindowItem.subitems) { - if (child.representedAsKeyWindow) { - actualKeyWindow = child; - break; - } - } - if (actualKeyWindow == keyWindowItem) { - actualKeyWindow = keyWindowItem.subitems.firstObject; - } - } - - [self.rawHierarchyInfo.displayItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull windowItem, NSUInteger idx, BOOL * _Nonnull stop) { - if (windowItem == keyWindowItem) { - return; - } - // 非 keyWindow 上的都折叠起来 - // 强制折叠:覆盖 referenceDict 的旧展开状态,防止非 key scene 保持展开 - NSArray *flatNonKey = [LookinDisplayItem flatItemsFromHierarchicalItems:@[windowItem]]; - for (LookinDisplayItem *obj in flatNonKey) { - obj.isExpanded = NO; - obj.hasDeterminedExpansion = YES; - } - }]; - - NSArray *UITransitionViewItems = [actualKeyWindow.subitems lookin_filter:^BOOL(LookinDisplayItem *obj) { - return [obj.title isEqualToString:@"UITransitionView"]; - }]; - [UITransitionViewItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hasDeterminedExpansion) { - return; - } - if (idx == (UITransitionViewItems.count - 1)) { - // 展开最后一个 UITransitionView - obj.isExpanded = YES; - } else { - // 折叠前几个 UITransitionView - obj.isExpanded = NO; - } - obj.hasDeterminedExpansion = YES; - }]; - - NSMutableArray *viewControllerItems = [NSMutableArray array]; - [[LookinDisplayItem flatItemsFromHierarchicalItems:@[keyWindowItem]] enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hostViewControllerObject || obj.hostWindowControllerObject) { - [viewControllerItems addObject:obj]; - return; - } - if (obj.hasDeterminedExpansion) { - return; - } - if (obj.inNoPreviewHierarchy || obj.preferToBeCollapsed || (![LKPreferenceManager mainManager].showHiddenItems && obj.inHiddenHierarchy)) { - // 把 noPreview 和 UIButton 之类常用控件叠起来 - obj.isExpanded = NO; - obj.hasDeterminedExpansion = YES; - return; - } - if ([obj itemIsKindOfClassesWithNames:[NSSet setWithObjects:@"UINavigationBar", @"UITabBar", nil]]) { - // 把 NavigationBar 和 TabBar 折叠起来 - [obj enumerateSelfAndChildren:^(LookinDisplayItem *item) { - if (item.hasDeterminedExpansion) { - return; - } - item.isExpanded = NO; - item.hasDeterminedExpansion = YES; - }]; - return; - } - }]; - - if (selectedItem) { - *selectedItem = viewControllerItems.lastObject; - } - - if (index == 1) { - // 恰好把 viewController 显示出来 - // 倒序,以确保多个 viewController 在同一条树上时,只有最 leaf 的那一个是被折叠的 - [viewControllerItems enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(LookinDisplayItem * _Nonnull viewControllerItem, NSUInteger idx, BOOL * _Nonnull stop) { - [viewControllerItem enumerateSelfAndAncestors:^(LookinDisplayItem *item, BOOL *stop) { - // 把 viewController 的 ancestors 都展开 - if (item.hasDeterminedExpansion) { - return; - } - item.isExpanded = (item != viewControllerItem); - item.hasDeterminedExpansion = YES; - }]; - }]; - - // 剩下未处理的都折叠 - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hasDeterminedExpansion) { - return; - } - obj.isExpanded = NO; - }]; - - } else if (index == 2) { - // 从 viewController 开始算向 leaf 多推 3 层 - [viewControllerItems enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(LookinDisplayItem * _Nonnull viewControllerItem, NSUInteger idx, BOOL * _Nonnull stop) { - [viewControllerItem enumerateAncestors:^(LookinDisplayItem *item, BOOL *stop) { - // 把 viewController 的 ancestors 都展开 - if (item.hasDeterminedExpansion) { - return; - } - item.isExpanded = YES; - item.hasDeterminedExpansion = YES; - }]; - - BOOL hasTableOrCollectionView = [viewControllerItem.subitems.firstObject itemIsKindOfClassesWithNames:[NSSet setWithObjects:@"UITableView", @"UICollectionView", nil]]; - // 如果是那种典型的 UITableView 或 UICollectionView 的话,则向 leaf 方向推进 2 层(这样就可以让 cell 恰好露出来而不露出来 cell 的 contentView),否则就推 3 层 - NSUInteger indentsForward = hasTableOrCollectionView ? 2 : 3; - - [viewControllerItem enumerateSelfAndChildren:^(LookinDisplayItem *item) { - if (item.hasDeterminedExpansion) { - return; - } - if (item.indentLevel < viewControllerItem.indentLevel + indentsForward) { - item.isExpanded = YES; - item.hasDeterminedExpansion = YES; - } - }]; - }]; - - // 剩下未处理的都折叠 - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hasDeterminedExpansion) { - return; - } - obj.isExpanded = NO; - }]; - - } else if (index == 3) { - // 展开大部分 - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.hasDeterminedExpansion) { - return; - } - obj.isExpanded = YES; - obj.hasDeterminedExpansion = YES; - }]; - } - } // if (keyWindowItem) - } - - [self buildDisplayingFlatItems]; -} - -- (LookinDisplayItem *)displayItemWithOid:(unsigned long)oid { - LookinDisplayItem *item = self.oidToDisplayItemMap[@(oid)]; - return item; -} - -- (void)selectAndRevealItem:(LookinDisplayItem *)item { - if (!item) { - return; - } - if (![self.flatItems containsObject:item]) { - if (self.state == LKHierarchyDataSourceStateSearch) { - [self endSearch]; - } else if (self.state == LKHierarchyDataSourceStateFocus) { - [self endFocus]; - } - } - if (![self.flatItems containsObject:item]) { - return; - } - if (!item.displayingInHierarchy) { - [self expandToShowItem:item]; - } - self.selectedItem = item; -} - -- (NSArray *)swiftUIBackingLayerItemsForItem:(LookinDisplayItem *)item { - NSMutableArray *result = [NSMutableArray array]; - for (NSString *address in [item lk_swiftUIBackingLayerMemoryAddresses]) { - LKAddUniqueDisplayItem(result, [self _layerItemWithMemoryAddress:address]); - } - for (NSNumber *displayListID in [item lk_swiftUIBackingDisplayListIDs]) { - LKAddUniqueDisplayItem(result, [self _layerItemWithDisplayListID:displayListID]); - } - return result; -} - -- (LookinDisplayItem *)swiftUISourceItemForLayerItem:(LookinDisplayItem *)item { - NSNumber *displayListID = [item lk_swiftUILayerDisplayListID]; - if (!displayListID) { - return nil; - } - return [self _swiftUIItemWithDisplayListID:displayListID] ?: [self _swiftUIItemForLayerItemByFrameAndSource:item]; -} - -- (LookinDisplayItem *)swiftUIJumpTargetForAttribute:(LookinAttribute *)attribute { - LookinDisplayItem *sourceItem = attribute.targetDisplayItem; - if (!sourceItem || ![attribute.value isKindOfClass:NSString.class]) { - return nil; - } - - NSString *title = attribute.displayTitle; - NSString *value = attribute.value; - BOOL sourceIsSwiftUI = sourceItem.customInfo.isSwiftUI || [sourceItem lk_swiftUIBackingDisplayListIDs].count || [sourceItem lk_swiftUIBackingLayerMemoryAddresses].count; - - if (sourceIsSwiftUI) { - if ([title hasSuffix:@"Backed By"]) { - return [self _layerItemWithMemoryAddress:[LookinDisplayItem lk_memoryAddressInObjectDescription:value]]; - } - if ([title hasSuffix:@"Display List ID"] || [title isEqualToString:@"Identity IDs"]) { - for (NSNumber *displayListID in [LookinDisplayItem lk_validSwiftUIDisplayListIDsInString:value]) { - LookinDisplayItem *target = [self _layerItemWithDisplayListID:displayListID]; - if (target) { - return target; - } - } - NSInteger layerIndex = LKSwiftUILayerOrdinalFromAttributeTitle(title); - NSArray *layerItems = [self swiftUIBackingLayerItemsForItem:sourceItem]; - if (layerIndex != NSNotFound && [layerItems lookin_hasIndex:layerIndex]) { - return layerItems[layerIndex]; - } - } - return nil; - } - - if (sourceItem.layerObject && [title isEqualToString:@"Display List ID"]) { - NSNumber *displayListID = [LookinDisplayItem lk_validSwiftUIDisplayListIDsInString:value].firstObject; - if (!displayListID) { - displayListID = [sourceItem lk_swiftUILayerDisplayListID]; - } - return [self _swiftUIItemWithDisplayListID:displayListID] ?: [self _swiftUIItemForLayerItemByFrameAndSource:sourceItem]; - } - return nil; -} - -- (void)setRawFlatItems:(NSArray *)rawFlatItems { - _rawFlatItems = rawFlatItems.copy; - - NSMutableDictionary *map = [NSMutableDictionary dictionaryWithCapacity:rawFlatItems.count * 2]; - [rawFlatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.viewObject.oid) { - map[@(obj.viewObject.oid)] = obj; - } - if (obj.layerObject.oid) { - map[@(obj.layerObject.oid)] = obj; - } - if (obj.windowObject.oid) { - map[@(obj.windowObject.oid)] = obj; - } - if (obj.kindObject.oid) { - map[@(obj.kindObject.oid)] = obj; - } - }]; - self.oidToDisplayItemMap = map; -} - -- (LookinDisplayItem *)_layerItemWithMemoryAddress:(NSString *)memoryAddress { - if (memoryAddress.length == 0) { - return nil; - } - NSString *normalizedAddress = memoryAddress.lowercaseString; - for (LookinDisplayItem *item in self.rawFlatItems) { - if ([item.layerObject.memoryAddress.lowercaseString isEqualToString:normalizedAddress]) { - return item; - } - } - return nil; -} - -- (LookinDisplayItem *)_layerItemWithDisplayListID:(NSNumber *)displayListID { - if (!displayListID) { - return nil; - } - for (LookinDisplayItem *item in self.rawFlatItems) { - if (!item.layerObject) { - continue; - } - if ([[item lk_swiftUILayerDisplayListID] isEqualToNumber:displayListID]) { - return item; - } - } - return nil; -} - -- (LookinDisplayItem *)_swiftUIItemWithDisplayListID:(NSNumber *)displayListID { - if (!displayListID) { - return nil; - } - LookinDisplayItem *result = nil; - for (LookinDisplayItem *item in self.rawFlatItems) { - if ([[item lk_swiftUIBackingDisplayListIDs] containsObject:displayListID]) { - if (!result || item.indentLevel > result.indentLevel) { - result = item; - } - } - } - return result; -} - -- (LookinDisplayItem *)_swiftUIItemForLayerItemByFrameAndSource:(LookinDisplayItem *)layerItem { - if (!layerItem.layerObject) { - return nil; - } - CGRect layerFrame = [layerItem calculateFrameToRoot]; - NSArray *sourceTypes = [layerItem lk_swiftUILayerSourceTypeNames]; - LookinDisplayItem *result = nil; - for (LookinDisplayItem *item in self.rawFlatItems) { - if (!item.customInfo.isSwiftUI || ![item hasValidFrameToRoot]) { - continue; - } - if (!LKSwiftUIItemMatchesSourceTypes(item, sourceTypes)) { - continue; - } - if (!LKRectsAlmostEqual(layerFrame, [item calculateFrameToRoot])) { - continue; - } - if (!result || item.indentLevel > result.indentLevel) { - result = item; - } - } - return result; -} - -- (void)buildDisplayingFlatItems { - BOOL showSystemLayoutGuides = [LKPreferenceManager mainManager].showSystemLayoutGuides.currentBOOLValue; - NSMutableArray *displayingItems = [NSMutableArray array]; - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (!obj.displayingInHierarchy) { - return; - } - // System-created layout guides can be toggled off; user guides always - // show, and the underlying data keeps every node either way. - if (!showSystemLayoutGuides - && obj.representsSystemManagedNode - && obj.resolvedNodeKind == LookinDisplayItemNodeKindLayoutGuide) { - return; - } - [displayingItems addObject:obj]; - }]; - self.displayingFlatItems = displayingItems; -} - -- (void)_handleShowSystemLayoutGuidesDidChange:(LookinMsgActionParams *)param { - [self buildDisplayingFlatItems]; -} - -- (void)collapseItem:(LookinDisplayItem *)item { - if (!item.isExpandable) { - return; - } - if (!item.isExpanded) { - return; - } - item.isExpanded = NO; - [self buildDisplayingFlatItems]; - [self persistExpansionStateToPreferences]; -} - -- (void)expandItem:(LookinDisplayItem *)item { - if (!item.isExpandable) { - return; - } - if (item.isExpanded) { - return; - } - item.isExpanded = YES; - [self buildDisplayingFlatItems]; - [self persistExpansionStateToPreferences]; -} - -- (void)expandToShowItem:(LookinDisplayItem *)item { - __block BOOL didChange = NO; - [item enumerateAncestors:^(LookinDisplayItem *targetItem, BOOL *stop) { - if (!targetItem.isExpanded) { - targetItem.isExpanded = YES; - didChange = YES; - } - }]; - - [self buildDisplayingFlatItems]; - if (didChange) { - [self persistExpansionStateToPreferences]; - } -} - -- (void)expandItemsRootedByItem:(LookinDisplayItem *)item { - if (item.preferToBeCollapsed) { - [item enumerateSelfAndChildren:^(LookinDisplayItem *targetItem) { - if (targetItem.isExpandable && !targetItem.isExpanded) { - targetItem.isExpanded = YES; - } - }]; - } else { - [item enumerateSelfAndChildren:^(LookinDisplayItem *targetItem) { - if (targetItem.isExpandable && !targetItem.isExpanded && ![targetItem preferToBeCollapsed]) { - targetItem.isExpanded = YES; - } - }]; - } - - [self buildDisplayingFlatItems]; - [self persistExpansionStateToPreferences]; -} - -- (void)collapseAllChildrenOfItem:(LookinDisplayItem *)item { - [item enumerateSelfAndChildren:^(LookinDisplayItem *enumeratedItem) { - if (enumeratedItem == item) { - return; - } - if (!enumeratedItem.isExpandable) { - return; - } - if (!enumeratedItem.isExpanded) { - return; - } - enumeratedItem.isExpanded = NO; - }]; - [self buildDisplayingFlatItems]; - [self persistExpansionStateToPreferences]; -} - -#pragma mark - Search - -- (void)searchWithString:(NSString *)string { - if (string.length == 0) { - NSAssert(NO, @""); - return; - } - - if (self.state != LKHierarchyDataSourceStateSearch) { - [self.rawFlatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.isExpandedBeforeSearchOrFocus = obj.isExpanded; - }]; - self.state = LKHierarchyDataSourceStateSearch; - } - - self.selectedItem = nil; - - /// 被打上这个标记的都是本次搜索需要在界面中显示出来的(尽管可能会被折叠) - NSString *Key_ShouldShow = @"show"; - [self.rawFlatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull displayItem, NSUInteger idx, BOOL * _Nonnull stop) { - // 先 - [displayItem lookin_bindBOOL:NO forKey:Key_ShouldShow]; - displayItem.highlightedSearchString = nil; - }]; - [self.rawFlatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull displayItem, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL isMatched = [displayItem isMatchedWithSearchString:string]; - if (isMatched) { - displayItem.highlightedSearchString = string; - [displayItem enumerateAncestors:^(LookinDisplayItem *ancestor, BOOL *stop) { - // 上级元素都显示且展开 - ancestor.isExpanded = YES; - [ancestor lookin_bindBOOL:YES forKey:Key_ShouldShow]; - }]; - [displayItem enumerateSelfAndChildren:^(LookinDisplayItem *selfOrChild) { - // 自身和下级元素都显示但折叠,允许用户手动展开 - selfOrChild.isExpanded = NO; - [selfOrChild lookin_bindBOOL:YES forKey:Key_ShouldShow]; - }]; - } - }]; - - NSArray *flatItems = [self.rawFlatItems lookin_filter:^BOOL(LookinDisplayItem *displayItem) { - BOOL shouldShow = [displayItem lookin_getBindBOOLForKey:Key_ShouldShow]; - if (shouldShow) { - displayItem.isInSearch = YES; - } - return shouldShow; - }]; - self.flatItems = flatItems; - [self.didReloadFlatItemsWithSearchOrFocus sendNext:nil]; - - [self buildDisplayingFlatItems]; -} - -// 有可能从 normal 状态或 search 状态进入该状态 -- (void)focusDisplayItem:(LookinDisplayItem *)item { - if (!item) { - NSAssert(NO, @""); - return; - } - - if (self.state == LKHierarchyDataSourceStateNormal) { - [self.rawFlatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.isExpandedBeforeSearchOrFocus = obj.isExpanded; - }]; - } else if (self.state == LKHierarchyDataSourceStateSearch) { - [self.rawFlatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.isInSearch = NO; - obj.highlightedSearchString = nil; - }]; - } - self.state = LKHierarchyDataSourceStateFocus; - - NSMutableArray *newFlatItems = [NSMutableArray array]; - [item enumerateSelfAndChildren:^(LookinDisplayItem *currItem) { - [newFlatItems addObject:currItem]; - }]; - self.flatItems = newFlatItems; - [self.didReloadFlatItemsWithSearchOrFocus sendNext:nil]; - [self buildDisplayingFlatItems]; -} - -- (void)endFocus { - if (self.state == LKHierarchyDataSourceStateNormal) { - return; - } - self.state = LKHierarchyDataSourceStateNormal; - - [self.rawFlatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.isExpanded = obj.isExpandedBeforeSearchOrFocus; - }]; - - self.flatItems = self.rawFlatItems; - [self.didReloadFlatItemsWithSearchOrFocus sendNext:nil]; - [self buildDisplayingFlatItems]; -} - -- (void)endSearch { - if (self.state == LKHierarchyDataSourceStateNormal) { - return; - } - self.state = LKHierarchyDataSourceStateNormal; - - [self.rawFlatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.isInSearch = NO; - obj.highlightedSearchString = nil; - obj.isExpanded = obj.isExpandedBeforeSearchOrFocus; - }]; - /// 搜索时被选中的 item,在结束搜索后也应该处于被选中且可见的状态 - [self.selectedItem enumerateAncestors:^(LookinDisplayItem *item, BOOL *stop) { - item.isExpanded = YES; - }]; - - self.flatItems = self.rawFlatItems; - [self.didReloadFlatItemsWithSearchOrFocus sendNext:nil]; - - [self buildDisplayingFlatItems]; -} - -#pragma mark - Colors - -- (NSArray *)aliasForColor:(NSColor *)color { - if (!color) { - return nil; - } - NSString *rgbaString = color.rgbaString; - NSArray *names = self.colorToAliasMap[rgbaString]; - return names; -} - -- (void)_setUpColors { - NSMutableDictionary *> *colorToAliasMap = [NSMutableDictionary dictionary]; - /// 成员可能是 LKSelectColorItem 和 LKSelectColorItemsSection 混杂在一起 - NSMutableArray *aliasColorItemsOrSections = [NSMutableArray array]; - - /** - hierarchyInfo.colorAlias 可以有三种结构: - 1)key 是颜色别名,value 是 UIColor/NSColor。即 - 2)key 是一组颜色的标题,value 是 NSDictionary,而这个 NSDictionary 的 key 是颜色别名,value 是 UIColor / NSColor。即 *> - 3)以上两者混在一起 - */ - [self.rawHierarchyInfo.colorAlias enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull key, id _Nonnull colorOrDict, BOOL * _Nonnull stop) { - if ([colorOrDict isKindOfClass:[NSColor class]]) { - NSString *colorDesc = [((NSColor *)colorOrDict) rgbaString]; - if (colorDesc) { - if (!colorToAliasMap[colorDesc]) { - colorToAliasMap[colorDesc] = [NSMutableArray array]; - } - [colorToAliasMap[colorDesc] addObject:key]; - } - - [aliasColorItemsOrSections addObject:[LKSelectColorItem itemWithTitle:key color:(NSColor *)colorOrDict]]; - - } else if ([colorOrDict isKindOfClass:[NSDictionary class]]) { - LKSelectColorItemsSection *section = [LKSelectColorItemsSection new]; - section.title = key; - NSMutableArray *aliasItems = [NSMutableArray array]; - - [((NSDictionary *)colorOrDict) enumerateKeysAndObjectsUsingBlock:^(NSString *colorAliaName, NSColor *colorObj, BOOL * _Nonnull stop) { - NSString *colorDesc = colorObj.rgbaString; - if (colorDesc) { - if (!colorToAliasMap[colorDesc]) { - colorToAliasMap[colorDesc] = [NSMutableArray array]; - } - [colorToAliasMap[colorDesc] addObject:colorAliaName]; - } - - [aliasItems addObject:[LKSelectColorItem itemWithTitle:colorAliaName color:colorObj]]; - }]; - - if (aliasItems.count) { - section.items = aliasItems; - [aliasColorItemsOrSections addObject:section]; - } - - } else { - NSAssert(NO, @""); - } - }]; - - [aliasColorItemsOrSections sortUsingComparator:^NSComparisonResult(id _Nonnull obj1, id _Nonnull obj2) { - if ([obj1 isKindOfClass:[LKSelectColorItem class]]) { - if ([obj2 isKindOfClass:[LKSelectColorItem class]]) { - return [((LKSelectColorItem *)obj1).title caseInsensitiveCompare:((LKSelectColorItem *)obj2).title]; - } - if ([obj2 isKindOfClass:[LKSelectColorItemsSection class]]) { - return NSOrderedAscending; - } - } - if ([obj1 isKindOfClass:[LKSelectColorItemsSection class]]) { - if ([obj2 isKindOfClass:[LKSelectColorItem class]]) { - return NSOrderedDescending; - } - if ([obj2 isKindOfClass:[LKSelectColorItemsSection class]]) { - return [((LKSelectColorItemsSection *)obj1).title caseInsensitiveCompare:((LKSelectColorItemsSection *)obj2).title]; - } - } - NSAssert(NO, @""); - return NSOrderedAscending; - }]; - - self.colorToAliasMap = colorToAliasMap; - self.selectColorMenu = [self _makeMenuWithAliasColorItemsOrSections:aliasColorItemsOrSections usingRGBAFormat:[LKPreferenceManager mainManager].rgbaFormat]; -} - -- (NSMenu *)_makeMenuWithAliasColorItemsOrSections:(NSArray *)AliasColorItemsOrSections usingRGBAFormat:(BOOL)rgbaFormat { - NSMutableArray *menuModel = [NSMutableArray array]; - - [menuModel addObject:[LKSelectColorItem itemWithTitle:@"nil" color:nil]]; - [menuModel addObject:[LKSelectColorItem itemWithTitle:@"clear color" color:LookinColorRGBAMake(0, 0, 0, 0)]]; - - NSArray *defaultColors = @[LookinColorMake(0, 0, 0), - LookinColorMake(126, 126, 126), - LookinColorMake(255, 255, 255), - LookinColorRGBAMake(0, 166, 248, .5), - LookinColorMake(253, 62, 0), - LookinColorMake(105, 190, 0), - LookinColorMake(254, 182, 2)]; - NSArray *defaultColorItems = [defaultColors lookin_map:^id(NSUInteger idx, NSColor *value) { - LKSelectColorItem *item = [LKSelectColorItem new]; - item.color = value; - if (value) { - item.title = rgbaFormat ? [value rgbaString]: [value hexString]; - } else { - item.title = @"nil"; - } - return item; - }]; - [menuModel addObjectsFromArray:defaultColorItems]; - - NSUInteger defaultItemsCount = menuModel.count; - - if (AliasColorItemsOrSections) { - [menuModel addObjectsFromArray:AliasColorItemsOrSections]; - } - - NSMenu *menu = [NSMenu new]; - [menuModel enumerateObjectsUsingBlock:^(id itemOrSection, NSUInteger idx, BOOL * _Nonnull stop) { - if (idx == defaultItemsCount) { - [menu addItem:[NSMenuItem separatorItem]]; - } - - if ([itemOrSection isKindOfClass:[LKSelectColorItemsSection class]]) { - LKSelectColorItemsSection *itemsSection = itemOrSection; - - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = [[NSImage alloc] initWithSize:NSMakeSize(1, 22)]; - [menu addItem:menuItem]; - menuItem.title = itemsSection.title; - - NSMenu *submenu = [NSMenu new]; - [itemsSection.items enumerateObjectsUsingBlock:^(LKSelectColorItem * _Nonnull subAliasItem, NSUInteger idx, BOOL * _Nonnull stop) { - NSMenuItem *subMenuItem = [self _menuItemFromColorItem:subAliasItem]; - [submenu addItem:subMenuItem]; - }]; - menuItem.submenu = submenu; - - } else if ([itemOrSection isKindOfClass:[LKSelectColorItem class]]) { - NSMenuItem *menuItem = [self _menuItemFromColorItem:itemOrSection]; - [menu addItem:menuItem]; - - } else { - NSAssert(NO, @""); - } - }]; - - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:({ - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = [[NSImage alloc] initWithSize:NSMakeSize(1, 22)]; - menuItem.title = NSLocalizedString(@"Other…", nil); - menuItem.tag = self.customColorMenuItemTag; - menuItem; - })]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:({ - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = [[NSImage alloc] initWithSize:NSMakeSize(1, 22)]; - if (rgbaFormat) { - menuItem.title = NSLocalizedString(@"Switch color format to HEX", nil); - } else { - menuItem.title = NSLocalizedString(@"Switch color format to RGBA", nil); - } - menuItem.tag = self.toggleColorFormatMenuItemTag; - menuItem; - })]; - - return menu; -} - -- (NSMenuItem *)_menuItemFromColorItem:(LKSelectColorItem *)item { - NSImage *image = [LKColorIndicatorLayer imageWithColor:item.color shapeSize:NSMakeSize(20, 20) insets:NSEdgeInsetsMake(4, 5, 4, 6)]; - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = image; - menuItem.title = item.title; - menuItem.representedObject = item.color; - return menuItem; -} - -- (NSInteger)customColorMenuItemTag { - return 10; -} - -- (NSInteger)toggleColorFormatMenuItemTag { - return 11; -} - -#pragma mark - Path Identity - -+ (NSString *)pathIdentifierForItem:(LookinDisplayItem *)item - inRootItems:(NSArray *)rootItems { - if (!item || rootItems.count == 0) { - return nil; - } - - // Walk up to root, recording (class, siblingIndex) segments leaf-first. - NSMutableArray *segments = [NSMutableArray array]; - LookinDisplayItem *cursor = item; - while (cursor.superItem) { - LookinDisplayItem *parent = cursor.superItem; - NSUInteger siblingIndex = [parent.subitems indexOfObject:cursor]; - if (siblingIndex == NSNotFound) { - return nil; - } - NSString *className = LKDisplayItemPrimaryClassName(cursor); - if (className.length == 0) { - return nil; - } - [segments addObject:[NSString stringWithFormat:@"%@:%lu", className, (unsigned long)siblingIndex]]; - cursor = parent; - } - - NSUInteger rootIndex = [rootItems indexOfObject:cursor]; - if (rootIndex == NSNotFound) { - return nil; - } - - NSMutableString *path = [NSMutableString stringWithFormat:@"%lu", (unsigned long)rootIndex]; - // segments was filled leaf-first; emit root → leaf order. - [segments enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(NSString * _Nonnull segment, NSUInteger idx, BOOL * _Nonnull stop) { - [path appendFormat:@"/%@", segment]; - }]; - return path.copy; -} - -- (NSDictionary *)_collectExpansionStateMap { - NSArray *rootItems = self.rawHierarchyInfo.displayItems; - if (rootItems.count == 0) { - return @{}; - } - - NSMutableDictionary *expansionState = [NSMutableDictionary dictionary]; - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (!obj.isExpandable) { - return; - } - NSString *path = [LKHierarchyDataSource pathIdentifierForItem:obj inRootItems:rootItems]; - if (path) { - expansionState[path] = @(obj.isExpanded); - } - }]; - return expansionState.copy; -} - -- (void)persistExpansionStateToPreferences { - LKPreferenceManager *prefs = self.preferenceManager; - if (!prefs.rememberExpansionState) { - return; - } - NSString *bundleId = self.rawHierarchyInfo.appInfo.appBundleIdentifier; - if (bundleId.length == 0) { - return; - } - NSDictionary *expansionState = [self _collectExpansionStateMap]; - [prefs setExpansionState:expansionState forBundleIdentifier:bundleId]; -} - -#pragma mark - Others - -/// 子类实现该方法 -- (LKPreferenceManager *)preferenceManager { - NSAssert(NO, @"should implement by subclass"); - return nil; -} - -- (void)dealloc { - NSLog(@"%@ dealloc", self.class); -} - -- (BOOL)isReadOnly { - return YES; -} - -@end diff --git a/LookInside/Hierarchy/LKHierarchyDataSource.swift b/LookInside/Hierarchy/LKHierarchyDataSource.swift new file mode 100644 index 00000000..ca4d7528 --- /dev/null +++ b/LookInside/Hierarchy/LKHierarchyDataSource.swift @@ -0,0 +1,1176 @@ +// +// LKHierarchyDataSource.swift +// LookInside +// +// Created by Li Kai on 2019/5/6. +// https://lookin.work +// + +import AppKit +import LookInsideHostCore + +/// What the data source is showing. KVO-observable through `state`. +@objc enum LKHierarchyDataSourceState: UInt { + case normal + case search + case focus +} + +/// What a hierarchy data source reports besides its KVO-observable +/// properties (`state`, `selectedItem`, `hoveredItem`, `displayingFlatItems`, +/// `rawHierarchyInfo`). +enum LKHierarchyDataSourceEvent { + case willReloadHierarchyInfo + case didReloadHierarchyInfo + case didReloadFlatItemsWithSearchOrFocus + case itemDidChangeHiddenAlphaValue(LookinDisplayItem) + case itemDidChangeAttrGroup(LookinDisplayItem) + /// Sent with no item: the preview rebuilds from the whole tree. + case itemDidChangeNoPreview + /// Static (live) data sources only. + case itemDidChangeFrame(LookinDisplayItem) +} + +/// Classes collapsed by the expansion presets, together with the +/// hierarchy's own `collapsedClassList`. +private let classesPreferredToCollapse: Set = [ + "UILabel", "UIPickerView", "UIProgressView", "UIActivityIndicatorView", "UIAlertView", "UIActionSheet", + "UISearchBar", "UIButton", "UITextView", "UIDatePicker", "UIPageControl", "UISegmentedControl", "UITextField", + "UISlider", "UISwitch", "UIVisualEffectView", "UIImageView", "WKCommonWebView", "UITextEffectsWindow", +] + +/// Windows that never get a preview. +private let classesWithNoPreview: Set = ["UITextEffectsWindow", "UIRemoteKeyboardWindow"] + +/// Associated-object key of the expansion recorded before a search or focus, +/// restored when it ends. +private let expandedBeforeSearchOrFocusKey = "isExpandedBeforeSearching" + +private func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = 1) -> NSColor { + NSColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) +} + +/// "Layer 2 Display List ID" -> 1; nil without a positive ordinal. +private func swiftUILayerOrdinal(fromAttributeTitle title: String) -> Int? { + guard title.hasPrefix("Layer ") else { + return nil + } + let scanner = Scanner(string: String(title.dropFirst("Layer ".count))) + guard let value = scanner.scanInt(), value > 0 else { + return nil + } + return value - 1 +} + +private func rectsAlmostEqual(_ lhs: CGRect, _ rhs: CGRect) -> Bool { + let tolerance: CGFloat = 1 + return abs(lhs.minX - rhs.minX) <= tolerance + && abs(lhs.minY - rhs.minY) <= tolerance + && abs(lhs.width - rhs.width) <= tolerance + && abs(lhs.height - rhs.height) <= tolerance +} + +private func swiftUINode(_ item: LookinDisplayItem, matchesSourceTypes sourceTypes: [String]) -> Bool { + guard !sourceTypes.isEmpty else { + return true + } + let itemTypes = item.lk_swiftUITypeNames() + return sourceTypes.contains { itemTypes.contains($0) } +} + +private func ancestors(of item: LookinDisplayItem) -> [LookinDisplayItem] { + var result: [LookinDisplayItem] = [] + item.enumerateAncestors { ancestor, _ in + result.append(ancestor) + } + return result +} + +private func selfAndDescendants(of item: LookinDisplayItem) -> [LookinDisplayItem] { + var result: [LookinDisplayItem] = [] + item.enumerateSelfAndChildren { node in + result.append(node) + } + return result +} + +/// The hierarchy tree of a document: the flat rows, selection, hover, +/// expansion, search and focus. Subclasses supply the preference manager. +@objc(LKHierarchyDataSource) +class LKHierarchyDataSource: NSObject { + /// KVO-observable. + @objc dynamic var state: LKHierarchyDataSourceState { + storedState + } + + // Synchronous events; see LKSyncSignal. Everything sent on them also + // reaches `events()`. + final let willReloadHierarchyInfo = LKSyncSignal() + final let didReloadHierarchyInfo = LKSyncSignal() + /// An item's `isHidden` or `alpha` changed. + final let itemDidChangeHiddenAlphaValue = LKSyncSignal() + /// An item's attribute groups changed. + final let itemDidChangeAttrGroup = LKSyncSignal() + /// The preview rebuilds from the whole tree. + final let itemDidChangeNoPreview = LKSyncSignal() + /// A search or focus changed `flatItems`. + final let didReloadFlatItemsWithSearchOrFocus = LKSyncSignal() + + /// Every display item of the tree, visible or not. + @objc var rawFlatItems: [LookinDisplayItem]! { + get { storedRawFlatItems } + set { + storedRawFlatItems = newValue + rebuildOidMap() + } + } + + /// All items in normal state; the shown subset while searching or + /// focusing. + @objc dynamic var flatItems: [LookinDisplayItem]! + + /// The rows: the items of `flatItems` that no collapsed ancestor hides. + /// KVO-observable. + @objc dynamic var displayingFlatItems: [LookinDisplayItem]! { + storedDisplayingFlatItems + } + + @objc dynamic weak var selectedItem: LookinDisplayItem? { + didSet { + selectedItemDidChange(from: oldValue) + } + } + + @objc dynamic weak var hoveredItem: LookinDisplayItem? { + didSet { + hoveredItemDidChange(from: oldValue) + } + } + + @objc var selectColorMenu: NSMenu! { + storedSelectColorMenu + } + + /// The tag of the color menu's "Other…" item. + @objc var customColorMenuItemTag: Int { + 10 + } + + /// The tag of the color menu's "switch color format" item. + @objc var toggleColorFormatMenuItemTag: Int { + 11 + } + + /// KVO-observable. + @objc dynamic var rawHierarchyInfo: LookinHierarchyInfo! { + storedRawHierarchyInfo + } + + /// While the dashboard is being searched, the preview must not change the + /// selection when a layer is clicked. + @objc var shouldAvoidChangingPreviewSelectionDueToDashboardSearch: Bool = false + + @objc var serverSideIsSwiftProject: Bool { + storedServerSideIsSwiftProject + } + + // Storage of the read-only properties. Writes go + // through the setters below, which post the KVO notifications. + private var storedState: LKHierarchyDataSourceState = .normal + private var storedDisplayingFlatItems: [LookinDisplayItem]? + private var storedRawHierarchyInfo: LookinHierarchyInfo? + private var storedSelectColorMenu: NSMenu? + private var storedServerSideIsSwiftProject = false + private var storedRawFlatItems: [LookinDisplayItem]? + private var oidToDisplayItem: [UInt: LookinDisplayItem] = [:] + /// Alias names by `rgbaString`. + private var colorToAliasMap: [String: [String]] = [:] + private var lastRGBAFormat: Bool? + private var rgbaFormatObservation: NSKeyValueObservation? + private var signalSubscriptions: [LKSyncSubscription] = [] + final let eventBroadcaster = AsyncBroadcaster() + + override init() { + super.init() + forwardSignalsToEvents() + + rgbaFormatObservation = LKPreferenceManager.shared.observe(\.rgbaFormat, options: [.new]) { [weak self] preferences, _ in + guard let self else { return } + // Every change after the first value, skipping repeats. + let format = preferences.rgbaFormat + if let lastRGBAFormat, lastRGBAFormat == format { + return + } + lastRGBAFormat = format + setUpColors() + } + + // Toggling the system-layout-guide visibility re-filters the visible + // rows; the tree data itself keeps every node. + LKPreferenceManager.shared.showSystemLayoutGuides.subscribe( + self, + action: #selector(handleShowSystemLayoutGuidesDidChange(_:)), + relatedObject: nil + ) + } + + deinit { + eventBroadcaster.finish() + NSLog("%@ dealloc", NSStringFromClass(type(of: self))) + } + + // MARK: - Reload + + @objc(reloadWithHierarchyInfo:keepState:) + func reload(with info: LookinHierarchyInfo!, keepState: Bool) { + // Path identifiers of the old items resolve their root index against + // the old root items, so take them before replacing the info. + let previousRootItems = rawHierarchyInfo?.displayItems ?? [] + + setKVO("rawHierarchyInfo") { storedRawHierarchyInfo = info } + + willReloadHierarchyInfo.send() + + if !(info.colorAlias ?? [:]).isEmpty { + LKPreferenceManager.shared.receivingConfigTime_Color = Date().timeIntervalSince1970 + } + if !(info.collapsedClassList ?? []).isEmpty { + LKPreferenceManager.shared.receivingConfigTime_Class = Date().timeIntervalSince1970 + } + + var previousSelectedOid: UInt = 0 + var previousExpansion: [String: NSNumber] = [:] + let prefersViewOID = LKHelper.appInfoLooksLikeMacTarget(info.appInfo) + if keepState { + previousSelectedOid = selectedItem?.bestObjectOidPreferView(prefersViewOID) ?? 0 + for item in flatItems ?? [] { + if let path = Self.pathIdentifier(for: item, inRootItems: previousRootItems) { + previousExpansion[path] = NSNumber(value: item.isExpanded) + } + } + } + + setUpColors() + + // Flatten the tree; this also sets every item's indentLevel. + rawFlatItems = LookinDisplayItem.flatItems(fromHierarchicalItems: info.displayItems ?? []) + let items = rawFlatItems ?? [] + + let collapsedClasses = classesPreferredToCollapse.union(info.collapsedClassList ?? []) + for item in items { + if item.itemIsKindOfClasses(withNames: collapsedClasses) { + item.enumerateSelfAndChildren { $0.preferToBeCollapsed = true } + } + // Windows sit at indent 0 (macOS, iOS before 13) or 1 (inside an + // iOS 13+ scene). + if item.indentLevel() <= 1, item.itemIsKindOfClasses(withNames: classesWithNoPreview) { + item.noPreview = true + } + if !item.isPixelBearing() { + // Guides, cells and a view's outer layer have no pixels of + // their own: a screenshot would draw the owning view's + // content a second time. No screenshot task; they stay in + // the preview (guides and cells on their owner's plane, the + // outer layer as a parallel plane of its own). + item.doNotFetchScreenshotReason = .doNotFetchScreenshotForNoPixels + } + if !item.isUserCustom(), !item.shouldCaptureImage { + item.enumerateSelfAndChildren { node in + node.noPreview = true + node.doNotFetchScreenshotReason = .doNotFetchScreenshotForUserConfig + } + } + if !serverSideIsSwiftProject, item.displayingObject()?.lk_completedDemangledClassName().contains(".") == true { + storedServerSideIsSwiftProject = true + } + if let source = item.customInfo?.danceuiSource, !source.isEmpty { + LKDanceUIAttrMaker.makeDanceUIJumpAttribute(item, danceSource: source) + } + } + + flatItems = items + + var itemToSelect: LookinDisplayItem? + if keepState { + itemToSelect = displayItem(withOid: previousSelectedOid) + } + + var expansionIndex = preferenceManager().expansionIndex + if (flatItems?.count ?? 0) > 300, expansionIndex > 2 { + expansionIndex = 2 + } + + var referenceDict: [String: NSNumber]? + if keepState { + referenceDict = previousExpansion + } else { + // Cold reload: restore the state persisted for this bundle id + // (expanded and user-collapsed items). Paths it lacks keep the + // preset's behavior. + let preferences = preferenceManager() + if let preferences, preferences.rememberExpansionState, + let bundleIdentifier = info.appInfo?.appBundleIdentifier, !bundleIdentifier.isEmpty + { + let stored = preferences.expansionState(forBundleIdentifier: bundleIdentifier) + if !stored.isEmpty { + referenceDict = stored + } + } + } + if itemToSelect == nil { + var presetSelection: LookinDisplayItem? + adjustExpansion(by: expansionIndex, referenceDict: referenceDict, selectedItem: &presetSelection) + itemToSelect = presetSelection + } else { + adjustExpansion(by: expansionIndex, referenceDict: referenceDict, selectedItem: nil) + } + + selectedItem = itemToSelect ?? flatItems?.first + + if state != .normal { + // Searching or focusing ends with a reload. + setState(.normal) + } + + didReloadHierarchyInfo.send() + } + + // MARK: - Rows + + @objc func numberOfRows() -> Int { + displayingFlatItems?.count ?? 0 + } + + @objc(itemAtRow:) + func item(atRow index: Int) -> LookinDisplayItem! { + guard let rows = displayingFlatItems, rows.indices.contains(index) else { + return nil + } + return rows[index] + } + + @objc(rowForItem:) + func row(for item: LookinDisplayItem!) -> Int { + displayingFlatItems?.firstIndex { $0 === item } ?? NSNotFound + } + + @objc(displayItemWithOid:) + func displayItem(withOid oid: UInt) -> LookinDisplayItem! { + oidToDisplayItem[oid] + } + + @objc func buildDisplayingFlatItems() { + let showSystemLayoutGuides = LKPreferenceManager.shared.showSystemLayoutGuides.currentBOOLValue + let rows = (flatItems ?? []).filter { item in + guard item.displayingInHierarchy else { + return false + } + // System-created layout guides can be hidden; user guides always + // show, and the data keeps every node either way. + if !showSystemLayoutGuides, item.representsSystemManagedNode, item.resolvedNodeKind() == .layoutGuide { + return false + } + return true + } + setKVO("displayingFlatItems") { storedDisplayingFlatItems = rows } + } + + /// Posts the KVO notifications of a read-only property around `write`. + private func setKVO(_ key: String, _ write: () -> Void) { + willChangeValue(forKey: key) + write() + didChangeValue(forKey: key) + } + + private func setState(_ state: LKHierarchyDataSourceState) { + setKVO("state") { storedState = state } + } + + // MARK: - Selection + + private func selectedItemDidChange(from previousItem: LookinDisplayItem?) { + let item = selectedItem + guard item !== previousItem else { + return + } + + previousItem?.notifySelectionChangeToDelegates() + item?.notifySelectionChangeToDelegates() + + LKUserActionManager.sharedInstance().send(.selectedItemChange) + + if NSColorPanel.sharedColorPanelExists { + NSColorPanel.shared.close() + } + + // Clearing the selection ends measuring. + if item == nil, let measureState = preferenceManager()?.measureState, + measureState.currentIntegerValue != LookinMeasureState.no.rawValue + { + measureState.setIntegerValue(LookinMeasureState.no.rawValue, ignoreSubscriber: nil) + } + } + + private func hoveredItemDidChange(from previousItem: LookinDisplayItem?) { + let item = hoveredItem + guard item !== previousItem else { + return + } + previousItem?.notifyHoverChangeToDelegates() + item?.notifyHoverChangeToDelegates() + } + + @objc(selectAndRevealItem:) + func selectAndRevealItem(_ item: LookinDisplayItem!) { + guard let item else { + return + } + if !containsInFlatItems(item) { + switch state { + case .search: + endSearch() + case .focus: + endFocus() + default: + break + } + } + guard containsInFlatItems(item) else { + return + } + if !item.displayingInHierarchy { + expand(toShow: item) + } + selectedItem = item + } + + private func containsInFlatItems(_ item: LookinDisplayItem) -> Bool { + (flatItems ?? []).contains { $0 === item } + } + + // MARK: - Expansion + + /// - Parameters: + /// - index: the expansion preset, 0 (collapse to the windows) to 4 + /// (expand everything). + /// - referenceDict: expansion by path identifier; an item with an + /// entry keeps it instead of the preset. + /// - selectedItem: receives the item the preset suggests selecting. + @objc(adjustExpansionByIndex:referenceDict:selectedItem:) + func adjustExpansion( + by index: Int, + referenceDict: [String: NSNumber]!, + selectedItem: AutoreleasingUnsafeMutablePointer! + ) { + var index = index + if index < 0 || index > 4 { + assertionFailure("adjustExpansionByIndex, index is \(index)") + index = max(min(index, 4), 0) + } + + preferenceManager().expansionIndex = index + + let rootItems = rawHierarchyInfo?.displayItems ?? [] + let items = flatItems ?? [] + + for item in items { + item.hasDeterminedExpansion = false + guard item.isExpandable else { + item.hasDeterminedExpansion = true + continue + } + if let referenceDict, + let path = Self.pathIdentifier(for: item, inRootItems: rootItems), + let previous = referenceDict[path] + { + // Known item: keep its state. + item.isExpanded = previous.boolValue + item.hasDeterminedExpansion = true + } + } + + func collapseUndetermined() { + for item in items where !item.hasDeterminedExpansion { + item.isExpanded = false + } + } + + switch index { + case 0: + // Collapse everything down to the top-level windows. + var preferredSelection: LookinDisplayItem? + for item in items where !item.hasDeterminedExpansion { + item.isExpanded = false + if item.representedAsKeyWindow { + preferredSelection = item + } + } + selectedItem?.pointee = preferredSelection + + case 4: + // Expand everything, buttons, tab bars and navigation bars + // included. + for item in items where !item.hasDeterminedExpansion { + item.isExpanded = !item.inNoPreviewHierarchy + } + if let selectedItem { + var preferredSelection: LookinDisplayItem? + if let keyWindowRoot = rootItems.first(where: { $0.representedAsKeyWindow }) { + let windowItems = LookinDisplayItem.flatItems(fromHierarchicalItems: [keyWindowRoot]) ?? [] + preferredSelection = windowItems.last { + $0.hostViewControllerObject != nil || $0.hostWindowControllerObject != nil + } + } + selectedItem.pointee = preferredSelection + } + + default: + guard let keyWindowItem = rootItems.first(where: { $0.representedAsKeyWindow }) ?? rootItems.first else { + break + } + applyViewControllerPreset( + index: index, + keyWindowItem: keyWindowItem, + rootItems: rootItems, + selectedItem: selectedItem, + collapseUndetermined: collapseUndetermined + ) + } + + buildDisplayingFlatItems() + } + + /// Presets 1 to 3, built around the key window's view controllers. + private func applyViewControllerPreset( + index: Int, + keyWindowItem: LookinDisplayItem, + rootItems: [LookinDisplayItem], + selectedItem: AutoreleasingUnsafeMutablePointer?, + collapseUndetermined: () -> Void + ) { + // A scene container (window object only, no view or layer) holds the + // real key window among its children; the UITransitionView search + // runs on that one. + var actualKeyWindow: LookinDisplayItem? = keyWindowItem + let keyWindowKind = keyWindowItem.resolvedNodeKind() + if keyWindowKind == .window || keyWindowKind == .windowScene { + let children = keyWindowItem.subitems ?? [] + actualKeyWindow = children.first { $0.representedAsKeyWindow } ?? children.first + } + + // Collapse every other window, overriding the reference state, so a + // scene that is not key never stays open. + for windowItem in rootItems where windowItem !== keyWindowItem { + for item in LookinDisplayItem.flatItems(fromHierarchicalItems: [windowItem]) ?? [] { + item.isExpanded = false + item.hasDeterminedExpansion = true + } + } + + // Open the last UITransitionView, collapse the ones before it. + let transitionViews = (actualKeyWindow?.subitems ?? []).filter { $0.title() == "UITransitionView" } + for (offset, item) in transitionViews.enumerated() where !item.hasDeterminedExpansion { + item.isExpanded = offset == transitionViews.count - 1 + item.hasDeterminedExpansion = true + } + + var viewControllerItems: [LookinDisplayItem] = [] + // The Objective-C version tested the show-hidden-items preference + // attribute object, not its value; the object is never nil, so + // hidden subtrees are not folded here. + let showHiddenItems = true + for item in LookinDisplayItem.flatItems(fromHierarchicalItems: [keyWindowItem]) ?? [] { + if item.hostViewControllerObject != nil || item.hostWindowControllerObject != nil { + viewControllerItems.append(item) + continue + } + if item.hasDeterminedExpansion { + continue + } + if item.inNoPreviewHierarchy || item.preferToBeCollapsed || (!showHiddenItems && item.inHiddenHierarchy) { + // Fold no-preview subtrees and the usual controls (UIButton + // and the like). + item.isExpanded = false + item.hasDeterminedExpansion = true + continue + } + if item.itemIsKindOfClasses(withNames: ["UINavigationBar", "UITabBar"]) { + item.enumerateSelfAndChildren { node in + guard !node.hasDeterminedExpansion else { + return + } + node.isExpanded = false + node.hasDeterminedExpansion = true + } + } + } + + selectedItem?.pointee = viewControllerItems.last + + switch index { + case 1: + // Show exactly the view controllers. Leaf first, so of several + // controllers on one branch only the deepest is collapsed. + for controllerItem in viewControllerItems.reversed() { + controllerItem.enumerateSelfAndAncestors { item, _ in + guard !item.hasDeterminedExpansion else { + return + } + item.isExpanded = item !== controllerItem + item.hasDeterminedExpansion = true + } + } + collapseUndetermined() + + case 2: + // Open three levels below each view controller. + for controllerItem in viewControllerItems.reversed() { + controllerItem.enumerateAncestors { item, _ in + guard !item.hasDeterminedExpansion else { + return + } + item.isExpanded = true + item.hasDeterminedExpansion = true + } + // A typical table or collection view opens two levels, so + // the cells show but not their content views. + let firstChild = controllerItem.subitems?.first + let hasTableOrCollectionView = firstChild?.itemIsKindOfClasses(withNames: ["UITableView", "UICollectionView"]) ?? false + let levels = hasTableOrCollectionView ? 2 : 3 + let controllerLevel = controllerItem.indentLevel() + controllerItem.enumerateSelfAndChildren { item in + guard !item.hasDeterminedExpansion else { + return + } + if item.indentLevel() < controllerLevel + levels { + item.isExpanded = true + item.hasDeterminedExpansion = true + } + } + } + collapseUndetermined() + + case 3: + // Open most of it. + for item in flatItems ?? [] where !item.hasDeterminedExpansion { + item.isExpanded = true + item.hasDeterminedExpansion = true + } + + default: + break + } + } + + @objc(collapseItem:) + func collapse(_ item: LookinDisplayItem!) { + guard let item, item.isExpandable, item.isExpanded else { + return + } + item.isExpanded = false + buildDisplayingFlatItems() + persistExpansionStateToPreferences() + } + + @objc(expandItem:) + func expand(_ item: LookinDisplayItem!) { + guard let item, item.isExpandable, !item.isExpanded else { + return + } + item.isExpanded = true + buildDisplayingFlatItems() + persistExpansionStateToPreferences() + } + + @objc(expandToShowItem:) + func expand(toShow item: LookinDisplayItem!) { + var didChange = false + item?.enumerateAncestors { ancestor, _ in + guard !ancestor.isExpanded else { + return + } + ancestor.isExpanded = true + didChange = true + } + buildDisplayingFlatItems() + if didChange { + persistExpansionStateToPreferences() + } + } + + @objc(expandItemsRootedByItem:) + func expandItemsRooted(by item: LookinDisplayItem!) { + let includePreferredCollapsed = item?.preferToBeCollapsed ?? false + item?.enumerateSelfAndChildren { node in + guard node.isExpandable, !node.isExpanded else { + return + } + if includePreferredCollapsed || !node.preferToBeCollapsed { + node.isExpanded = true + } + } + buildDisplayingFlatItems() + persistExpansionStateToPreferences() + } + + @objc(collapseAllChildrenOfItem:) + func collapseAllChildren(of item: LookinDisplayItem!) { + item?.enumerateSelfAndChildren { node in + guard node !== item, node.isExpandable, node.isExpanded else { + return + } + node.isExpanded = false + } + buildDisplayingFlatItems() + persistExpansionStateToPreferences() + } + + // MARK: - Path identity + + /// "/:/..." from the root to `item`; nil + /// when a node of the chain has no class (UserCustom-only nodes), when + /// a sibling index cannot be resolved, or when `rootItems` is empty. + @objc(pathIdentifierForItem:inRootItems:) + class func pathIdentifier(for item: LookinDisplayItem!, inRootItems rootItems: [LookinDisplayItem]!) -> String! { + guard let item else { + return nil + } + return HierarchyPathIdentifier.make( + for: item, + rootItems: rootItems ?? [], + parent: { $0.super }, + children: { $0.subitems ?? [] }, + // The leaf class in the view, layer, window order of `title`. + className: { $0.displayingObject()?.classChainList?.first } + ) + } + + /// Persists every expandable item's state, expanded and collapsed, for + /// the target app's bundle identifier, so a cold reload restores the + /// user's collapses too. Does nothing when remembering is off or there is + /// no bundle identifier. + @objc func persistExpansionStateToPreferences() { + guard let preferences = preferenceManager(), preferences.rememberExpansionState else { + return + } + guard let bundleIdentifier = rawHierarchyInfo?.appInfo?.appBundleIdentifier, !bundleIdentifier.isEmpty else { + return + } + preferences.setExpansionState(collectExpansionState(), forBundleIdentifier: bundleIdentifier) + } + + private func collectExpansionState() -> [String: NSNumber] { + let rootItems = rawHierarchyInfo?.displayItems ?? [] + guard !rootItems.isEmpty else { + return [:] + } + var state: [String: NSNumber] = [:] + for item in flatItems ?? [] where item.isExpandable { + if let path = Self.pathIdentifier(for: item, inRootItems: rootItems) { + state[path] = NSNumber(value: item.isExpanded) + } + } + return state + } + + // MARK: - Search and focus + + /// Call while the user types a non-empty search string; replaces + /// `flatItems` and `displayingFlatItems`. + @objc(searchWithString:) + func search(with string: String!) { + guard let string, !string.isEmpty else { + assertionFailure("searching for an empty string") + return + } + let items = rawFlatItems ?? [] + + if state != .search { + recordExpansionBeforeSearchOrFocus(items) + setState(.search) + } + + selectedItem = nil + + for item in items { + item.highlightedSearchString = nil + } + let shown = HierarchySearchVisibility.apply( + flatItems: items, + matches: { $0.isMatched(withSearch: string) }, + ancestors: ancestors(of:), + selfAndDescendants: selfAndDescendants(of:), + setExpanded: { $0.isExpanded = $1 }, + didMatch: { $0.highlightedSearchString = string } + ) + for item in shown { + item.isInSearch = true + } + flatItems = shown + didReloadFlatItemsWithSearchOrFocus.send() + + buildDisplayingFlatItems() + } + + /// Call when the search field is closed; restores the expansion from + /// before the search and keeps the selection visible. + @objc func endSearch() { + guard state != .normal else { + return + } + setState(.normal) + + for item in rawFlatItems ?? [] { + item.isInSearch = false + item.highlightedSearchString = nil + item.isExpanded = item.lookin_getBindBOOL(forKey: expandedBeforeSearchOrFocusKey) + } + // The item selected while searching stays selected and visible. + selectedItem?.enumerateAncestors { item, _ in + item.isExpanded = true + } + + flatItems = rawFlatItems + didReloadFlatItemsWithSearchOrFocus.send() + + buildDisplayingFlatItems() + } + + /// Entered from the normal or the search state. + @objc(focusDisplayItem:) + func focus(_ item: LookinDisplayItem!) { + guard let item else { + assertionFailure("focusing nil") + return + } + switch state { + case .normal: + recordExpansionBeforeSearchOrFocus(rawFlatItems ?? []) + case .search: + for item in rawFlatItems ?? [] { + item.isInSearch = false + item.highlightedSearchString = nil + } + default: + break + } + setState(.focus) + + flatItems = selfAndDescendants(of: item) + didReloadFlatItemsWithSearchOrFocus.send() + buildDisplayingFlatItems() + } + + @objc func endFocus() { + guard state != .normal else { + return + } + setState(.normal) + + for item in rawFlatItems ?? [] { + item.isExpanded = item.lookin_getBindBOOL(forKey: expandedBeforeSearchOrFocusKey) + } + + flatItems = rawFlatItems + didReloadFlatItemsWithSearchOrFocus.send() + buildDisplayingFlatItems() + } + + private func recordExpansionBeforeSearchOrFocus(_ items: [LookinDisplayItem]) { + for item in items { + item.lookin_bindBOOL(item.isExpanded, forKey: expandedBeforeSearchOrFocusKey) + } + } + + // MARK: - SwiftUI jumps + + /// SwiftUI node -> its matched CALayer nodes; empty when there is none. + @objc(swiftUIBackingLayerItemsForItem:) + func swiftUIBackingLayerItems(for item: LookinDisplayItem!) -> [LookinDisplayItem]! { + guard let item else { + return [] + } + var result: [LookinDisplayItem] = [] + func add(_ candidate: LookinDisplayItem?) { + if let candidate, !result.contains(where: { $0 === candidate }) { + result.append(candidate) + } + } + for address in item.lk_swiftUIBackingLayerMemoryAddresses() { + add(layerItem(withMemoryAddress: address)) + } + for displayListID in item.lk_swiftUIBackingDisplayListIDs() { + add(layerItem(withDisplayListID: displayListID)) + } + return result + } + + /// CALayer node -> its SwiftUI node, or nil. + @objc(swiftUISourceItemForLayerItem:) + func swiftUISourceItem(forLayerItem item: LookinDisplayItem!) -> LookinDisplayItem! { + guard let item, let displayListID = item.lk_swiftUILayerDisplayListID() else { + return nil + } + return swiftUIItem(withDisplayListID: displayListID) ?? swiftUIItemForLayerItemByFrameAndSource(item) + } + + /// Dashboard row -> its SwiftUI / CALayer jump target, or nil when the + /// row has none. + @objc(swiftUIJumpTargetForAttribute:) + func swiftUIJumpTarget(for attribute: LookinAttribute!) -> LookinDisplayItem! { + guard let sourceItem = attribute?.targetDisplayItem, let value = attribute.value as? String else { + return nil + } + let title = attribute.displayTitle ?? "" + let sourceIsSwiftUI = sourceItem.customInfo?.isSwiftUI == true + || !sourceItem.lk_swiftUIBackingDisplayListIDs().isEmpty + || !sourceItem.lk_swiftUIBackingLayerMemoryAddresses().isEmpty + + if sourceIsSwiftUI { + if title.hasSuffix("Backed By") { + return layerItem(withMemoryAddress: LookinDisplayItem.lk_memoryAddress(inObjectDescription: value)) + } + if title.hasSuffix("Display List ID") || title == "Identity IDs" { + for displayListID in LookinDisplayItem.lk_validSwiftUIDisplayListIDs(in: value) { + if let target = layerItem(withDisplayListID: displayListID) { + return target + } + } + let layerItems = swiftUIBackingLayerItems(for: sourceItem) ?? [] + if let ordinal = swiftUILayerOrdinal(fromAttributeTitle: title), layerItems.indices.contains(ordinal) { + return layerItems[ordinal] + } + } + return nil + } + + if sourceItem.layerObject != nil, title == "Display List ID" { + let displayListID = LookinDisplayItem.lk_validSwiftUIDisplayListIDs(in: value).first + ?? sourceItem.lk_swiftUILayerDisplayListID() + return swiftUIItem(withDisplayListID: displayListID) ?? swiftUIItemForLayerItemByFrameAndSource(sourceItem) + } + return nil + } + + private func layerItem(withMemoryAddress memoryAddress: String?) -> LookinDisplayItem? { + guard let memoryAddress, !memoryAddress.isEmpty else { + return nil + } + let normalized = memoryAddress.lowercased() + return (rawFlatItems ?? []).first { $0.layerObject?.memoryAddress?.lowercased() == normalized } + } + + private func layerItem(withDisplayListID displayListID: NSNumber?) -> LookinDisplayItem? { + guard let displayListID else { + return nil + } + return (rawFlatItems ?? []).first { item in + item.layerObject != nil && item.lk_swiftUILayerDisplayListID()?.isEqual(to: displayListID) == true + } + } + + /// The deepest SwiftUI node backed by `displayListID`. + private func swiftUIItem(withDisplayListID displayListID: NSNumber?) -> LookinDisplayItem? { + guard let displayListID else { + return nil + } + var result: LookinDisplayItem? + for item in rawFlatItems ?? [] where item.lk_swiftUIBackingDisplayListIDs().contains(displayListID) { + if result == nil || item.indentLevel() > result!.indentLevel() { + result = item + } + } + return result + } + + /// The deepest SwiftUI node with the layer's frame and source type. + private func swiftUIItemForLayerItemByFrameAndSource(_ layerItem: LookinDisplayItem) -> LookinDisplayItem? { + guard layerItem.layerObject != nil else { + return nil + } + let layerFrame = layerItem.calculateFrameToRoot() + let sourceTypes = layerItem.lk_swiftUILayerSourceTypeNames() + var result: LookinDisplayItem? + for item in rawFlatItems ?? [] { + guard item.customInfo?.isSwiftUI == true, item.hasValidFrameToRoot() else { + continue + } + guard swiftUINode(item, matchesSourceTypes: sourceTypes), + rectsAlmostEqual(layerFrame, item.calculateFrameToRoot()) + else { + continue + } + if result == nil || item.indentLevel() > result!.indentLevel() { + result = item + } + } + return result + } + + // MARK: - Colors + + /// The hierarchy's alias names of `color`, or nil. + @objc(aliasForColor:) + func alias(for color: NSColor!) -> [String]! { + guard let color else { + return nil + } + return colorToAliasMap[color.rgbaString()] + } + + private func setUpColors() { + let catalog = ColorAliasCatalog(aliases: colorAliasEntries(), colorKey: { $0.rgbaString() }) + colorToAliasMap = catalog.aliasesByColorKey + storedSelectColorMenu = makeColorMenu(aliasEntries: catalog.entries, usingRGBAFormat: LKPreferenceManager.shared.rgbaFormat) + } + + /// `colorAlias` maps an alias to a color, or a group title to a + /// dictionary of aliases and colors. Read as the Objective-C dictionary + /// so its entries keep their enumeration order. + private func colorAliasEntries() -> [(key: String, value: ColorAliasCatalog.Value)] { + guard let colorAlias = rawHierarchyInfo?.value(forKey: "colorAlias") as? NSDictionary else { + return [] + } + var entries: [(key: String, value: ColorAliasCatalog.Value)] = [] + colorAlias.enumerateKeysAndObjects { key, value, _ in + guard let key = key as? String else { + return + } + if let color = value as? NSColor { + entries.append((key, .color(color))) + } else if let group = value as? NSDictionary { + var members: [(alias: String, color: NSColor)] = [] + group.enumerateKeysAndObjects { alias, color, _ in + if let alias = alias as? String, let color = color as? NSColor { + members.append((alias, color)) + } + } + entries.append((key, .group(members))) + } else { + assertionFailure("unsupported color alias value") + entries.append((key, .unsupported)) + } + } + return entries + } + + private func makeColorMenu(aliasEntries: [ColorAliasCatalog.Entry], usingRGBAFormat rgbaFormat: Bool) -> NSMenu { + let defaultColors = [ + color(0, 0, 0), + color(126, 126, 126), + color(255, 255, 255), + color(0, 166, 248, 0.5), + color(253, 62, 0), + color(105, 190, 0), + color(254, 182, 2), + ] + var defaultItems: [(title: String, color: NSColor?)] = [ + ("nil", nil), + ("clear color", color(0, 0, 0, 0)), + ] + defaultItems += defaultColors.map { (rgbaFormat ? $0.rgbaString() : $0.hexString(), $0) } + + let menu = NSMenu() + for item in defaultItems { + menu.addItem(colorMenuItem(title: item.title, color: item.color)) + } + if !aliasEntries.isEmpty { + menu.addItem(.separator()) + } + for entry in aliasEntries { + switch entry { + case let .color(title, color): + menu.addItem(colorMenuItem(title: title, color: color)) + case let .section(title, items): + let sectionItem = NSMenuItem() + sectionItem.image = NSImage(size: NSSize(width: 1, height: 22)) + menu.addItem(sectionItem) + sectionItem.title = title + let submenu = NSMenu() + for item in items { + submenu.addItem(colorMenuItem(title: item.title, color: item.color)) + } + sectionItem.submenu = submenu + } + } + + menu.addItem(.separator()) + let otherItem = NSMenuItem() + otherItem.image = NSImage(size: NSSize(width: 1, height: 22)) + otherItem.title = NSLocalizedString("Other…", comment: "") + otherItem.tag = customColorMenuItemTag + menu.addItem(otherItem) + + menu.addItem(.separator()) + let formatItem = NSMenuItem() + formatItem.image = NSImage(size: NSSize(width: 1, height: 22)) + formatItem.title = rgbaFormat + ? NSLocalizedString("Switch color format to HEX", comment: "") + : NSLocalizedString("Switch color format to RGBA", comment: "") + formatItem.tag = toggleColorFormatMenuItemTag + menu.addItem(formatItem) + + return menu + } + + private func colorMenuItem(title: String, color: NSColor?) -> NSMenuItem { + let item = NSMenuItem() + item.image = LKColorIndicatorLayer.image(with: color, shapeSize: NSSize(width: 20, height: 20), insets: NSEdgeInsets(top: 4, left: 5, bottom: 4, right: 6)) + item.title = title + item.representedObject = color + return item + } + + // MARK: - Others + + /// Subclasses return their preference manager. + @objc func preferenceManager() -> LKPreferenceManager! { + assertionFailure("should implement by subclass") + return nil + } + + /// YES in read-only mode, such as an opened file. + @objc func isReadOnly() -> Bool { + true + } + + private func rebuildOidMap() { + var map: [UInt: LookinDisplayItem] = [:] + map.reserveCapacity((storedRawFlatItems?.count ?? 0) * 2) + for item in storedRawFlatItems ?? [] { + for object in [item.viewObject, item.layerObject, item.windowObject, item.kindObject] { + if let oid = object?.oid, oid != 0 { + map[UInt(oid)] = item + } + } + } + oidToDisplayItem = map + } + + @objc private func handleShowSystemLayoutGuidesDidChange(_: LookinMsgActionParams?) { + buildDisplayingFlatItems() + } + + private func forwardSignalsToEvents() { + let broadcaster = eventBroadcaster + func forward(_ signal: LKSyncSignal, _ event: @escaping (Value) -> LKHierarchyDataSourceEvent) { + signalSubscriptions.append(signal.observe { [weak broadcaster] value in + broadcaster?.yield(event(value)) + }) + } + forward(willReloadHierarchyInfo) { .willReloadHierarchyInfo } + forward(didReloadHierarchyInfo) { .didReloadHierarchyInfo } + forward(didReloadFlatItemsWithSearchOrFocus) { .didReloadFlatItemsWithSearchOrFocus } + forward(itemDidChangeNoPreview) { .itemDidChangeNoPreview } + forward(itemDidChangeHiddenAlphaValue) { .itemDidChangeHiddenAlphaValue($0) } + forward(itemDidChangeAttrGroup) { .itemDidChangeAttrGroup($0) } + } +} + +extension LKHierarchyDataSource { + /// The data source's events from now on; see `LKHierarchyDataSourceEvent`. + /// Ends when the data source is released. + func events() -> AsyncStream { + eventBroadcaster.subscribe() + } +} diff --git a/LookInside/Hierarchy/LKHierarchyFrameLayout.swift b/LookInside/Hierarchy/LKHierarchyFrameLayout.swift new file mode 100644 index 00000000..cde272d2 --- /dev/null +++ b/LookInside/Hierarchy/LKHierarchyFrameLayout.swift @@ -0,0 +1,263 @@ +// +// LKHierarchyFrameLayout.swift +// LookInside +// +// Frame layout for the hierarchy outline and its table views, with the +// arithmetic and pixel snapping of the ShortCocoa `$(view)` calls it +// replaces, so rows land on the same pixels as before. +// + +import AppKit +import QuartzCore + +/// Lays out one view or layer inside its superview / superlayer. +/// +/// Every value written is rounded up to the main screen's pixel grid, as +/// ShortCocoa did. Methods return `self` so calls chain in the same order as +/// the ShortCocoa expressions they replace. +struct LKHierarchyFrameLayout { + private let getFrame: () -> CGRect + private let setFrame: (CGRect) -> Void + private let ownBounds: () -> CGRect + private let superBounds: () -> CGRect? + private let superIsFlipped: () -> Bool + /// `sizeThatFits` of the view a fitting call sizes. LookInside's + /// ShortCocoa fitting (ShortCocoa+LookinClient) sized LKBaseViews and + /// NSControls only; other views have none. + private let measure: ((NSSize) -> NSSize)? + /// NaN sizes count as 0 for controls, as there. + private let zeroesNaN: Bool + + init(_ view: NSView) { + getFrame = { view.frame } + setFrame = { view.frame = $0 } + ownBounds = { view.bounds } + superBounds = { view.superview?.bounds } + superIsFlipped = { view.superview?.isFlipped ?? false } + if let baseView = view as? LKBaseView { + measure = { baseView.sizeThatFits($0) } + zeroesNaN = false + } else if let control = view as? NSControl { + measure = { control.sizeThatFits($0) } + zeroesNaN = true + } else { + measure = nil + zeroesNaN = false + } + } + + init(_ layer: CALayer) { + getFrame = { layer.frame } + setFrame = { layer.frame = $0 } + ownBounds = { layer.bounds } + superBounds = { layer.superlayer?.bounds } + superIsFlipped = { layer.superlayer?.contentsAreFlipped() ?? false } + measure = nil + zeroesNaN = false + } + + static func snap(_ value: CGFloat) -> CGFloat { + if value == .leastNormalMagnitude || value == .greatestFiniteMagnitude { + return value + } + let scale = NSScreen.main?.backingScaleFactor ?? 1 + return ceil(value * scale) / scale + } + + private func update(_ change: (inout CGRect) -> Void) { + var frame = getFrame() + change(&frame) + setFrame(frame) + } + + @discardableResult + func x(_ value: CGFloat) -> Self { + update { $0.origin.x = Self.snap(value) } + return self + } + + @discardableResult + func y(_ value: CGFloat) -> Self { + update { $0.origin.y = Self.snap(value) } + return self + } + + @discardableResult + func width(_ value: CGFloat) -> Self { + update { $0.size.width = Self.snap(value) } + return self + } + + @discardableResult + func height(_ value: CGFloat) -> Self { + update { $0.size.height = Self.snap(value) } + return self + } + + @discardableResult + func frame(_ value: CGRect) -> Self { + x(value.minX).y(value.minY).width(value.width).height(value.height) + } + + @discardableResult + func midX(_ value: CGFloat) -> Self { + x(value - ownBounds().width / 2) + } + + @discardableResult + func midY(_ value: CGFloat) -> Self { + y(value - ownBounds().height / 2) + } + + @discardableResult + func maxY(_ value: CGFloat) -> Self { + y(value - ownBounds().height) + } + + @discardableResult + func offsetY(_ value: CGFloat) -> Self { + update { $0.origin.y = Self.snap($0.minY + value) } + return self + } + + /// Keeps the left edge and moves the right edge to `value` inset from + /// the superview's right edge. + @discardableResult + func toRight(_ value: CGFloat) -> Self { + let superWidth = superBounds()?.width ?? 0 + update { $0.size.width = max(Self.snap(superWidth - $0.minX - value), 0) } + return self + } + + /// Keeps the left edge and moves the right edge to `value`. + @discardableResult + func toMaxX(_ value: CGFloat) -> Self { + update { $0.size.width = Self.snap(max(value, $0.minX) - $0.minX) } + return self + } + + /// Keeps the top edge and moves the bottom edge to `value`. + @discardableResult + func toMaxY(_ value: CGFloat) -> Self { + update { $0.size.height = Self.snap(max(value, $0.minY) - $0.minY) } + return self + } + + /// Keeps the bottom edge and moves the top edge to `value`. + @discardableResult + func toY(_ value: CGFloat) -> Self { + update { frame in + let top = min(value, frame.maxY) + frame.size.height = Self.snap(frame.maxY - top) + frame.origin.y = Self.snap(top) + } + return self + } + + /// Moves the bottom edge to `value` above the superview's bottom. + @discardableResult + func toBottom(_ value: CGFloat) -> Self { + guard let superBounds = superBounds() else { + return self + } + if superIsFlipped() { + return toMaxY(superBounds.height - value) + } + return toY(value) + } + + /// Places the bottom edge `value` above the superview's bottom. + @discardableResult + func bottom(_ value: CGFloat) -> Self { + guard let superBounds = superBounds() else { + return self + } + if superIsFlipped() { + return maxY(superBounds.height - value) + } + return y(value) + } + + @discardableResult + func fullWidth() -> Self { + x(0).toRight(0) + } + + @discardableResult + func fullHeight() -> Self { + guard let superBounds = superBounds() else { + return self + } + return height(superBounds.height).y(0) + } + + @discardableResult + func fullFrame() -> Self { + fullWidth().fullHeight() + } + + @discardableResult + func horAlign() -> Self { + guard let superBounds = superBounds() else { + return self + } + return midX(superBounds.width / 2) + } + + @discardableResult + func verAlign() -> Self { + guard let superBounds = superBounds() else { + return self + } + return midY(superBounds.height / 2) + } + + @discardableResult + func centerAlign() -> Self { + verAlign().horAlign() + } + + /// Sizes an LKBaseView or a control to `sizeThatFits` with no limit, as + /// LookInside's replacement for ShortCocoa's `sizeToFit` did; other + /// views are left alone. AppKit's own `-sizeToFit` sizes an image view + /// to nothing, which hid the row icons. + @discardableResult + func sizeToFit() -> Self { + guard let measure else { + return self + } + var size = measure(NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude)) + if zeroesNaN, size.width.isNaN { + size.width = 0 + } + if zeroesNaN, size.height.isNaN { + size.height = 0 + } + var rect = getFrame() + rect.size = size + setFrame(rect) + return self + } + + /// Fits an LKBaseView's or a control's height to its width; other views + /// are left alone. + @discardableResult + func heightToFit() -> Self { + guard let measure else { + return self + } + let fitting = measure(NSSize(width: ownBounds().width, height: .greatestFiniteMagnitude)) + var rect = getFrame() + rect.size.height = fitting.height + setFrame(rect) + return self + } +} + +extension NSView { + /// ShortCocoa's visibility test for layout: shown, in a superview and + /// not transparent. + var lkHierarchyIsLaidOutVisible: Bool { + !isHidden && superview != nil && alphaValue >= 0.01 + } +} diff --git a/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.h b/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.h deleted file mode 100644 index b92b47ce..00000000 --- a/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LKHierarchyHandlersPopoverController.h -// Lookin -// -// Created by Li Kai on 2019/8/11. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LookinDisplayItem; - -@interface LKHierarchyHandlersPopoverController : LKBaseViewController - -- (instancetype)initWithDisplayItem:(LookinDisplayItem *)item editable:(BOOL)editable; - -- (NSSize)neededSize; - -@end diff --git a/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.m b/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.m deleted file mode 100644 index 15b96dd0..00000000 --- a/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.m +++ /dev/null @@ -1,79 +0,0 @@ -// -// LKHierarchyHandlersPopoverController.m -// Lookin -// -// Created by Li Kai on 2019/8/11. -// https://lookin.work -// - -#import "LKHierarchyHandlersPopoverController.h" -#import "LKHierarchyHandlersPopoverItemView.h" -#import "LookinEventHandler.h" -#import "LookinDisplayItem.h" - -@interface LKHierarchyHandlersPopoverController () - -@property(nonatomic, strong) NSScrollView *scrollView; -@property(nonatomic, copy) NSArray *itemViews; - -@end - -@implementation LKHierarchyHandlersPopoverController { - CGFloat _verInset; -} - -- (instancetype)initWithDisplayItem:(LookinDisplayItem *)displayItem editable:(BOOL)editable { - if (self = [self initWithContainerView:nil]) { - _verInset = 0; - self.scrollView.documentView = [LKBaseView new]; - - self.itemViews = [displayItem.eventHandlers lookin_map:^id(NSUInteger idx, LookinEventHandler *handler) { - LKHierarchyHandlersPopoverItemView *view = [[LKHierarchyHandlersPopoverItemView alloc] initWithEventHandler:handler editable:editable]; - [self.scrollView.documentView addSubview:view]; - view.needTopBorder = (idx > 0); - view.hidden = NO; - return view; - }]; - } - return self; -} - -- (NSView *)makeContainerView { - self.scrollView = [NSScrollView new]; - self.scrollView.drawsBackground = NO; - return self.scrollView; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - - $(self.scrollView.documentView).fullWidth.y(0); - - __block CGFloat maxY = 0; - NSArray *visibleViews = self.itemViews.lk_visibleViews; - [visibleViews enumerateObjectsUsingBlock:^(LKHierarchyHandlersPopoverItemView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - LKHierarchyHandlersPopoverItemView *prevView = (idx > 0 ? self.itemViews[idx - 1] : nil); - CGFloat y = prevView ? prevView.$maxY : self->_verInset; - $(view).fullWidth.heightToFit.y(y); - - if (idx == visibleViews.count - 1) { - maxY = view.$maxY; - } - }]; - $(self.scrollView.documentView).height(maxY); -} - -- (NSSize)neededSize { - __block NSSize resultSize = NSMakeSize(0, _verInset * 2); - [self.itemViews enumerateObjectsUsingBlock:^(LKHierarchyHandlersPopoverItemView * _Nonnull itemView, NSUInteger idx, BOOL * _Nonnull stop) { - if (itemView.hidden) { - return; - } - NSSize itemSize = [itemView sizeThatFits:NSSizeMax]; - resultSize.width = MAX(resultSize.width, itemSize.width); - resultSize.height += itemSize.height; - }]; - return resultSize; -} - -@end diff --git a/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.swift b/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.swift new file mode 100644 index 00000000..8c42acd5 --- /dev/null +++ b/LookInside/Hierarchy/LKHierarchyHandlersPopoverController.swift @@ -0,0 +1,264 @@ +// +// LKHierarchyHandlersPopoverController.swift +// LookInside +// +// Created by Li Kai on 2019/8/11. +// https://lookin.work +// + +import AppKit + +/// The popover a hierarchy row's event-handler button opens: one item per +/// gesture recognizer or target-action of the node. +final class LKHierarchyHandlersPopoverController: LKBaseViewController { + private let scrollView: NSScrollView = { + let scrollView = NSScrollView() + scrollView.drawsBackground = false + return scrollView + }() + + private var itemViews: [LKHierarchyHandlersPopoverItemView] = [] + private let verInset: CGFloat = 0 + + /// - Parameter editable: false in read mode, where a gesture recognizer + /// cannot be switched on or off. + init(displayItem: LookinDisplayItem, editable: Bool) { + super.init(containerView: nil) + let documentView = LKBaseView() + scrollView.documentView = documentView + + itemViews = (displayItem.eventHandlers ?? []).enumerated().map { index, handler in + let view = LKHierarchyHandlersPopoverItemView(eventHandler: handler, editable: editable) + documentView.addSubview(view) + view.needTopBorder = index > 0 + view.isHidden = false + return view + } + } + + required init?(coder _: NSCoder) { + fatalError("LKHierarchyHandlersPopoverController is not loaded from archives") + } + + override func makeContainerView() -> NSView { + scrollView + } + + override func viewDidLayout() { + super.viewDidLayout() + guard let documentView = scrollView.documentView else { + return + } + LKHierarchyFrameLayout(documentView).fullWidth().y(0) + + // Item views are LKBaseViews: the height fit sizes them with + // sizeThatFits, as LookInside's ShortCocoa fitting did. + var maxY: CGFloat = 0 + let visibleViews = itemViews.filter { !$0.isHidden } + for (index, view) in visibleViews.enumerated() { + let y = index > 0 ? itemViews[index - 1].frame.maxY : verInset + LKHierarchyFrameLayout(view).fullWidth().heightToFit().y(y) + if index == visibleViews.count - 1 { + maxY = view.frame.maxY + } + } + LKHierarchyFrameLayout(documentView).height(maxY) + } + + func neededSize() -> NSSize { + var size = NSSize(width: 0, height: verInset * 2) + for itemView in itemViews where !itemView.isHidden { + let itemSize = itemView.sizeThatFits(NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude)) + size.width = max(size.width, itemSize.width) + size.height += itemSize.height + } + return size + } +} + +/// One event handler in the popover: its name, targets and actions, and +/// for a gesture recognizer its delegate, ivar traces and an enabled switch. +final class LKHierarchyHandlersPopoverItemView: LKBaseView { + var needTopBorder = false { + didSet { + topSepLayer.isHidden = !needTopBorder + } + } + + private let eventHandler: LookinEventHandler + private let iconImageView = NSImageView() + private let titleLabel = LKLabel() + private var subtitleLabel: LKLabel? + private var recognizerEnableButton: NSButton? + private let contentView = LKTextsMenuView() + private let topSepLayer = CALayer() + + private let contentX: CGFloat = 28 + private let insetRight: CGFloat = 16 + private let verInset: CGFloat = 10 + private let contentMarginTop: CGFloat = 4 + private let subtitleMarginTop: CGFloat = 3 + + /// - Parameter editable: false in read mode. + init(eventHandler: LookinEventHandler, editable: Bool) { + self.eventHandler = eventHandler + super.init(frame: .zero) + + layer?.addSublayer(topSepLayer) + addSubview(iconImageView) + + titleLabel.isSelectable = true + titleLabel.maximumNumberOfLines = 1 + titleLabel.lineBreakMode = .byTruncatingMiddle + addSubview(titleLabel) + + contentView.font = .systemFont(ofSize: 13) + addSubview(contentView) + + topSepLayer.backgroundColor = isDarkMode() + ? NSColor(red: 1, green: 1, blue: 1, alpha: 0.15).cgColor + : NSColor(red: 0, green: 0, blue: 0, alpha: 0.12).cgColor + + let isGesture = eventHandler.handlerType == .gesture + var texts: [LookinStringTwoTuple] = [] + if isGesture { + texts.append(LookinStringTwoTuple(first: "Enabled", second: "")) + if editable { + let button = NSButton() + button.setButtonType(.switch) + button.title = "" + button.target = self + button.action = #selector(handleGestureButton(_:)) + recognizerEnableButton = button + renderRecognizerEnabledButton() + contentView.add(button, at: 0) + } else { + texts.append(LookinStringTwoTuple(first: "Enabled", second: eventHandler.gestureRecognizerIsEnabled ? "YES" : "NO")) + } + texts.append(LookinStringTwoTuple(first: "Delegate", second: eventHandler.gestureRecognizerDelegator ?? "nil")) + // Gesture recognizer names are long; use a smaller title. + titleLabel.font = .boldSystemFont(ofSize: 12) + } else { + titleLabel.font = .boldSystemFont(ofSize: 13) + } + + let targetActions = eventHandler.targetActions ?? [] + switch targetActions.count { + case 0: + texts.append(LookinStringTwoTuple(first: "Target", second: "nil")) + texts.append(LookinStringTwoTuple(first: "Action", second: "NULL")) + case 1: + texts.append(LookinStringTwoTuple(first: "Target", second: targetActions[0].first)) + texts.append(LookinStringTwoTuple(first: "Action", second: targetActions[0].second)) + default: + for (index, tuple) in targetActions.enumerated() { + texts.append(LookinStringTwoTuple(first: "Target \(index + 1)", second: tuple.first)) + texts.append(LookinStringTwoTuple(first: "Action \(index + 1)", second: tuple.second)) + } + } + contentView.texts = texts + + titleLabel.stringValue = eventHandler.eventName ?? "" + if isGesture { + iconImageView.image = NSImage(named: "icon_gesture_tap") + } else if eventHandler.eventName?.hasPrefix("UIControlEventEditing") == true { + iconImageView.image = NSImage(named: "icon_targetaction_edit") + } else { + iconImageView.image = NSImage(named: "icon_targetaction_touch") + } + + if isGesture { + var lines: [String] = [] + if let inherited = eventHandler.inheritedRecognizerName { + lines.append("\(NSLocalizedString("Inherits from", comment: "")) \(inherited)") + } + lines.append(contentsOf: eventHandler.recognizerIvarTraces ?? []) + if !lines.isEmpty { + let label = LKLabel() + // LKHelper's NSColorGray9 / NSColorGray1. + label.textColor = isDarkMode() + ? NSColor(red: 216 / 255, green: 220 / 255, blue: 228 / 255, alpha: 1).withAlphaComponent(0.5) + : NSColor(red: 53 / 255, green: 60 / 255, blue: 70 / 255, alpha: 1).withAlphaComponent(0.6) + label.stringValue = lines.joined(separator: "\n") + label.isSelectable = true + label.font = .systemFont(ofSize: 12) + label.maximumNumberOfLines = 0 + label.lineBreakMode = .byTruncatingMiddle + addSubview(label) + subtitleLabel = label + } + } + } + + required init?(coder _: NSCoder) { + fatalError("LKHierarchyHandlersPopoverItemView is not loaded from archives") + } + + override func layout() { + super.layout() + LKHierarchyFrameLayout(topSepLayer).x(contentX).toRight(insetRight).y(0).height(1) + + LKHierarchyFrameLayout(titleLabel).x(contentX).toRight(insetRight).heightToFit().y(verInset) + + var y = titleLabel.frame.maxY + if let subtitleLabel { + LKHierarchyFrameLayout(subtitleLabel).x(contentX).toRight(insetRight).heightToFit().y(y + subtitleMarginTop) + y = subtitleLabel.frame.maxY + } + + LKHierarchyFrameLayout(contentView).x(contentX).toRight(insetRight).heightToFit().y(y + contentMarginTop) + LKHierarchyFrameLayout(iconImageView).sizeToFit().midX(contentX / 2 + 1).midY(titleLabel.frame.midY) + } + + override func sizeThatFits(_ limitedSize: NSSize) -> NSSize { + let unlimited = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + let titleSize = titleLabel.sizeThatFits(unlimited) + let contentSize = contentView.sizeThatFits(unlimited) + let subtitleSize = subtitleLabel?.sizeThatFits(unlimited) ?? .zero + var size = limitedSize + // 2 more points absorb the pixel rounding of the layout. + size.width = max(titleSize.width, contentSize.width, subtitleSize.width) + contentX + insetRight + 2 + size.height = titleSize.height + contentSize.height + contentMarginTop + verInset * 2 + if subtitleLabel != nil { + size.height += subtitleMarginTop + subtitleSize.height + } + return size + } + + @objc private func handleGestureButton(_ button: NSButton) { + // The popover sits on the Live Doc that hosts it, so the app and the + // alert's window both come from `window`. + let hostWindow = window + guard let inspectableApp = LookinLiveDocument.document(in: hostWindow)?.inspectableApp else { + Self.present(LKConnectionError.noConnect, in: hostWindow) + renderRecognizerEnabledButton() + return + } + let shouldEnable = button.state == .on + let oid = UInt(eventHandler.recognizerOid) + Task { @MainActor [weak self] in + do { + let isEnabled = try await inspectableApp.setGestureRecognizer(oid: oid, enabled: shouldEnable) + guard let self else { return } + eventHandler.gestureRecognizerIsEnabled = isEnabled + renderRecognizerEnabledButton() + } catch { + Self.present(error as NSError, in: hostWindow) + self?.renderRecognizerEnabledButton() + } + } + } + + private func renderRecognizerEnabledButton() { + recognizerEnableButton?.state = eventHandler.gestureRecognizerIsEnabled ? .on : .off + } + + /// LKHelper's AlertError: a sheet for every error except a discarded + /// request. + private static func present(_ error: NSError, in window: NSWindow?) { + guard error.code != LookinErrCode_Discard, let window else { + return + } + NSAlert(error: error).beginSheetModal(for: window, completionHandler: nil) + } +} diff --git a/LookInside/Hierarchy/LKHierarchyHandlersPopoverItemView.h b/LookInside/Hierarchy/LKHierarchyHandlersPopoverItemView.h deleted file mode 100644 index 4668a9b8..00000000 --- a/LookInside/Hierarchy/LKHierarchyHandlersPopoverItemView.h +++ /dev/null @@ -1,20 +0,0 @@ -// -// LKHierarchyHandlersPopoverItemView.h -// Lookin -// -// Created by Li Kai on 2019/8/11. -// https://lookin.work -// - -#import "LKBaseView.h" - -@class LookinEventHandler; - -@interface LKHierarchyHandlersPopoverItemView : LKBaseView - -/// read 模式下的 editable 需要传入 NO -- (instancetype)initWithEventHandler:(LookinEventHandler *)handler editable:(BOOL)editable; - -@property(nonatomic, assign) BOOL needTopBorder; - -@end diff --git a/LookInside/Hierarchy/LKHierarchyHandlersPopoverItemView.m b/LookInside/Hierarchy/LKHierarchyHandlersPopoverItemView.m deleted file mode 100644 index ac7bbd39..00000000 --- a/LookInside/Hierarchy/LKHierarchyHandlersPopoverItemView.m +++ /dev/null @@ -1,210 +0,0 @@ -// -// LKHierarchyHandlersPopoverItemView.m -// Lookin -// -// Created by Li Kai on 2019/8/11. -// https://lookin.work -// - -#import "LKHierarchyHandlersPopoverItemView.h" -#import "LookinEventHandler.h" -#import "LKTextsMenuView.h" -#import "LookinIvarTrace.h" -#import "LKInspectableApp.h" -#import "LookinLiveDocument.h" - -@interface LKHierarchyHandlersPopoverItemView () - -@property(nonatomic, strong) LookinEventHandler *eventHandler; - -@property(nonatomic, strong) NSImageView *iconImageView; - -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKLabel *subtitleLabel; -@property(nonatomic, strong) NSButton *recognizerEnableButton; - - -@property(nonatomic, strong) LKTextsMenuView *contentView; - -@property(nonatomic, strong) CALayer *topSepLayer; - - -@end - -@implementation LKHierarchyHandlersPopoverItemView { - CGFloat _contentX; - CGFloat _insetRight; - CGFloat _verInset; - CGFloat _contentMarginTop; - CGFloat _subtitleMarginTop; -} - -- (instancetype)initWithEventHandler:(LookinEventHandler *)eventHandler editable:(BOOL)editable { - if (self = [self initWithFrame:NSZeroRect]) { - _contentX = 28; - _insetRight = 16; - _verInset = 10; - _contentMarginTop = 4; - _subtitleMarginTop = 3; - - self.eventHandler = eventHandler; - - self.topSepLayer = [CALayer layer]; - [self.layer addSublayer:self.topSepLayer]; - - self.iconImageView = [NSImageView new]; - [self addSubview:self.iconImageView]; - - self.titleLabel = [LKLabel new]; - self.titleLabel.selectable = YES; - self.titleLabel.maximumNumberOfLines = 1; - self.titleLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - [self addSubview:self.titleLabel]; - - self.contentView = [LKTextsMenuView new]; - self.contentView.font = NSFontMake(13); - [self addSubview:self.contentView]; - - self.topSepLayer.backgroundColor = self.isDarkMode ? LookinColorRGBAMake(255, 255, 255, .15).CGColor : LookinColorRGBAMake(0, 0, 0, .12).CGColor; - - NSMutableArray *texts = [NSMutableArray array]; - if (eventHandler.handlerType == LookinEventHandlerTypeGesture) { - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Enabled" second:@""]]; - - if (editable) { - self.recognizerEnableButton = [NSButton new]; - [self.recognizerEnableButton setButtonType:NSButtonTypeSwitch]; - self.recognizerEnableButton.title = @""; - self.recognizerEnableButton.target = self; - self.recognizerEnableButton.action = @selector(_handleGestureButton:); - [self _renderRecognizerEnabledButton]; - [self.contentView addButton:self.recognizerEnableButton atIndex:0]; - } else { - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Enabled" second:(eventHandler.gestureRecognizerIsEnabled ? @"YES" : @"NO")]]; - } - - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Delegate" second:(eventHandler.gestureRecognizerDelegator ? : @"nil")]]; - // gesture 的名字都太长了,把字号弄小一点 - self.titleLabel.font = [NSFont boldSystemFontOfSize:12]; - } else { - self.titleLabel.font = [NSFont boldSystemFontOfSize:13]; - } - - if (eventHandler.targetActions.count == 0) { - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Target" second:@"nil"]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Action" second:@"NULL"]]; - } else if (eventHandler.targetActions.count == 1) { - LookinStringTwoTuple *tuple = eventHandler.targetActions.firstObject; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Target" second:tuple.first]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:@"Action" second:tuple.second]]; - } else { - [eventHandler.targetActions enumerateObjectsUsingBlock:^(LookinStringTwoTuple * _Nonnull tuple, NSUInteger idx, BOOL * _Nonnull stop) { - [texts addObject:[LookinStringTwoTuple tupleWithFirst:[NSString stringWithFormat:@"Target %@", @(idx + 1)] second:tuple.first]]; - [texts addObject:[LookinStringTwoTuple tupleWithFirst:[NSString stringWithFormat:@"Action %@", @(idx + 1)] second:tuple.second]]; - }]; - } - self.contentView.texts = texts; - - if (eventHandler.handlerType == LookinEventHandlerTypeGesture) { - self.titleLabel.stringValue = eventHandler.eventName; - self.iconImageView.image = NSImageMake(@"icon_gesture_tap"); - } else { - self.titleLabel.stringValue = eventHandler.eventName; - if ([eventHandler.eventName hasPrefix:@"UIControlEventEditing"]) { - self.iconImageView.image = NSImageMake(@"icon_targetaction_edit"); - } else { - self.iconImageView.image = NSImageMake(@"icon_targetaction_touch"); - } - } - - if (eventHandler.handlerType == LookinEventHandlerTypeGesture) { - NSMutableArray *texts = [NSMutableArray array]; - if (eventHandler.inheritedRecognizerName) { - [texts addObject:[NSString stringWithFormat:@"%@ %@", NSLocalizedString(@"Inherits from", nil), eventHandler.inheritedRecognizerName]]; - } - [texts addObjectsFromArray:eventHandler.recognizerIvarTraces]; - if (texts.count > 0) { - NSString *text = [texts componentsJoinedByString:@"\n"]; - self.subtitleLabel = [LKLabel new]; - self.subtitleLabel.textColor = self.isDarkMode ? [NSColorGray9 colorWithAlphaComponent:.5] : [NSColorGray1 colorWithAlphaComponent:.6]; - self.subtitleLabel.stringValue = text; - self.subtitleLabel.selectable = YES; - self.subtitleLabel.font = NSFontMake(12); - self.subtitleLabel.maximumNumberOfLines = 0; - self.subtitleLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - [self addSubview:self.subtitleLabel]; - } - } - } - return self; -} - -- (void)layout { - [super layout]; - $(self.topSepLayer).x(_contentX).toRight(_insetRight).y(0).height(1); - - $(self.titleLabel).x(_contentX).toRight(_insetRight).heightToFit.y(_verInset); - - CGFloat y = self.titleLabel.$maxY; - if (self.subtitleLabel) { - $(self.subtitleLabel).x(_contentX).toRight(_insetRight).heightToFit.y(y + _subtitleMarginTop); - y = self.subtitleLabel.$maxY; - } - - $(self.contentView).x(_contentX).toRight(_insetRight).heightToFit.y(y + _contentMarginTop); - $(self.iconImageView).sizeToFit.midX(_contentX / 2.0 + 1).midY(self.titleLabel.$midY); -} - -- (void)setNeedTopBorder:(BOOL)needTopBorder { - _needTopBorder = needTopBorder; - self.topSepLayer.hidden = !needTopBorder; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - NSSize titleSize = [self.titleLabel bestSize]; - NSSize contentSize = [self.contentView sizeThatFits:NSSizeMax]; - NSSize subtitleSize = [self.subtitleLabel bestSize]; - limitedSize.width = MAX(MAX(titleSize.width, contentSize.width), subtitleSize.width) + _contentX + _insetRight + 2; // 加个 2 冗余一下像素取整误差 - limitedSize.height = titleSize.height + contentSize.height + _contentMarginTop + _verInset * 2; - if (self.subtitleLabel) { - limitedSize.height += (_subtitleMarginTop + subtitleSize.height); - } - return limitedSize; -} - -- (void)_handleGestureButton:(NSButton *)button { - // Phase F: a popover item lives on top of the Live Doc that hosts it, - // so the inspectable app and the alert host both flow from `self.window`. - NSWindow *mainWindow = self.window; - LKInspectableApp *inspectableApp = [LookinLiveDocument documentInWindow:mainWindow].inspectableApp; - - if (!inspectableApp) { - AlertError(LookinErr_NoConnect, mainWindow); - [self _renderRecognizerEnabledButton]; - return; - } - BOOL shouldEnableRecognizer; - if (button.state == NSControlStateValueOn) { - shouldEnableRecognizer = YES; - } else { - shouldEnableRecognizer = NO; - } - @weakify(self); - [[inspectableApp modifyGestureRecognizer:self.eventHandler.recognizerOid toBeEnabled:shouldEnableRecognizer] subscribeNext:^(NSNumber *enabled_number) { - @strongify(self); - BOOL isEnabled = [enabled_number boolValue]; - self.eventHandler.gestureRecognizerIsEnabled = isEnabled; - [self _renderRecognizerEnabledButton]; - - } error:^(NSError * _Nullable error) { - @strongify(self); - AlertError(error, mainWindow); - [self _renderRecognizerEnabledButton]; - }]; -} - -- (void)_renderRecognizerEnabledButton { - self.recognizerEnableButton.state = (self.eventHandler.gestureRecognizerIsEnabled ? NSControlStateValueOn : NSControlStateValueOff); -} - -@end diff --git a/LookInside/Hierarchy/LKHierarchyRowView.h b/LookInside/Hierarchy/LKHierarchyRowView.h deleted file mode 100644 index 8cb88a4d..00000000 --- a/LookInside/Hierarchy/LKHierarchyRowView.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// LKHierarchyRowView.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKOutlineRowView.h" - -@class LookinDisplayItem, LKHierarchyDataSource; - -@interface LKHierarchyRowView : LKOutlineRowView - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource; - -/// 注意这里是 weak,因为 tableView 会缓存很多 rowView(macOS 下的 tableView 要比 iOS 里的 tableView 缓存的更多,因为屏幕大),如果这里是 strong 的话会导致 displayItem 不能随着 hierarchy reload 而被及时释放,而 displayItem 又 retain 了很多 image 这种占内存的东西,所以这里要写成 weak -@property(nonatomic, weak) LookinDisplayItem *displayItem; - -@property(nonatomic, assign) NSInteger minIndentLevel; - -/// 左侧的小蓝条图标 -@property(nonatomic, strong, readonly) NSButton *eventHandlerButton; - -@end diff --git a/LookInside/Hierarchy/LKHierarchyRowView.m b/LookInside/Hierarchy/LKHierarchyRowView.m deleted file mode 100644 index a6d330e1..00000000 --- a/LookInside/Hierarchy/LKHierarchyRowView.m +++ /dev/null @@ -1,512 +0,0 @@ -// -// LKHierarchyRowView.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKHierarchyRowView.h" -#import "LookinDisplayItem.h" -#import "LookinIvarTrace.h" -#import "LookinDisplayItem.h" -#import "LKHierarchyHandlersPopoverController.h" -#import "LookinLiveDocument.h" -#import "LKHierarchyDataSource.h" -#import "LookinDisplayItem+LookinClient.h" -#import "LookinCustomDisplayItemInfo.h" -#import "LookinAttributesGroup.h" -#import "LKHelper.h" -#import "LKTableViewHorizontalScrollWidthManager.h" -#import "LookInside-Swift.h" - -// Wire-compat fallback for v8 servers (no isSwiftUI flag yet). SwiftUI nodes -// always get at least one custom attr group whose userCustomTitle starts with -// "SwiftUI" (LKS_SwiftUIAttrGroupsMaker always emits "SwiftUI Type"). -// LKS_CustomDisplayItemsMaker (lookin_customDebugInfos path) sets customInfo -// but never produces SwiftUI-prefixed groups, so this distinguishes correctly. -// Remove once v8 support is dropped. -static BOOL LKDisplayItemLooksLikeSwiftUI(LookinDisplayItem *item) { - if (item.customInfo.isSwiftUI) return YES; - for (LookinAttributesGroup *group in item.customAttrGroupList) { - if ([group.userCustomTitle hasPrefix:@"SwiftUI"]) return YES; - } - return NO; -} - -@interface LKHierarchyRowView () - -@property(nonatomic, strong) CALayer *strikethroughLayer; - -@property(nonatomic, strong) CALayer *eventHandlerButtonColorLayer; -@property(nonatomic, assign) BOOL isFocusingHandlerButton; - -@property(nonatomic, strong) NSImageView *swiftUIBadge; - -@property(nonatomic, strong) LKHierarchyDataSource *dataSource; - -@end - -@implementation LKHierarchyRowView - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource { - if (self = [super initWithFrame:CGRectZero]) { - self.dataSource = dataSource; - } - return self; -} -- (void)layout { - [super layout]; - - $(self.eventHandlerButton).y(3).toBottom(3).width(10).x(3); - [self _updateEventHandlerButtonLayout]; - - // Insert SwiftUI badge between title and (optional) subtitle. Super layout - // placed subtitle at titleLabel.$maxX + _subtitleLeft, which would overlap - // the badge — shift subtitle right and re-publish row width. - if (self.swiftUIBadge && !self.swiftUIBadge.hidden) { - $(self.swiftUIBadge).sizeToFit.x(self.titleLabel.$maxX + 4).verAlign; - CGFloat finalMaxX = self.swiftUIBadge.$maxX; - if (self.subtitleLabel.isVisible) { - $(self.subtitleLabel).x(self.swiftUIBadge.$maxX + _subtitleLeft); - finalMaxX = self.subtitleLabel.$maxX; - } - if (self.horizontalScrollWidthManager) { - [self.horizontalScrollWidthManager rowDidLayoutWithWidth:finalMaxX]; - } - } - - if (self.strikethroughLayer && !self.strikethroughLayer.hidden) { - CGFloat maxX = self.subtitleLabel.hidden ? (self.titleLabel.$maxX + 2) : self.subtitleLabel.$maxX + 2; - $(self.strikethroughLayer).height(1).x(self.titleLabel.$x - 1).toMaxX(maxX).midY(self.titleLabel.$midY + 1); - } -} - -- (void)setDisplayItem:(LookinDisplayItem *)displayItem { - if (displayItem.rowViewDelegate != self) { - // 在 _displayItem == displayItem 的情况下,displayItem.rowViewDelegate 仍然可能不指向 self,因此这里必须重新配置一下,否则会有 Bug:先选中一个 view 并进入 focus 状态,然后退出 focus,然后就发现那个 view 的选中态无法取消 - displayItem.rowViewDelegate = self; - } - if (displayItem != _displayItem) { - _displayItem = displayItem; - [self reRender]; - } -} - -- (void)reRender { - if (!self.displayItem) { - return; - } - self.isSelected = (self.dataSource.selectedItem == self.displayItem); - self.isHovered = (self.dataSource.hoveredItem == self.displayItem); - self.image = [self resolveIconImage]; - self.indentLevel = self.displayItem.indentLevel - self.minIndentLevel; - [self updateEventsButton]; - [self updateExpandStatus]; - [self updateStrikethroughLayer]; - [self _updateLabelStringsAndImageViewAlpha]; - [self _updateLabelsFonts]; - [self _updateSwiftUIBadge]; - - [self setNeedsLayout:YES]; -} - -- (void)_updateSwiftUIBadge { - BOOL isSwiftUINode = LKDisplayItemLooksLikeSwiftUI(self.displayItem); - if (isSwiftUINode && !self.swiftUIBadge) { - NSImage *image = [NSImage imageWithSystemSymbolName:@"sparkles" accessibilityDescription:nil]; - image.template = YES; - _swiftUIBadge = [NSImageView new]; - _swiftUIBadge.image = image; - _swiftUIBadge.contentTintColor = [LKHelper accentColor]; - [self addSubview:_swiftUIBadge]; - } - if (self.swiftUIBadge) { - self.swiftUIBadge.hidden = !isSwiftUINode; - } - self.toolTip = isSwiftUINode ? NSLocalizedString(@"LookInside Pro · Activated", nil) : nil; -} - -- (void)setIsHovered:(BOOL)isHovered { - [super setIsHovered:isHovered]; - [self _updateEventHandlerButtonColors]; -} - -- (void)viewDidChangeEffectiveAppearance { - [super viewDidChangeEffectiveAppearance]; - [self _updateEventHandlerButtonColors]; -} - -#pragma mark - Event Handler - -- (void)mouseMoved:(NSEvent *)event { - [super mouseMoved:event]; - NSPoint rawPoint = [event locationInWindow]; - NSPoint point = [self convertPoint:rawPoint fromView:nil]; - if (point.x < 16) { - self.isFocusingHandlerButton = YES; - } else { - self.isFocusingHandlerButton = NO; - } -} - -- (void)mouseExited:(NSEvent *)event { - [super mouseExited:event]; - self.isFocusingHandlerButton = NO; -} - -- (void)setIsFocusingHandlerButton:(BOOL)isFocusingHandlerButton { - if (_isFocusingHandlerButton == isFocusingHandlerButton) { - return; - } - _isFocusingHandlerButton = isFocusingHandlerButton; - [self _updateEventHandlerButtonLayout]; - [self _updateEventHandlerButtonColors]; -} - -- (void)_handleClickEventHandlerButton:(NSButton *)button { - NSPopover *popover = [[NSPopover alloc] init]; - - // Phase F: a row hosted inside a Live Doc's window is editable; rows - // hosted in a read-only Archive Doc / launch window are not. - BOOL editable = ([LookinLiveDocument documentInWindow:self.window] != nil); - - LKHierarchyHandlersPopoverController *vc = [[LKHierarchyHandlersPopoverController alloc] initWithDisplayItem:self.displayItem editable:editable]; - popover.animates = NO; - popover.behavior = NSPopoverBehaviorTransient; - popover.contentSize = vc.neededSize; - popover.contentViewController = vc; - [popover showRelativeToRect:NSMakeRect(0, 0, button.bounds.size.width, button.bounds.size.height) ofView:button preferredEdge:NSRectEdgeMaxY]; -} - -- (void)_updateLabelStringsAndImageViewAlpha { - NSColor *titleColor; - NSColor *subtitleColor; - - if (self.isSelected) { - titleColor = [NSColor whiteColor]; - subtitleColor = [NSColor whiteColor]; - self.imageView.alphaValue = 1; - } else if ([self resolveIfShouldFadeContent]) { - titleColor = [NSColor tertiaryLabelColor]; - subtitleColor = [NSColor tertiaryLabelColor]; - self.imageView.alphaValue = .6; - } else { - titleColor = [NSColor labelColor]; - subtitleColor = [NSColor secondaryLabelColor]; - self.imageView.alphaValue = 1; - } - - NSAttributedString *titleString = $(self.displayItem.title).textColor(titleColor).attrString; - NSAttributedString *subtitleString = $(self.displayItem.subtitle).textColor(subtitleColor).attrString; - - if (self.displayItem.isInSearch && self.displayItem.highlightedSearchString.length) { - // 搜索状态下,且命中了搜索词 - NSColor *bgColor = self.isDarkMode ? LookinColorMake(190, 120, 0) : LookinColorMake(255, 240, 100); - NSDictionary *attrs = @{NSBackgroundColorAttributeName: bgColor, NSForegroundColorAttributeName: [NSColor labelColor]}; - - NSRange titleHighlightRange = [titleString.string rangeOfString:self.displayItem.highlightedSearchString options:NSCaseInsensitiveSearch]; - if (titleHighlightRange.location != NSNotFound) { - NSMutableAttributedString *titleString_m = titleString.mutableCopy; - [titleString_m addAttributes:attrs range:titleHighlightRange]; - titleString = titleString_m; - } - - NSRange subtitleHighlightRange = [subtitleString.string rangeOfString:self.displayItem.highlightedSearchString options:NSCaseInsensitiveSearch]; - if (subtitleHighlightRange.location != NSNotFound) { - NSMutableAttributedString *subtitleString_m = subtitleString.mutableCopy; - [subtitleString_m addAttributes:attrs range:subtitleHighlightRange]; - subtitleString = subtitleString_m; - } - } - - self.titleLabel.attributedStringValue = titleString; - self.subtitleLabel.attributedStringValue = subtitleString; -} - -- (void)updateEventsButton { - if (!self.displayItem || self.displayItem.eventHandlers.count == 0) { - self.eventHandlerButton.hidden = YES; - return; - } - if (!self.eventHandlerButton) { - _eventHandlerButton = [NSButton new]; - [self.eventHandlerButton setTitle:@""]; - self.eventHandlerButton.target = self; - self.eventHandlerButton.action = @selector(_handleClickEventHandlerButton:); - self.eventHandlerButton.wantsLayer = YES; - self.eventHandlerButton.bordered = NO; - [self.eventHandlerButton setBezelStyle:NSBezelStyleRoundRect]; - self.eventHandlerButton.layer.backgroundColor = [NSColor clearColor].CGColor; - [self addSubview:self.eventHandlerButton]; - - self.eventHandlerButtonColorLayer = [CALayer layer]; - self.eventHandlerButtonColorLayer.actions = @{NSStringFromSelector(@selector(contents)): [NSNull null]}; - [self.eventHandlerButton.layer addSublayer:self.eventHandlerButtonColorLayer]; - } - self.eventHandlerButton.hidden = NO; - [self _updateEventHandlerButtonColors]; -} - -#pragma mark - - -- (void)displayItem:(LookinDisplayItem *)displayItem propertyDidChange:(LookinDisplayItemProperty)property { - if (property == LookinDisplayItemProperty_IsHovered) { - self.isHovered = (self.dataSource.hoveredItem == self.displayItem); - } else { - [self reRender]; - } -} - -- (void)updateStrikethroughLayer { - BOOL shouldShow = [self resolveIfShouldShowStrikethrough]; - if (shouldShow) { - if (!self.strikethroughLayer) { - self.strikethroughLayer = [CALayer layer]; - [self.strikethroughLayer lookin_removeImplicitAnimations]; - [self.layer addSublayer:self.strikethroughLayer]; - } - if (self.isSelected) { - self.strikethroughLayer.backgroundColor = [[NSColor whiteColor] colorWithAlphaComponent:.75].CGColor; - } else { - self.strikethroughLayer.backgroundColor = self.isDarkMode ? LookinColorRGBAMake(255, 255, 255, .2).CGColor : LookinColorRGBAMake(0, 0, 0, .2).CGColor; - } - self.strikethroughLayer.hidden = NO; - [self setNeedsLayout:YES]; - - } else { - self.strikethroughLayer.hidden = YES; - } -} - -- (BOOL)resolveIfShouldShowStrikethrough { - LookinDisplayItem *item = self.displayItem; - if (!item) { - return NO; - } - if (![item hasPreviewBoxAbility]) { - return NO; - } - if (item.inNoPreviewHierarchy) { - return YES; - } - return NO; -} - -- (void)setIsDarkMode:(BOOL)isDarkMode { - [super setIsDarkMode:isDarkMode]; - [self updateStrikethroughLayer]; -} - -- (void)_updateEventHandlerButtonColors { - if (self.eventHandlerButton.hidden) { - return; - } - NSColor *color; - if (self.isSelected) { - color = [NSColor whiteColor]; - } else { - CGFloat alpha = self.isFocusingHandlerButton ? 1 : .5; - color = LookinColorRGBAMake(74, 144, 226, alpha); - } - self.eventHandlerButtonColorLayer.backgroundColor = color.CGColor; -} - -- (void)_updateEventHandlerButtonLayout { - CGFloat width = self.isFocusingHandlerButton ? 8 : 5; - $(self.eventHandlerButtonColorLayer).fullHeight.width(width).horAlign; - self.eventHandlerButtonColorLayer.cornerRadius = width / 2.0; -} - -- (void)_updateLabelsFonts { - BOOL noImage = self.displayItem.inNoPreviewHierarchy || self.displayItem.inHiddenHierarchy; - BOOL isPrivate = [[LKPrivateDiscriminatorStore shared] isPrivateDisplayItem:self.displayItem]; - if (!self.displayItem.isUserCustom && (noImage || isPrivate)) { - self.titleLabel.font = [LKHelper italicFontOfSize:13]; - self.subtitleLabel.font = [LKHelper italicFontOfSize:12]; - } else { - self.titleLabel.font = NSFontMake(13); - self.subtitleLabel.font = NSFontMake(12); - } - [self setNeedsLayout:YES]; -} - -- (void)updateExpandStatus { - if (!self.displayItem) { - self.status = LKOutlineRowViewStatusNotExpandable; - return; - } - if (!self.displayItem.isExpandable) { - self.status = LKOutlineRowViewStatusNotExpandable; - } else if (self.displayItem.isExpanded) { - self.status = LKOutlineRowViewStatusExpanded; - } else { - self.status = LKOutlineRowViewStatusCollapsed; - } -} - -#pragma mark - Others - -- (NSImage *)resolveIconImage { - static dispatch_once_t viewOnceToken; - static NSArray *> *viewsList = nil; - dispatch_once(&viewOnceToken,^{ - viewsList = @[ - @{@"UIWindow": @"hierarchy_window"}, - @{@"UIWindowScene": @"hierarchy_window"}, - @{@"UINavigationBar": @"hierarchy_navigationbar"}, - @{@"UITabBar": @"hierarchy_tabbar"}, - @{@"UITextView": @"hierarchy_textview"}, - @{@"UIStackView": @"hierarchy_stackview"}, - @{@"UITextField": @"hierarchy_textfield"}, - @{@"UITableView": @"hierarchy_tableview"}, - @{@"UICollectionView": @"hierarchy_collectionview"}, - @{@"UICollectionViewCell": @"hierarchy_collectioncell"}, - @{@"UICollectionReusableView": @"hierarchy_collectionreuseview"}, - @{@"UITableViewCell": @"hierarchy_tablecell"}, - @{@"UISlider": @"hierarchy_slider"}, - @{@"WKWebView": @"hierarchy_webview"}, - @{@"UIWebView": @"hierarchy_webview"}, - @{@"_UITableViewCellSeparatorView": @"hierarchy_tablecellseparator"}, - @{@"UITableViewCellContentView": @"hierarchy_cellcontent"}, - @{@"_UITableViewHeaderFooterContentView": @"hierarchy_cellcontent"}, - @{@"UITableViewHeaderFooterView": @"hierarchy_tableheaderfooter"}, - @{@"UIScrollView": @"hierarchy_scrollview"}, - @{@"UILabel": @"hierarchy_label"}, - @{@"UIButton": @"hierarchy_button"}, - @{@"UIImageView": @"hierarchy_imageview"}, - @{@"UIControl": @"hierarchy_control"}, - @{@"UIVisualEffectView": @"hierarchy_effectview"}, - - @{@"NSWindow": @"hierarchy_window"}, - @{@"NSTabView": @"hierarchy_tabbar"}, - @{@"NSTextView": @"hierarchy_textview"}, - @{@"NSStackView": @"hierarchy_stackview"}, - @{@"NSTextField": @"hierarchy_textfield"}, - @{@"NSTableView": @"hierarchy_tableview"}, - @{@"NSCollectionView": @"hierarchy_collectionview"}, - @{@"NSCollectionViewItem": @"hierarchy_collectioncell"}, - @{@"NSTableCellView": @"hierarchy_tablecell"}, - @{@"NSSlider": @"hierarchy_slider"}, - @{@"NSScrollView": @"hierarchy_scrollview"}, - @{@"NSButton": @"hierarchy_button"}, - @{@"NSImageView": @"hierarchy_imageview"}, - @{@"NSControl": @"hierarchy_control"}, - @{@"NSVisualEffectView": @"hierarchy_effectview"} - ]; - }); - - LookinDisplayItem *item = self.displayItem; - if (!item) { - return nil; - } - __block NSString *imageName = nil; - if (item.isUserCustom) { - imageName = @"hierarchy_custom"; - - } else if (item.hostViewControllerObject || item.hostWindowControllerObject) { - imageName = @"hierarchy_controller"; - - } else { - switch (item.resolvedNodeKind) { - case LookinDisplayItemNodeKindView: - [item.viewObject.classChainList enumerateObjectsUsingBlock:^(NSString * _Nonnull className, NSUInteger idx, BOOL * _Nonnull stop) { - imageName = [viewsList lookin_firstFiltered:^BOOL(NSDictionary *obj) { - return !!obj[className]; - }][className]; - - if (imageName) { - *stop = YES; - } - }]; - - if (!imageName) { - imageName = @"hierarchy_view"; - } - break; - - case LookinDisplayItemNodeKindLayer: - // A view's outer layer is still a layer as far as the row icon goes. - case LookinDisplayItemNodeKindViewOuterLayer: - // So is a backing layer node (backing-layer-toggle proposal). - case LookinDisplayItemNodeKindBackingLayer: - [item.layerObject.classChainList enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if ([obj isEqualToString:@"CAShapeLayer"]) { - imageName = @"hierarchy_shapelayer"; - *stop = YES; - return; - } - if ([obj isEqualToString:@"CAGradientLayer"]) { - imageName = @"hierarchy_gradientlayer"; - *stop = YES; - return; - } - }]; - if (!imageName) { - imageName = @"hierarchy_layer"; - } - break; - - case LookinDisplayItemNodeKindWindow: - case LookinDisplayItemNodeKindWindowScene: - imageName = @"hierarchy_window"; - break; - - case LookinDisplayItemNodeKindLayoutGuide: - imageName = @"hierarchy_layoutguide"; - break; - - case LookinDisplayItemNodeKindCell: - imageName = @"hierarchy_cell"; - break; - - case LookinDisplayItemNodeKindCustom: - case LookinDisplayItemNodeKindUnspecified: - break; - } - } - - if (!imageName) { - imageName = @"hierarchy_view"; - } - - if (self.dataSource.selectedItem == self.displayItem) { - NSString *selectedImageName = [imageName stringByAppendingString:@"_selected"]; - NSImage *selectedImage = NSImageMake(selectedImageName); - if (selectedImage) { - return selectedImage; - } else { - return NSImageMake(imageName); - } - } else { - return NSImageMake(imageName); - } -} - -- (BOOL)resolveIfShouldFadeContent { - LookinDisplayItem *item = self.displayItem; - - if (item.isInSearch && !item.highlightedSearchString.length) { - return YES; - } - if (![item hasPreviewBoxAbility]) { - return NO; - } - return (item.inHiddenHierarchy || item.inNoPreviewHierarchy); -} - -+ (CGFloat)insetLeft { - return 13; -} - -- (void)updateTrackingAreas { - [super updateTrackingAreas]; - [self.trackingAreas enumerateObjectsUsingBlock:^(NSTrackingArea * _Nonnull oldArea, NSUInteger idx, BOOL * _Nonnull stop) { - [self removeTrackingArea:oldArea]; - }]; - - NSTrackingArea *newArea = [[NSTrackingArea alloc] initWithRect:self.bounds options:NSTrackingMouseEnteredAndExited|NSTrackingMouseMoved|NSTrackingActiveInKeyWindow|NSTrackingInVisibleRect owner:self userInfo:nil]; - [self addTrackingArea:newArea]; -} - -@end diff --git a/LookInside/Hierarchy/LKHierarchyRowView.swift b/LookInside/Hierarchy/LKHierarchyRowView.swift new file mode 100644 index 00000000..d0b736b9 --- /dev/null +++ b/LookInside/Hierarchy/LKHierarchyRowView.swift @@ -0,0 +1,451 @@ +// +// LKHierarchyRowView.swift +// LookInside +// +// Created by Li Kai on 2018/8/4. +// https://lookin.work +// + +import AppKit + +/// Row icons by class, searched along the node's class chain in this order. +private let classIconNames: [(className: String, imageName: String)] = [ + ("UIWindow", "hierarchy_window"), + ("UIWindowScene", "hierarchy_window"), + ("UINavigationBar", "hierarchy_navigationbar"), + ("UITabBar", "hierarchy_tabbar"), + ("UITextView", "hierarchy_textview"), + ("UIStackView", "hierarchy_stackview"), + ("UITextField", "hierarchy_textfield"), + ("UITableView", "hierarchy_tableview"), + ("UICollectionView", "hierarchy_collectionview"), + ("UICollectionViewCell", "hierarchy_collectioncell"), + ("UICollectionReusableView", "hierarchy_collectionreuseview"), + ("UITableViewCell", "hierarchy_tablecell"), + ("UISlider", "hierarchy_slider"), + ("WKWebView", "hierarchy_webview"), + ("UIWebView", "hierarchy_webview"), + ("_UITableViewCellSeparatorView", "hierarchy_tablecellseparator"), + ("UITableViewCellContentView", "hierarchy_cellcontent"), + ("_UITableViewHeaderFooterContentView", "hierarchy_cellcontent"), + ("UITableViewHeaderFooterView", "hierarchy_tableheaderfooter"), + ("UIScrollView", "hierarchy_scrollview"), + ("UILabel", "hierarchy_label"), + ("UIButton", "hierarchy_button"), + ("UIImageView", "hierarchy_imageview"), + ("UIControl", "hierarchy_control"), + ("UIVisualEffectView", "hierarchy_effectview"), + + ("NSWindow", "hierarchy_window"), + ("NSTabView", "hierarchy_tabbar"), + ("NSTextView", "hierarchy_textview"), + ("NSStackView", "hierarchy_stackview"), + ("NSTextField", "hierarchy_textfield"), + ("NSTableView", "hierarchy_tableview"), + ("NSCollectionView", "hierarchy_collectionview"), + ("NSCollectionViewItem", "hierarchy_collectioncell"), + ("NSTableCellView", "hierarchy_tablecell"), + ("NSSlider", "hierarchy_slider"), + ("NSScrollView", "hierarchy_scrollview"), + ("NSButton", "hierarchy_button"), + ("NSImageView", "hierarchy_imageview"), + ("NSControl", "hierarchy_control"), + ("NSVisualEffectView", "hierarchy_effectview"), +] + +/// Wire-compat fallback for v8 servers (no isSwiftUI flag yet). SwiftUI +/// nodes always get at least one custom attr group whose userCustomTitle +/// starts with "SwiftUI" (LKS_SwiftUIAttrGroupsMaker always emits "SwiftUI +/// Type"). LKS_CustomDisplayItemsMaker (lookin_customDebugInfos path) sets +/// customInfo but never produces SwiftUI-prefixed groups, so this +/// distinguishes correctly. Remove once v8 support is dropped. +private func displayItemLooksLikeSwiftUI(_ item: LookinDisplayItem) -> Bool { + if item.customInfo?.isSwiftUI == true { + return true + } + return (item.customAttrGroupList ?? []).contains { $0.userCustomTitle?.hasPrefix("SwiftUI") == true } +} + +private func rgba(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat) -> NSColor { + NSColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) +} + +@objc(LKHierarchyRowView) +@objcMembers +class LKHierarchyRowView: LKOutlineRowView { + /// Weak: the table keeps many row views around, and a strong reference + /// would keep display items (and their screenshots) alive across + /// hierarchy reloads. + weak var displayItem: LookinDisplayItem? { + didSet { + displayItemDidChange(from: oldValue) + } + } + + var minIndentLevel: Int = 0 + + /// 左侧的小蓝条图标 + private(set) var eventHandlerButton: NSButton? + + private var dataSource: LKHierarchyDataSource? + private var strikethroughLayer: CALayer? + private var eventHandlerButtonColorLayer: CALayer? + private var swiftUIBadge: NSImageView? + private var isFocusingHandlerButton = false { + didSet { + guard isFocusingHandlerButton != oldValue else { + return + } + updateEventHandlerButtonLayout() + updateEventHandlerButtonColors() + } + } + + init!(dataSource: LKHierarchyDataSource!) { + self.dataSource = dataSource + super.init(compactUI: false) + } + + required init?(coder _: NSCoder) { + fatalError("LKHierarchyRowView is not loaded from archives") + } + + override func layout() { + super.layout() + + if let eventHandlerButton { + LKHierarchyFrameLayout(eventHandlerButton).y(3).toBottom(3).width(10).x(3) + } + updateEventHandlerButtonLayout() + + // Put the SwiftUI badge between the title and the (optional) + // subtitle. The superclass placed the subtitle right after the title, + // where it would overlap the badge, so move it right and report the + // row width again. + if let swiftUIBadge, !swiftUIBadge.isHidden { + LKHierarchyFrameLayout(swiftUIBadge).sizeToFit().x(titleLabel.frame.maxX + 4).verAlign() + var finalMaxX = swiftUIBadge.frame.maxX + if !subtitleLabel.isHidden, subtitleLabel.alphaValue > 0 { + LKHierarchyFrameLayout(subtitleLabel).x(swiftUIBadge.frame.maxX + subtitleLeft) + finalMaxX = subtitleLabel.frame.maxX + } + horizontalScrollWidthManager?.rowDidLayout(withWidth: finalMaxX) + } + + if let strikethroughLayer, !strikethroughLayer.isHidden { + let maxX = subtitleLabel.isHidden ? titleLabel.frame.maxX + 2 : subtitleLabel.frame.maxX + 2 + LKHierarchyFrameLayout(strikethroughLayer) + .height(1) + .x(titleLabel.frame.minX - 1) + .toMaxX(maxX) + .midY(titleLabel.frame.midY + 1) + } + } + + override var isHovered: Bool { + didSet { + updateEventHandlerButtonColors() + } + } + + override class func insetLeft() -> CGFloat { + 13 + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateEventHandlerButtonColors() + } + + override func setIsDarkMode(_ isDarkMode: Bool) { + super.setIsDarkMode(isDarkMode) + updateStrikethroughLayer() + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + let point = convert(event.locationInWindow, from: nil) + isFocusingHandlerButton = point.x < 16 + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + isFocusingHandlerButton = false + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + for area in trackingAreas { + removeTrackingArea(area) + } + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + // MARK: - Rendering + + private func displayItemDidChange(from previousItem: LookinDisplayItem?) { + // Even when the item is unchanged its rowViewDelegate may point at + // another row: select a view, focus it, leave focus, and without this + // the row could no longer be deselected. + if let item = displayItem, item.rowViewDelegate !== self { + item.rowViewDelegate = self + } + if displayItem !== previousItem { + reRender() + } + } + + fileprivate func reRender() { + guard let item = displayItem else { + return + } + isRowSelected = (dataSource?.selectedItem === item) + isHovered = (dataSource?.hoveredItem === item) + image = resolveIconImage(for: item) + indentLevel = UInt(bitPattern: item.indentLevel() - minIndentLevel) + updateEventsButton() + updateExpandStatus() + updateStrikethroughLayer() + updateLabelStringsAndImageViewAlpha(for: item) + updateLabelsFonts(for: item) + updateSwiftUIBadge(for: item) + + needsLayout = true + } + + fileprivate func updateHoverState() { + isHovered = (dataSource?.hoveredItem === displayItem) + } + + private func updateSwiftUIBadge(for item: LookinDisplayItem) { + let isSwiftUINode = displayItemLooksLikeSwiftUI(item) + if isSwiftUINode, swiftUIBadge == nil { + let image = NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil) + image?.isTemplate = true + let badge = NSImageView() + badge.image = image + badge.contentTintColor = LKHelper.accentColor() + addSubview(badge) + swiftUIBadge = badge + } + swiftUIBadge?.isHidden = !isSwiftUINode + toolTip = isSwiftUINode ? NSLocalizedString("LookInside Pro · Activated", comment: "") : nil + } + + private func updateLabelStringsAndImageViewAlpha(for item: LookinDisplayItem) { + let titleColor: NSColor + let subtitleColor: NSColor + if isSelected { + titleColor = .white + subtitleColor = .white + imageView.alphaValue = 1 + } else if shouldFadeContent(of: item) { + titleColor = .tertiaryLabelColor + subtitleColor = .tertiaryLabelColor + imageView.alphaValue = 0.6 + } else { + titleColor = .labelColor + subtitleColor = .secondaryLabelColor + imageView.alphaValue = 1 + } + + let title = NSMutableAttributedString(string: item.title(), attributes: [.foregroundColor: titleColor]) + let subtitle = NSMutableAttributedString(string: item.subtitle() ?? "", attributes: [.foregroundColor: subtitleColor]) + + if item.isInSearch, let searchString = item.highlightedSearchString, !searchString.isEmpty { + // Filtering, and this row matched the search string. + let background = isDarkMode ? rgba(190, 120, 0, 1) : rgba(255, 240, 100, 1) + let attributes: [NSAttributedString.Key: Any] = [ + .backgroundColor: background, + .foregroundColor: NSColor.labelColor, + ] + for string in [title, subtitle] { + let range = (string.string as NSString).range(of: searchString, options: .caseInsensitive) + if range.location != NSNotFound { + string.addAttributes(attributes, range: range) + } + } + } + + titleLabel.attributedStringValue = title + subtitleLabel.attributedStringValue = subtitle + } + + private func updateEventsButton() { + guard let item = displayItem, !(item.eventHandlers ?? []).isEmpty else { + eventHandlerButton?.isHidden = true + return + } + if eventHandlerButton == nil { + let button = NSButton() + button.title = "" + button.target = self + button.action = #selector(handleClickEventHandlerButton(_:)) + button.wantsLayer = true + button.isBordered = false + button.bezelStyle = .roundRect + button.layer?.backgroundColor = NSColor.clear.cgColor + addSubview(button) + eventHandlerButton = button + + let colorLayer = CALayer() + colorLayer.actions = ["contents": NSNull()] + button.layer?.addSublayer(colorLayer) + eventHandlerButtonColorLayer = colorLayer + } + eventHandlerButton?.isHidden = false + updateEventHandlerButtonColors() + } + + private func updateStrikethroughLayer() { + guard let item = displayItem, item.hasPreviewBoxAbility(), item.inNoPreviewHierarchy else { + strikethroughLayer?.isHidden = true + return + } + let layer: CALayer + if let strikethroughLayer { + layer = strikethroughLayer + } else { + layer = CALayer() + layer.lookin_removeImplicitAnimations() + self.layer?.addSublayer(layer) + strikethroughLayer = layer + } + if isSelected { + layer.backgroundColor = NSColor.white.withAlphaComponent(0.75).cgColor + } else { + layer.backgroundColor = isDarkMode ? rgba(255, 255, 255, 0.2).cgColor : rgba(0, 0, 0, 0.2).cgColor + } + layer.isHidden = false + needsLayout = true + } + + private func updateEventHandlerButtonColors() { + guard let eventHandlerButton, !eventHandlerButton.isHidden else { + return + } + let color = isSelected ? NSColor.white : rgba(74, 144, 226, isFocusingHandlerButton ? 1 : 0.5) + eventHandlerButtonColorLayer?.backgroundColor = color.cgColor + } + + private func updateEventHandlerButtonLayout() { + guard let eventHandlerButtonColorLayer else { + return + } + let width: CGFloat = isFocusingHandlerButton ? 8 : 5 + LKHierarchyFrameLayout(eventHandlerButtonColorLayer).fullHeight().width(width).horAlign() + eventHandlerButtonColorLayer.cornerRadius = width / 2 + } + + private func updateLabelsFonts(for item: LookinDisplayItem) { + let noImage = item.inNoPreviewHierarchy || item.inHiddenHierarchy + let isPrivate = LKPrivateDiscriminatorStore.shared.isPrivateDisplayItem(item) + if !item.isUserCustom(), noImage || isPrivate { + titleLabel.font = LKHelper.italicFont(ofSize: 13) + subtitleLabel.font = LKHelper.italicFont(ofSize: 12) + } else { + titleLabel.font = .systemFont(ofSize: 13) + subtitleLabel.font = .systemFont(ofSize: 12) + } + needsLayout = true + } + + private func updateExpandStatus() { + guard let item = displayItem, item.isExpandable else { + status = .notExpandable + return + } + status = item.isExpanded ? .expanded : .collapsed + } + + private func shouldFadeContent(of item: LookinDisplayItem) -> Bool { + if item.isInSearch, (item.highlightedSearchString ?? "").isEmpty { + return true + } + guard item.hasPreviewBoxAbility() else { + return false + } + return item.inHiddenHierarchy || item.inNoPreviewHierarchy + } + + private func resolveIconImage(for item: LookinDisplayItem) -> NSImage? { + var imageName: String? + if item.isUserCustom() { + imageName = "hierarchy_custom" + } else if item.hostViewControllerObject != nil || item.hostWindowControllerObject != nil { + imageName = "hierarchy_controller" + } else { + switch item.resolvedNodeKind() { + case .view: + let classChain = item.viewObject?.classChainList ?? [] + imageName = classChain.lazy.compactMap { className in + classIconNames.first { $0.className == className }?.imageName + }.first ?? "hierarchy_view" + case .layer, .viewOuterLayer, .backingLayer: + // A view's outer layer and a backing layer node are still + // layers as far as the row icon goes. + for className in item.layerObject?.classChainList ?? [] { + if className == "CAShapeLayer" { + imageName = "hierarchy_shapelayer" + break + } + if className == "CAGradientLayer" { + imageName = "hierarchy_gradientlayer" + break + } + } + if imageName == nil { + imageName = "hierarchy_layer" + } + case .window, .windowScene: + imageName = "hierarchy_window" + case .layoutGuide: + imageName = "hierarchy_layoutguide" + case .cell: + imageName = "hierarchy_cell" + default: + break + } + } + let name = imageName ?? "hierarchy_view" + + if dataSource?.selectedItem === item, let selectedImage = NSImage(named: name + "_selected") { + return selectedImage + } + return NSImage(named: name) + } + + // MARK: - Event handlers + + @objc private func handleClickEventHandlerButton(_ button: NSButton) { + guard let item = displayItem else { + return + } + // A row inside a Live Doc's window is editable; rows in a read-only + // archive or launch window are not. + let editable = LookinLiveDocument.document(in: window) != nil + + let controller = LKHierarchyHandlersPopoverController(displayItem: item, editable: editable) + let popover = NSPopover() + popover.animates = false + popover.behavior = .transient + popover.contentSize = controller.neededSize() + popover.contentViewController = controller + popover.show(relativeTo: NSRect(origin: .zero, size: button.bounds.size), of: button, preferredEdge: .maxY) + } +} + +extension LKHierarchyRowView: LookinDisplayItemDelegate { + func displayItem(_: LookinDisplayItem!, propertyDidChange property: LookinDisplayItemProperty) { + if property == .isHovered { + updateHoverState() + } else { + reRender() + } + } +} diff --git a/LookInside/Hierarchy/LKHierarchyView.h b/LookInside/Hierarchy/LKHierarchyView.h deleted file mode 100644 index b177557b..00000000 --- a/LookInside/Hierarchy/LKHierarchyView.h +++ /dev/null @@ -1,61 +0,0 @@ -// -// LKHierarchyView.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKBaseView.h" -#import "LKHierarchyRowView.h" - -@class LKHierarchyView, LKTableView, LKHierarchyDataSource, LKStaticAsyncUpdateManager; - -@protocol LKHierarchyViewDelegate - -- (void)hierarchyView:(LKHierarchyView *)view didSelectItem:(LookinDisplayItem *)item; - -- (void)hierarchyView:(LKHierarchyView *)view didDoubleClickItem:(LookinDisplayItem *)item; - -- (void)hierarchyView:(LKHierarchyView *)view didHoverAtItem:(LookinDisplayItem *)item; - -- (void)hierarchyView:(LKHierarchyView *)view needToExpandItem:(LookinDisplayItem *)item recursively:(BOOL)recursively; - -- (void)hierarchyView:(LKHierarchyView *)view needToCollapseItem:(LookinDisplayItem *)item; - -- (void)hierarchyView:(LKHierarchyView *)view needToCollapseChildrenOfItem:(LookinDisplayItem *)item; - -/// 在底部的搜索框里输入了文字,string 可能为空字符串或 nil -/// 当用户通过搜索框的关闭按钮、ESC 等方式手动结束搜索时,该方法同样会被调用,参数是 nil -- (void)hierarchyView:(LKHierarchyView *)view didInputSearchString:(NSString *)string; - -@optional - -- (void)hierarchyView:(LKHierarchyView *)view needToCancelPreviewOfItem:(LookinDisplayItem *)item; - -- (void)hierarchyView:(LKHierarchyView *)view needToShowPreviewOfItem:(LookinDisplayItem *)item; - -@end - -@interface LKHierarchyView : LKBaseView - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource; - -@property(nonatomic, strong, readonly) LKTableView *tableView; - -@property(nonatomic, strong) LKHierarchyDataSource *dataSource; - -/// Phase A 引入:由 owner(LKStaticHierarchyController / LKStaticViewController 链路)注入的 per-instance update manager(weak)。 -/// 若 nil 则 fallback 到 +sharedInstance,以保留 read-only / archive workspace 的 legacy 行为。 -@property(nonatomic, weak) LKStaticAsyncUpdateManager *asyncUpdateManager; - -@property(nonatomic, weak) id delegate; - -- (void)scrollToMakeItemVisible:(LookinDisplayItem *)item; - -- (void)updateGuidesWithHoveredItem:(LookinDisplayItem *)item; - -/// 激活搜索框 -- (void)activateSearchBar; - -@end diff --git a/LookInside/Hierarchy/LKHierarchyView.m b/LookInside/Hierarchy/LKHierarchyView.m deleted file mode 100644 index ac19cfaa..00000000 --- a/LookInside/Hierarchy/LKHierarchyView.m +++ /dev/null @@ -1,666 +0,0 @@ -// -// LKHierarchyView.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKHierarchyView.h" -#import "LKStaticHierarchyDataSource.h" -#import "LookinDisplayItem.h" -#import "LKPreferenceManager.h" -#import "LKWindowController.h" -#import "LKTableView.h" -#import "LookinIvarTrace.h" -#import "LKNavigationManager.h" -#import "LKTextFieldView.h" -#import "LKTipsView.h" -#import "LKStaticAsyncUpdateManager.h" -#import "LKAppsManager.h" -#import "LKInspectableApp.h" -#import "LookinDisplayItem+LookinClient.h" - -static NSString * const kMenuBindKey_RowView = @"view"; -static CGFloat const kRowHeight = 28; -extern NSString *const LKAppShowConsoleNotificationName; - -@interface LKHierarchyView () - -@property(nonatomic, strong) LKVisualEffectView *backgroundEffectView; - -@property(nonatomic, strong) CAShapeLayer *guidesShapeLayer; - -@property(nonatomic, strong) LKTextFieldView *searchTextFieldView; - -@property(nonatomic, strong) LKLabel *emptyDataLabel; - -@property(nonatomic, copy) NSArray *displayItems; - -@property(nonatomic, assign) NSInteger minIndentLevel; - -- (NSMenuItem *)_jumpMenuItemWithTitle:(NSString *)title targetItem:(LookinDisplayItem *)targetItem; - -@end - -@implementation LKHierarchyView - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource { - if (self = [super initWithFrame:CGRectZero]) { - self.dataSource = dataSource; - - self.backgroundEffectView = [LKVisualEffectView new]; - self.backgroundEffectView.material = NSVisualEffectMaterialSidebar; - self.backgroundEffectView.blendingMode = NSVisualEffectBlendingModeBehindWindow; - self.backgroundEffectView.state = NSVisualEffectStateActive; - [self addSubview:self.backgroundEffectView]; - - _tableView = [LKTableView new]; - self.tableView.adjustsSelectionAutomatically = NO; - self.tableView.delegate = self; - self.tableView.dataSource = self; - [self addSubview:self.tableView]; - [self.tableView reloadData]; - - self.guidesShapeLayer = [CAShapeLayer layer]; - self.guidesShapeLayer.lineWidth = 1; - self.guidesShapeLayer.hidden = YES; - [self.guidesShapeLayer lookin_removeImplicitAnimations]; - [self.guidesShapeLayer setLineDashPattern:[NSArray arrayWithObjects:[NSNumber numberWithInt:2], [NSNumber numberWithInt:2], nil]]; - [self.tableView.contentView.documentView.layer addSublayer:self.guidesShapeLayer]; - - self.searchTextFieldView = [LKTextFieldView new]; - self.searchTextFieldView.textField.placeholderString = NSLocalizedString(@"Filter", nil); - [self.searchTextFieldView initCloseButton]; - self.searchTextFieldView.insets = NSEdgeInsetsMake(0, 7, 0, 1); - self.searchTextFieldView.textField.font = NSFontMake(13); - self.searchTextFieldView.textField.usesSingleLineMode = YES; - self.searchTextFieldView.textField.lineBreakMode = NSLineBreakByTruncatingTail; - self.searchTextFieldView.textField.drawsBackground = NO; - self.searchTextFieldView.textField.bordered = NO; - self.searchTextFieldView.textField.focusRingType = NSFocusRingTypeNone; - self.searchTextFieldView.borderPosition = LKViewBorderPositionTop; - self.searchTextFieldView.borderColors = LKColorsCombine(LookinColorMake(200, 201, 202), LookinColorMake(67, 68, 69)); - self.searchTextFieldView.image = NSImageMake(@"icon_hierarchy_search"); - self.searchTextFieldView.textField.delegate = self; - [self addSubview:self.searchTextFieldView]; - @weakify(self); - [[[self.searchTextFieldView.textField.rac_textSignal throttle:0.5] skip:1] subscribeNext:^(NSString * _Nullable x) { - @strongify(self); - x = [x stringByReplacingOccurrencesOfString:@"\\s" withString:@"" options:NSRegularExpressionSearch range:NSMakeRange(0, x.length)]; // 去掉所有空白字符 - if ([self.delegate respondsToSelector:@selector(hierarchyView:didInputSearchString:)]) { - [self.delegate hierarchyView:self didInputSearchString:x]; - } - }]; - self.searchTextFieldView.closeButton.target = self; - self.searchTextFieldView.closeButton.action = @selector(_handleSearchCloseButton); - - [[RACObserve(self.tableView, contentInsets) distinctUntilChanged] subscribeNext:^(NSValue *x) { - CGFloat insetTop = [x edgeInsetsValue].top; - [LKNavigationManager sharedInstance].windowTitleBarHeight = insetTop; - }]; - - [RACObserve(dataSource, selectedItem) subscribeNext:^(LookinDisplayItem * _Nullable item) { - @strongify(self); - [self.tableView reloadDataWithOffset]; - [self scrollToMakeItemVisible:item]; - }]; - - [[RACObserve(self.dataSource, hoveredItem) distinctUntilChanged] subscribeNext:^(LookinDisplayItem * _Nullable x) { - @strongify(self); - [self updateGuidesWithHoveredItem:x]; - }]; - - [RACObserve(dataSource, displayingFlatItems) subscribeNext:^(NSArray *x) { - @strongify(self); - [self renderWithDisplayItems:x]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self updateGuidesWithHoveredItem:self.dataSource.hoveredItem]; - }); - }]; - [[RACObserve(dataSource, displayingFlatItems) throttle:.75] subscribeNext:^(id _Nullable x) { - @strongify(self); - [self _bringGuidesLayerToFront]; - }]; - [RACObserve(dataSource, state) subscribeNext:^(NSNumber *x) { - @strongify(self); - // 隐藏搜索功能,避免同时处于 focus 和 search 状态,从而避免状态维护太过复杂 - LKHierarchyDataSourceState state = x.unsignedIntegerValue; - BOOL isFocus = (state == LKHierarchyDataSourceStateFocus); - self.searchTextFieldView.hidden = isFocus; - - if (isFocus && self.searchTextFieldView.textField.stringValue.length > 0) { - self.searchTextFieldView.textField.stringValue = @""; - [self.window makeFirstResponder:nil]; - } - }]; - - [self updateColors]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.backgroundEffectView).fullFrame; - $(self.searchTextFieldView).fullWidth.height(25).bottom(0); - - $(self.tableView).fullFrame.y(self.searchTextFieldView.$y); - $(self.tableView).fullFrame.toMaxY(self.searchTextFieldView.$y); - - $(self.guidesShapeLayer).frame(CGRectZero); - - if (self.emptyDataLabel.isVisible) { - $(self.emptyDataLabel).sizeToFit.centerAlign; - } -} - -- (void)renderWithDisplayItems:(NSArray *)displayItems { - self.displayItems = displayItems; - - _minIndentLevel = [[displayItems lookin_reduce:^NSNumber *(NSNumber *accumulator, NSUInteger idx, LookinDisplayItem *obj) { - NSInteger res = accumulator ? MIN(accumulator.integerValue, obj.indentLevel):obj.indentLevel; - return @(res); - }] integerValue]; - - [self.tableView reloadDataWithOffset]; - - if (displayItems.count == 0) { - if (!self.emptyDataLabel) { - self.emptyDataLabel = [LKLabel new]; - self.emptyDataLabel.font = NSFontMake(15); - self.emptyDataLabel.stringValue = NSLocalizedString(@"No Filter Results", nil); - [self addSubview:self.emptyDataLabel]; - } - self.emptyDataLabel.hidden = NO; - [self setNeedsLayout:YES]; - } else { - self.emptyDataLabel.hidden = YES; - } -} - -- (void)scrollToMakeItemVisible:(LookinDisplayItem *)item { - if (!item) { - return; - } - NSUInteger row = [self.displayItems indexOfObject:item]; - if (row < 0 || row == NSNotFound) { - return; - } - [self.tableView scrollRowToVisible:row]; -} - -- (void)updateColors { - [super updateColors]; - self.guidesShapeLayer.strokeColor = self.isDarkMode ? LookinColorRGBAMake(255, 255, 255, .3).CGColor : LookinColorRGBAMake(0, 0, 0, .3).CGColor; -} - -- (void)_exitAndClearSearch { - self.searchTextFieldView.textField.stringValue = @""; - - [self.window makeFirstResponder:nil]; - - if ([self.delegate respondsToSelector:@selector(hierarchyView:didInputSearchString:)]) { - [self.delegate hierarchyView:self didInputSearchString:nil]; - } -} - -- (void)activateSearchBar { - [self.searchTextFieldView.textField becomeFirstResponder]; -} - -#pragma mark - NSTableView - -- (void)tableView:(LKTableView *)tableView didHoverAtRow:(NSInteger)row { - // 注意这里 item 可能为 nil - LookinDisplayItem *item = [self _safeItemAtRow:row]; - - if ([self.delegate respondsToSelector:@selector(hierarchyView:didHoverAtItem:)]) { - [self.delegate hierarchyView:self didHoverAtItem:item]; - } -} - -- (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row { - return kRowHeight; -} - -- (NSInteger)numberOfRowsInTableView:(NSTableView *)tableView { - return self.displayItems.count; -} - -- (LKTableRowView *)tableView:(NSTableView *)tableView rowViewForRow:(NSInteger)row { - LookinDisplayItem *item = [self _safeItemAtRow:row]; - if (!item) { - NSAssert(NO, @""); - return [LKTableBlankRowView new]; - } - - LKHierarchyRowView *view = [tableView makeViewWithIdentifier:@"cell" owner:self]; - if (!view) { - view = [[LKHierarchyRowView alloc] initWithDataSource:self.dataSource]; - view.disclosureButton.target = self; - view.disclosureButton.action = @selector(_handleDisclosureButton:); - view.identifier = @"cell"; - - view.menu = [NSMenu new]; - view.menu.autoenablesItems = YES; - [view.menu lookin_bindObjectWeakly:view forKey:kMenuBindKey_RowView]; - view.menu.delegate = self; - } - - view.minIndentLevel = self.minIndentLevel; - view.displayItem = item; - - view.disclosureButton.tag = row; - return view; -} - -- (void)tableView:(NSTableView *)tableView didSelectRow:(NSInteger)row { - LookinDisplayItem *item = [self _safeItemAtRow:row]; - if (!item) { - return; - } - - if ([self.delegate respondsToSelector:@selector(hierarchyView:didSelectItem:)]) { - [self.delegate hierarchyView:self didSelectItem:item]; - } - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self _bringGuidesLayerToFront]; - }); -} - -- (void)tableView:(NSTableView *)tableView didDoubleClickAtRow:(NSInteger)row { - LookinDisplayItem *item = [self _safeItemAtRow:row]; - if (!item) { - return; - } - if ([self.delegate respondsToSelector:@selector(hierarchyView:didDoubleClickItem:)]) { - [self.delegate hierarchyView:self didDoubleClickItem:item]; - } -} - -#pragma mark - - -- (void)menuNeedsUpdate:(NSMenu *)menu { - LKHierarchyRowView *rowView = [menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *displayItem = rowView.displayItem; - - [menu removeAllItems]; - - if (!displayItem.isUserCustom) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleFocusCurrentItem:); - item.title = NSLocalizedString(@"Focus", nil); - item; - })]; - if (!self.dataSource.isReadOnly) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handlePrintItem:); - item.title = NSLocalizedString(@"Print", nil); - item; - })]; - } - - [menu addItem:[NSMenuItem separatorItem]]; - - if (!self.dataSource.isReadOnly) { - BOOL isUpdating = self.asyncUpdateManager.isUpdating; - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.title = NSLocalizedString(@"Reload layer", nil); - if (!isUpdating) { - item.target = self; - item.action = @selector(_handleReloadSelfItem:); - } - item; - })]; - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.title = NSLocalizedString(@"Reload layer and its children", nil); - if (!isUpdating) { - item.target = self; - item.action = @selector(_handleReloadSelfAndChildrenItem:); - } - item; - })]; - } - } - - // 复制文字 - NSMutableArray *stringsToCopy = [NSMutableArray array]; - - BOOL doNotCopyTitle = NO; - if ([displayItem.title hasPrefix:@"UI"] || [displayItem.title hasPrefix:@"CA"]) { - if (displayItem.title.length < 10) { - // 不显示常见的 UIView、CALayer 等系统类,避免干扰 - doNotCopyTitle = YES; - } - } - if (!doNotCopyTitle) { - [stringsToCopy addObject:displayItem.title]; - - } - NSString *hostViewControllerName = displayItem.hostViewControllerObject.lk_simpleDemangledClassName; - if (hostViewControllerName.length) { - [stringsToCopy addObject:hostViewControllerName]; - } - NSString *hostWindowControllerName = displayItem.hostWindowControllerObject.lk_simpleDemangledClassName; - if (hostWindowControllerName.length) { - [stringsToCopy addObject:hostWindowControllerName]; - } - if (displayItem.displayingObject.ivarTraces.count) { - NSArray *ivarNames = [[displayItem.displayingObject.ivarTraces lookin_map:^id(NSUInteger idx, LookinIvarTrace *value) { - NSString *name = value.ivarName; - if ([name hasPrefix:@"_"]) { - name = [name substringFromIndex:1]; - } - return name; - }] lookin_nonredundantArray]; - if (ivarNames.count) { - [stringsToCopy addObjectsFromArray:ivarNames]; - } - } - [stringsToCopy enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (idx == 0) { - [menu addItem:[NSMenuItem separatorItem]]; - } - if (obj.length == 0) { - NSAssert(NO, @"LKHierarchyView, menuNeedsUpdate, stringsToCopy length is zero."); - return; - } - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleCopyDisplayItemName:); - item.representedObject = obj; - item.title = [NSString stringWithFormat:NSLocalizedString(@"Copy text \"%@\"", nil), obj]; - item; - })]; - }]; - - NSArray *backingLayerItems = [self.dataSource swiftUIBackingLayerItemsForItem:displayItem]; - LookinDisplayItem *swiftUISourceItem = [self.dataSource swiftUISourceItemForLayerItem:displayItem]; - if (backingLayerItems.count || swiftUISourceItem) { - [menu addItem:[NSMenuItem separatorItem]]; - if (backingLayerItems.count == 1) { - [menu addItem:[self _jumpMenuItemWithTitle:NSLocalizedString(@"Jump to backing CALayer", nil) targetItem:backingLayerItems.firstObject]]; - } else if (backingLayerItems.count > 1) { - NSMenuItem *submenuItem = [NSMenuItem new]; - submenuItem.title = NSLocalizedString(@"Jump to backing CALayer", nil); - NSMenu *submenu = [NSMenu new]; - [backingLayerItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { - NSString *title = [NSString stringWithFormat:@"%@ %@", item.title ?: @"CALayer", item.layerObject.memoryAddress ?: @""]; - [submenu addItem:[self _jumpMenuItemWithTitle:title targetItem:item]]; - }]; - submenuItem.submenu = submenu; - [menu addItem:submenuItem]; - } - if (swiftUISourceItem) { - [menu addItem:[self _jumpMenuItemWithTitle:NSLocalizedString(@"Jump to SwiftUI node", nil) targetItem:swiftUISourceItem]]; - } - } - - [menu addItem:[NSMenuItem separatorItem]]; - - if (displayItem.isExpandable) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleExpandRecursively:); - item.title = NSLocalizedString(@"Expand recursively", nil); - item; - })]; - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleCollapseChildren:); - item.title = NSLocalizedString(@"Collapse children", nil); - item; - })]; - } - - // 显示和隐藏图像 - [menu addItem:[NSMenuItem separatorItem]]; - - if ([displayItem hasPreviewBoxAbility]) { - if (displayItem.inNoPreviewHierarchy) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleShowPreview:); - if (displayItem.doNotFetchScreenshotReason == LookinFetchScreenshotPermitted) { - item.title = NSLocalizedString(@"Show screenshot", nil); - } else { - item.title = NSLocalizedString(@"Show layer border", nil); - } - item; - })]; - } else { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleCancelPreview:); - item.title = NSLocalizedString(@"Hide screenshot this time", nil); - item; - })]; - } - } - - if (!displayItem.isUserCustom && !displayItem.inNoPreviewHierarchy) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleHideScreenshotForever); - item.title = NSLocalizedString(@"Hide screenshot forever…", nil); - item; - })]; - } - - [menu addItem:[NSMenuItem separatorItem]]; - - if (displayItem.groupScreenshot) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleExportScreenshot:); - item.title = NSLocalizedString(@"Export screenshot…", nil); - item; - })]; - } -} - -#pragma mark - - -- (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)commandSelector { - if (commandSelector == NSSelectorFromString(@"cancelOperation:")) { - // 按下了 esc 键 - [self _exitAndClearSearch]; - return YES; - } - return NO; -} - -#pragma mark - Events Handler -- (void)_handlePrintItem:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *item = view.displayItem; - [[NSNotificationCenter defaultCenter] postNotificationName:LKAppShowConsoleNotificationName object:item]; -} - -- (void)_handleReloadSelfItem:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *item = view.displayItem; - if (!item) { - return; - } - [self.asyncUpdateManager reloadSingleDisplayItem:item]; -} - -- (void)_handleReloadSelfAndChildrenItem:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *item = view.displayItem; - if (!item) { - return; - } - [self.asyncUpdateManager reloadDisplayItemAndChildren:item]; -} - -- (void)_handleFocusCurrentItem:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *item = view.displayItem; - if (!item) { - NSAssert(NO, @""); - return; - } - [self.dataSource focusDisplayItem:item]; -} - -- (NSMenuItem *)_jumpMenuItemWithTitle:(NSString *)title targetItem:(LookinDisplayItem *)targetItem { - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleJumpToDisplayItem:); - item.title = title; - item.representedObject = targetItem; - return item; -} - -- (void)_handleJumpToDisplayItem:(NSMenuItem *)menuItem { - LookinDisplayItem *targetItem = menuItem.representedObject; - [self.dataSource selectAndRevealItem:targetItem]; -} - -- (void)_handleSearchCloseButton { - [self _exitAndClearSearch]; -} - -- (void)_handleShowPreview:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *item = view.displayItem; - if ([self.delegate respondsToSelector:@selector(hierarchyView:needToShowPreviewOfItem:)]) { - [self.delegate hierarchyView:self needToShowPreviewOfItem:item]; - } -} - -- (void)_handleCancelPreview:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *item = view.displayItem; - if ([self.delegate respondsToSelector:@selector(hierarchyView:needToCancelPreviewOfItem:)]) { - [self.delegate hierarchyView:self needToCancelPreviewOfItem:item]; - } -} - -- (void)_handleExportScreenshot:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - [LKExportManager exportScreenshotWithDisplayItem:view.displayItem]; -} - -- (void)_handleCopyDisplayItemName:(NSMenuItem *)menuItem { - NSString *stringToCopy = menuItem.representedObject; - - NSPasteboard *paste = [NSPasteboard generalPasteboard]; - [paste clearContents]; - [paste writeObjects:@[stringToCopy]]; -} - -- (void)_handleExpandRecursively:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *item = view.displayItem; - NSAssert(item, @""); - if ([self.delegate respondsToSelector:@selector(hierarchyView:needToExpandItem:recursively:)]) { - [self.delegate hierarchyView:self needToExpandItem:item recursively:YES]; - } -} - -- (void)_handleCollapseChildren:(NSMenuItem *)menuItem { - LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; - LookinDisplayItem *item = view.displayItem; - NSAssert(item, @""); - if ([self.delegate respondsToSelector:@selector(hierarchyView:needToCollapseChildrenOfItem:)]) { - [self.delegate hierarchyView:self needToCollapseChildrenOfItem:item]; - } -} - -- (void)_handleDisclosureButton:(NSButton *)button { - NSUInteger row = button.tag; - LookinDisplayItem *item = [self _safeItemAtRow:row]; - if (!item.isExpandable) { - NSAssert(NO, @""); - return; - } - if (item.isExpanded) { - if ([self.delegate respondsToSelector:@selector(hierarchyView:needToCollapseItem:)]) { - [self.delegate hierarchyView:self needToCollapseItem:item]; - } - } else { - if ([self.delegate respondsToSelector:@selector(hierarchyView:needToExpandItem:recursively:)]) { - [self.delegate hierarchyView:self needToExpandItem:item recursively:NO]; - } - } -} - -- (void)_handleHideScreenshotForever { - [LKHelper openCustomConfigWebsite]; -} - -#pragma mark - Guides - -- (void)updateGuidesWithHoveredItem:(LookinDisplayItem *)item { - if (!item) { - self.guidesShapeLayer.hidden = YES; - return; - } - - LookinDisplayItem *rootItem = item.superItem; - NSUInteger rootRow = [self.displayItems indexOfObject:rootItem]; - NSArray *childrenItems = [rootItem.subitems lookin_filter:^BOOL(LookinDisplayItem *obj) { - return [self.displayItems containsObject:obj]; - }]; - - if (rootRow == NSNotFound || childrenItems.count == 0) { - self.guidesShapeLayer.hidden = YES; - return; - } - - CGFloat rootX = [LKHierarchyRowView dislosureMidXWithIndentLevel:rootItem.indentLevel - self.minIndentLevel]; - CGFloat rootY = kRowHeight * rootRow + kRowHeight / 2.0; - CGFloat rootMaxY = [self.displayItems indexOfObject:childrenItems.lastObject] * kRowHeight + kRowHeight / 2.0; - - CGMutablePathRef path = CGPathCreateMutable(); - CGPathMoveToPoint(path, NULL, rootX, rootY); - CGPathAddLineToPoint(path, NULL, rootX, rootMaxY); - [childrenItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat childrenY = [self.displayItems indexOfObject:obj] * kRowHeight + kRowHeight / 2.0; - CGPathMoveToPoint(path, NULL, rootX, childrenY); - CGFloat maxX = obj.isExpandable ? (rootX + 10) : (rootX + 28); - CGPathAddLineToPoint(path, NULL, maxX, childrenY); - }]; - [self.guidesShapeLayer setPath:path]; - self.guidesShapeLayer.hidden = NO; - CGPathRelease(path); -} - -- (void)_bringGuidesLayerToFront { - [self.guidesShapeLayer removeFromSuperlayer]; - [self.tableView.contentView.documentView.layer addSublayer:self.guidesShapeLayer]; -} - -#pragma mark - Others - -- (LookinDisplayItem *)_safeItemAtRow:(NSInteger)row { - return [self.displayItems lookin_hasIndex:row] ? self.displayItems[row] : nil; -} - -- (LKStaticHierarchyDataSource *)realtimeDataSource { - if (![self.dataSource isKindOfClass:[LKStaticHierarchyDataSource class]]) { - NSAssert(NO, @""); - return nil; - } - return (LKStaticHierarchyDataSource *)self.dataSource; -} - -@end diff --git a/LookInside/Hierarchy/LKHierarchyView.swift b/LookInside/Hierarchy/LKHierarchyView.swift new file mode 100644 index 00000000..54eebce4 --- /dev/null +++ b/LookInside/Hierarchy/LKHierarchyView.swift @@ -0,0 +1,649 @@ +// +// LKHierarchyView.swift +// LookInside +// +// Created by Li Kai on 2018/8/4. +// https://lookin.work +// + +import AppKit + +private let rowHeight: CGFloat = 28 + +/// Posted with the display item as object to print it in the console; +/// LKStaticViewController shows the console. +private let showConsoleNotification = Notification.Name("LKAppShowConsoleNotificationName") + +/// A row's context menu, which knows the row it belongs to. +private final class LKHierarchyRowMenu: NSMenu { + weak var rowView: LKHierarchyRowView? +} + +/// Runs the latest scheduled action once no new one arrived for `delay`. +private final class LKHierarchyDebouncer { + private let delay: TimeInterval + private var pending: DispatchWorkItem? + + init(delay: TimeInterval) { + self.delay = delay + } + + func schedule(_ action: @escaping () -> Void) { + pending?.cancel() + let item = DispatchWorkItem(block: action) + pending = item + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item) + } + + deinit { + pending?.cancel() + } +} + +@objc protocol LKHierarchyViewDelegate: NSObjectProtocol { + @objc(hierarchyView:didSelectItem:) + func hierarchyView(_ view: LKHierarchyView!, didSelect item: LookinDisplayItem!) + + @objc(hierarchyView:didDoubleClickItem:) + func hierarchyView(_ view: LKHierarchyView!, didDoubleClick item: LookinDisplayItem!) + + @objc(hierarchyView:didHoverAtItem:) + func hierarchyView(_ view: LKHierarchyView!, didHoverAt item: LookinDisplayItem!) + + @objc(hierarchyView:needToExpandItem:recursively:) + func hierarchyView(_ view: LKHierarchyView!, needToExpand item: LookinDisplayItem!, recursively: Bool) + + @objc(hierarchyView:needToCollapseItem:) + func hierarchyView(_ view: LKHierarchyView!, needToCollapse item: LookinDisplayItem!) + + @objc(hierarchyView:needToCollapseChildrenOfItem:) + func hierarchyView(_ view: LKHierarchyView!, needToCollapseChildrenOf item: LookinDisplayItem!) + + /// 在底部的搜索框里输入了文字,string 可能为空字符串或 nil + /// 当用户通过搜索框的关闭按钮、ESC 等方式手动结束搜索时,该方法同样会被调用,参数是 nil + @objc(hierarchyView:didInputSearchString:) + func hierarchyView(_ view: LKHierarchyView!, didInputSearch string: String!) + + @objc(hierarchyView:needToCancelPreviewOfItem:) + optional func hierarchyView(_ view: LKHierarchyView!, needToCancelPreviewOf item: LookinDisplayItem!) + + @objc(hierarchyView:needToShowPreviewOfItem:) + optional func hierarchyView(_ view: LKHierarchyView!, needToShowPreviewOf item: LookinDisplayItem!) +} + +@objc(LKHierarchyView) +@objcMembers +class LKHierarchyView: LKBaseView { + let tableView: LKTableView! + /// The filter field under the rows. The DEBUG UI snapshots type into it. + let searchTextFieldView: LKTextFieldView! + var dataSource: LKHierarchyDataSource! + /// Phase A 引入:由 owner(LKStaticHierarchyController / LKStaticViewController 链路)注入的 per-instance update manager(weak)。 + /// 若 nil 则 fallback 到 +sharedInstance,以保留 read-only / archive workspace 的 legacy 行为。 + weak var asyncUpdateManager: LKStaticAsyncUpdateManager? + weak var delegate: LKHierarchyViewDelegate? + + private let backgroundEffectView = LKVisualEffectView() + private let guidesShapeLayer = CAShapeLayer() + private var emptyDataLabel: LKLabel? + private var displayItems: [LookinDisplayItem] = [] + private var minIndentLevel = 0 + private var observations: [NSKeyValueObservation] = [] + private var textChangeObserver: NSObjectProtocol? + private let searchDebouncer = LKHierarchyDebouncer(delay: 0.5) + private let guidesDebouncer = LKHierarchyDebouncer(delay: 0.75) + + init!(dataSource: LKHierarchyDataSource!) { + self.dataSource = dataSource + tableView = LKTableView() + searchTextFieldView = LKTextFieldView() + super.init(frame: .zero) + + backgroundEffectView.material = .sidebar + backgroundEffectView.blendingMode = .behindWindow + backgroundEffectView.state = .active + addSubview(backgroundEffectView) + + tableView.adjustsSelectionAutomatically = false + tableView.delegate = self + tableView.dataSource = self + addSubview(tableView) + tableView.reloadData() + + guidesShapeLayer.lineWidth = 1 + guidesShapeLayer.isHidden = true + guidesShapeLayer.lookin_removeImplicitAnimations() + guidesShapeLayer.lineDashPattern = [2, 2] + tableView.contentView.documentView?.layer?.addSublayer(guidesShapeLayer) + + setUpSearchField() + observe(dataSource) + updateColors() + } + + required init?(coder _: NSCoder) { + fatalError("LKHierarchyView is not loaded from archives") + } + + deinit { + if let textChangeObserver { + NotificationCenter.default.removeObserver(textChangeObserver) + } + } + + override func layout() { + super.layout() + LKHierarchyFrameLayout(backgroundEffectView).fullFrame() + LKHierarchyFrameLayout(searchTextFieldView).fullWidth().height(25).bottom(0) + + LKHierarchyFrameLayout(tableView).fullFrame().y(searchTextFieldView.frame.minY) + LKHierarchyFrameLayout(tableView).fullFrame().toMaxY(searchTextFieldView.frame.minY) + + LKHierarchyFrameLayout(guidesShapeLayer).frame(.zero) + + if let emptyDataLabel, !emptyDataLabel.isHidden, emptyDataLabel.alphaValue > 0 { + LKHierarchyFrameLayout(emptyDataLabel).sizeToFit().centerAlign() + } + } + + override func updateColors() { + super.updateColors() + guidesShapeLayer.strokeColor = isDarkMode() + ? NSColor(red: 1, green: 1, blue: 1, alpha: 0.3).cgColor + : NSColor(red: 0, green: 0, blue: 0, alpha: 0.3).cgColor + } + + @objc(scrollToMakeItemVisible:) + func scroll(toMakeItemVisible item: LookinDisplayItem!) { + guard let item, let row = displayItems.firstIndex(where: { $0 === item }) else { + return + } + tableView.scrollRowToVisible(row) + } + + /// 激活搜索框 + func activateSearchBar() { + searchTextFieldView.textField.becomeFirstResponder() + } + + // MARK: - Setup + + private func setUpSearchField() { + let textField = searchTextFieldView.textField + textField.placeholderString = NSLocalizedString("Filter", comment: "") + searchTextFieldView.initCloseButton() + searchTextFieldView.insets = NSEdgeInsets(top: 0, left: 7, bottom: 0, right: 1) + textField.font = .systemFont(ofSize: 13) + textField.usesSingleLineMode = true + textField.lineBreakMode = .byTruncatingTail + textField.drawsBackground = false + textField.isBordered = false + textField.focusRingType = .none + searchTextFieldView.borderPosition = .top + searchTextFieldView.borderColors = LKTwoColors( + colorInLightMode: NSColor(red: 200 / 255, green: 201 / 255, blue: 202 / 255, alpha: 1), + colorInDarkMode: NSColor(red: 67 / 255, green: 68 / 255, blue: 69 / 255, alpha: 1) + ) + searchTextFieldView.image = NSImage(named: "icon_hierarchy_search") + textField.delegate = self + addSubview(searchTextFieldView) + + // What the user types reaches the delegate 0.5 s after the last + // keystroke, without any whitespace. + textChangeObserver = NotificationCenter.default.addObserver( + forName: NSControl.textDidChangeNotification, + object: textField, + queue: nil + ) { [weak self] _ in + self?.searchDebouncer.schedule { [weak self] in + self?.deliverSearchString() + } + } + + searchTextFieldView.closeButton?.target = self + searchTextFieldView.closeButton?.action = #selector(handleSearchCloseButton) + } + + private func observe(_ dataSource: LKHierarchyDataSource?) { + var observations = [ + // Reads the insets from the scroll view: NSEdgeInsets does not + // bridge from the NSValue KVO delivers, so change.newValue is + // always nil and the title bar height was never passed on. + tableView.observe(\.contentInsets, options: [.initial, .new]) { tableView, _ in + MainActor.assumeIsolated { + let top = tableView.contentInsets.top + let navigation = LKNavigationManager.shared + if navigation.windowTitleBarHeight != top { + navigation.windowTitleBarHeight = top + } + } + }, + ] + guard let dataSource else { + self.observations = observations + return + } + + observations.append(dataSource.observe(\.selectedItem, options: [.initial]) { [weak self] dataSource, _ in + guard let self else { return } + tableView.reloadDataWithOffset() + scroll(toMakeItemVisible: dataSource.selectedItem) + }) + + var lastHoveredItem: LookinDisplayItem? + var hasHoveredItem = false + observations.append(dataSource.observe(\.hoveredItem, options: [.initial]) { [weak self] dataSource, _ in + let item = dataSource.hoveredItem + if hasHoveredItem, item === lastHoveredItem { + return + } + hasHoveredItem = true + lastHoveredItem = item + self?.updateGuides(withHoveredItem: item) + }) + + observations.append(dataSource.observe(\.displayingFlatItems, options: [.initial]) { [weak self] dataSource, _ in + guard let self else { return } + render(dataSource.displayingFlatItems ?? []) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + updateGuides(withHoveredItem: self.dataSource.hoveredItem) + } + guidesDebouncer.schedule { [weak self] in + self?.bringGuidesLayerToFront() + } + }) + + observations.append(dataSource.observe(\.state, options: [.initial]) { [weak self] dataSource, _ in + guard let self else { return } + // Hide the filter while focusing, so the hierarchy is never in + // both states at once. + let isFocus = dataSource.state == .focus + searchTextFieldView.isHidden = isFocus + if isFocus, !searchTextFieldView.textField.stringValue.isEmpty { + searchTextFieldView.textField.stringValue = "" + window?.makeFirstResponder(nil) + } + }) + self.observations = observations + } + + // MARK: - Rendering + + private func render(_ items: [LookinDisplayItem]) { + displayItems = items + minIndentLevel = items.map { $0.indentLevel() }.min() ?? 0 + + tableView.reloadDataWithOffset() + + if items.isEmpty { + let label: LKLabel + if let emptyDataLabel { + label = emptyDataLabel + } else { + label = LKLabel() + label.font = .systemFont(ofSize: 15) + label.stringValue = NSLocalizedString("No Filter Results", comment: "") + addSubview(label) + emptyDataLabel = label + } + label.isHidden = false + needsLayout = true + } else { + emptyDataLabel?.isHidden = true + } + } + + private func deliverSearchString() { + let raw = searchTextFieldView.textField.stringValue + let string = raw.replacingOccurrences(of: "\\s", with: "", options: .regularExpression) + delegate?.hierarchyView(self, didInputSearch: string) + } + + private func exitAndClearSearch() { + searchTextFieldView.textField.stringValue = "" + window?.makeFirstResponder(nil) + delegate?.hierarchyView(self, didInputSearch: nil) + } + + // MARK: - Guides + + func updateGuides(withHoveredItem item: LookinDisplayItem!) { + guard let item, let rootItem = item.super, + let rootRow = displayItems.firstIndex(where: { $0 === rootItem }) + else { + guidesShapeLayer.isHidden = true + return + } + let childrenItems = (rootItem.subitems ?? []).filter { child in + displayItems.contains { $0 === child } + } + guard let lastChild = childrenItems.last, + let lastChildRow = displayItems.firstIndex(where: { $0 === lastChild }) + else { + guidesShapeLayer.isHidden = true + return + } + + let rootX = LKHierarchyRowView.dislosureMidX(withIndentLevel: UInt(bitPattern: rootItem.indentLevel() - minIndentLevel)) + let rootY = rowHeight * CGFloat(rootRow) + rowHeight / 2 + let rootMaxY = CGFloat(lastChildRow) * rowHeight + rowHeight / 2 + + let path = CGMutablePath() + path.move(to: CGPoint(x: rootX, y: rootY)) + path.addLine(to: CGPoint(x: rootX, y: rootMaxY)) + for child in childrenItems { + guard let row = displayItems.firstIndex(where: { $0 === child }) else { + continue + } + let childY = CGFloat(row) * rowHeight + rowHeight / 2 + path.move(to: CGPoint(x: rootX, y: childY)) + path.addLine(to: CGPoint(x: child.isExpandable ? rootX + 10 : rootX + 28, y: childY)) + } + guidesShapeLayer.path = path + guidesShapeLayer.isHidden = false + } + + private func bringGuidesLayerToFront() { + guidesShapeLayer.removeFromSuperlayer() + tableView.contentView.documentView?.layer?.addSublayer(guidesShapeLayer) + } + + fileprivate func item(atRow row: Int) -> LookinDisplayItem? { + displayItems.indices.contains(row) ? displayItems[row] : nil + } + + fileprivate func displayItem(of menuItem: NSMenuItem) -> LookinDisplayItem? { + (menuItem.menu as? LKHierarchyRowMenu)?.rowView?.displayItem + } + + // MARK: - Menu and control actions + + @objc private func handleSearchCloseButton() { + exitAndClearSearch() + } + + @objc private func handleDisclosureButton(_ button: NSButton) { + guard let item = item(atRow: button.tag), item.isExpandable else { + assertionFailure("the disclosure button of a row that cannot expand") + return + } + if item.isExpanded { + delegate?.hierarchyView(self, needToCollapse: item) + } else { + delegate?.hierarchyView(self, needToExpand: item, recursively: false) + } + } + + @objc fileprivate func handlePrintItem(_ menuItem: NSMenuItem) { + NotificationCenter.default.post(name: showConsoleNotification, object: displayItem(of: menuItem)) + } + + @objc fileprivate func handleReloadSelfItem(_ menuItem: NSMenuItem) { + guard let item = displayItem(of: menuItem) else { return } + asyncUpdateManager?.reloadSingleDisplayItem(item) + } + + @objc fileprivate func handleReloadSelfAndChildrenItem(_ menuItem: NSMenuItem) { + guard let item = displayItem(of: menuItem) else { return } + asyncUpdateManager?.reloadDisplayItemAndChildren(item) + } + + @objc fileprivate func handleFocusCurrentItem(_ menuItem: NSMenuItem) { + guard let item = displayItem(of: menuItem) else { + assertionFailure("a menu without its row") + return + } + dataSource.focus(item) + } + + @objc fileprivate func handleJumpToDisplayItem(_ menuItem: NSMenuItem) { + dataSource.selectAndRevealItem(menuItem.representedObject as? LookinDisplayItem) + } + + @objc fileprivate func handleShowPreview(_ menuItem: NSMenuItem) { + delegate?.hierarchyView?(self, needToShowPreviewOf: displayItem(of: menuItem)) + } + + @objc fileprivate func handleCancelPreview(_ menuItem: NSMenuItem) { + delegate?.hierarchyView?(self, needToCancelPreviewOf: displayItem(of: menuItem)) + } + + @objc fileprivate func handleExportScreenshot(_ menuItem: NSMenuItem) { + guard let item = displayItem(of: menuItem) else { return } + LKExportManager.exportScreenshot(with: item) + } + + @objc fileprivate func handleCopyDisplayItemName(_ menuItem: NSMenuItem) { + guard let string = menuItem.representedObject as? String else { return } + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.writeObjects([string as NSString]) + } + + @objc fileprivate func handleExpandRecursively(_ menuItem: NSMenuItem) { + let item = displayItem(of: menuItem) + assert(item != nil) + delegate?.hierarchyView(self, needToExpand: item, recursively: true) + } + + @objc fileprivate func handleCollapseChildren(_ menuItem: NSMenuItem) { + let item = displayItem(of: menuItem) + assert(item != nil) + delegate?.hierarchyView(self, needToCollapseChildrenOf: item) + } + + @objc fileprivate func handleHideScreenshotForever() { + LKHelper.openCustomConfigWebsite() + } + + fileprivate func menuItem(_ title: String, action: Selector?, representedObject: Any? = nil) -> NSMenuItem { + let item = NSMenuItem() + if let action { + item.target = self + item.action = action + } + item.title = title + item.representedObject = representedObject + return item + } + + fileprivate func jumpMenuItem(_ title: String, target: LookinDisplayItem) -> NSMenuItem { + menuItem(title, action: #selector(handleJumpToDisplayItem(_:)), representedObject: target) + } +} + +// MARK: - LKTableViewDataSource / LKTableViewDelegate + +extension LKHierarchyView: LKTableViewDataSource, LKTableViewDelegate { + func tableView(_: LKTableView!, didHoverAtRow row: Int) { + // nil when the mouse is not over a row. + delegate?.hierarchyView(self, didHoverAt: item(atRow: row)) + } + + func tableView(_: NSTableView, heightOfRow _: Int) -> CGFloat { + rowHeight + } + + func numberOfRows(in _: NSTableView) -> Int { + displayItems.count + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + guard let item = item(atRow: row) else { + assertionFailure("no item at row \(row)") + return LKTableBlankRowView() + } + let rowView: LKHierarchyRowView + if let reused = tableView.makeView(withIdentifier: NSUserInterfaceItemIdentifier("cell"), owner: self) as? LKHierarchyRowView { + rowView = reused + } else { + rowView = LKHierarchyRowView(dataSource: dataSource) + rowView.disclosureButton.target = self + rowView.disclosureButton.action = #selector(handleDisclosureButton(_:)) + rowView.identifier = NSUserInterfaceItemIdentifier("cell") + + let menu = LKHierarchyRowMenu() + menu.autoenablesItems = true + menu.rowView = rowView + menu.delegate = self + rowView.menu = menu + } + rowView.minIndentLevel = minIndentLevel + rowView.displayItem = item + rowView.disclosureButton.tag = row + return rowView + } + + func tableView(_: LKTableView!, didSelectRow row: Int) { + guard let item = item(atRow: row) else { + return + } + delegate?.hierarchyView(self, didSelect: item) + DispatchQueue.main.async { [weak self] in + self?.bringGuidesLayerToFront() + } + } + + func tableView(_: LKTableView!, didDoubleClickAtRow row: Int) { + guard let item = item(atRow: row) else { + return + } + delegate?.hierarchyView(self, didDoubleClick: item) + } +} + +// MARK: - NSMenuDelegate + +extension LKHierarchyView: NSMenuDelegate { + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + guard let displayItem = (menu as? LKHierarchyRowMenu)?.rowView?.displayItem else { + return + } + let isReadOnly = dataSource.isReadOnly() + + if !displayItem.isUserCustom() { + menu.addItem(menuItem(NSLocalizedString("Focus", comment: ""), action: #selector(handleFocusCurrentItem(_:)))) + if !isReadOnly { + menu.addItem(menuItem(NSLocalizedString("Print", comment: ""), action: #selector(handlePrintItem(_:)))) + } + menu.addItem(.separator()) + if !isReadOnly { + let isUpdating = asyncUpdateManager?.isUpdating() ?? false + menu.addItem(menuItem( + NSLocalizedString("Reload layer", comment: ""), + action: isUpdating ? nil : #selector(handleReloadSelfItem(_:)) + )) + menu.addItem(menuItem( + NSLocalizedString("Reload layer and its children", comment: ""), + action: isUpdating ? nil : #selector(handleReloadSelfAndChildrenItem(_:)) + )) + } + } + + // Copy text: the class name (unless it is a short system class such + // as UIView or CALayer), the host controllers and the ivar names. + var stringsToCopy: [String] = [] + let title = displayItem.title() + if !((title.hasPrefix("UI") || title.hasPrefix("CA")) && (title as NSString).length < 10) { + stringsToCopy.append(title) + } + if let name = displayItem.hostViewControllerObject?.lk_simpleDemangledClassName(), !name.isEmpty { + stringsToCopy.append(name) + } + if let name = displayItem.hostWindowControllerObject?.lk_simpleDemangledClassName(), !name.isEmpty { + stringsToCopy.append(name) + } + let ivarTraces = displayItem.displayingObject()?.ivarTraces ?? [] + if !ivarTraces.isEmpty { + let ivarNames = ivarTraces.map { trace -> String in + let name = trace.ivarName ?? "" + return name.hasPrefix("_") ? String(name.dropFirst()) : name + } + // Unique, in set order, as before. + stringsToCopy += NSSet(array: ivarNames).allObjects.compactMap { $0 as? String } + } + for (index, string) in stringsToCopy.enumerated() { + if index == 0 { + menu.addItem(.separator()) + } + guard !string.isEmpty else { + assertionFailure("LKHierarchyView, menuNeedsUpdate, stringsToCopy length is zero.") + continue + } + menu.addItem(menuItem( + String(format: NSLocalizedString("Copy text \"%@\"", comment: ""), string), + action: #selector(handleCopyDisplayItemName(_:)), + representedObject: string + )) + } + + let backingLayerItems = dataSource.swiftUIBackingLayerItems(for: displayItem) ?? [] + let swiftUISourceItem = dataSource.swiftUISourceItem(forLayerItem: displayItem) + if !backingLayerItems.isEmpty || swiftUISourceItem != nil { + menu.addItem(.separator()) + let jumpTitle = NSLocalizedString("Jump to backing CALayer", comment: "") + if backingLayerItems.count == 1 { + menu.addItem(jumpMenuItem(jumpTitle, target: backingLayerItems[0])) + } else if backingLayerItems.count > 1 { + let submenuItem = NSMenuItem() + submenuItem.title = jumpTitle + let submenu = NSMenu() + for item in backingLayerItems { + let itemTitle = "\(item.title()) \(item.layerObject?.memoryAddress ?? "")" + submenu.addItem(jumpMenuItem(itemTitle, target: item)) + } + submenuItem.submenu = submenu + menu.addItem(submenuItem) + } + if let swiftUISourceItem { + menu.addItem(jumpMenuItem(NSLocalizedString("Jump to SwiftUI node", comment: ""), target: swiftUISourceItem)) + } + } + + menu.addItem(.separator()) + + if displayItem.isExpandable { + menu.addItem(menuItem(NSLocalizedString("Expand recursively", comment: ""), action: #selector(handleExpandRecursively(_:)))) + menu.addItem(menuItem(NSLocalizedString("Collapse children", comment: ""), action: #selector(handleCollapseChildren(_:)))) + } + + // Show and hide the screenshot. + menu.addItem(.separator()) + + if displayItem.hasPreviewBoxAbility() { + if displayItem.inNoPreviewHierarchy { + let title = displayItem.doNotFetchScreenshotReason == .fetchScreenshotPermitted + ? NSLocalizedString("Show screenshot", comment: "") + : NSLocalizedString("Show layer border", comment: "") + menu.addItem(menuItem(title, action: #selector(handleShowPreview(_:)))) + } else { + menu.addItem(menuItem(NSLocalizedString("Hide screenshot this time", comment: ""), action: #selector(handleCancelPreview(_:)))) + } + } + + if !displayItem.isUserCustom(), !displayItem.inNoPreviewHierarchy { + menu.addItem(menuItem(NSLocalizedString("Hide screenshot forever…", comment: ""), action: #selector(handleHideScreenshotForever))) + } + + menu.addItem(.separator()) + + if displayItem.groupScreenshot != nil { + menu.addItem(menuItem(NSLocalizedString("Export screenshot…", comment: ""), action: #selector(handleExportScreenshot(_:)))) + } + } +} + +// MARK: - NSTextFieldDelegate + +extension LKHierarchyView: NSTextFieldDelegate { + func control(_: NSControl, textView _: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard commandSelector == #selector(NSResponder.cancelOperation(_:)) else { + return false + } + // Escape. + exitAndClearSearch() + return true + } +} diff --git a/LookInside/Hierarchy/LKSyncSignal.swift b/LookInside/Hierarchy/LKSyncSignal.swift new file mode 100644 index 00000000..190d52f4 --- /dev/null +++ b/LookInside/Hierarchy/LKSyncSignal.swift @@ -0,0 +1,73 @@ +// +// LKSyncSignal.swift +// LookInside +// +// The hierarchy data sources' synchronous events. `send` runs every +// observer before it returns, on the sender's thread, in subscription +// order. The preview and the async update manager depend on that: they +// must react to a reload or an item change before the sender goes on, +// which an `events()` stream, delivering later, cannot give them. +// + +/// A synchronous multicast event. +final class LKSyncSignal { + private var observers: [(id: Int, handler: (Value) -> Void)] = [] + private var nextObserverID = 0 + + /// Runs `handler` on every value sent until the subscription is + /// cancelled or released. + func observe(_ handler: @escaping (Value) -> Void) -> LKSyncSubscription { + let id = nextObserverID + nextObserverID += 1 + observers.append((id, handler)) + return LKSyncSubscription { [weak self] in + self?.observers.removeAll { $0.id == id } + } + } + + /// Runs the observers subscribed now; one cancelled by an earlier + /// observer during this send is skipped. + func send(_ value: Value) { + let snapshot = observers + for observer in snapshot where observers.contains(where: { $0.id == observer.id }) { + observer.handler(value) + } + } +} + +extension LKSyncSignal where Value == Void { + func send() { + send(()) + } +} + +/// Ends an `LKSyncSignal` observation when cancelled or released. +final class LKSyncSubscription { + private var onCancel: (() -> Void)? + + init(_ onCancel: @escaping () -> Void) { + self.onCancel = onCancel + } + + func cancel() { + let onCancel = onCancel + self.onCancel = nil + onCancel?() + } + + deinit { + cancel() + } +} + +/// Subscriptions that end together when the owner releases the bag. +final class LKSyncSubscriptionBag { + private var subscriptions: [LKSyncSubscription] = [] + + /// Runs `handler` on every value `signal` sends, ignoring the value. A + /// nil signal is ignored. + func observe(_ signal: LKSyncSignal?, _ handler: @escaping () -> Void) { + guard let signal else { return } + subscriptions.append(signal.observe { _ in handler() }) + } +} diff --git a/LookInside/Injection/Device/LKAttachedDeviceMonitor.swift b/LookInside/Injection/Device/LKAttachedDeviceMonitor.swift index 2d2e9761..f9581b13 100644 --- a/LookInside/Injection/Device/LKAttachedDeviceMonitor.swift +++ b/LookInside/Injection/Device/LKAttachedDeviceMonitor.swift @@ -6,16 +6,16 @@ import AppKit /// tracks the same attaches for a different purpose (it turns each one into /// five server ports to probe) and keeps the result private. /// -/// It runs its **own** `Lookin_PTUSBHub`. The shared hub posts attach -/// notifications for already-connected devices when it starts listening — which -/// the connection manager causes at launch — so an observer registered later -/// sees nothing until the next replug. A private hub starts listening when this -/// does, and therefore reports the devices that are already there. +/// It runs its **own** `LookinUSBMuxClient`. usbmuxd answers a Listen request +/// with one Attached broadcast per device already connected, and only on the +/// socket that sent it — so the connection manager's subscription, opened at +/// launch, says nothing to an observer that arrives later. A private client +/// listens when this starts, and therefore reports the devices already there. @MainActor final class LKAttachedDeviceMonitor { struct Device: Hashable { /// usbmuxd's handle for the device. Not stable across replugs. - let identifier: NSNumber + let identifier: Int /// The device's serial number, which is what identifies it to a person. let serialNumber: String @@ -26,13 +26,14 @@ final class LKAttachedDeviceMonitor { /// Attached devices, in the order they were reported. private(set) var devices: [Device] = [] - private let usbHub = Lookin_PTUSBHub() + private let usbMuxClient = LookinUSBMuxClient(queue: .main) private var hasStartedListening = false - private var observers: [any NSObjectProtocol] = [] - /// The hub a client should reach these devices through — the one that - /// enumerated them. - var connectionHub: Lookin_PTUSBHub { usbHub } + /// The usbmuxd client a control client should reach these devices + /// through — the one that enumerated them. + var connectionClient: LookinUSBMuxClient { + usbMuxClient + } private init() {} @@ -50,49 +51,30 @@ final class LKAttachedDeviceMonitor { guard !hasStartedListening else { return } hasStartedListening = true - let notificationCenter = NotificationCenter.default - observers.append( - notificationCenter.addObserver( - forName: .Lookin_PTUSBDeviceDidAttach, - object: usbHub, - queue: .main - ) { [weak self] notification in - MainActor.assumeIsolated { self?.handleAttach(notification) } - } - ) - observers.append( - notificationCenter.addObserver( - forName: .Lookin_PTUSBDeviceDidDetach, - object: usbHub, - queue: .main - ) { [weak self] notification in - MainActor.assumeIsolated { self?.handleDetach(notification) } - } - ) - - usbHub.listen(on: .main, onStart: nil, onEnd: nil) + usbMuxClient.startListening { [weak self] event in + MainActor.assumeIsolated { self?.handle(event) } + } try? await Task.sleep(nanoseconds: 300_000_000) } - private func handleAttach(_ notification: Notification) { - guard let identifier = notification.userInfo?["DeviceID"] as? NSNumber else { return } - let properties = notification.userInfo?["Properties"] as? [String: Any] - - // Deliberately **not** filtered by `ConnectionType`. A `Network` entry - // is an iOS device paired over Wi-Fi, not some other kind of machine, - // and usbmuxd's Connect reaches one exactly the same way — so filtering - // to `USB` would hide a jailbroken iPhone that happened to be off the - // cable. The host's own `LKConnectionManager` does not filter either, - // and this list has to agree with it: a device this skipped could still - // have its injected server found afterwards, which would read as the - // injector list being wrong. - let serialNumber = properties?["SerialNumber"] as? String ?? identifier.stringValue - guard !devices.contains(where: { $0.identifier == identifier }) else { return } - devices.append(Device(identifier: identifier, serialNumber: serialNumber)) - } - - private func handleDetach(_ notification: Notification) { - guard let identifier = notification.userInfo?["DeviceID"] as? NSNumber else { return } - devices.removeAll { $0.identifier == identifier } + private func handle(_ event: LookinUSBMuxClient.Event) { + switch event { + case let .attached(identifier, properties): + // Deliberately **not** filtered by `ConnectionType`. A `Network` + // entry is an iOS device paired over Wi-Fi, not some other kind of + // machine, and usbmuxd's Connect reaches one exactly the same way — + // so filtering to `USB` would hide a jailbroken iPhone that + // happened to be off the cable. The host's own + // `LKConnectionManager` does not filter either, and this list has + // to agree with it: a device this skipped could still have its + // injected server found afterwards, which would read as the + // injector list being wrong. + let serialNumber = (properties["Properties"] as? [String: Any])?["SerialNumber"] as? String + ?? String(identifier) + guard !devices.contains(where: { $0.identifier == identifier }) else { return } + devices.append(Device(identifier: identifier, serialNumber: serialNumber)) + case let .detached(identifier): + devices.removeAll { $0.identifier == identifier } + } } } diff --git a/LookInside/Injection/Device/LKDeviceControlClient.swift b/LookInside/Injection/Device/LKDeviceControlClient.swift index 2faadf9d..8ffed13b 100644 --- a/LookInside/Injection/Device/LKDeviceControlClient.swift +++ b/LookInside/Injection/Device/LKDeviceControlClient.swift @@ -3,18 +3,18 @@ import Foundation /// Talks to the LookInside injector running on one attached iOS device. /// /// The transport is the one the host already uses to reach a LookInside server -/// on a device — `Lookin_PTChannel` over usbmuxd — pointed at the injector's own -/// port instead of the server's range. Nothing here touches a network +/// on a device — a `LookinFrameChannel` over a usbmuxd tunnel — pointed at the +/// injector's own port instead of the server's range. Nothing here touches a network /// interface: usbmuxd carries the bytes over the cable and dials 127.0.0.1 /// inside the device, so the feature needs no Wi-Fi, no pairing beyond what /// Xcode already set up, and no change to anybody's network configuration. /// -/// `@MainActor` because `Lookin_PTChannel.channelWithDelegate:` binds the -/// channel to the main queue's protocol object, so every delegate callback -/// arrives there. Making that explicit is cheaper than defending the pending -/// table with a lock that would only ever be taken from one thread. +/// `@MainActor` because the usbmuxd client and the frame channel are both +/// given the main queue, so every callback arrives there. Making that explicit +/// is cheaper than defending the pending table with a lock that would only +/// ever be taken from one thread. @MainActor -final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { +final class LKDeviceControlClient { /// Why a request did not produce an answer. enum Failure: LocalizedError { /// usbmuxd would not connect. By far the most common cause is the @@ -25,15 +25,6 @@ final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { /// The channel closed while a request was in flight. case channelEnded - /// Peertalk would not hand out a channel at all. - /// - /// Not reachable in practice — the factory allocates and returns, and - /// the optional is only an artefact of Peertalk's headers carrying no - /// nullability annotations. Stated as a case rather than force - /// unwrapped, so an impossible state is reported instead of crashing - /// the app. - case channelUnavailable - /// The device accepted the request and never answered. case timedOut(command: LKDeviceControlCommand) @@ -57,17 +48,12 @@ final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { "The connection to the injector on the device closed before it answered.", comment: "" ) - case .channelUnavailable: - NSLocalizedString( - "LookInside could not open a USB connection to the device.", - comment: "" - ) case .timedOut: NSLocalizedString( "The injector on the device accepted the request and did not answer it.", comment: "" ) - case .deviceReported(let message): + case let .deviceReported(message): message case .malformedAnswer: NSLocalizedString( @@ -81,7 +67,7 @@ final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { /// usbmuxd's identifier for the device, as it appears in the attach /// notification. Not stable across replugs, which is why the serial number /// is what gets shown to the user. - let deviceIdentifier: NSNumber + let deviceIdentifier: Int /// The device's serial number, used as its name in the interface. /// @@ -91,58 +77,69 @@ final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { /// unambiguous identifier beats a pretty one. let serialNumber: String - /// The usbmuxd connection these channels are opened through. - /// - /// The monitor's own hub rather than `Lookin_PTUSBHub.sharedHub`, and for a - /// reason worth keeping: the shared hub posts its attach notifications for - /// already-connected devices only when it *starts* listening, which the - /// host's connection manager triggers at launch. A monitor built later - /// would see nothing until the next replug, so it runs a hub of its own — - /// and a client reaching a device that hub enumerated should go through the - /// same one. - private let usbHub: Lookin_PTUSBHub + /// The usbmuxd client the tunnel is opened through: the monitor's own, + /// the one that enumerated the device (see `LKAttachedDeviceMonitor` for + /// why it does not share the connection manager's). + private let usbMuxClient: LookinUSBMuxClient - private var channel: Lookin_PTChannel? + private var channel: LookinFrameChannel? /// Requests sent and not yet answered, by frame tag. private var pendingByFrameTag: [UInt32: (Result) -> Void] = [:] - /// Tags start at 1 because Peertalk reserves 0 for "no tag". + /// Tags start at 1 because the frame format reserves 0 for "no tag". private var nextFrameTag: UInt32 = 1 - init(deviceIdentifier: NSNumber, serialNumber: String, usbHub: Lookin_PTUSBHub) { + init(deviceIdentifier: Int, serialNumber: String, usbMuxClient: LookinUSBMuxClient) { self.deviceIdentifier = deviceIdentifier self.serialNumber = serialNumber - self.usbHub = usbHub - super.init() + self.usbMuxClient = usbMuxClient } // MARK: - Connecting func connect() async throws { - // Swift imports `+[Lookin_PTChannel channelWithDelegate:]` as an - // initializer, the way it does `+[NSString stringWithFormat:]`. That - // factory is the one to use rather than `init()`: it binds the channel - // to the main queue's protocol object — which is what puts every - // delegate callback on the main thread, and therefore what makes this - // class's `@MainActor` isolation true rather than hopeful. - guard let newChannel = Lookin_PTChannel(delegate: self) else { - throw Failure.channelUnavailable - } - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - newChannel.connect( - toPort: LKDeviceControlWire.portNumber, - overUSBHub: usbHub, - deviceID: deviceIdentifier - ) { error in - if let error { - newChannel.close() + let tunnel: (fileDescriptor: Int32, initialBytes: Data) = try await withCheckedThrowingContinuation { continuation in + usbMuxClient.connect(deviceID: deviceIdentifier, port: UInt16(LKDeviceControlWire.portNumber)) { result in + switch result { + case let .success(tunnel): + continuation.resume(returning: tunnel) + case let .failure(error): continuation.resume(throwing: Failure.couldNotConnect(underlying: error)) - } else { - continuation.resume() } } } + // Any payload size, as the server channel takes: a process list can + // run long, and the device is the injector this build was paired with. + let newChannel = LookinFrameChannel( + fileDescriptor: tunnel.fileDescriptor, + queue: .main, + maxPayloadSize: .max, + initialBytes: tunnel.initialBytes + ) + // Only answers are expected here. A frame of any other type is refused + // rather than read: this channel's requests all travel the other way, + // so one arriving would mean the two ends had swapped roles. + newChannel.shouldAcceptFrame = { header in + header.type == LKDeviceControlWire.responseFrameType + } + newChannel.onFrame = { [weak self] frame in + // The channel calls back on the main queue, so this is already the + // main thread; `MainActor.assumeIsolated` states that rather than + // hopping and letting answers arrive out of order. + MainActor.assumeIsolated { + guard let self, let pending = self.pendingByFrameTag.removeValue(forKey: frame.tag) else { return } + pending(.success(frame.payload)) + } + } + newChannel.onEnd = { [weak self, weak newChannel] _ in + MainActor.assumeIsolated { + guard let self, let newChannel, self.channel === newChannel else { return } + self.channel = nil + self.failAllPending(with: .channelEnded) + } + } + newChannel.start() channel = newChannel } @@ -196,7 +193,7 @@ final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { private func send( _ command: LKDeviceControlCommand, processIdentifier: pid_t? = nil, - expecting resultType: Result.Type, + expecting _: Result.Type, timeout: TimeInterval ) async throws -> Result { guard let channel, channel.isConnected else { @@ -225,14 +222,16 @@ final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { pending(.failure(.timedOut(command: command))) } - channel.sendFrame( - ofType: LKDeviceControlWire.requestFrameType, + channel.send( + type: LKDeviceControlWire.requestFrameType, tag: frameTag, - withPayload: (requestData as NSData).createReferencingDispatchData() + payload: requestData ) { [weak self] error in guard let error else { return } - guard let self, let pending = pendingByFrameTag.removeValue(forKey: frameTag) else { return } - pending(.failure(.couldNotConnect(underlying: error))) + MainActor.assumeIsolated { + guard let self, let pending = self.pendingByFrameTag.removeValue(forKey: frameTag) else { return } + pending(.failure(.couldNotConnect(underlying: error))) + } } } @@ -243,9 +242,9 @@ final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { throw Failure.malformedAnswer(command: command, underlying: error) } switch outcome { - case .succeeded(let result): + case let .succeeded(result): return result - case .failed(let message): + case let .failed(message): throw Failure.deviceReported(message: message) } } @@ -257,45 +256,4 @@ final class LKDeviceControlClient: NSObject, Lookin_PTChannelDelegate { complete(.failure(failure)) } } - - // MARK: - Lookin_PTChannelDelegate - - /// Only answers are expected here. A frame of any other type is refused - /// rather than read: this channel's requests all travel the other way, so - /// one arriving would mean the two ends had swapped roles. - nonisolated func ioFrameChannel( - _: Lookin_PTChannel!, - shouldAcceptFrameOfType type: UInt32, - tag _: UInt32, - payloadSize _: UInt32 - ) -> Bool { - type == LKDeviceControlWire.responseFrameType - } - - nonisolated func ioFrameChannel( - _: Lookin_PTChannel!, - didReceiveFrameOfType _: UInt32, - tag: UInt32, - payload: Lookin_PTData! - ) { - // Peertalk binds this channel to the main queue, so this is already the - // main thread; `MainActor.assumeIsolated` states that rather than - // hopping and letting answers arrive out of order. - let answer: Data = if let payload, payload.length > 0, let bytes = payload.data { - Data(bytes: bytes, count: payload.length) - } else { - Data() - } - MainActor.assumeIsolated { - guard let pending = pendingByFrameTag.removeValue(forKey: tag) else { return } - pending(.success(answer)) - } - } - - nonisolated func ioFrameChannel(_: Lookin_PTChannel!, didEndWithError _: Error!) { - MainActor.assumeIsolated { - channel = nil - failAllPending(with: .channelEnded) - } - } } diff --git a/LookInside/Injection/Device/LKDeviceControlWire.swift b/LookInside/Injection/Device/LKDeviceControlWire.swift index 12b085b7..412207c8 100644 --- a/LookInside/Injection/Device/LKDeviceControlWire.swift +++ b/LookInside/Injection/Device/LKDeviceControlWire.swift @@ -24,7 +24,8 @@ enum LKDeviceControlWire { /// apps. static let portNumber: Int32 = 47180 - /// Peertalk frame types. 0 is Peertalk's own end-of-stream marker. + /// Frame types. 0 is the frame format's own end-of-stream marker (the + /// format is Peertalk's, byte for byte, which the injector still speaks). static let requestFrameType: UInt32 = 1 static let responseFrameType: UInt32 = 2 } @@ -79,16 +80,16 @@ enum LKDeviceControlOutcome: Codable, Hashable { if let failureMessage = try container.decodeIfPresent(String.self, forKey: .failureMessage) { self = .failed(message: failureMessage) } else { - self = .succeeded(try container.decode(Result.self, forKey: .result)) + self = try .succeeded(container.decode(Result.self, forKey: .result)) } } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { - case .succeeded(let result): + case let .succeeded(result): try container.encode(result, forKey: .result) - case .failed(let message): + case let .failed(message): try container.encode(message, forKey: .failureMessage) } } @@ -178,7 +179,7 @@ enum LKDeviceControlInjectability: Codable, Hashable { case .requiresRootOnTarget: self = .requiresRootOnTarget case .notInjectable: - self = .notInjectable(reason: try container.decode(String.self, forKey: .reason)) + self = try .notInjectable(reason: container.decode(String.self, forKey: .reason)) } } @@ -189,7 +190,7 @@ enum LKDeviceControlInjectability: Codable, Hashable { try container.encode(Kind.injectable, forKey: .kind) case .requiresRootOnTarget: try container.encode(Kind.requiresRootOnTarget, forKey: .kind) - case .notInjectable(let reason): + case let .notInjectable(reason): try container.encode(Kind.notInjectable, forKey: .kind) try container.encode(reason, forKey: .reason) } @@ -212,7 +213,7 @@ enum LKDeviceControlInjectability: Codable, Hashable { "That process runs as root, and the injector on the device does not — it cannot take a root process's task port whatever entitlements it carries.", comment: "" ) - case .notInjectable(let reason): + case let .notInjectable(reason): reason } } @@ -248,13 +249,13 @@ enum LKDeviceControlInjectionResult: Codable, Hashable { case .injected: self = .injected case .taskPortUnavailable: - self = .taskPortUnavailable(reason: try container.decode(String.self, forKey: .reason)) + self = try .taskPortUnavailable(reason: container.decode(String.self, forKey: .reason)) case .targetRefusedPayload: - self = .targetRefusedPayload(reason: try container.decode(String.self, forKey: .reason)) + self = try .targetRefusedPayload(reason: container.decode(String.self, forKey: .reason)) case .failed: - self = .failed( - code: try container.decode(Int.self, forKey: .code), - reason: try container.decode(String.self, forKey: .reason) + self = try .failed( + code: container.decode(Int.self, forKey: .code), + reason: container.decode(String.self, forKey: .reason) ) } } @@ -264,13 +265,13 @@ enum LKDeviceControlInjectionResult: Codable, Hashable { switch self { case .injected: try container.encode(Kind.injected, forKey: .kind) - case .taskPortUnavailable(let reason): + case let .taskPortUnavailable(reason): try container.encode(Kind.taskPortUnavailable, forKey: .kind) try container.encode(reason, forKey: .reason) - case .targetRefusedPayload(let reason): + case let .targetRefusedPayload(reason): try container.encode(Kind.targetRefusedPayload, forKey: .kind) try container.encode(reason, forKey: .reason) - case .failed(let code, let reason): + case let .failed(code, reason): try container.encode(Kind.failed, forKey: .kind) try container.encode(code, forKey: .code) try container.encode(reason, forKey: .reason) @@ -287,8 +288,8 @@ enum LKDeviceControlInjectionResult: Codable, Hashable { var failureReason: String? { switch self { case .injected: nil - case .taskPortUnavailable(let reason), .targetRefusedPayload(let reason): reason - case .failed(_, let reason): reason + case let .taskPortUnavailable(reason), let .targetRefusedPayload(reason): reason + case let .failed(_, reason): reason } } } diff --git a/LookInside/Injection/Device/LKDeviceInjectionFlow.swift b/LookInside/Injection/Device/LKDeviceInjectionFlow.swift index 18007ef2..d02e5c48 100644 --- a/LookInside/Injection/Device/LKDeviceInjectionFlow.swift +++ b/LookInside/Injection/Device/LKDeviceInjectionFlow.swift @@ -141,11 +141,11 @@ final class LKDeviceInjectionFlow: NSObject { let client = LKDeviceControlClient( deviceIdentifier: attachedDevice.identifier, serialNumber: attachedDevice.serialNumber, - usbHub: LKAttachedDeviceMonitor.shared.connectionHub + usbMuxClient: LKAttachedDeviceMonitor.shared.connectionClient ) do { try await client.connect() - reachable.append(ReachableDevice(client: client, capability: try await client.injectionCapability())) + try reachable.append(ReachableDevice(client: client, capability: await client.injectionCapability())) } catch { // A refused connection is the ordinary answer for a device that // has never had the injector opened, so it is not reported as a @@ -265,19 +265,22 @@ final class LKDeviceInjectionFlow: NSObject { // here without them reopening anything. Matches the cadence the // library's own local picker uses, which a measured round trip of // 70–100 ms over USB easily affords. + // A main-run-loop timer fires on the main thread. let refreshTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] timer in - guard let self, let picker = self.picker else { - timer.invalidate() - return + MainActor.assumeIsolated { + guard let self, let picker = self.picker else { + timer.invalidate() + return + } + // Stop polling a channel that has already failed once + // rather than hammering it; the list stays on screen, and + // picking a row will surface the real error. + guard !snapshot.isStale else { + timer.invalidate() + return + } + picker.reloadProcesses() } - // Stop polling a channel that has already failed once rather - // than hammering it; the list stays on screen, and picking a - // row will surface the real error. - guard !snapshot.isStale else { - timer.invalidate() - return - } - picker.reloadProcesses() } var hasResumed = false diff --git a/LookInside/Injection/LKInjectableFrameworkInstaller.swift b/LookInside/Injection/LKInjectableFrameworkInstaller.swift index 7b7357b2..5ab0ccb1 100644 --- a/LookInside/Injection/LKInjectableFrameworkInstaller.swift +++ b/LookInside/Injection/LKInjectableFrameworkInstaller.swift @@ -251,8 +251,12 @@ final class LKInjectableFrameworkInstaller { for index in 0 ..< count { let leftValue = index < leftParts.count ? leftParts[index] : 0 let rightValue = index < rightParts.count ? rightParts[index] : 0 - if leftValue < rightValue { return -1 } - if leftValue > rightValue { return 1 } + if leftValue < rightValue { + return -1 + } + if leftValue > rightValue { + return 1 + } } return 0 } diff --git a/LookInside/Injection/LKInjectionFlow.swift b/LookInside/Injection/LKInjectionFlow.swift index 71faa84a..21797c57 100644 --- a/LookInside/Injection/LKInjectionFlow.swift +++ b/LookInside/Injection/LKInjectionFlow.swift @@ -11,7 +11,7 @@ import Foundation /// downloaded under `~/Library/Application Support/LookInside/InjectableFrameworks/`. /// 4. `LKInjectionService.attach(pid:dylibURL:)` — fires /// `InjectApplicationRequest` at the root daemon over XPC. -/// 5. Brief sleep so the target app's Peertalk listener can bind; the existing +/// 5. Brief sleep so the target app's Server listener can bind; the existing /// `LKLaunchViewController` 1.5s refresh tick then discovers the newly /// inspectable app automatically. @objc(LKInjectionFlow) @@ -234,7 +234,9 @@ final class LKInjectionFlow: NSObject { private extension LKInjectableFrameworkInstallerError { var isCancellation: Bool { - if case .cancelled = self { return true } + if case .cancelled = self { + return true + } return false } } diff --git a/LookInside/Injection/LKInjectionStartGate.swift b/LookInside/Injection/LKInjectionStartGate.swift index 4c8db8be..562d683b 100644 --- a/LookInside/Injection/LKInjectionStartGate.swift +++ b/LookInside/Injection/LKInjectionStartGate.swift @@ -43,7 +43,7 @@ enum LKInjectionDaemonNextStep: Equatable { case reportUnsupportedStatus(Int) } -struct LKInjectionDaemonReadiness { +enum LKInjectionDaemonReadiness { static func nextStep(for status: LKInjectionDaemonStatusSnapshot) -> LKInjectionDaemonNextStep { switch status { case .enabled: diff --git a/LookInside/Launch/LKLaunchAppView.h b/LookInside/Launch/LKLaunchAppView.h deleted file mode 100644 index 5ef70bff..00000000 --- a/LookInside/Launch/LKLaunchAppView.h +++ /dev/null @@ -1,20 +0,0 @@ -// -// LKLaunchAppView.h -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKBaseView.h" - -@class LKInspectableApp; - -@interface LKLaunchAppView : LKBaseControl - -/// 默认为 NO -@property(nonatomic, assign) BOOL compactLayout; - -@property(nonatomic, strong) LKInspectableApp *app; - -@end diff --git a/LookInside/Launch/LKLaunchAppView.m b/LookInside/Launch/LKLaunchAppView.m deleted file mode 100644 index dc0a153b..00000000 --- a/LookInside/Launch/LKLaunchAppView.m +++ /dev/null @@ -1,239 +0,0 @@ -// -// LKLaunchAppView.m -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKLaunchAppView.h" -#import "LKInspectableApp.h" -#import "LookInside-Swift.h" - -@interface LKLaunchAppView () - -@property(nonatomic, strong) CALayer *hoverBgLayer; -@property(nonatomic, strong) NSImageView *previewImageView; -@property(nonatomic, strong) NSImageView *iconImageView; -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKLabel *subtitleLabel; - -@property(nonatomic, strong) NSImageView *errorImageView; -@property(nonatomic, strong) LKLabel *errorTitleLabel; -@property(nonatomic, strong) LKLabel *errorSubtitleLabel; - -@end - -@implementation LKLaunchAppView { - NSSize _previewSize; - NSEdgeInsets _insets; - CGFloat _iconTop; - CGFloat _iconMarginRight; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.layer.cornerRadius = 4; - - self.hoverBgLayer = [CALayer layer]; - self.hoverBgLayer.opacity = 0; - self.hoverBgLayer.cornerRadius = 4; - [self.layer addSublayer:self.hoverBgLayer]; - - self.previewImageView = [NSImageView new]; - [self addSubview:self.previewImageView]; - - self.iconImageView = [NSImageView new]; - [self addSubview:self.iconImageView]; - - self.titleLabel = [LKLabel new]; - self.titleLabel.textColor = [NSColor labelColor]; - [self addSubview:self.titleLabel]; - - self.subtitleLabel = [LKLabel new]; - self.subtitleLabel.textColor = [NSColor labelColor]; - [self addSubview:self.subtitleLabel]; - - self.compactLayout = NO; - } - return self; -} - -- (void)setCompactLayout:(BOOL)compactLayout { - if (compactLayout) { - _previewSize = NSMakeSize(120, 220); - _insets = NSEdgeInsetsMake(12, 13, 8, 13); - _iconTop = 10; - _iconMarginRight = 6; - self.titleLabel.font = NSFontMake(12); - self.subtitleLabel.font = NSFontMake(11); - } else { - _previewSize = NSMakeSize(142, 260); - _insets = NSEdgeInsetsMake(12, 25, 12, 25); - _iconTop = 10; - _iconMarginRight = 8; - self.titleLabel.font = NSFontMake(13); - self.subtitleLabel.font = NSFontMake(12); - } - - [self setNeedsLayout:YES]; -} - -- (void)layout { - [super layout]; - - self.hoverBgLayer.frame = self.layer.bounds; - - if (self.app.serverVersionError) { - $(self.errorImageView).sizeToFit.horAlign; - $(self.errorTitleLabel).x(10).toRight(10).heightToFit.y(self.errorImageView.$maxY + 15); - $(self.errorSubtitleLabel).sizeToFit.horAlign.y(self.errorTitleLabel.$maxY + 10); - $(self.errorImageView, self.errorTitleLabel, self.errorSubtitleLabel).groupVerAlign.offsetY(-10); - - } else { - $(self.previewImageView).size(_previewSize).horAlign.y(_insets.top); - - $(self.iconImageView).sizeToFit.y(_insets.top + _previewSize.height + _iconTop); - - $(self.titleLabel).sizeToFit; - $(self.subtitleLabel).sizeToFit.y(self.titleLabel.$maxY + 2); - $(self.titleLabel, self.subtitleLabel).x(self.iconImageView.$maxX + _iconMarginRight).groupMidY(self.iconImageView.$midY); - - $(self.iconImageView, self.titleLabel, self.subtitleLabel).groupHorAlign.offsetX(-2); - } -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - if (self.app.serverVersionError) { - return NSMakeSize(_previewSize.width + _insets.left + _insets.right, _insets.top + _previewSize.height + _iconTop + _insets.bottom); - - } else { - CGFloat previewWidth = _previewSize.width + _insets.left + _insets.right; - CGFloat labelsWidth = self.iconImageView.image.size.width + _iconMarginRight + MAX([self.titleLabel sizeThatFits:NSSizeMax].width, [self.subtitleLabel sizeThatFits:NSSizeMax].width) + _insets.left + _insets.right; - - CGFloat width = MAX(previewWidth, labelsWidth); - CGFloat height = _insets.top + _previewSize.height + _iconTop + self.iconImageView.image.size.height + _insets.bottom; - return NSMakeSize(width, height); - } -} - -- (void)sizeToFit { - NSSize size = [self sizeThatFits:NSSizeMax]; - [self setFrameSize:size]; -} - -- (void)setApp:(LKInspectableApp *)app { - _app = app; - - if (app.serverVersionError) { - [self _initErrorViewsIfNeeded]; - - self.errorImageView.hidden = NO; - self.errorTitleLabel.hidden = NO; - self.errorSubtitleLabel.hidden = NO; - - self.previewImageView.hidden = YES; - self.iconImageView.hidden = YES; - self.titleLabel.hidden = YES; - self.subtitleLabel.hidden = YES; - - if (app.serverVersionError.code == LookinErrCode_ServerVersionTooLow) { - self.errorTitleLabel.stringValue = NSLocalizedString(@"The version of LookinServer linked with this iOS App is too low.", nil); - - } else if (app.serverVersionError.code == LookinErrCode_ServerVersionTooHigh) { - self.errorTitleLabel.stringValue = NSLocalizedString(@"Unable to inspect this iOS App. Current version of LookInside app is too low.", nil); - - } else { - self.errorTitleLabel.stringValue = app.serverVersionError.localizedDescription.length ? app.serverVersionError.localizedDescription : NSLocalizedString(@"Unable to inspect this app.", nil); - } - - } else { - self.errorImageView.hidden = YES; - self.errorTitleLabel.hidden = YES; - self.errorSubtitleLabel.hidden = YES; - - self.previewImageView.hidden = NO; - self.iconImageView.hidden = NO; - self.titleLabel.hidden = NO; - self.subtitleLabel.hidden = NO; - - self.previewImageView.image = app.appInfo.screenshot; - // Prefer the icon of the actual hardware model the app runs on. Falls back to the - // per-family asset when the peer's LookinServer does not report a model identifier. - NSImage *deviceIcon = [LKDeviceIconProvider deviceIconForAppInfo:app.appInfo - pointSize:LKDeviceIconProvider.launchPointSize]; - if (deviceIcon) { - self.iconImageView.image = deviceIcon; - } else if ([LKHelper appInfoLooksLikeMacTarget:app.appInfo] - || app.appInfo.deviceType == LookinAppInfoDeviceMacCatalyst) { - // A Catalyst app runs on Mac hardware, so it gets the Mac icon even though - // +appInfoLooksLikeMacTarget: deliberately answers NO for it (its views are UIKit). - self.iconImageView.image = NSImageMake(@"icon_mac_big"); - } else if (app.appInfo.deviceType == LookinAppInfoDeviceSimulator) { - self.iconImageView.image = NSImageMake(@"icon_simulator_big"); - } else if (app.appInfo.deviceType == LookinAppInfoDeviceIPad) { - self.iconImageView.image = NSImageMake(@"icon_ipad_big"); - } else { - self.iconImageView.image = NSImageMake(@"icon_iphone_big"); - } - self.titleLabel.stringValue = app.appInfo.deviceDescription; - self.subtitleLabel.stringValue = [NSString stringWithFormat:@"%@", app.appInfo.osDescription]; - } - - [self updateLayer]; - [self setNeedsLayout:YES]; -} - -- (void)mouseEntered:(NSEvent *)event { - [super mouseEntered:event]; - self.hoverBgLayer.opacity = 1; -} - -- (void)mouseExited:(NSEvent *)event { - [super mouseExited:event]; - self.hoverBgLayer.opacity = 0; -} - -- (void)updateLayer { - [super updateLayer]; - self.hoverBgLayer.backgroundColor = self.effectiveAppearance.lk_isDarkMode ? LookinColorRGBAMake(0, 0, 0, .17).CGColor : LookinColorRGBAMake(0, 0, 0, .08).CGColor; - - if (self.app.serverVersionError) { - self.layer.backgroundColor = self.effectiveAppearance.lk_isDarkMode ? LookinColorRGBAMake(0, 0, 0, .13).CGColor : LookinColorRGBAMake(0, 0, 0, .05).CGColor; - } else { - self.layer.backgroundColor = [NSColor clearColor].CGColor; - } -} - -- (void)updateTrackingAreas { - [super updateTrackingAreas]; - [self.trackingAreas enumerateObjectsUsingBlock:^(NSTrackingArea * _Nonnull oldArea, NSUInteger idx, BOOL * _Nonnull stop) { - [self removeTrackingArea:oldArea]; - }]; - - NSTrackingArea *newArea = [[NSTrackingArea alloc] initWithRect:self.bounds options:NSTrackingMouseEnteredAndExited|NSTrackingActiveAlways owner:self userInfo:nil]; - [self addTrackingArea:newArea]; -} - -- (void)_initErrorViewsIfNeeded { - if (!self.errorImageView) { - self.errorImageView = [NSImageView new]; - self.errorImageView.image = NSImageMake(@"icon_alert_big"); - [self addSubview:self.errorImageView]; - } - if (!self.errorTitleLabel) { - self.errorTitleLabel = [LKLabel new]; - self.errorTitleLabel.textColor = [NSColor labelColor]; - self.errorTitleLabel.alignment = NSTextAlignmentCenter; - self.errorTitleLabel.maximumNumberOfLines = 0; - [self addSubview:self.errorTitleLabel]; - } - if (!self.errorSubtitleLabel) { - self.errorSubtitleLabel = [LKLabel new]; - self.errorSubtitleLabel.stringValue = NSLocalizedString(@"Find solution…", nil); - self.errorSubtitleLabel.textColor = [NSColor linkColor]; - [self addSubview:self.errorSubtitleLabel]; - } -} - -@end diff --git a/LookInside/Launch/LKLaunchAppView.swift b/LookInside/Launch/LKLaunchAppView.swift new file mode 100644 index 00000000..11660f33 --- /dev/null +++ b/LookInside/Launch/LKLaunchAppView.swift @@ -0,0 +1,234 @@ +// +// LKLaunchAppView.swift +// Lookin +// +// Created by Li Kai on 2018/11/3. +// https://lookin.work +// + +import AppKit + +/// One inspectable app in the launch window or the apps popover: its +/// screenshot over the device icon, device name and OS. An app whose Server +/// version is unsupported shows the error and a link to the explanation +/// instead. +@objc(LKLaunchAppView) +final class LKLaunchAppView: LKBaseControl { + private let hoverBgLayer = CALayer() + private let previewImageView = NSImageView() + private let iconImageView = NSImageView() + private let titleLabel = LKLabel() + private let subtitleLabel = LKLabel() + + private var errorImageView: NSImageView? + private var errorTitleLabel: LKLabel? + private var errorSubtitleLabel: LKLabel? + + private var previewSize = NSSize.zero + private var insets = NSEdgeInsetsZero + private var iconTop: CGFloat = 0 + private var iconMarginRight: CGFloat = 0 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + layer?.cornerRadius = 4 + + hoverBgLayer.opacity = 0 + hoverBgLayer.cornerRadius = 4 + layer?.addSublayer(hoverBgLayer) + + addSubview(previewImageView) + addSubview(iconImageView) + + titleLabel.textColor = .labelColor + addSubview(titleLabel) + + subtitleLabel.textColor = .labelColor + addSubview(subtitleLabel) + + applyLayoutMetrics() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + /// The smaller metrics the apps popover uses. Defaults to false. + @objc var compactLayout = false { + didSet { applyLayoutMetrics() } + } + + @objc var app: LKInspectableApp? { + didSet { appDidChange() } + } + + private var hasServerVersionError: Bool { + app?.serverVersionError != nil + } + + private func applyLayoutMetrics() { + if compactLayout { + previewSize = NSSize(width: 120, height: 220) + insets = NSEdgeInsets(top: 12, left: 13, bottom: 8, right: 13) + iconTop = 10 + iconMarginRight = 6 + titleLabel.font = NSFont.systemFont(ofSize: 12) + subtitleLabel.font = NSFont.systemFont(ofSize: 11) + } else { + previewSize = NSSize(width: 142, height: 260) + insets = NSEdgeInsets(top: 12, left: 25, bottom: 12, right: 25) + iconTop = 10 + iconMarginRight = 8 + titleLabel.font = NSFont.systemFont(ofSize: 13) + subtitleLabel.font = NSFont.systemFont(ofSize: 12) + } + needsLayout = true + } + + override func layout() { + super.layout() + hoverBgLayer.frame = layer?.bounds ?? .zero + + if hasServerVersionError, let errorImageView, let errorTitleLabel, let errorSubtitleLabel { + LKViewFrameLayout(errorImageView).sizeToFit().horAlign() + LKViewFrameLayout(errorTitleLabel).x(10).toRight(10).heightToFit().y(errorImageView.frame.maxY + 15) + LKViewFrameLayout(errorSubtitleLabel).sizeToFit().horAlign().y(errorTitleLabel.frame.maxY + 10) + let errorViews: [NSView] = [errorImageView, errorTitleLabel, errorSubtitleLabel] + LKViewFrameLayout.groupVerAlign(errorViews) + errorViews.forEach { LKViewFrameLayout($0).offsetY(-10) } + } else if !hasServerVersionError { + LKViewFrameLayout(previewImageView).size(previewSize).horAlign().y(insets.top) + LKViewFrameLayout(iconImageView).sizeToFit().y(insets.top + previewSize.height + iconTop) + + LKViewFrameLayout(titleLabel).sizeToFit() + LKViewFrameLayout(subtitleLabel).sizeToFit().y(titleLabel.frame.maxY + 2) + let labels: [NSView] = [titleLabel, subtitleLabel] + labels.forEach { LKViewFrameLayout($0).x(iconImageView.frame.maxX + iconMarginRight) } + LKViewFrameLayout.groupMidY(labels, iconImageView.frame.midY) + + let row: [NSView] = [iconImageView, titleLabel, subtitleLabel] + LKViewFrameLayout.groupHorAlign(row) + row.forEach { LKViewFrameLayout($0).offsetX(-2) } + } + } + + override func sizeThatFits(_: NSSize) -> NSSize { + if hasServerVersionError { + return NSSize( + width: previewSize.width + insets.left + insets.right, + height: insets.top + previewSize.height + iconTop + insets.bottom + ) + } + let unlimited = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + let iconSize = iconImageView.image?.size ?? .zero + let previewWidth = previewSize.width + insets.left + insets.right + let labelsWidth = iconSize.width + iconMarginRight + + max(titleLabel.sizeThatFits(unlimited).width, subtitleLabel.sizeThatFits(unlimited).width) + + insets.left + insets.right + return NSSize( + width: max(previewWidth, labelsWidth), + height: insets.top + previewSize.height + iconTop + iconSize.height + insets.bottom + ) + } + + override func sizeToFit() { + setFrameSize(sizeThatFits(NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude))) + } + + private func appDidChange() { + let normalViews: [NSView] = [previewImageView, iconImageView, titleLabel, subtitleLabel] + if let error = app?.serverVersionError { + makeErrorViewsIfNeeded() + [errorImageView, errorTitleLabel, errorSubtitleLabel].forEach { $0?.isHidden = false } + normalViews.forEach { $0.isHidden = true } + errorTitleLabel?.stringValue = LKLaunchRules.serverVersionErrorTitle( + errorCode: error.code, + localizedDescription: error.localizedDescription + ) + } else { + [errorImageView, errorTitleLabel, errorSubtitleLabel].forEach { $0?.isHidden = true } + normalViews.forEach { $0.isHidden = false } + + let appInfo = app?.appInfo + previewImageView.image = appInfo?.screenshot + iconImageView.image = Self.deviceIcon(for: appInfo) + titleLabel.stringValue = appInfo?.deviceDescription ?? "" + subtitleLabel.stringValue = appInfo?.osDescription ?? "(null)" + } + updateLayer() + needsLayout = true + } + + /// Prefer the icon of the actual hardware model the app runs on. Falls back to the + /// per-family asset when the peer's LookinServer does not report a model identifier. + private static func deviceIcon(for appInfo: LookinAppInfo?) -> NSImage? { + if let icon = LKDeviceIconProvider.deviceIcon(forAppInfo: appInfo, pointSize: LKDeviceIconProvider.launchPointSize) { + return icon + } + if LKHelper.appInfoLooksLikeMacTarget(appInfo) || appInfo?.deviceType == .macCatalyst { + // A Catalyst app runs on Mac hardware, so it gets the Mac icon even though + // +appInfoLooksLikeMacTarget: deliberately answers NO for it (its views are UIKit). + return NSImage(named: "icon_mac_big") + } + switch appInfo?.deviceType { + case .simulator?: + return NSImage(named: "icon_simulator_big") + case .iPad?: + return NSImage(named: "icon_ipad_big") + default: + return NSImage(named: "icon_iphone_big") + } + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + hoverBgLayer.opacity = 1 + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + hoverBgLayer.opacity = 0 + } + + override func updateLayer() { + super.updateLayer() + let isDarkMode = effectiveAppearance.lk_isDarkMode + hoverBgLayer.backgroundColor = NSColor(red: 0, green: 0, blue: 0, alpha: isDarkMode ? 0.17 : 0.08).cgColor + if hasServerVersionError { + layer?.backgroundColor = NSColor(red: 0, green: 0, blue: 0, alpha: isDarkMode ? 0.13 : 0.05).cgColor + } else { + layer?.backgroundColor = NSColor.clear.cgColor + } + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach { removeTrackingArea($0) } + addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways], owner: self, userInfo: nil)) + } + + private func makeErrorViewsIfNeeded() { + if errorImageView == nil { + let imageView = NSImageView() + imageView.image = NSImage(named: "icon_alert_big") + addSubview(imageView) + errorImageView = imageView + } + if errorTitleLabel == nil { + let label = LKLabel() + label.textColor = .labelColor + label.alignment = .center + label.maximumNumberOfLines = 0 + addSubview(label) + errorTitleLabel = label + } + if errorSubtitleLabel == nil { + let label = LKLabel() + label.stringValue = NSLocalizedString("Find solution…", comment: "") + label.textColor = .linkColor + addSubview(label) + errorSubtitleLabel = label + } + } +} diff --git a/LookInside/Launch/LKLaunchRules.swift b/LookInside/Launch/LKLaunchRules.swift new file mode 100644 index 00000000..70f5a9ba --- /dev/null +++ b/LookInside/Launch/LKLaunchRules.swift @@ -0,0 +1,36 @@ +// +// LKLaunchRules.swift +// LookInside +// +// The decisions of the launch window, kept free of AppKit views so they can +// be tested on their own. +// + +import Foundation + +enum LKLaunchRules { + /// Whether the launch window opens the only app it found by itself. + /// + /// Only the first scan after the window opens may do this, only with + /// exactly one app, only when that app's Server version is supported and + /// only when the activation is in place. + static func canAutoEnter(requested: Bool, appCount: Int, onlyAppHasServerVersionError: Bool, isActivated: Bool) -> Bool { + requested && appCount == 1 && !onlyAppHasServerVersionError && isActivated + } + + /// The website page that explains a Server version error. + static func serverVersionHelpPath(errorCode: Int) -> String { + errorCode == LookinErrCode_ServerVersionTooLow ? "faq/server-version-too-low/" : "faq/server-version-too-high/" + } + + /// The text an app card shows for a Server version error. + static func serverVersionErrorTitle(errorCode: Int, localizedDescription: String) -> String { + if errorCode == LookinErrCode_ServerVersionTooLow { + return NSLocalizedString("The version of LookinServer linked with this iOS App is too low.", comment: "") + } + if errorCode == LookinErrCode_ServerVersionTooHigh { + return NSLocalizedString("Unable to inspect this iOS App. Current version of LookInside app is too low.", comment: "") + } + return localizedDescription.isEmpty ? NSLocalizedString("Unable to inspect this app.", comment: "") : localizedDescription + } +} diff --git a/LookInside/Launch/LKLaunchViewController.h b/LookInside/Launch/LKLaunchViewController.h deleted file mode 100644 index ff0eb45a..00000000 --- a/LookInside/Launch/LKLaunchViewController.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKLaunchViewController.h -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@interface LKLaunchViewController : LKBaseViewController - -- (instancetype)initWithWindow:(NSWindow *)window; - -- (instancetype)initWithWindow:(NSWindow *)window autoEnterOnInitialReload:(BOOL)autoEnterOnInitialReload; - -@end diff --git a/LookInside/Launch/LKLaunchViewController.m b/LookInside/Launch/LKLaunchViewController.m deleted file mode 100644 index 1e854e7a..00000000 --- a/LookInside/Launch/LKLaunchViewController.m +++ /dev/null @@ -1,296 +0,0 @@ -// -// LKLaunchViewController.m -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKLaunchViewController.h" -#import "LKLaunchAppView.h" -#import "LKProgressIndicatorView.h" -#import "LKStaticHierarchyDataSource.h" -#import "LKAppsManager.h" -#import "LookinHierarchyInfo.h" -#import "LKNavigationManager.h" -#import "LKStaticWindowController.h" -#import "LookinLiveDocument.h" -#import "LookinLiveDocumentController.h" -#import "LKPreferenceManager.h" -#import "LKTextControl.h" -#import "LKPerformanceReporter.h" -#import "LookInside-Swift.h" - -@interface LKLaunchViewController () - -@property(nonatomic, weak) NSWindow *window; -@property(nonatomic, copy) NSArray *appViews; -@property(nonatomic, strong) LKProgressIndicatorView *bottomIndicatorView; -@property(nonatomic, strong) NSProgressIndicator *reloadingIndicator; -@property(nonatomic, strong) LKLabel *noAppsTitleLabel; -@property(nonatomic, strong) NSButton *attachToRunningAppButton; -@property(nonatomic, copy) void (^crashBlock)(void); - -@property(nonatomic, assign) BOOL isEnteringApp; - -@property(nonatomic, copy) NSArray *appInfos; -@property(nonatomic, assign) BOOL autoEnterOnInitialReload; - -@end - -@implementation LKLaunchViewController { - CGFloat _appViewInterSpace; - CGFloat _contentHorInset; - CGFloat _contentHeight; -} - -- (instancetype)initWithWindow:(NSWindow *)window { - return [self initWithWindow:window autoEnterOnInitialReload:NO]; -} - -- (instancetype)initWithWindow:(NSWindow *)window autoEnterOnInitialReload:(BOOL)autoEnterOnInitialReload { - self.window = window; - self.autoEnterOnInitialReload = autoEnterOnInitialReload; - return [self init]; -} - -- (NSView *)makeContainerView { - _appViewInterSpace = 10; - _contentHeight = 400; - _contentHorInset = 30; - - LKVisualEffectView *containerView = [LKVisualEffectView new]; - containerView.blendingMode = NSVisualEffectBlendingModeBehindWindow; - containerView.state = NSVisualEffectStateActive; - - self.appViews = [NSArray array]; - - self.bottomIndicatorView = [LKProgressIndicatorView new]; - [containerView addSubview:self.bottomIndicatorView]; - - [self.bottomIndicatorView animateToProgress:.7 duration:0.5]; - - // Attach-to-running-app entry: always visible (including the no-apps state) - // so users can inject LookInsideServer.framework into an app that hasn't - // statically linked the SDK. - self.attachToRunningAppButton = [NSButton buttonWithTitle:NSLocalizedString(@"Attach to Running App…", nil) - target:self - action:@selector(_handleAttachToRunningAppClick:)]; - self.attachToRunningAppButton.bezelStyle = NSBezelStyleRounded; - [containerView addSubview:self.attachToRunningAppButton]; - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - // 延时一下,因为如果有 USB 设备被连接,则需要一定时间来使 ConnectionManager 检测到,这个时间实际实验大概 0.1 秒,这里稍微宽裕一点给个 0.2 秒 - BOOL autoEnter = self.autoEnterOnInitialReload; - self.autoEnterOnInitialReload = NO; - [self _reloadWithAutoEntering:autoEnter]; - }); - - return containerView; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - - if (self.reloadingIndicator && self.noAppsTitleLabel) { - [self.reloadingIndicator sizeToFit]; - $(self.noAppsTitleLabel).sizeToFit.x(self.reloadingIndicator.$maxX + 5).midY(self.reloadingIndicator.$midY); - $(self.reloadingIndicator, self.noAppsTitleLabel).groupHorAlign.midY(self.view.$height * .35); - } - - __block CGFloat posX = _contentHorInset; - [self.appViews enumerateObjectsUsingBlock:^(LKLaunchAppView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - $(obj).sizeToFit.x(posX).y(30); - posX = obj.$maxX + self->_appViewInterSpace; - }]; - - $(_bottomIndicatorView).fullWidth.height(3).bottom(0); - - [self.attachToRunningAppButton sizeToFit]; - $(self.attachToRunningAppButton).midX(self.view.$width / 2).y(self.view.$height - self.attachToRunningAppButton.$height - 14); -} - -- (void)_handleAttachToRunningAppClick:(id)sender { - if (![[LKSwiftUISupportGatekeeper sharedInstance] allowProtectedFeatureAccessForWindow:self.window]) { - return; - } - [[LKInjectionFlow sharedInstance] startFromWindow:self.window]; -} - -#pragma mark - Others - -- (void)_reloadWithAutoEntering:(BOOL)autoEnter { - if (self.isEnteringApp) { - return; - } - - @weakify(self); - [[[[LKAppsManager sharedInstance] fetchAppInfosWithImage:YES localInfos:self.appInfos] deliverOnMainThread] subscribeNext:^(NSArray *apps) { - @strongify(self); - - self.appInfos = [apps lookin_map:^id(NSUInteger idx, LKInspectableApp *value) { - return value.appInfo; - }]; - - BOOL canAutoEnter = autoEnter - && apps.count == 1 - && !apps.firstObject.serverVersionError - && [LKSwiftUISupportGatekeeper sharedInstance].activationState == LKSwiftUISupportActivationStateActivated; - - if (canAutoEnter) { - // 进入页面时自动触发的 refetch,并且只有一个 app,则自动拉取 hierarchy - [self _renderWithApps:apps]; - [self _enterApp:apps.firstObject]; - - } else { - if (self.bottomIndicatorView.progress > 0) { - [self.bottomIndicatorView finishWithCompletion:nil]; - } - - [self _renderWithApps:apps]; - - // 每 1.5 秒刷新一次 - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self _reloadWithAutoEntering:NO]; - }); - } - }]; -} - -- (void)_handleClickAppView:(LKLaunchAppView *)view { - if (self.isEnteringApp) { - return; - } - if (view.app.serverVersionError) { - if (view.app.serverVersionError.code == LookinErrCode_ServerVersionTooLow) { - [LKHelper openLookinWebsiteWithPath:@"faq/server-version-too-low/"]; - } else { - [LKHelper openLookinWebsiteWithPath:@"faq/server-version-too-high/"]; - } - - } else { - [self.bottomIndicatorView animateToProgress:.8 duration:1]; - - LKInspectableApp *app = view.app; - [self _enterApp:app]; - } -} - -- (void)_renderWithApps:(NSArray *)apps { - if (!apps || apps.count == 0) { - [self.window setContentSize:NSMakeSize(256, _contentHeight)]; - - [self showNoAppsView]; - $(self.appViews).hide; - - } else { - __block CGFloat windowWidth = _contentHorInset * 2 + (apps.count - 1) * _appViewInterSpace; - self.appViews = [self.appViews lookin_resizeWithCount:apps.count add:^LKLaunchAppView *(NSUInteger idx) { - LKLaunchAppView *view = [LKLaunchAppView new]; - [self.view addSubview:view]; - return view; - } remove:^(NSUInteger idx, LKLaunchAppView *obj) { - [obj removeFromSuperview]; - } doNext:^(NSUInteger idx, LKLaunchAppView *obj) { - obj.app = apps[idx]; - [obj addTarget:self clickAction:@selector(_handleClickAppView:)]; - windowWidth += [obj sizeThatFits:NSSizeMax].width; - }]; - $(self.appViews).show; - - [self.window setContentSize:NSMakeSize(windowWidth, _contentHeight)]; - - [self hideNoAppsViews]; - } - - [self.view setNeedsLayout:YES]; -} - -- (void)_enterApp:(LKInspectableApp *)app { - if (self.isEnteringApp) { - return; - } - self.isEnteringApp = YES; - - [LKPerformanceReporter.sharedInstance willStartReload]; - - @weakify(self); - [[app fetchHierarchyData] subscribeNext:^(LookinHierarchyInfo *info) { - @strongify(self); - - if (!info) { - [self _handleEnterAppFailWithError:LookinErr_Inner]; - [LKPerformanceReporter.sharedInstance didFetchHierarchy]; - return; - } - - // Phase D: a successful Launch-screen pick creates a Live Doc - // (which owns its own hierarchy data source + async update - // manager). The hierarchy info we just fetched is fed into the - // new doc so it appears immediately without a redundant round- - // trip. - [self.bottomIndicatorView finishWithCompletion:^{ - @strongify(self); - [[LookinLiveDocumentController sharedInstance] - openLiveDocumentForInspectableApp:app - completion:^(LookinLiveDocument *doc, BOOL alreadyOpen, NSError *err) { - if (err && !doc) { - [self _handleEnterAppFailWithError:err]; - return; - } - if (!alreadyOpen) { - // Brand new doc: prime its data source with the hierarchy - // we already pulled, so the window opens populated. - [doc.hierarchyDataSource reloadWithHierarchyInfo:info keepState:NO]; - } - [[LKNavigationManager sharedInstance] closeLaunch]; - NSWindow *liveWindow = doc.windowControllers.firstObject.window; - [[LKSwiftUISupportGatekeeper sharedInstance] promptForPendingDetectedSwiftUISupportIfNeededForWindow:liveWindow]; - self.isEnteringApp = NO; - }]; - }]; - - [LKPerformanceReporter.sharedInstance didFetchHierarchy]; - - } error:^(NSError * _Nullable error) { - @strongify(self); - [self _handleEnterAppFailWithError:error]; - }]; -} - -- (void)_handleEnterAppFailWithError:(NSError *)error { - self.isEnteringApp = NO; - [self.bottomIndicatorView resetToZero]; - [[NSAlert alertWithError:error] beginSheetModalForWindow:self.window completionHandler:^(NSModalResponse returnCode) { - [self _reloadWithAutoEntering:NO]; - }]; -} - -- (void)showNoAppsView { - if (!self.noAppsTitleLabel) { - self.noAppsTitleLabel = [[LKLabel alloc] init]; - self.noAppsTitleLabel.textColor = [NSColor labelColor]; - self.noAppsTitleLabel.font = NSFontMake(14); - self.noAppsTitleLabel.stringValue = NSLocalizedString(@"Searching for inspectable apps", nil); - [self.view addSubview:self.noAppsTitleLabel]; - } - if (!self.reloadingIndicator) { - self.reloadingIndicator = [NSProgressIndicator new]; - self.reloadingIndicator.indeterminate = YES; - self.reloadingIndicator.style = NSProgressIndicatorStyleSpinning; - self.reloadingIndicator.controlSize = NSControlSizeSmall; - [self.view addSubview:self.reloadingIndicator]; - [self.reloadingIndicator startAnimation:self]; - } - [self.view setNeedsLayout:YES]; -} - -- (void)hideNoAppsViews { - [_noAppsTitleLabel removeFromSuperview]; - _noAppsTitleLabel = nil; - - [self.reloadingIndicator removeFromSuperview]; - self.reloadingIndicator = nil; -} - -@end diff --git a/LookInside/Launch/LKLaunchViewController.swift b/LookInside/Launch/LKLaunchViewController.swift new file mode 100644 index 00000000..b25cfba5 --- /dev/null +++ b/LookInside/Launch/LKLaunchViewController.swift @@ -0,0 +1,282 @@ +// +// LKLaunchViewController.swift +// Lookin +// +// Created by Li Kai on 2018/11/3. +// https://lookin.work +// + +import AppKit + +/// The launch window's content: the inspectable apps found so far, rescanned +/// every 1.5 seconds, and the attach-to-running-app button. Picking an app +/// fetches its hierarchy and opens a live document for it. +@objc(LKLaunchViewController) +final class LKLaunchViewController: LKBaseViewController { + private static let appViewInterSpace: CGFloat = 10 + private static let contentHorInset: CGFloat = 30 + private static let contentHeight: CGFloat = 400 + /// A USB device that is plugged in takes about 0.1 seconds to reach the + /// connection manager; the first scan waits a little longer than that. + private static let initialScanDelay: TimeInterval = 0.2 + private static let rescanInterval: TimeInterval = 1.5 + + private weak var window: NSWindow? + private var autoEnterOnInitialReload: Bool + + private var appViews: [LKLaunchAppView] = [] + private let bottomIndicatorView = LKProgressIndicatorView() + private var reloadingIndicator: NSProgressIndicator? + private var noAppsTitleLabel: LKLabel? + private let attachToRunningAppButton = NSButton( + title: NSLocalizedString("Attach to Running App…", comment: ""), + target: nil, + action: nil + ) + + private var isEnteringApp = false + /// The infos of the last scan, so an unchanged app does not send its + /// images again. + private var appInfos: [LookinAppInfo]? + + @objc(initWithWindow:) + convenience init(window: NSWindow?) { + self.init(window: window, autoEnterOnInitialReload: false) + } + + @objc(initWithWindow:autoEnterOnInitialReload:) + init(window: NSWindow?, autoEnterOnInitialReload: Bool) { + self.window = window + self.autoEnterOnInitialReload = autoEnterOnInitialReload + super.init(containerView: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func makeContainerView() -> NSView { + let containerView = LKVisualEffectView() + containerView.blendingMode = .behindWindow + containerView.state = .active + + containerView.addSubview(bottomIndicatorView) + bottomIndicatorView.animate(toProgress: 0.7, duration: 0.5) + + // Attach-to-running-app entry: always visible (including the no-apps state) + // so users can inject LookInsideServer.framework into an app that hasn't + // statically linked the SDK. + attachToRunningAppButton.target = self + attachToRunningAppButton.action = #selector(handleAttachToRunningAppClick(_:)) + attachToRunningAppButton.bezelStyle = .rounded + containerView.addSubview(attachToRunningAppButton) + + DispatchQueue.main.asyncAfter(deadline: .now() + Self.initialScanDelay) { + let autoEnter = self.autoEnterOnInitialReload + self.autoEnterOnInitialReload = false + self.reload(autoEntering: autoEnter) + } + return containerView + } + + override func viewDidLayout() { + super.viewDidLayout() + + if let reloadingIndicator, let noAppsTitleLabel { + reloadingIndicator.sizeToFit() + LKViewFrameLayout(noAppsTitleLabel).sizeToFit().x(reloadingIndicator.frame.maxX + 5).midY(reloadingIndicator.frame.midY) + let views: [NSView] = [reloadingIndicator, noAppsTitleLabel] + LKViewFrameLayout.groupHorAlign(views) + LKViewFrameLayout.groupMidY(views, view.bounds.height * 0.35) + } + + var posX = Self.contentHorInset + for appView in appViews { + LKViewFrameLayout(appView).sizeToFit().x(posX).y(30) + posX = appView.frame.maxX + Self.appViewInterSpace + } + + LKViewFrameLayout(bottomIndicatorView).fullWidth().height(3).bottom(0) + + attachToRunningAppButton.sizeToFit() + LKViewFrameLayout(attachToRunningAppButton) + .midX(view.bounds.width / 2) + .y(view.bounds.height - attachToRunningAppButton.frame.height - 14) + } + + @objc private func handleAttachToRunningAppClick(_: Any?) { + guard LKSwiftUISupportGatekeeper.sharedInstance().allowProtectedFeatureAccess(for: window) else { + return + } + LKInjectionFlow.shared.startFromWindow(window) + } + + // MARK: - Scanning + + private func reload(autoEntering autoEnter: Bool) { + if isEnteringApp { + return + } + Task { [weak self, appInfos] in + let apps = await LKAppsManager.shared.fetchAppInfos(needImages: true, localInfos: appInfos) + self?.didScan(apps, autoEnter: autoEnter) + } + } + + private func didScan(_ apps: [LKInspectableApp], autoEnter: Bool) { + appInfos = apps.compactMap(\.appInfo) + + let canAutoEnter = LKLaunchRules.canAutoEnter( + requested: autoEnter, + appCount: apps.count, + onlyAppHasServerVersionError: apps.first?.serverVersionError != nil, + isActivated: LKSwiftUISupportGatekeeper.sharedInstance().activationState == .activated + ) + if canAutoEnter, let app = apps.first { + // The scan that runs as the window opens found exactly one app: + // fetch its hierarchy right away. + render(apps) + enter(app) + return + } + + if bottomIndicatorView.progress > 0 { + bottomIndicatorView.finish(completion: nil) + } + render(apps) + + DispatchQueue.main.asyncAfter(deadline: .now() + Self.rescanInterval) { + self.reload(autoEntering: false) + } + } + + private func render(_ apps: [LKInspectableApp]) { + if apps.isEmpty { + window?.setContentSize(NSSize(width: 256, height: Self.contentHeight)) + showNoAppsViews() + appViews.forEach { $0.isHidden = true } + } else { + while appViews.count < apps.count { + let appView = LKLaunchAppView() + view.addSubview(appView) + appViews.append(appView) + } + while appViews.count > apps.count { + appViews.removeLast().removeFromSuperview() + } + + let unlimited = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + var windowWidth = Self.contentHorInset * 2 + CGFloat(apps.count - 1) * Self.appViewInterSpace + for (appView, app) in zip(appViews, apps) { + appView.app = app + appView.addTarget(self, clickAction: #selector(handleClickAppView(_:))) + windowWidth += appView.sizeThatFits(unlimited).width + appView.isHidden = false + } + window?.setContentSize(NSSize(width: windowWidth, height: Self.contentHeight)) + hideNoAppsViews() + } + view.needsLayout = true + } + + // MARK: - Entering an app + + @objc private func handleClickAppView(_ appView: LKLaunchAppView) { + if isEnteringApp { + return + } + guard let app = appView.app else { return } + if let error = app.serverVersionError { + LKHelper.openLookinWebsite(withPath: LKLaunchRules.serverVersionHelpPath(errorCode: error.code)) + } else { + bottomIndicatorView.animate(toProgress: 0.8, duration: 1) + enter(app) + } + } + + private func enter(_ app: LKInspectableApp) { + if isEnteringApp { + return + } + isEnteringApp = true + LKPerformanceReporter.sharedInstance().willStartReload() + + Task { [weak self] in + let info: LookinHierarchyInfo + do { + info = try await app.hierarchy() + } catch { + self?.enterFailed(error) + return + } + self?.didFetchHierarchy(info, of: app) + LKPerformanceReporter.sharedInstance().didFetchHierarchy() + } + } + + /// A successful pick opens a live document, which owns its own data + /// source and update manager. The hierarchy just fetched primes the new + /// document so its window opens populated. + private func didFetchHierarchy(_ info: LookinHierarchyInfo, of app: LKInspectableApp) { + bottomIndicatorView.finish { [weak self] in + self?.openLiveDocument(for: app, priming: info) + } + } + + private func openLiveDocument(for app: LKInspectableApp, priming info: LookinHierarchyInfo) { + let (document, alreadyOpen) = LookinLiveDocumentController.shared.openLiveDocument(for: app) + if !alreadyOpen { + document.hierarchyDataSource?.reload(with: info, keepState: false) + } + LKNavigationManager.shared.closeLaunch() + let liveWindow = document.windowControllers.first?.window + LKSwiftUISupportGatekeeper.sharedInstance().promptForPendingDetectedSwiftUISupportIfNeeded(window: liveWindow) + isEnteringApp = false + } + + private func enterFailed(_ error: Error) { + isEnteringApp = false + bottomIndicatorView.resetToZero() + let alert = NSAlert(error: error) + guard let window else { + // No window to hang the sheet on: the alert runs app-modal. + alert.runModal() + reload(autoEntering: false) + return + } + alert.beginSheetModal(for: window) { _ in + self.reload(autoEntering: false) + } + } + + // MARK: - No apps + + private func showNoAppsViews() { + if noAppsTitleLabel == nil { + let label = LKLabel() + label.textColor = .labelColor + label.font = NSFont.systemFont(ofSize: 14) + label.stringValue = NSLocalizedString("Searching for inspectable apps", comment: "") + view.addSubview(label) + noAppsTitleLabel = label + } + if reloadingIndicator == nil { + let indicator = NSProgressIndicator() + indicator.isIndeterminate = true + indicator.style = .spinning + indicator.controlSize = .small + view.addSubview(indicator) + indicator.startAnimation(self) + reloadingIndicator = indicator + } + view.needsLayout = true + } + + private func hideNoAppsViews() { + noAppsTitleLabel?.removeFromSuperview() + noAppsTitleLabel = nil + reloadingIndicator?.removeFromSuperview() + reloadingIndicator = nil + } +} diff --git a/LookInside/Launch/LKLaunchWindowController.h b/LookInside/Launch/LKLaunchWindowController.h deleted file mode 100644 index 1881a78c..00000000 --- a/LookInside/Launch/LKLaunchWindowController.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LKLaunchWindowController.h -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKWindowController.h" - -@class LKLaunchViewController; - -@interface LKLaunchWindowController : LKWindowController - -- (instancetype)initWithAutoEnterOnInitialReload:(BOOL)autoEnterOnInitialReload; - -@property(nonatomic, strong, readonly) LKLaunchViewController *launchViewController; - -@end diff --git a/LookInside/Launch/LKLaunchWindowController.m b/LookInside/Launch/LKLaunchWindowController.m deleted file mode 100644 index 7af9d7ea..00000000 --- a/LookInside/Launch/LKLaunchWindowController.m +++ /dev/null @@ -1,39 +0,0 @@ -// -// LKLaunchWindowController.m -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKLaunchWindowController.h" -#import "LKLaunchViewController.h" -#import "LKWindow.h" - -@interface LKLaunchWindowController () - -@end - -@implementation LKLaunchWindowController - -- (instancetype)init { - return [self initWithAutoEnterOnInitialReload:NO]; -} - -- (instancetype)initWithAutoEnterOnInitialReload:(BOOL)autoEnterOnInitialReload { - LKWindow *window = [[LKWindow alloc] initWithContentRect:NSMakeRect(0, 0, 252, 400) styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskMiniaturizable|NSWindowStyleMaskFullSizeContentView backing:NSBackingStoreBuffered defer:YES]; - window.backgroundColor = [NSColor clearColor]; - window.titlebarAppearsTransparent = YES; - window.movableByWindowBackground = YES; - [window center]; - - if (self = [self initWithWindow:window]) { - _launchViewController = [[LKLaunchViewController alloc] initWithWindow:window - autoEnterOnInitialReload:autoEnterOnInitialReload]; - window.contentView = self.launchViewController.view; - self.contentViewController = self.launchViewController; - } - return self; -} - -@end diff --git a/LookInside/Launch/LKLaunchWindowController.swift b/LookInside/Launch/LKLaunchWindowController.swift new file mode 100644 index 00000000..baeea327 --- /dev/null +++ b/LookInside/Launch/LKLaunchWindowController.swift @@ -0,0 +1,45 @@ +// +// LKLaunchWindowController.swift +// Lookin +// +// Created by Li Kai on 2018/11/3. +// https://lookin.work +// + +import AppKit + +/// The small launch window that lists the inspectable apps. +@objc(LKLaunchWindowController) +final class LKLaunchWindowController: LKWindowController { + @objc let launchViewController: LKLaunchViewController + + /// - Parameter autoEnterOnInitialReload: whether the first scan opens + /// the only app it finds by itself. + @objc(initWithAutoEnterOnInitialReload:) + init(autoEnterOnInitialReload: Bool) { + let window = LKWindow( + contentRect: NSRect(x: 0, y: 0, width: 252, height: 400), + styleMask: [.titled, .closable, .miniaturizable, .fullSizeContentView], + backing: .buffered, + defer: true + ) + window.backgroundColor = .clear + window.titlebarAppearsTransparent = true + window.isMovableByWindowBackground = true + window.center() + + launchViewController = LKLaunchViewController(window: window, autoEnterOnInitialReload: autoEnterOnInitialReload) + super.init(window: window) + window.contentView = launchViewController.view + contentViewController = launchViewController + } + + @objc convenience init() { + self.init(autoEnterOnInitialReload: false) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } +} diff --git a/LookInside/Localizable.xcstrings b/LookInside/Localizable.xcstrings index ec12a2e8..e49f69fc 100644 --- a/LookInside/Localizable.xcstrings +++ b/LookInside/Localizable.xcstrings @@ -1,6 +1,23 @@ { "sourceLanguage" : "en", "strings" : { + "The app's integrator should upgrade LookInside Server." : { + "comment" : "Tip shown once per inspected app whose LookInside Server is 0.2.9 or older", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The app's integrator should upgrade LookInside Server." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "建议集成方升级 LookInside Server" + } + } + } + }, "Loading capture…" : { "localizations" : { "en" : { @@ -378,22 +395,6 @@ } } }, - "Auth Server state file is not a JSON object." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auth Server state file is not a JSON object." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "Auth Server 状态文件不是 JSON 对象。" - } - } - } - }, "Automatic update checks are disabled in this community build." : { "extractionState" : "stale", "localizations" : { @@ -475,22 +476,6 @@ } } }, - "Background Auth Server install is waiting after a recent failure." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Background Auth Server install is waiting after a recent failure." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "后台授权服务器安装在最近失败后等待重试。" - } - } - } - }, "Cancel" : { "localizations" : { "en" : { @@ -619,22 +604,6 @@ } } }, - "Checking LookInside Auth Server" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Checking LookInside Auth Server" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在检查 LookInside 授权服务器" - } - } - } - }, "Choose the local Swift source folder for %@." : { "localizations" : { "en" : { @@ -1488,22 +1457,6 @@ } } }, - "Failed to download LookInside Auth Server.\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to download LookInside Auth Server.\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "下载 LookInside 授权服务器失败。\n%@" - } - } - } - }, "Failed to download LookInside Server framework.\n%@" : { "localizations" : { "en" : { @@ -1520,22 +1473,6 @@ } } }, - "Failed to extract LookInside Auth Server.\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to extract LookInside Auth Server.\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "解压 LookInside 授权服务器失败。\n%@" - } - } - } - }, "Failed to extract LookInside Server framework.\n%@" : { "localizations" : { "en" : { @@ -1584,22 +1521,6 @@ } } }, - "Failed to install LookInside Auth Server.\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to install LookInside Auth Server.\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "安装 LookInside 授权服务器失败。\n%@" - } - } - } - }, "Failed to install LookInside Server framework.\n%@" : { "localizations" : { "en" : { @@ -1680,22 +1601,6 @@ } } }, - "Failed to verify LookInside Auth Server download.\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to verify LookInside Auth Server download.\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "验证 LookInside 授权服务器下载失败。\n%@" - } - } - } - }, "Failed: %@" : { "localizations" : { "en" : { @@ -2320,22 +2225,6 @@ } } }, - "Installing LookInside Auth Server" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Installing LookInside Auth Server" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "正在安装 LookInside 授权服务器" - } - } - } - }, "Installing…" : { "localizations" : { "en" : { @@ -2786,198 +2675,6 @@ } } }, - "LookInside Auth Server connection failed.\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server connection failed.\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器连接失败。\n%@" - } - } - } - }, - "LookInside Auth Server could not be launched.\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server could not be launched.\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器无法启动。\n%@" - } - } - } - }, - "LookInside Auth Server did not respond after launch.\nSocket path:\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server did not respond after launch.\nSocket path:\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器启动后无响应。\nSocket 路径:\n%@" - } - } - } - }, - "LookInside Auth Server is not installed.\nExpected executable:\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server is not installed.\nExpected executable:\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器未安装。\n期望的可执行文件:\n%@" - } - } - } - }, - "LookInside Auth Server is signed by a different team.\nExpected: %1$@\nFound: %2$@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server is signed by a different team.\nExpected: %1$@\nFound: %2$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器的签名团队不匹配。\n期望:%1$@\n实际:%2$@" - } - } - } - }, - "LookInside Auth Server protocol is incompatible.\nApp expects v%1$ld, helper provides v%2$ld." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server protocol is incompatible.\nApp expects v%1$ld, helper provides v%2$ld." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器协议不兼容。\nApp 期望 v%1$ld,辅助程序提供 v%2$ld。" - } - } - } - }, - "LookInside Auth Server Required" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server Required" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "需要 LookInside 授权服务器" - } - } - } - }, - "LookInside Auth Server returned %1$@.\n%2$@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server returned %1$@.\n%2$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器返回 %1$@。\n%2$@" - } - } - } - }, - "LookInside Auth Server returned an unreadable response.\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server returned an unreadable response.\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器返回了无法读取的响应。\n%@" - } - } - } - }, - "LookInside Auth Server socket path is too long for a Unix domain socket.\n%@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server socket path is too long for a Unix domain socket.\n%@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器 Socket 路径超过 Unix 域套接字长度限制。\n%@" - } - } - } - }, - "LookInside Auth Server Updating" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server Updating" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器正在更新" - } - } - } - }, - "LookInside Auth Server version mismatch.\nExpected: %1$@\nFound: %2$@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside Auth Server version mismatch.\nExpected: %1$@\nFound: %2$@" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 授权服务器版本不匹配。\n期望:%1$@\n实际:%2$@" - } - } - } - }, "LookInside doesn't support invoking methods with arguments yet." : { "localizations" : { "en" : { @@ -3010,22 +2707,6 @@ } } }, - "LookInside is preparing the local Auth Server. Try the action again after the update finishes." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside is preparing the local Auth Server. Try the action again after the update finishes." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 正在准备本地授权服务器。更新完成后再试一次。" - } - } - } - }, "LookInside license verification failed." : { "localizations" : { "en" : { @@ -3380,22 +3061,6 @@ } } }, - "No local LookInside Pro license is stored on this Mac." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No local LookInside Pro license is stored on this Mac." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "本机尚未保存 LookInside Pro 许可证。" - } - } - } - }, "No match found." : { "localizations" : { "en" : { @@ -4689,22 +4354,6 @@ } } }, - "The downloaded archive did not contain a LookInside Auth Server bundle." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The downloaded archive did not contain a LookInside Auth Server bundle." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "下载的压缩包中未包含 LookInside 授权服务器。" - } - } - } - }, "The downloaded archive did not contain a macOS slice of LookInsideServer.xcframework." : { "localizations" : { "en" : { @@ -5282,34 +4931,28 @@ } } }, - "Unable to read the code signature of LookInside Auth Server.\n%@" : { + "Unable to read the code signature of LookInside Server framework.\n%@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Unable to read the code signature of LookInside Auth Server.\n%@" + "value" : "Unable to read the code signature of LookInside Server framework.\n%@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无法读取 LookInside 授权服务器的代码签名。\n%@" + "value" : "无法读取 LookInside Server framework 的代码签名。\n%@" } } } }, - "Unable to read the code signature of LookInside Server framework.\n%@" : { + "Unable to Refresh License Status" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unable to read the code signature of LookInside Server framework.\n%@" - } - }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无法读取 LookInside Server framework 的代码签名。\n%@" + "value" : "无法刷新许可证状态" } } } diff --git a/LookInside/MCPBridge/LKMCPBridgeAttributeEncoder.swift b/LookInside/MCPBridge/LKMCPBridgeAttributeEncoder.swift deleted file mode 100644 index 55b30912..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeAttributeEncoder.swift +++ /dev/null @@ -1,432 +0,0 @@ -// LKMCPBridgeAttributeEncoder.swift -// -// Encodes a host-side `LookinAttribute` into the wire DTO -// `LKMCPBridgeAttribute` for the `attributes.read` MCPBridge method. -// -// The host represents an attribute's payload as a polymorphic `id value` -// disambiguated by `LookinAttrType`. The wire format flattens that into a -// `kind` discriminator string plus a JSON value whose shape depends on -// kind. v1 of this encoder handles the structurally distinct kinds with -// dedicated projections (numbers, bools, strings, geometry struct types, -// colors, shadows, enums, JSON pass-through); rarer or implementation- -// specific kinds fall back to a `{ "rawDescription": "..." }` payload so -// agents can still read something meaningful instead of seeing a missing -// field. Adding more first-class encoders here is the safest place to -// extend coverage. - -import CoreGraphics -import Foundation - -#if canImport(AppKit) -import AppKit -#endif - -enum LKMCPBridgeAttributeEncoder { - - /// Encodes one host-side `LookinAttribute` into the wire DTO. - /// - /// - Parameter redactingSecureContent: when `true`, string-valued kinds - /// (`string`, `selector`, `class`, `enum` string variant) are replaced - /// with a `kind: "redacted"` marker and a nil value before the DTO - /// leaves this encoder. Geometry / numeric / color projections are - /// not affected — they cannot reveal secret user content. The flag is - /// evaluated once per display item by the caller (see - /// `LKMCPBridgeSecureContentDetector`) and applies uniformly to every - /// attribute carried by that item, so a secure text field never leaks - /// either its `text`, its `placeholder`, or any custom-attribute - /// string the host might collect via `lookin_customDebugInfos`. - static func encode( - _ attribute: LookinAttribute, - redactingSecureContent: Bool - ) -> LKMCPBridgeAttribute { - let projection = projectValue(of: attribute) - let finalProjection = redactingSecureContent - ? redactedIfString(projection) - : projection - return LKMCPBridgeAttribute( - identifier: attribute.identifier, - displayTitle: attribute.displayTitle, - isUserCustom: attribute.isUserCustom(), - kind: finalProjection.kind, - value: finalProjection.value, - extraValue: redactingSecureContent ? nil : encodeExtraValue(attribute), - customSetterIdentifier: attribute.customSetterID - ) - } - - /// When the enclosing display item is flagged as secure, swap any - /// string-bearing projection for a redaction marker. Numeric, boolean, - /// geometry, color, shadow, json, and custom-object projections fall - /// through unchanged — they don't carry text values that could leak. - private static func redactedIfString(_ projection: Projection) -> Projection { - switch projection.kind { - case "string", "selector", "class": - return Projection(kind: "redacted", value: nil) - case "enum": - // `enum` covers both EnumInt/EnumLong (numeric) and EnumString - // (textual). Only the latter actually carries a user-visible - // string; the projection layer collapses them into the same - // `kind`, so we conservatively redact both. The numeric variant - // case is degenerate (no leak possible from an integer enum - // value), the cost of redaction is just losing the raw int. - return Projection(kind: "redacted", value: nil) - default: - return projection - } - } - - // MARK: - Per-type projection - - private struct Projection { - let kind: String - let value: LKMCPBridgeJSONValue? - } - - private static func projectValue(of attribute: LookinAttribute) -> Projection { - let rawValue: Any? = attribute.value - switch attribute.attrType { - case .none, .void: - return Projection(kind: "void", value: nil) - - case .char, .int, .short, .long, .longLong, - .unsignedChar, .unsignedInt, .unsignedShort, - .unsignedLong, .unsignedLongLong, - .enumInt, .enumLong: - // EnumInt / EnumLong land here because the actual stored value - // is an NSNumber; the enum case-name table travels in `extraValue`. - let kind: String - switch attribute.attrType { - case .enumInt, .enumLong: - kind = "enum" - default: - kind = "integer" - } - return Projection(kind: kind, value: encodeNumberAsInteger(rawValue)) - - case .float, .double: - return Projection(kind: "double", value: encodeNumberAsDouble(rawValue)) - - case .BOOL: - // ObjC `LookinAttrTypeBOOL` keeps its uppercase initialism after - // the importer strips the type prefix. - return Projection(kind: "bool", value: .bool((rawValue as? NSNumber)?.boolValue ?? false)) - - case .sel: - return Projection(kind: "selector", value: encodeAsString(rawValue)) - - case .class: - return Projection(kind: "class", value: encodeAsString(rawValue)) - - case .nsString: - return Projection(kind: "string", value: encodeAsString(rawValue)) - - case .enumString: - return Projection(kind: "enum", value: encodeAsString(rawValue)) - - case .cgPoint: - return Projection(kind: "point", value: encodePoint(rawValue)) - - case .cgVector: - return Projection(kind: "vector", value: encodeVector(rawValue)) - - case .cgSize: - return Projection(kind: "size", value: encodeSize(rawValue)) - - case .cgRect: - return Projection(kind: "rect", value: encodeRect(rawValue)) - - case .cgAffineTransform: - return Projection(kind: "transform", value: encodeAffineTransform(rawValue)) - - case .uiEdgeInsets: - return Projection(kind: "edgeInsets", value: encodeEdgeInsets(rawValue)) - - case .uiOffset: - return Projection(kind: "offset", value: encodeOffset(rawValue)) - - case .uiColor: - return Projection(kind: "color", value: encodeColor(rawValue)) - - case .shadow: - return Projection(kind: "shadow", value: encodeShadow(rawValue)) - - case .json: - return Projection(kind: "json", value: encodeArbitraryJSON(rawValue)) - - case .customObj: - if let constraints = rawValue as? [LookinAutoLayoutConstraint], constraints.isEmpty == false { - return Projection(kind: "constraints", value: encodeConstraints(constraints)) - } - return Projection(kind: "custom", value: encodeCustomObject(rawValue)) - - @unknown default: - return Projection(kind: "unknown", value: encodeFallbackDescription(rawValue)) - } - } - - // MARK: - extraValue - - private static func encodeExtraValue(_ attribute: LookinAttribute) -> LKMCPBridgeJSONValue? { - guard let extra = attribute.extraValue else { return nil } - // For LookinAttrTypeEnumString, `extraValue` is the array of all - // possible enum case names — surface it directly. Other attribute - // kinds rarely populate `extraValue`; when they do, just pass the - // object through `encodeArbitraryJSON` which falls back to a - // description-string if the type cannot be projected losslessly. - if let strings = extra as? [String] { - return .array(strings.map(LKMCPBridgeJSONValue.string)) - } - return encodeArbitraryJSON(extra) - } - - // MARK: - Number helpers - - private static func encodeNumberAsInteger(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let number = raw as? NSNumber else { return nil } - return .integer(Int64(truncatingIfNeeded: number.int64Value)) - } - - private static func encodeNumberAsDouble(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let number = raw as? NSNumber else { return nil } - return .double(number.doubleValue) - } - - private static func encodeAsString(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let text = raw as? String else { return nil } - return .string(text) - } - - // MARK: - Geometry helpers - - private static func cgFloatValue(_ raw: Any?) -> Double? { - guard let number = raw as? NSNumber else { return nil } - return number.doubleValue - } - - private static func encodePoint(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let nsValue = raw as? NSValue else { return nil } - var point = CGPoint.zero - nsValue.getValue(&point, size: MemoryLayout.size) - return .object([ - "x": .double(Double(point.x)), - "y": .double(Double(point.y)), - ]) - } - - private static func encodeVector(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let nsValue = raw as? NSValue else { return nil } - var vector = CGVector.zero - nsValue.getValue(&vector, size: MemoryLayout.size) - return .object([ - "dx": .double(Double(vector.dx)), - "dy": .double(Double(vector.dy)), - ]) - } - - private static func encodeSize(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let nsValue = raw as? NSValue else { return nil } - var size = CGSize.zero - nsValue.getValue(&size, size: MemoryLayout.size) - return .object([ - "width": .double(Double(size.width)), - "height": .double(Double(size.height)), - ]) - } - - private static func encodeRect(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let nsValue = raw as? NSValue else { return nil } - var rect = CGRect.zero - nsValue.getValue(&rect, size: MemoryLayout.size) - return .object([ - "x": .double(Double(rect.origin.x)), - "y": .double(Double(rect.origin.y)), - "width": .double(Double(rect.size.width)), - "height": .double(Double(rect.size.height)), - ]) - } - - private static func encodeAffineTransform(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let nsValue = raw as? NSValue else { return nil } - var transform = CGAffineTransform.identity - nsValue.getValue(&transform, size: MemoryLayout.size) - return .object([ - "a": .double(Double(transform.a)), - "b": .double(Double(transform.b)), - "c": .double(Double(transform.c)), - "d": .double(Double(transform.d)), - "tx": .double(Double(transform.tx)), - "ty": .double(Double(transform.ty)), - ]) - } - - private static func encodeEdgeInsets(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let nsValue = raw as? NSValue else { return nil } - // UIKit's UIEdgeInsets and AppKit's NSEdgeInsets share the same - // memory layout: top / left / bottom / right CGFloats. - var insets: (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) - nsValue.getValue(&insets, size: MemoryLayout.size(ofValue: insets)) - return .object([ - "top": .double(Double(insets.0)), - "left": .double(Double(insets.1)), - "bottom": .double(Double(insets.2)), - "right": .double(Double(insets.3)), - ]) - } - - private static func encodeOffset(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let nsValue = raw as? NSValue else { return nil } - var offset: (CGFloat, CGFloat) = (0, 0) - nsValue.getValue(&offset, size: MemoryLayout.size(ofValue: offset)) - return .object([ - "horizontal": .double(Double(offset.0)), - "vertical": .double(Double(offset.1)), - ]) - } - - // MARK: - Color / Shadow - - private static func encodeColor(_ raw: Any?) -> LKMCPBridgeJSONValue? { - // LookinServer encodes UIColor as @[NSNumber, NSNumber, NSNumber, NSNumber] - // in RGBA, components in 0...1. Surface as a named-key object for - // agent readability while still preserving the underlying numbers. - guard let components = raw as? [NSNumber], components.count >= 4 else { - return nil - } - return .object([ - "red": .double(components[0].doubleValue), - "green": .double(components[1].doubleValue), - "blue": .double(components[2].doubleValue), - "alpha": .double(components[3].doubleValue), - ]) - } - - private static func encodeShadow(_ raw: Any?) -> LKMCPBridgeJSONValue? { - // Server-side shape (LKS_CustomAttrGroupsMaker.m): NSDictionary with - // keys offset (NSValue), opacity (NSNumber), radius - // (NSNumber), color ([NSNumber] RGBA). - guard let dictionary = raw as? [String: Any] else { return nil } - var encoded: [String: LKMCPBridgeJSONValue] = [:] - if let offset = encodeSize(dictionary["offset"]) { - encoded["offset"] = offset - } - if let opacity = dictionary["opacity"] as? NSNumber { - encoded["opacity"] = .double(opacity.doubleValue) - } - if let radius = dictionary["radius"] as? NSNumber { - encoded["radius"] = .double(radius.doubleValue) - } - if let color = encodeColor(dictionary["color"]) { - encoded["color"] = color - } - return .object(encoded) - } - - // MARK: - Custom / JSON / fallback - - private static func encodeArbitraryJSON(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let raw else { return nil } - // Try to round-trip through Foundation's JSONSerialization. This - // handles dictionaries, arrays, NSNumber, NSString, NSNull cleanly - // and lets us reuse the existing JSONValue decoder. - if JSONSerialization.isValidJSONObject(raw) { - do { - let data = try JSONSerialization.data(withJSONObject: raw, options: [.fragmentsAllowed]) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } catch { - // Fall through to description fallback. - } - } - return encodeFallbackDescription(raw) - } - - // MARK: - Constraints - - private static func constraintItemTypeString(_ itemType: LookinConstraintItemType) -> String { - switch itemType { - case .`nil`: return "nil" - case .view: return "view" - case .`self`: return "self" - case .super: return "super" - case .layoutGuide: return "layoutGuide" - case .unknown: return "unknown" - @unknown default: return "unknown" - } - } - - private static func constraintRelationString(_ relation: NSLayoutConstraint.Relation) -> String { - switch relation { - case .lessThanOrEqual: return "lessThanOrEqual" - case .equal: return "equal" - case .greaterThanOrEqual: return "greaterThanOrEqual" - @unknown default: return "unknown" - } - } - - private static func encodeConstraintEndpoint(_ endpointObject: LookinObject?, itemType: LookinConstraintItemType) -> LKMCPBridgeJSONValue { - var fields: [String: LKMCPBridgeJSONValue] = [ - "itemType": .string(constraintItemTypeString(itemType)), - ] - if let endpointObject, endpointObject.oid != 0 { - fields["objectIdentifier"] = .string(String(format: "0x%lx", endpointObject.oid)) - } - if let className = endpointObject?.classChainList?.first { - fields["className"] = .string(className) - } - return .object(fields) - } - - /// Structured projection of an AutoLayout constraints list — previously - /// this fell into the description-string fallback and lost every field. - /// The attribute numbers stay raw NSInteger values: UIKit and AppKit - /// assign different numbers to NSLayoutAttribute cases, and the bridge - /// does not know which platform produced the data. - private static func encodeConstraints(_ constraints: [LookinAutoLayoutConstraint]) -> LKMCPBridgeJSONValue { - let encodedConstraints: [LKMCPBridgeJSONValue] = constraints.map { constraint in - var fields: [String: LKMCPBridgeJSONValue] = [ - "firstItem": encodeConstraintEndpoint(constraint.firstItem, itemType: constraint.firstItemType), - "firstAttributeRawValue": .integer(Int64(constraint.firstAttribute)), - "relation": .string(constraintRelationString(constraint.relation)), - "secondItem": encodeConstraintEndpoint(constraint.secondItem, itemType: constraint.secondItemType), - "secondAttributeRawValue": .integer(Int64(constraint.secondAttribute)), - "multiplier": .double(Double(constraint.multiplier)), - "constant": .double(Double(constraint.constant)), - "priority": .double(Double(constraint.priority)), - "isActive": .bool(constraint.active), - "isEffective": .bool(constraint.effective), - ] - if constraint.constraintOid != 0 { - // The same constraint appears in both endpoints' lists; this - // identifier is what lets an agent deduplicate the copies. - fields["constraintObjectIdentifier"] = .string(String(format: "0x%lx", constraint.constraintOid)) - } - if let identifier = constraint.identifier, identifier.isEmpty == false { - fields["identifier"] = .string(identifier) - } - return .object(fields) - } - return .array(encodedConstraints) - } - - private static func encodeCustomObject(_ raw: Any?) -> LKMCPBridgeJSONValue? { - // Custom objects don't have a fixed shape; surface their class name - // and a description so agents have something to render. Bridge - // consumers that need a typed projection should use the attribute - // identifier to know what they're looking at. - guard let raw else { return nil } - let object = raw as AnyObject - let className = String(describing: type(of: object)) - let descriptionText = String(describing: object) - return .object([ - "className": .string(className), - "description": .string(descriptionText), - ]) - } - - private static func encodeFallbackDescription(_ raw: Any?) -> LKMCPBridgeJSONValue? { - guard let raw else { return nil } - return .object([ - "rawDescription": .string(String(describing: raw)), - ]) - } -} - diff --git a/LookInside/MCPBridge/LKMCPBridgeAttributeValueDecoder.swift b/LookInside/MCPBridge/LKMCPBridgeAttributeValueDecoder.swift deleted file mode 100644 index fa29c4a7..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeAttributeValueDecoder.swift +++ /dev/null @@ -1,392 +0,0 @@ -// LKMCPBridgeAttributeValueDecoder.swift -// -// Symmetric inverse of LKMCPBridgeAttributeEncoder: turns the wire -// `{ kind, data }` shape that a bridge client uses to express an -// attribute write back into the polymorphic Objective-C value that -// LookinAttributeModification carries to the inspected server -// (RPC 204). -// -// Layout: one entry point (`decode(wireKind:wireData:expectedAttrType:)`) -// switches on `(kind, attrType)` and produces the right NSNumber / -// NSString / NSValue / NSArray. The function is `throws`; the thrown -// errors are mapped to structured `modify.*` wire codes by the -// invocation service so agents see precise reasons. -// -// v0.4 scope: integer / double / bool / string / selector / class / -// point / vector / size / rect / transform / edgeInsets / offset / -// color / enum. The structurally-richer kinds (shadow / json / custom) -// are deliberately rejected with `unsupportedKind`; supporting them -// safely requires a per-attribute schema that the bridge doesn't yet -// have. - -import CoreGraphics -import Foundation - -@MainActor -enum LKMCPBridgeAttributeValueDecoder { - - // MARK: - Errors - - enum DecodeError: Error { - /// The wire `kind` is one of the v0.4 unsupported categories - /// (shadow / custom / json) or a string the encoder never - /// produces. - case unsupportedKind(String) - - /// The wire `kind` is valid in isolation but does not match - /// the attribute's declared `LookinAttrType`. E.g. wire says - /// `"color"` but the attribute is `LookinAttrTypeBOOL`. - case kindMismatch(wireKind: String, expectedAttrType: LookinAttrType) - - /// The wire `data` payload is missing required fields or - /// carries the wrong JSON shape for its `kind` (e.g. an `int` - /// where an object is expected). - case shapeInvalid(reason: String) - } - - // MARK: - Entry point - - /// Decodes a `(wireKind, wireData)` pair into the polymorphic id - /// value that `LookinAttributeModification.value` requires, - /// validated against `expectedAttrType`. Throws on any - /// inconsistency; the caller turns the throw into a structured - /// bridge error. - static func decode( - wireKind: String, - wireData: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - switch wireKind { - case "integer": - return try decodeInteger(wireData, expectedAttrType: expectedAttrType) - case "double": - return try decodeDouble(wireData, expectedAttrType: expectedAttrType) - case "bool": - return try decodeBool(wireData, expectedAttrType: expectedAttrType) - case "string": - return try decodeString(wireData, expectedAttrType: expectedAttrType, allowedTypes: [.nsString]) - case "selector": - return try decodeString(wireData, expectedAttrType: expectedAttrType, allowedTypes: [.sel]) - case "class": - return try decodeString(wireData, expectedAttrType: expectedAttrType, allowedTypes: [.class]) - case "enum": - return try decodeEnum(wireData, expectedAttrType: expectedAttrType) - case "point": - return try decodePoint(wireData, expectedAttrType: expectedAttrType) - case "vector": - return try decodeVector(wireData, expectedAttrType: expectedAttrType) - case "size": - return try decodeSize(wireData, expectedAttrType: expectedAttrType) - case "rect": - return try decodeRect(wireData, expectedAttrType: expectedAttrType) - case "transform": - return try decodeAffineTransform(wireData, expectedAttrType: expectedAttrType) - case "edgeInsets": - return try decodeEdgeInsets(wireData, expectedAttrType: expectedAttrType) - case "offset": - return try decodeOffset(wireData, expectedAttrType: expectedAttrType) - case "color": - return try decodeColor(wireData, expectedAttrType: expectedAttrType) - case "shadow", "json", "custom": - throw DecodeError.unsupportedKind(wireKind) - default: - throw DecodeError.unsupportedKind(wireKind) - } - } - - // MARK: - Numeric kinds - - private static let integerCompatibleTypes: Set = [ - .char, .int, .short, .long, .longLong, - .unsignedChar, .unsignedInt, .unsignedShort, - .unsignedLong, .unsignedLongLong, - ] - - private static func decodeInteger( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard integerCompatibleTypes.contains(expectedAttrType) else { - throw DecodeError.kindMismatch(wireKind: "integer", expectedAttrType: expectedAttrType) - } - guard let data else { - throw DecodeError.shapeInvalid(reason: "integer value missing") - } - switch data { - case .integer(let value): - return NSNumber(value: value) - case .double(let value): - // Tolerate Double when JSON decode coerced it; truncate. - return NSNumber(value: Int64(value)) - default: - throw DecodeError.shapeInvalid(reason: "integer expects a JSON number") - } - } - - private static let doubleCompatibleTypes: Set = [.float, .double] - - private static func decodeDouble( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard doubleCompatibleTypes.contains(expectedAttrType) else { - throw DecodeError.kindMismatch(wireKind: "double", expectedAttrType: expectedAttrType) - } - guard let data else { - throw DecodeError.shapeInvalid(reason: "double value missing") - } - switch data { - case .double(let value): - return NSNumber(value: value) - case .integer(let value): - return NSNumber(value: Double(value)) - default: - throw DecodeError.shapeInvalid(reason: "double expects a JSON number") - } - } - - private static func decodeBool( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .BOOL else { - throw DecodeError.kindMismatch(wireKind: "bool", expectedAttrType: expectedAttrType) - } - guard case .bool(let value)? = data else { - throw DecodeError.shapeInvalid(reason: "bool expects a JSON boolean") - } - return NSNumber(value: value) - } - - // MARK: - String-ish kinds - - private static func decodeString( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType, - allowedTypes: Set - ) throws -> Any { - guard allowedTypes.contains(expectedAttrType) else { - let wireKind: String - switch allowedTypes.first { - case .nsString: wireKind = "string" - case .sel: wireKind = "selector" - case .class: wireKind = "class" - default: wireKind = "string" - } - throw DecodeError.kindMismatch(wireKind: wireKind, expectedAttrType: expectedAttrType) - } - guard case .string(let value)? = data else { - throw DecodeError.shapeInvalid(reason: "string-kind value expects a JSON string") - } - return value as NSString - } - - // MARK: - Enums - - private static let enumNumericTypes: Set = [.enumInt, .enumLong] - - private static func decodeEnum( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - switch expectedAttrType { - case .enumInt, .enumLong: - // Numeric enum: wire data is an integer (raw case value). - guard let data else { - throw DecodeError.shapeInvalid(reason: "enum value missing") - } - switch data { - case .integer(let value): - return NSNumber(value: value) - case .double(let value): - return NSNumber(value: Int64(value)) - default: - throw DecodeError.shapeInvalid(reason: "numeric enum expects a JSON integer") - } - case .enumString: - guard case .string(let value)? = data else { - throw DecodeError.shapeInvalid(reason: "string enum expects a JSON string case name") - } - return value as NSString - default: - throw DecodeError.kindMismatch(wireKind: "enum", expectedAttrType: expectedAttrType) - } - } - - // MARK: - Geometry - - private static func decodePoint( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .cgPoint else { - throw DecodeError.kindMismatch(wireKind: "point", expectedAttrType: expectedAttrType) - } - let object = try requireObject(data, fieldName: "point") - let x = try requireDouble(object["x"], fieldName: "point.x") - let y = try requireDouble(object["y"], fieldName: "point.y") - return NSValue(point: CGPoint(x: x, y: y)) - } - - private static func decodeVector( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .cgVector else { - throw DecodeError.kindMismatch(wireKind: "vector", expectedAttrType: expectedAttrType) - } - let object = try requireObject(data, fieldName: "vector") - let dx = try requireDouble(object["dx"], fieldName: "vector.dx") - let dy = try requireDouble(object["dy"], fieldName: "vector.dy") - return boxStruct(CGVector(dx: dx, dy: dy), objCType: "{CGVector=dd}") - } - - private static func decodeSize( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .cgSize else { - throw DecodeError.kindMismatch(wireKind: "size", expectedAttrType: expectedAttrType) - } - let object = try requireObject(data, fieldName: "size") - let width = try requireDouble(object["width"], fieldName: "size.width") - let height = try requireDouble(object["height"], fieldName: "size.height") - return NSValue(size: CGSize(width: width, height: height)) - } - - private static func decodeRect( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .cgRect else { - throw DecodeError.kindMismatch(wireKind: "rect", expectedAttrType: expectedAttrType) - } - let object = try requireObject(data, fieldName: "rect") - let x = try requireDouble(object["x"], fieldName: "rect.x") - let y = try requireDouble(object["y"], fieldName: "rect.y") - let width = try requireDouble(object["width"], fieldName: "rect.width") - let height = try requireDouble(object["height"], fieldName: "rect.height") - return NSValue(rect: CGRect(x: x, y: y, width: width, height: height)) - } - - private static func decodeAffineTransform( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .cgAffineTransform else { - throw DecodeError.kindMismatch(wireKind: "transform", expectedAttrType: expectedAttrType) - } - let object = try requireObject(data, fieldName: "transform") - let a = try requireDouble(object["a"], fieldName: "transform.a") - let b = try requireDouble(object["b"], fieldName: "transform.b") - let c = try requireDouble(object["c"], fieldName: "transform.c") - let d = try requireDouble(object["d"], fieldName: "transform.d") - let tx = try requireDouble(object["tx"], fieldName: "transform.tx") - let ty = try requireDouble(object["ty"], fieldName: "transform.ty") - let transform = CGAffineTransform(a: CGFloat(a), b: CGFloat(b), c: CGFloat(c), d: CGFloat(d), tx: CGFloat(tx), ty: CGFloat(ty)) - return boxStruct(transform, objCType: "{CGAffineTransform=dddddd}") - } - - private static func decodeEdgeInsets( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .uiEdgeInsets else { - throw DecodeError.kindMismatch(wireKind: "edgeInsets", expectedAttrType: expectedAttrType) - } - let object = try requireObject(data, fieldName: "edgeInsets") - let top = try requireDouble(object["top"], fieldName: "edgeInsets.top") - let left = try requireDouble(object["left"], fieldName: "edgeInsets.left") - let bottom = try requireDouble(object["bottom"], fieldName: "edgeInsets.bottom") - let right = try requireDouble(object["right"], fieldName: "edgeInsets.right") - // UIEdgeInsets and NSEdgeInsets share memory layout: top / left - // / bottom / right as four CGFloats. The server-side handler - // reads back via a cross-platform `InsetsValue` helper that - // accepts either objCType string. - var insets: (CGFloat, CGFloat, CGFloat, CGFloat) = (CGFloat(top), CGFloat(left), CGFloat(bottom), CGFloat(right)) - return withUnsafeBytes(of: &insets) { rawBuffer -> NSValue in - return NSValue(bytes: rawBuffer.baseAddress!, objCType: "{UIEdgeInsets=dddd}") - } - } - - private static func decodeOffset( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .uiOffset else { - throw DecodeError.kindMismatch(wireKind: "offset", expectedAttrType: expectedAttrType) - } - let object = try requireObject(data, fieldName: "offset") - let horizontal = try requireDouble(object["horizontal"], fieldName: "offset.horizontal") - let vertical = try requireDouble(object["vertical"], fieldName: "offset.vertical") - // UIOffset layout: two CGFloats. - var offset: (CGFloat, CGFloat) = (CGFloat(horizontal), CGFloat(vertical)) - return withUnsafeBytes(of: &offset) { rawBuffer -> NSValue in - return NSValue(bytes: rawBuffer.baseAddress!, objCType: "{UIOffset=dd}") - } - } - - // MARK: - Color - - private static func decodeColor( - _ data: LKMCPBridgeJSONValue?, - expectedAttrType: LookinAttrType - ) throws -> Any { - guard expectedAttrType == .uiColor else { - throw DecodeError.kindMismatch(wireKind: "color", expectedAttrType: expectedAttrType) - } - let object = try requireObject(data, fieldName: "color") - let red = try requireDouble(object["red"], fieldName: "color.red") - let green = try requireDouble(object["green"], fieldName: "color.green") - let blue = try requireDouble(object["blue"], fieldName: "color.blue") - let alpha = try requireDouble(object["alpha"], fieldName: "color.alpha") - // Server reconstructs the platform-specific color via - // `+[LookinColor lks_colorFromRGBAComponents:]`, which expects - // an NSArray in RGBA order, components in 0.0...1.0. - return [ - NSNumber(value: red), - NSNumber(value: green), - NSNumber(value: blue), - NSNumber(value: alpha), - ] as NSArray - } - - // MARK: - Helpers - - private static func requireObject( - _ data: LKMCPBridgeJSONValue?, - fieldName: String - ) throws -> [String: LKMCPBridgeJSONValue] { - guard case .object(let object)? = data else { - throw DecodeError.shapeInvalid(reason: "\(fieldName) expects a JSON object") - } - return object - } - - private static func requireDouble( - _ data: LKMCPBridgeJSONValue?, - fieldName: String - ) throws -> Double { - guard let data else { - throw DecodeError.shapeInvalid(reason: "\(fieldName) is missing") - } - switch data { - case .double(let value): - return value - case .integer(let value): - return Double(value) - default: - throw DecodeError.shapeInvalid(reason: "\(fieldName) expects a JSON number") - } - } - - private static func boxStruct( - _ value: StructType, - objCType: String - ) -> NSValue { - var copy = value - return withUnsafeBytes(of: ©) { rawBuffer -> NSValue in - return NSValue(bytes: rawBuffer.baseAddress!, objCType: objCType) - } - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeConnection.swift b/LookInside/MCPBridge/LKMCPBridgeConnection.swift deleted file mode 100644 index a7139f13..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeConnection.swift +++ /dev/null @@ -1,230 +0,0 @@ -// LKMCPBridgeConnection.swift -// -// Per-client connection handler for the MCPBridge Unix domain socket. -// -// Each accepted connection becomes one `LKMCPBridgeConnection` instance that -// reads newline-delimited JSON frames from its socket, decodes them as -// `LKMCPBridgeRequest`, dispatches via a request handler closure, and writes -// the resulting `LKMCPBridgeResponse` (or pushed `LKMCPBridgeEvent`) back. -// -// Buffering is line-oriented: bytes accumulate in a buffer until a `\n` -// separator is found; partial frames are tolerated across read events but -// individual frames must fit within the buffer's high-water mark. -// -// Concurrency: every piece of mutable state here — the read buffer, the -// closed flag, the read source — and every `write(2)` on the socket is -// confined to `connectionQueue`, a serial queue private to this -// connection. The serialization is load-bearing rather than defensive: -// requests are dispatched concurrently (each inbound frame spawns its -// own `Task`), so without it two responses could interleave their -// `write(2)` calls mid-frame and hand the peer a corrupt JSON line. -// Large frames — screenshots above all — widen that window enough to -// make it routine. The queue targets the shared I/O queue supplied by -// the server, so distinct connections still proceed in parallel. - -import Darwin -import Dispatch -import Foundation -import os - -public final class LKMCPBridgeConnection { - public typealias RequestHandler = @Sendable (LKMCPBridgeRequest) async -> LKMCPBridgeResponse - - /// Maximum accumulated bytes for a single frame before the connection is - /// torn down. Inspection responses can carry large screenshot data so the - /// limit is generous; this is purely a safety cap against runaway peers. - private static let maximumFrameBytes = 8 * 1024 * 1024 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Connection") - - private let fileDescriptor: Int32 - private let connectionQueue: DispatchQueue - private let requestHandler: RequestHandler - private let onClose: @Sendable (LKMCPBridgeConnection) -> Void - - /// All three are `connectionQueue`-confined; never touch them from - /// another thread. - private var readSource: DispatchSourceRead? - private var readBuffer = Data() - private var isClosed = false - - public init( - fileDescriptor: Int32, - queue: DispatchQueue, - requestHandler: @escaping RequestHandler, - onClose: @escaping @Sendable (LKMCPBridgeConnection) -> Void - ) { - self.fileDescriptor = fileDescriptor - self.connectionQueue = DispatchQueue( - label: "com.lookinside.mcp-bridge.connection.\(fileDescriptor)", - target: queue - ) - self.requestHandler = requestHandler - self.onClose = onClose - } - - public func start() { - connectionQueue.async { [weak self] in - guard let self else { return } - let source = DispatchSource.makeReadSource( - fileDescriptor: self.fileDescriptor, - queue: self.connectionQueue - ) - source.setEventHandler { [weak self] in - self?.handleReadable() - } - source.setCancelHandler { [weak self] in - guard let self else { return } - Darwin.close(self.fileDescriptor) - } - self.readSource = source - source.resume() - } - } - - /// Closes the connection. Safe to call from any thread; the actual - /// teardown hops onto `connectionQueue`. - public func close(reason: String) { - // Strong capture on purpose. This block is what cancels the read - // source and closes the socket, so it must still run when the caller - // drops its reference first -- which is exactly what - // `LKMCPBridgeServer.stop()` does when it clears `openConnections`. - // Captured weakly, shutdown would leak the descriptor and leave a - // live read source firing on a connection nobody owns. - connectionQueue.async { - self.closeOnConnectionQueue(reason: reason) - } - } - - /// Teardown proper. Callers must already be on `connectionQueue`. - private func closeOnConnectionQueue(reason: String) { - dispatchPrecondition(condition: .onQueue(connectionQueue)) - guard isClosed == false else { return } - isClosed = true - Self.logger.notice("Connection closed: \(reason, privacy: .public)") - readSource?.cancel() - readSource = nil - onClose(self) - } - - // MARK: - Read path - - private func handleReadable() { - dispatchPrecondition(condition: .onQueue(connectionQueue)) - guard isClosed == false else { return } - var buffer = [UInt8](repeating: 0, count: 4096) - let bytesRead = buffer.withUnsafeMutableBufferPointer { pointer -> ssize_t in - return read(fileDescriptor, pointer.baseAddress, pointer.count) - } - if bytesRead == 0 { - closeOnConnectionQueue(reason: "peer closed (EOF)") - return - } - if bytesRead < 0 { - let errorNumber = errno - if errorNumber == EAGAIN || errorNumber == EWOULDBLOCK { - return - } - closeOnConnectionQueue(reason: "read failed (errno \(errorNumber))") - return - } - readBuffer.append(buffer, count: bytesRead) - if readBuffer.count > Self.maximumFrameBytes { - closeOnConnectionQueue(reason: "frame buffer exceeded \(Self.maximumFrameBytes) bytes") - return - } - drainFrames() - } - - private func drainFrames() { - while let newlineIndex = readBuffer.firstIndex(of: UInt8(ascii: "\n")) { - let frameSlice = readBuffer[readBuffer.startIndex.. ssize_t in - guard let baseAddress = rawBufferPointer.baseAddress else { return 0 } - return write(fileDescriptor, baseAddress, rawBufferPointer.count) - } - if written < 0 { - let errorNumber = errno - if errorNumber == EINTR { continue } - if errorNumber == EAGAIN || errorNumber == EWOULDBLOCK { - // Unreachable while the descriptor is blocking, which - // `LKMCPBridgeListenSocket.acceptConnection` guarantees -- - // and that guarantee is load-bearing: on macOS accept(2) - // inherits O_NONBLOCK from the (necessarily non-blocking) - // listener, and a non-blocking descriptor lands here - // partway through any response larger than the socket send - // buffer, tearing the connection down mid-frame. Kept as a - // backstop; if it ever fires, fix the descriptor's mode - // rather than making this branch retry. - closeOnConnectionQueue(reason: "write would block (errno \(errorNumber))") - return - } - closeOnConnectionQueue(reason: "write failed (errno \(errorNumber))") - return - } - remaining.removeFirst(written) - } - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeDetailsService.swift b/LookInside/MCPBridge/LKMCPBridgeDetailsService.swift deleted file mode 100644 index e59bf060..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeDetailsService.swift +++ /dev/null @@ -1,409 +0,0 @@ -// LKMCPBridgeDetailsService.swift -// -// Handles the `details.read` bridge route: actively triggers RPC 203 -// `HierarchyDetails` for a batch of object identifiers and returns -// their attribute detail to the agent. Closes the painful "agent -// asked to read / modify a view whose detail the host has not yet -// fetched" loop that v0.4 surfaced as `modify.attributeNotFound`. -// -// Differences from `attributes.read`: -// - `attributes.read` reads the host's cache as-is; if the host has -// not seen the view yet, the agent gets an empty `groups` array -// plus `detailsCached: false`. -// - `details.read` sends RPC 203 to the inspected app, awaits the -// full streamed response (one frame per server-side task package), -// and surfaces every successful view's detail. The same routing -// also writes the response through -// `LKStaticHierarchyDataSource.modifyWithDisplayItemDetail:` so -// subsequent `attributes.read` calls see populated cache and the -// inspector UI reflects the new state. -// -// Scope: v0.5 only fetches attribute groups (no screenshots, no -// frame/bounds/hidden/alpha, no subitems). Screenshots get their -// own dedicated route; visual info is already in `get_hierarchy`; -// subtree changes are out of scope (agents follow up with -// `get_hierarchy` when they need to.) - -import AppKit -import CoreGraphics -import Foundation -import os - -@MainActor -public final class LKMCPBridgeDetailsService { - - /// Hard cap on per-call batch size. Matches the host inspector's - /// own `packageMaxTasksCount` (see `LKStaticAsyncUpdateManager`). - /// Larger batches are rejected at the wire boundary; agents that - /// need more issue multiple calls. - private static let maximumObjectIdentifiersPerCall = 100 - - // MARK: - Error code constants from LookinDefines.h - // - // See LKMCPBridgeInvocationService for the duplication rationale. - - private static let lookinErrCodeObjectNotFound = -500 - private static let lookinErrCodeInner = -401 - private static let lookinErrCodeLicenseRequired = -408 - private static let lookinErrCodeNoConnect = -403 - private static let lookinErrCodeTimeout = -405 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Details") - - public init() {} - - // MARK: - Entry point - - public func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - guard request.method == "details.read" else { - return .failure(identifier: request.identifier, error: .unknownMethod) - } - return await handleDetailsRead( - identifier: request.identifier, - parameters: request.parameters - ) - } - - // MARK: - details.read - - private func handleDetailsRead( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) async -> LKMCPBridgeResponse { - // Parameter extraction - guard let parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"], - case .array(let identifierWireValues)? = parameters["objectIdentifiers"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - - // Materialize the identifier list as distinct Swift strings. The - // de-duplication is load-bearing, not tidiness: two equal identifiers - // resolve to the same display item and therefore the same oid, and - // the oid-keyed index built below traps on a duplicate key, which - // would take down the whole host app rather than fail this request. - let requestedIdentifiers: [String] - switch LKMCPBridgeObjectIdentifierList.parse( - wireValues: identifierWireValues, - limit: Self.maximumObjectIdentifiersPerCall - ) { - case .success(let parsed): - requestedIdentifiers = parsed.identifiers - if parsed.droppedDuplicateCount > 0 { - Self.logger.debug( - "details.read collapsed \(parsed.droppedDuplicateCount, privacy: .public) duplicate object identifier(s)" - ) - } - case .failure(.notAllStrings), .failure(.empty): - return .failure(identifier: identifier, error: .invalidParameters) - case .failure(.tooMany(let distinctCount, let limit)): - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "dispatch.tooMany", - message: "details.read accepts at most \(limit) object identifiers per call; received \(distinctCount) distinct. Split into multiple calls." - ) - ) - } - - let includeUserCustom: Bool - if case .bool(let raw)? = parameters["includeUserCustom"] { - includeUserCustom = raw - } else { - includeUserCustom = true - } - - // Live-document lookup - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - - guard document.hierarchyDataSource != nil else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has not loaded a hierarchy yet." - ) - ) - } - - // Resolve each requested identifier to a display item. Missing - // identifiers go into failedIdentifiers and skip the server - // round-trip; the agent learns which oids vanished without the - // request blowing up entirely. - let roots = LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document) - var resolvedItems: [(identifier: String, displayItem: LookinDisplayItem, nativeOid: UInt)] = [] - resolvedItems.reserveCapacity(requestedIdentifiers.count) - var failedIdentifiers: [String] = [] - - for requested in requestedIdentifiers { - guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( - amongRoots: roots, - matchingObjectIdentifier: requested - ), - let nativeOid = displayItem.displayingObject()?.oid, - nativeOid != 0 - else { - failedIdentifiers.append(requested) - continue - } - resolvedItems.append((identifier: requested, displayItem: displayItem, nativeOid: nativeOid)) - } - - // If everything is gone, surface a structured error instead of - // an empty success response — the agent likely passed stale oids. - if resolvedItems.isEmpty { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "details.objectNotFound", - message: "None of the requested object identifiers are present in this target's hierarchy. Refresh the hierarchy via get_hierarchy and retry." - ) - ) - } - - // Build the RPC 203 task package. Single package — we already - // cap the batch at 100 entries, so the host inspector's own - // pixel/count packing thresholds never need to come into play. - let clientReadableVersion = LKHelper.lookinReadableVersion() - let tasks = resolvedItems.map { resolved -> LookinStaticAsyncUpdateTask in - let task = LookinStaticAsyncUpdateTask() - task.oid = resolved.nativeOid - task.taskType = .noScreenshot - task.attrRequest = .need - task.needBasisVisualInfo = false - task.needSubitems = false - task.clientReadableVersion = clientReadableVersion - return task - } - let package = LookinStaticAsyncUpdateTasksPackage() - package.tasks = tasks - - // Round-trip through Peertalk. RPC 203 streams one frame per - // package; with a single package we expect exactly one frame. - // Still go through awaitAllValues so the bridge stays robust - // to future multi-package batching here without touching - // the await semantics. - guard let signal = document.inspectableApp.rawFetchHierarchyDetail(withTaskPackages: [package]) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "details.internalError", - message: "The target's inspectable app returned no signal for the fetch." - ) - ) - } - - let frames: [NSArray] - do { - frames = try await LKMCPBridgeRACBridge.awaitAllValues(of: signal, as: NSArray.self) - } catch RACBridgeError.completedWithoutValue { - // Empty success (server completed before any frame) → treat - // as zero successful details, all requested identifiers - // surface as failed. - return successResponse( - identifier: identifier, - details: [], - failedIdentifiers: requestedIdentifiers - ) - } catch RACBridgeError.cancelled { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "details.cancelled", - message: "The fetch was cancelled before the target app finished streaming details." - ) - ) - } catch let error as NSError { - // Must stay below the RACBridgeError clauses. Every Swift error - // bridges to NSError, so this pattern matches everything -- above - // them it silently swallows both, and the empty-success path just - // above could never run. - return .failure(identifier: identifier, error: mapDetailsError(error)) - } catch { - Self.logger.error("details.read bridge error: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - - // Flatten the per-package arrays into a single ordered list of - // detail objects. - var allDetails: [LookinDisplayItemDetail] = [] - allDetails.reserveCapacity(resolvedItems.count) - for frame in frames { - for entry in frame { - if let detail = entry as? LookinDisplayItemDetail { - allDetails.append(detail) - } - } - } - - // Index resolved items by native oid so we can match incoming - // details back to the display item (needed for secureContent - // detection and for downstream cache merging). - // - // Keep-first on collision rather than `uniqueKeysWithValues:`. The - // identifier list is already de-duplicated, so a collision here would - // mean two *different* identifier strings resolved to one object -- - // impossible today, since identifiers are generated from the oid and - // matched by exact equality. Trapping on it anyway would turn a future - // change in identifier syntax into a host crash, which is far worse - // than serving the first of two equivalent entries. - let itemsByOid: [UInt: (identifier: String, displayItem: LookinDisplayItem, nativeOid: UInt)] = Dictionary( - resolvedItems.map { ($0.nativeOid, $0) }, - uniquingKeysWith: { first, _ in first } - ) - - // Resolve the concrete data source once. We thread it through - // the per-detail loop rather than walking back from each - // display item, because LookinDisplayItem has no back-reference - // to its owning document. - let staticDataSource = document.hierarchyDataSource - - var emittedDetails: [LKMCPBridgeViewDetail] = [] - emittedDetails.reserveCapacity(allDetails.count) - var seenOids: Set = [] - - for detail in allDetails { - seenOids.insert(detail.displayItemOid) - - // Server-side failure surface: failureCode == -1 means the - // server couldn't resolve the oid (probably deallocated - // mid-request). Push it to failedIdentifiers and skip. - if detail.failureCode == -1 { - if let resolved = itemsByOid[detail.displayItemOid] { - failedIdentifiers.append(resolved.identifier) - } - continue - } - - guard let resolved = itemsByOid[detail.displayItemOid] else { - // The server emitted a detail for an oid we did not - // request. Defensive: ignore it. - continue - } - - // Merge the detail into the host's cache so the inspector - // UI reflects the update and a follow-up attributes.read - // hits the same data without re-fetching. - staticDataSource?.modify(with: detail) - - // Encode the attribute groups carried in this detail - // through the read-side encoder so the wire shape matches - // attributes.read. - let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: resolved.displayItem) - var rawGroups: [LookinAttributesGroup] = [] - if let inbuiltGroups = detail.attributesGroupList { - rawGroups.append(contentsOf: inbuiltGroups) - } - if includeUserCustom, let customGroups = detail.customAttrGroupList { - rawGroups.append(contentsOf: customGroups) - } - let encodedGroups = rawGroups.map { group -> LKMCPBridgeAttributeGroup in - let sections = (group.attrSections ?? []).map { section -> LKMCPBridgeAttributeSection in - let attributes = (section.attributes ?? []).map { attribute in - LKMCPBridgeAttributeEncoder.encode( - attribute, - redactingSecureContent: redactSecureContent - ) - } - return LKMCPBridgeAttributeSection( - identifier: section.identifier ?? "", - attributes: attributes - ) - } - let groupIdentifier = group.userCustomTitle ?? group.identifier ?? "" - return LKMCPBridgeAttributeGroup( - identifier: groupIdentifier, - isUserCustom: group.userCustomTitle != nil, - isSwiftUIGroup: group.isSwiftUIGroup, - sections: sections - ) - } - - emittedDetails.append(LKMCPBridgeViewDetail( - objectIdentifier: resolved.identifier, - groups: encodedGroups, - secureContent: redactSecureContent - )) - } - - // Any resolved oid we never saw a detail for (server omitted it - // from the stream, despite no failureCode) joins failedIdentifiers - // so the agent gets an honest accounting. - for resolved in resolvedItems where seenOids.contains(resolved.nativeOid) == false { - failedIdentifiers.append(resolved.identifier) - } - - return successResponse( - identifier: identifier, - details: emittedDetails, - failedIdentifiers: failedIdentifiers - ) - } - - // MARK: - Helpers - - private func successResponse( - identifier: String, - details: [LKMCPBridgeViewDetail], - failedIdentifiers: [String] - ) -> LKMCPBridgeResponse { - let result = LKMCPBridgeDetailsReadResult( - details: details, - failedIdentifiers: failedIdentifiers - ) - do { - let payload = try encodeAsJSONValue(result) - return .success(identifier: identifier, result: payload) - } catch { - Self.logger.error("details.read encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - private func mapDetailsError(_ error: NSError) -> LKMCPBridgeErrorPayload { - switch error.code { - case Self.lookinErrCodeObjectNotFound: - return LKMCPBridgeErrorPayload( - code: "details.objectNotFound", - message: "The target app could not find one or more of the requested objects. They may have been deallocated; refresh via get_hierarchy and retry." - ) - case Self.lookinErrCodeInner: - return LKMCPBridgeErrorPayload( - code: "details.internalError", - message: "The target app rejected the details request with a generic inner error." - ) - case Self.lookinErrCodeLicenseRequired: - return .licenseRequired - case Self.lookinErrCodeNoConnect: - return LKMCPBridgeErrorPayload( - code: "details.disconnected", - message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." - ) - case Self.lookinErrCodeTimeout: - return LKMCPBridgeErrorPayload( - code: "details.timeout", - message: "The target app did not respond within the request timeout. Reduce the batch size or check whether the target is paused in Xcode." - ) - default: - Self.logger.error("details.read received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") - return LKMCPBridgeErrorPayload( - code: "details.internalError", - message: "The target app reported an unexpected error (code \(error.code))." - ) - } - } - - private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeEventPublisher.swift b/LookInside/MCPBridge/LKMCPBridgeEventPublisher.swift deleted file mode 100644 index 246c3e96..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeEventPublisher.swift +++ /dev/null @@ -1,334 +0,0 @@ -// LKMCPBridgeEventPublisher.swift -// -// Turns host-side state changes into `LKMCPBridgeEvent` frames and hands -// them to `LKMCPBridgeServer.broadcast(event:)`. -// -// What belongs here, and what deliberately does not: -// -// Only things a bridge client cannot learn on its own get published. A -// client that calls a tool already has the tool's return value, so -// echoing the same fact back as an event would be noise at best and, at -// worst, read as independent news about the app. -// -// That rules out the data source's three item-change subjects -// (`itemDidChangeAttrGroup`, `itemDidChangeHiddenAlphaValue`, -// `itemsDidChangeFrame`). All three fire only from inside -// `-[LKStaticHierarchyDataSource modifyWithDisplayItemDetail:]`, which is -// the moment fetched detail lands in the cache -- and the fetch was -// almost always the caller's own `details.read` / `screenshot.read` / -// `attribute.modify`. Publishing them would tell a client "something -// changed" about a change it just made. -// -// `hierarchy.reloaded` is the one case where the client's own action and -// a human's produce the same signal, so it carries an `initiator` field -// instead of being dropped. -// -// What this publisher does NOT and cannot offer: notice that the -// inspected app's UI changed by itself. LookInside is a manual-refresh -// inspector; the host only learns the UI moved when someone asks it to -// look. Every topic here is about the *host's* state, never about the -// target app spontaneously changing. The CLI's resource descriptions say -// so in as many words, because a client that assumes otherwise will -// subscribe and then wait forever. -// -// Subscription filtering is not done here. The host stays ignorant of how -// many bridge clients exist and what each one cares about; it broadcasts -// to every open connection and lets each client filter. That keeps this -// file free of any Model Context Protocol concepts, which the GPL/MIT -// split requires. - -import AppKit -import Foundation -import os - -@MainActor -public final class LKMCPBridgeEventPublisher { - - /// `LookinPush_SwiftUISupportDetected` from `LookinDefines.h`. Declared - /// locally for the same reason the RPC services declare their error - /// codes locally: pulling LookinDefines.h into the bridging header - /// would touch every Swift compilation in the target. - private static let lookinPushSwiftUISupportDetected: UInt32 = 305 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Events") - - /// Where finished events go. Injected rather than reaching for the - /// server singleton so this type can be exercised without a socket. - private let broadcast: @MainActor (LKMCPBridgeEvent) -> Void - - /// Subscriptions that live as long as the publisher does. - private var globalDisposables: [RACDisposable] = [] - - /// One entry per open live document, holding that document's - /// `didReloadHierarchyInfo` subscription. Documents come and go, so - /// these are attached on open and disposed on close; leaving a stale - /// one behind would keep a closed document's data source alive and - /// keep publishing reloads for a window nobody can see. - private var documentDisposables: [ObjectIdentifier: RACDisposable] = [:] - - private var hasStarted = false - - public init(broadcast: @escaping @MainActor (LKMCPBridgeEvent) -> Void) { - self.broadcast = broadcast - } - - /// Runs `body` on the main actor, synchronously when already there. - /// - /// Every subject this file watches is main-queue bound today -- - /// Peertalk builds its protocol, channel, and USB hub on - /// `dispatch_get_main_queue()`, and hierarchy reloads are driven from - /// the inspector's UI. `MainActor.assumeIsolated` would document that - /// and check it, but it checks by trapping: if any of those ever - /// moves off the main queue, the whole inspector dies on the next - /// target disconnect. Publishing an event is not worth that. The - /// synchronous path is kept because a reload's payload is read from - /// mutable host state (`rawFlatItems`, `lastReloadInitiator`) that a - /// later reload would overwrite. - private static func onMainActor(_ body: @escaping @MainActor () -> Void) { - if Thread.isMainThread { - MainActor.assumeIsolated { body() } - } else { - Task { @MainActor in body() } - } - } - - // MARK: - Lifecycle - - /// Idempotent. The bridge server starts asynchronously and may be - /// asked to start more than once over a process's life. - public func start() { - guard hasStarted == false else { return } - hasStarted = true - - subscribeToDocumentLifecycle() - subscribeToConnectionManager() - subscribeToActivationState() - - // Documents can already exist: the bridge server starts on - // `applicationDidFinishLaunching:`, but a document opened by an - // auto-connect environment variable races that. Adopt whatever is - // already open rather than only ever seeing the next one. - for document in LKMCPBridgeLiveDocumentLookup.enumerateLiveDocuments() { - attachReloadSubscription(to: document) - } - } - - public func stop() { - guard hasStarted else { return } - hasStarted = false - NotificationCenter.default.removeObserver(self) - for disposable in globalDisposables { - disposable.dispose() - } - globalDisposables.removeAll() - for disposable in documentDisposables.values { - disposable.dispose() - } - documentDisposables.removeAll() - } - - // MARK: - Document lifecycle - - private func subscribeToDocumentLifecycle() { - NotificationCenter.default.addObserver( - self, - selector: #selector(handleDocumentDidOpen(_:)), - name: NSNotification.Name(rawValue: "LookinLiveDocumentDidOpenNotification"), - object: nil - ) - NotificationCenter.default.addObserver( - self, - selector: #selector(handleDocumentWillClose(_:)), - name: NSNotification.Name(rawValue: "LookinLiveDocumentWillCloseNotification"), - object: nil - ) - } - - @objc private func handleDocumentDidOpen(_ notification: Notification) { - guard let document = notification.object as? LookinLiveDocument else { return } - attachReloadSubscription(to: document) - // Carries the whole target record rather than just an identifier: - // a client that learns a target appeared will immediately want to - // know what it is, and the round-trip back to `targets.list` buys - // nothing the host does not already have in hand. - broadcast( - LKMCPBridgeEvent( - topic: "targets.attached", - payload: targetPayload(for: document) - ) - ) - } - - @objc private func handleDocumentWillClose(_ notification: Notification) { - guard let document = notification.object as? LookinLiveDocument else { return } - detachReloadSubscription(from: document) - broadcast( - LKMCPBridgeEvent( - topic: "targets.detached", - payload: targetPayload(for: document) - ) - ) - } - - // MARK: - Per-document reload subscription - - private func attachReloadSubscription(to document: LookinLiveDocument) { - let key = ObjectIdentifier(document) - guard documentDisposables[key] == nil else { return } - guard let dataSource = document.hierarchyDataSource else { return } - - // `didReloadHierarchyInfo` is the data source's own announcement - // that it has finished absorbing a new tree, so by the time this - // fires the hierarchy a client would read is already the new one. - let disposable = dataSource.didReloadHierarchyInfo.subscribeNext { [weak self, weak document] _ in - Self.onMainActor { - guard let self, let document else { return } - self.publishHierarchyReloaded(for: document) - } - } - documentDisposables[key] = disposable - } - - private func detachReloadSubscription(from document: LookinLiveDocument) { - let key = ObjectIdentifier(document) - documentDisposables.removeValue(forKey: key)?.dispose() - } - - private func publishHierarchyReloaded(for document: LookinLiveDocument) { - var payload = targetPayload(for: document) - payload["nodeCount"] = .integer(Int64(document.hierarchyDataSource?.rawFlatItems?.count ?? 0)) - // `agent` means "a bridge client asked for this", so a client that - // did the asking can recognize its own echo and ignore it. Only - // `host` is news. - let initiator = document.staticWindowController?.lastReloadInitiator ?? .host - payload["initiator"] = .string(initiator == .agent ? "agent" : "host") - broadcast(LKMCPBridgeEvent(topic: "hierarchy.reloaded", payload: payload)) - } - - // MARK: - Connection manager - - private func subscribeToConnectionManager() { - guard let connectionManager = LKConnectionManager.sharedInstance() else { return } - - // A channel ending is the one thing here that is genuinely about - // the target app rather than the host: the app was killed, the USB - // cable came out, the simulator shut down. A client holding object - // identifiers for that target needs to stop trusting them. - let channelEndDisposable = connectionManager.channelWillEnd.subscribeNext { [weak self] channel in - Self.onMainActor { - guard let self else { return } - self.publishDisconnected(channel: channel) - } - } - globalDisposables.append(channelEndDisposable) - - // `didReceivePush` carries a RACTuple of (channel, pushType, data) - // for every server-initiated push. Only one push type exists today. - let pushDisposable = connectionManager.didReceivePush.subscribeNext { [weak self] value in - Self.onMainActor { - guard let self, let tuple = value as? RACTuple else { return } - self.handleServerPush(tuple) - } - } - globalDisposables.append(pushDisposable) - } - - /// Resolves a `Lookin_PTChannel` back to the document that owns it, so - /// an event can name a target identifier the client already knows - /// rather than an opaque channel the bridge protocol never exposes. - /// - /// Delegates to the host's own `liveDocumentForChannel:` instead of - /// walking the documents here: matching a channel to a document is - /// exactly the question that method answers, and reimplementing it - /// would leave two versions to disagree after a reconnect swaps a - /// document's underlying channel. - private func document(forChannel channel: Any?) -> LookinLiveDocument? { - guard let typedChannel = channel as? Lookin_PTChannel else { return nil } - return LookinLiveDocumentController.sharedInstance().liveDocument(for: typedChannel) - } - - private func publishDisconnected(channel: Any?) { - guard let document = document(forChannel: channel) else { return } - broadcast( - LKMCPBridgeEvent( - topic: "targets.disconnected", - payload: targetPayload(for: document) - ) - ) - } - - private func handleServerPush(_ tuple: RACTuple) { - guard let pushTypeNumber = tuple.second as? NSNumber, - pushTypeNumber.uint32Value == Self.lookinPushSwiftUISupportDetected - else { return } - var payload: [String: LKMCPBridgeJSONValue] = [:] - if let document = document(forChannel: tuple.first) { - payload = targetPayload(for: document) - } - payload["capability"] = .string("swiftUI") - broadcast(LKMCPBridgeEvent(topic: "capabilities.swiftUIDetected", payload: payload)) - } - - // MARK: - Activation state - - private func subscribeToActivationState() { - NotificationCenter.default.addObserver( - self, - selector: #selector(handleActivationStateDidChange(_:)), - name: LKSwiftUISupportGatekeeper.activationStateDidChangeNotification, - object: nil - ) - } - - @objc private func handleActivationStateDidChange(_ notification: Notification) { - // Read silently. `allowProtectedFeatureAccessForWindow:` would put - // an activation window on screen, which an event handler must - // never do -- nobody asked for it. - let state = LKSwiftUISupportGatekeeper.sharedInstance().activationState - broadcast( - LKMCPBridgeEvent( - topic: "license.stateChanged", - payload: ["state": .string(Self.wireName(for: state))] - ) - ) - } - - private static func wireName(for state: LKSwiftUISupportActivationState) -> String { - switch state { - case .activated: - return "activated" - case .notActivated: - return "notActivated" - case .unknown: - return "unknown" - @unknown default: - return "unknown" - } - } - - // MARK: - Payload helper - - /// The same target description `targets.list` returns, minus the - /// fields that require a round-trip. Kept in one place so every topic - /// names a target the same way. - private func targetPayload(for document: LookinLiveDocument) -> [String: LKMCPBridgeJSONValue] { - var payload: [String: LKMCPBridgeJSONValue] = [:] - guard let appInfo = document.inspectableApp.appInfo else { - return payload - } - payload["targetIdentifier"] = .string(String(appInfo.appInfoIdentifier)) - if let applicationName = appInfo.appName, applicationName.isEmpty == false { - payload["applicationName"] = .string(applicationName) - } - if let bundleIdentifier = appInfo.appBundleIdentifier, bundleIdentifier.isEmpty == false { - payload["bundleIdentifier"] = .string(bundleIdentifier) - } - if let deviceDescription = appInfo.deviceDescription, deviceDescription.isEmpty == false { - payload["deviceDescription"] = .string(deviceDescription) - } - if let operatingSystemDescription = appInfo.osDescription, operatingSystemDescription.isEmpty == false { - payload["operatingSystemDescription"] = .string(operatingSystemDescription) - } - return payload - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeFrame.swift b/LookInside/MCPBridge/LKMCPBridgeFrame.swift deleted file mode 100644 index 956d9c3b..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeFrame.swift +++ /dev/null @@ -1,183 +0,0 @@ -// LKMCPBridgeFrame.swift -// -// Newline-delimited JSON wire frames spoken on the MCPBridge Unix domain socket. -// -// The schema here is intentionally a generic inspection IPC and MUST NOT -// reference Model Context Protocol concepts (no `tools/list`, -// `resources/subscribe`, `notifications/resources/updated` field names or -// method strings). Keeping this layer generic preserves GPL compatibility for -// reuse by other proprietary or open consumers (CI bridge, remote inspector, -// automation runners). - -import Foundation - -// MARK: - Envelope - -/// Discriminates the three frame kinds carried on the wire. -public enum LKMCPBridgeFrameKind: String, Sendable, Codable { - case request - case response - case event -} - -// MARK: - Request - -/// A request frame originating from a connected client. -/// -/// `identifier` correlates request and response frames; it is opaque to the -/// server and echoed back verbatim. `method` selects an inspection verb on the -/// server side (for example, `targets.list`, `hierarchy.read`). -public struct LKMCPBridgeRequest: Sendable, Codable { - public let kind: LKMCPBridgeFrameKind - public let identifier: String - public let method: String - public let parameters: [String: LKMCPBridgeJSONValue]? - - enum CodingKeys: String, CodingKey { - case kind - case identifier = "id" - case method - case parameters = "params" - } -} - -// MARK: - Response - -/// A response frame returned to the client for a previously received request. -/// -/// Exactly one of `result` or `error` is set. The server MUST echo the -/// request's `identifier` verbatim. -public struct LKMCPBridgeResponse: Sendable, Codable { - public let kind: LKMCPBridgeFrameKind - public let identifier: String - public let result: LKMCPBridgeJSONValue? - public let error: LKMCPBridgeErrorPayload? - - enum CodingKeys: String, CodingKey { - case kind - case identifier = "id" - case result - case error - } - - public static func success(identifier: String, result: LKMCPBridgeJSONValue?) -> LKMCPBridgeResponse { - return LKMCPBridgeResponse(kind: .response, identifier: identifier, result: result, error: nil) - } - - public static func failure(identifier: String, error: LKMCPBridgeErrorPayload) -> LKMCPBridgeResponse { - return LKMCPBridgeResponse(kind: .response, identifier: identifier, result: nil, error: error) - } -} - -// MARK: - Event - -/// An unsolicited event frame pushed by the server. The `topic` is a dotted -/// path identifying the event category (for example, `hierarchy.invalidated`, -/// `targets.attached`); the `payload` carries topic-specific structure. -public struct LKMCPBridgeEvent: Sendable, Codable { - public let kind: LKMCPBridgeFrameKind - public let topic: String - public let payload: [String: LKMCPBridgeJSONValue]? - - public init(topic: String, payload: [String: LKMCPBridgeJSONValue]?) { - self.kind = .event - self.topic = topic - self.payload = payload - } -} - -// MARK: - Error payload - -/// A structured error returned in a response frame. Error codes follow a -/// dotted-namespace convention (for example, `dispatch.unknownMethod`, -/// `license.entitlementRequired`); free-form messages may be present for -/// debuggability but must not be relied on for programmatic dispatch. -public struct LKMCPBridgeErrorPayload: Sendable, Codable, Error { - public let code: String - public let message: String - - public init(code: String, message: String) { - self.code = code - self.message = message - } - - public static let unknownMethod = LKMCPBridgeErrorPayload( - code: "dispatch.unknownMethod", - message: "The requested method is not implemented by this server." - ) - - public static let invalidParameters = LKMCPBridgeErrorPayload( - code: "dispatch.invalidParameters", - message: "The request parameters could not be decoded into the expected shape." - ) - - public static let licenseRequired = LKMCPBridgeErrorPayload( - code: "license.entitlementRequired", - message: "The connected LookInside license does not include the required entitlement." - ) - - public static let internalError = LKMCPBridgeErrorPayload( - code: "dispatch.internalError", - message: "An unexpected internal error occurred while servicing the request." - ) -} - -// MARK: - JSONValue - -/// A minimal JSON value type that round-trips through Codable without requiring -/// the inspection schema to be statically typed at the wire layer. Used inside -/// request `parameters`, response `result`, and event `payload` containers so -/// the server can route opaque JSON to method-specific handlers. -public enum LKMCPBridgeJSONValue: Sendable, Codable { - case null - case bool(Bool) - case integer(Int64) - case double(Double) - case string(String) - case array([LKMCPBridgeJSONValue]) - case object([String: LKMCPBridgeJSONValue]) - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let boolean = try? container.decode(Bool.self) { - self = .bool(boolean) - } else if let integer = try? container.decode(Int64.self) { - self = .integer(integer) - } else if let floating = try? container.decode(Double.self) { - self = .double(floating) - } else if let text = try? container.decode(String.self) { - self = .string(text) - } else if let elements = try? container.decode([LKMCPBridgeJSONValue].self) { - self = .array(elements) - } else if let object = try? container.decode([String: LKMCPBridgeJSONValue].self) { - self = .object(object) - } else { - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Encountered a JSON value that is none of null / bool / number / string / array / object." - ) - } - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .null: - try container.encodeNil() - case .bool(let boolean): - try container.encode(boolean) - case .integer(let integer): - try container.encode(integer) - case .double(let floating): - try container.encode(floating) - case .string(let text): - try container.encode(text) - case .array(let elements): - try container.encode(elements) - case .object(let object): - try container.encode(object) - } - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeInspectionModels.swift b/LookInside/MCPBridge/LKMCPBridgeInspectionModels.swift deleted file mode 100644 index be0e247d..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeInspectionModels.swift +++ /dev/null @@ -1,524 +0,0 @@ -// LKMCPBridgeInspectionModels.swift -// -// Codable DTOs returned in MCPBridge response frames. These are deliberately -// flat, JSON-friendly value types so the wire format stays self-describing -// to any consumer (proprietary `lookinside-mcp` shim, future CI bridges, -// remote inspectors). They do not depend on AppKit / UIKit types. -// -// The naming follows the wire vocabulary: a "target" is one inspection -// session bound to a running app (one `LookinLiveDocument`); a "view node" -// is one row in a target's UI hierarchy (one `LookinDisplayItem`). - -import CoreGraphics -import Foundation - -// MARK: - Rect - -/// CGRect represented as JSON-friendly doubles. Origin is the root coordinate -/// space of the inspected hierarchy. -public struct LKMCPBridgeRect: Sendable, Codable, Equatable { - public let x: Double - public let y: Double - public let width: Double - public let height: Double - - public init(x: Double, y: Double, width: Double, height: Double) { - self.x = x - self.y = y - self.width = width - self.height = height - } - - public init(cgRect: CGRect) { - self.init( - x: Double(cgRect.origin.x), - y: Double(cgRect.origin.y), - width: Double(cgRect.size.width), - height: Double(cgRect.size.height) - ) - } -} - -// MARK: - TargetInfo - -/// One row in a `targets.list` response: a single live inspection session -/// (typically corresponding to one inspector window in the host UI). -public struct LKMCPBridgeTargetInfo: Sendable, Codable { - /// Stable identifier for the duration of the inspection session. - /// Derived from `LookinAppInfo.appInfoIdentifier` (randomly generated per - /// app launch and reused across reconnects to the same app instance). - public let targetIdentifier: String - - /// Human-readable application name, e.g. "WeRead". - public let applicationName: String? - - /// Reverse-DNS bundle identifier, e.g. "com.example.weread". - public let bundleIdentifier: String? - - /// Human-readable device description, e.g. "iPhone 15 Pro". - public let deviceDescription: String? - - /// Human-readable operating-system description, e.g. "17.4". - public let operatingSystemDescription: String? - - /// One of `simulator`, `iPad`, `device`, `mac`, `unknown`. - public let deviceKind: String - - /// Numeric `LookinServer` version reported by the connected app. - public let serverVersion: Int - - /// One of `licensed`, `trial`, `unlicensed`. V0 reports `licensed` for - /// every entry; the entitlement gate that produces real values lands in - /// a follow-up commit. - public let licenseState: String -} - -// MARK: - ViewNode - -/// One node in a target's UI hierarchy. Returned by `hierarchy.read`. -/// -/// The shape is intentionally minimal for v1 — frame, identity, visibility, -/// and the immediate parent / child relationship. Per-attribute reads and -/// screenshot URIs live in separate methods to be added in subsequent -/// commits. -public struct LKMCPBridgeViewNode: Sendable, Codable { - /// Hex-encoded `LookinObject.oid`, prefixed with `0x` (for example, - /// `0x600000abc123`). Stable within a single connected app instance. - public let objectIdentifier: String - - /// Leaf Objective-C class name (head of `LookinObject.classChainList`), - /// e.g. "UIButton". May be empty if the underlying display item is - /// configured by the in-app `lookin_customDebugInfos` hook without - /// touching a real view / layer. - public let className: String - - /// What the node is: "view" | "layer" | "window" | "windowScene" | - /// "layoutGuide" | "custom". Mapped from the display item's resolved - /// node kind, so it is present for old servers too (slot derivation). - public let nodeKind: String - - /// Frame in the root coordinate space (host calls this `frameToRoot`). - public let frame: LKMCPBridgeRect - - /// `true` when the view is hidden via UIKit / AppKit visibility flags. - public let isHidden: Bool - - /// Composite alpha in `0.0 ... 1.0`. - public let alpha: Double - - /// `true` when this node represents the key `UIWindow` / `NSWindow`. - public let representsKeyWindow: Bool - - /// Object identifiers of immediate children, in source order. - public let childObjectIdentifiers: [String] - - /// Inlined child nodes when the request asked for a depth greater than - /// one. `nil` means children were not expanded; an empty array means - /// children were expanded and there are none. - public let children: [LKMCPBridgeViewNode]? -} - -// MARK: - AttributeGroup / Section / Attribute - -/// One attribute "card" in the host's inspector — a coherent bundle of -/// related attributes (Frame, View, Layer, AutoLayout, UIControl, …). -public struct LKMCPBridgeAttributeGroup: Sendable, Codable { - /// Group identifier (`LookinAttrGroupIdentifier`, e.g. `Layout`, - /// `UIScrollView`, `NSWindow`), or the user-supplied title when the - /// group originates from `lookin_customDebugInfos`. - public let identifier: String - - /// `true` when this group comes from in-app `lookin_customDebugInfos` - /// rather than LookinServer's built-in introspection. - public let isUserCustom: Bool - - /// `true` when this group was produced by the activation-gated SwiftUI - /// extension; agents can use this to flag paid-feature data origin. - public let isSwiftUIGroup: Bool - - public let sections: [LKMCPBridgeAttributeSection] -} - -/// One sub-row inside an attribute group. -public struct LKMCPBridgeAttributeSection: Sendable, Codable { - public let identifier: String - public let attributes: [LKMCPBridgeAttribute] -} - -/// One inspected attribute value with a type-discriminating `kind`. -public struct LKMCPBridgeAttribute: Sendable, Codable { - /// `LookinAttrIdentifier` string (e.g. `BasicViewClass_Frame`, - /// `BasicViewClass_Hidden`). Stable across LookinServer versions. - public let identifier: String - - /// Human-readable title set by `lookin_customDebugInfos`; empty for - /// built-in attributes. - public let displayTitle: String? - - /// `true` when this attribute originates from `lookin_customDebugInfos`. - public let isUserCustom: Bool - - /// Type discriminator for `value`. See `LKMCPBridgeAttributeEncoder` - /// for the full kind → JSON-shape mapping. Common values: - /// `integer`, `double`, `bool`, `string`, `selector`, `class`, - /// `point`, `size`, `rect`, `edgeInsets`, `offset`, `transform`, - /// `color`, `shadow`, `enum`, `json`, `custom`, `void`, - /// `unknown` (when the encoder cannot project the type cleanly and - /// falls back to a `{ "rawDescription": "..." }` payload). - public let kind: String - - /// The encoded attribute value, shape-correlated with `kind`. `nil` - /// when the source attribute carries no value (`LookinAttrTypeVoid` - /// or genuinely empty optional fields). - public let value: LKMCPBridgeJSONValue? - - /// Auxiliary payload for select kinds. For `enum` types, this is the - /// list of all enum case names the inspected object can hold (the - /// host calls these `extraValue` on `LookinAttribute`). - public let extraValue: LKMCPBridgeJSONValue? - - /// Server-side identifier of a custom-attribute setter, present only - /// when the host can write to this attribute through a registered - /// custom setter. Pass-through; the bridge does not interpret it. - public let customSetterIdentifier: String? -} - -// MARK: - Invocation - -/// Structural metadata for an `NSObject` returned by an invocation -/// (RPC 206). The receiver of an `invoke.method` response may use -/// `objectIdentifier` as a handle for a follow-up `invoke.method` call on -/// the same object — but the identifier is a fresh server-registered -/// `LookinObject.oid` and is NOT guaranteed to appear in a subsequent -/// `hierarchy.read` (the hierarchy walks the view tree, not the server's -/// general object registry). -public struct LKMCPBridgeReturnedObject: Sendable, Codable { - /// Hex-encoded `LookinObject.oid` for the returned object, prefixed - /// with `0x` (matches the form used everywhere else on the bridge). - public let objectIdentifier: String - - /// Pointer-formatted memory address of the returned object inside - /// the inspected app's address space, e.g. `0x100abcd00`. - public let memoryAddress: String - - /// Full class chain of the returned object (head is the leaf class, - /// tail is `NSObject`). Identical shape to the - /// `LookinObject.classChainList` produced by the inspection routes. - public let classChainList: [String] - - /// Optional debug annotation that the in-app `lookin_specialTrace` - /// hook may set. `nil` when the object does not opt in. - public let specialTrace: String? -} - -/// Result envelope for `invoke.method`. Always carries `returnedVoid` so -/// callers can disambiguate "method returned `nil` / `0` / empty string" -/// from "method returned void"; the two cases produce the same JSON -/// `description: null` shape on most type-erased clients otherwise. -public struct LKMCPBridgeInvocationResult: Sendable, Codable { - /// Stringified return value. `nil` when the method returned `void` - /// or when `secureContent` is `true`. For scalar (non-object, - /// non-void) returns the server fills in a generic `"Method invoked."` - /// placeholder; the bridge surfaces that string verbatim. - public let description: String? - - /// `true` when the inspected method's return type is `void` (the - /// server signals this via the `LOOKIN_TAG_RETURN_VALUE_VOID` marker). - public let returnedVoid: Bool - - /// Present only when the method returned an `NSObject`. Even when - /// `secureContent` is `true` the structural metadata stays — it - /// carries no user secret, only the object's class chain / address / - /// `oid` — matching the redaction philosophy of `attributes.read` - /// (string-bearing attribute values get redacted; structural - /// metadata does not). - public let returnObject: LKMCPBridgeReturnedObject? - - /// `true` when the receiver display item is treated as carrying - /// secure user content (see `LKMCPBridgeSecureContentDetector`). In - /// that case `description` is redacted to `nil` to avoid leaking - /// passwords / OTPs into agent transcripts. `returnObject` is kept; - /// see its doc comment for the redaction rationale. - public let secureContent: Bool -} - -// MARK: - Modification - -/// Wire envelope for an attribute value passed across the bridge in a -/// modification context: `kind` is the same string the read-side -/// `LKMCPBridgeAttributeEncoder` produces, `data` is its corresponding -/// JSON-friendly payload. The bridge consumes this in both directions: -/// requests carry it as the value to apply; responses echo it as -/// `requestedValue` so agents can compare against `effectiveAttribute` -/// without having to reconstruct the wire form. -public struct LKMCPBridgeAttributeValueWire: Sendable, Codable { - public let kind: String - public let data: LKMCPBridgeJSONValue? -} - -/// Result envelope for `attribute.modify`. Echoes the requested value, -/// surfaces the post-layout effective attribute, and includes the -/// host-visible side-effect snapshot (frame / bounds / hidden / alpha) -/// that the server captures in `LookinDisplayItemDetail` after the -/// setter has run and a layout pass has completed. -public struct LKMCPBridgeModificationResult: Sendable, Codable { - /// Echo of the attribute identifier the agent asked to modify. - public let attributeIdentifier: String - - /// Echo of the wire `value` payload the agent supplied. Lets the - /// agent diff against `effectiveAttribute.value` without re-deriving - /// the wire shape from the encoded result. - public let requestedValue: LKMCPBridgeAttributeValueWire - - /// Fully-encoded attribute as the host saw it AFTER the setter ran - /// and a layout pass settled. May differ from the request (autolayout - /// adjusts frames; some setters round to pixel boundaries; some - /// setters are no-ops). When `secureContent` is `true` the value - /// here is redacted in the same way `read_attributes` redacts. - public let effectiveAttribute: LKMCPBridgeAttribute - - /// `true` when the effective wire value is structurally equal to - /// the requested wire value. Strict equality — no float epsilon. - /// `false` means the inspected app's layout pass / setter / autolayout - /// constraints rejected or adjusted the requested value; agents - /// should surface this to the user as a real signal. - public let effectiveMatchesRequested: Bool - - /// Post-modification frame snapshot. Often differs from the previous - /// `get_hierarchy` result when the modification touched layout. - public let frame: LKMCPBridgeRect - - /// Post-modification bounds snapshot. - public let bounds: LKMCPBridgeRect - - /// Post-modification `isHidden` snapshot. - public let isHidden: Bool - - /// Post-modification composite alpha snapshot in `0.0...1.0`. - public let alpha: Double - - /// Same secure-content semantics as `read_attributes`: when `true`, - /// any string-bearing `effectiveAttribute.value` fields are redacted. - public let secureContent: Bool -} - -// MARK: - Details prefetch - -/// One per-view entry in a `details.read` response. The shape is -/// deliberately a subset of `attributes.read`'s envelope: agents that -/// already consume `attributes.read` can reuse their `groups` parsing -/// code unchanged. The `secureContent` flag is per-item because a -/// single batch may contain both regular and secure-input views. -public struct LKMCPBridgeViewDetail: Sendable, Codable { - public let objectIdentifier: String - public let groups: [LKMCPBridgeAttributeGroup] - public let secureContent: Bool -} - -/// Envelope for `details.read`. Successful per-view detail goes in -/// `details`. Object identifiers that were missing from the client -/// hierarchy or returned `failureCode = -1` from the server fall -/// into `failedIdentifiers` — the call as a whole still succeeds so -/// agents can act on whatever did come back. -public struct LKMCPBridgeDetailsReadResult: Sendable, Codable { - public let details: [LKMCPBridgeViewDetail] - public let failedIdentifiers: [String] -} - -// MARK: - Screenshot - -/// Result envelope for `screenshot.read`. Carries the image inline as -/// base64 rather than as a URI: the bridge has no static file server, -/// and the consuming MCP shim needs the bytes anyway to build an image -/// content block. -public struct LKMCPBridgeScreenshotResult: Sendable, Codable { - /// The object actually captured. Differs from the request when the - /// caller omitted `objectIdentifier` and the service resolved the - /// key window on their behalf. - public let objectIdentifier: String - - /// `solo` (view alone, subviews hidden) or `group` (view plus its - /// whole subtree). Echoed so callers that relied on the default can - /// see which one they got. - public let mode: String - - /// Base64-encoded image bytes. - public let imageData: String - - /// IANA media type for `imageData`. Always `image/png` today. - public let mimeType: String - - /// Pixel dimensions of the returned image, after any downscale. - public let pixelWidth: Int - public let pixelHeight: Int - - /// Pixel dimensions as captured, before any downscale. Equal to - /// `pixelWidth` / `pixelHeight` when no downscale was applied. - /// Agents can compare the two to know whether fine detail was lost - /// and re-request with a larger `maximumPixelDimension`. - public let sourcePixelWidth: Int - public let sourcePixelHeight: Int - - /// Decoded (pre-base64) size of `imageData`, in bytes. - public let byteCount: Int - - /// Frame of the captured view in the hierarchy's root coordinate - /// space, in points. Lets an agent map pixel coordinates in the - /// image back onto the coordinates `hierarchy.read` reports. - public let frame: LKMCPBridgeRect - - /// `true` when the image came from the host's existing cache rather - /// than a fresh render in the target app. Cached images can lag - /// behind the live UI — notably right after `attribute.modify`. - public let servedFromCache: Bool - - /// `true` when the captured region includes a view the secure-content - /// detector flags (for `group`, anywhere in the subtree). - /// - /// Screenshots are NOT redacted: unlike attribute strings, there is - /// no way to blank a value without destroying the picture. This flag - /// is the honest signal that the returned pixels may show sensitive - /// user input, so the caller can decide whether to keep it. - public let containsSecureContent: Bool -} - -// MARK: - Hierarchy search - -/// One hit in a `hierarchy.find` response. -/// -/// Carries enough context to act on the result without a follow-up -/// `hierarchy.read`: the identifier for subsequent calls, the ancestor -/// chain for orientation, and the fields that matched so the caller can -/// tell a class-name hit apart from an incidental substring in a subtitle. -public struct LKMCPBridgeSearchMatch: Sendable, Codable { - public let objectIdentifier: String - - /// Leaf Objective-C class name, matching `hierarchy.read`'s field of - /// the same name. - public let className: String - - /// The host inspector's row label. `null` when the secure-content - /// detector flagged this view. - public let title: String? - - /// The host inspector's secondary label. `null` when the - /// secure-content detector flagged this view. - public let subtitle: String? - - /// Frame in the hierarchy's root coordinate space, in points. - public let frame: LKMCPBridgeRect - - public let isHidden: Bool - public let alpha: Double - - /// Nesting level, `0` for a window / scene root. Equal to the number - /// of entries in `ancestorObjectIdentifiers`. - public let depth: Int - - /// Ancestor identifiers ordered root first, parent last. Feed any of - /// them to `hierarchy.read`'s `rootObjectIdentifier` to inspect the - /// surrounding subtree. - public let ancestorObjectIdentifiers: [String] - - /// Human-readable ancestry, e.g. `NSWindow > NSView > NSButton`, - /// ending with this node. For orientation in a log or a reply; do not - /// parse it — use `ancestorObjectIdentifiers`. - public let pathDescription: String - - /// Which searchable fields contained the query, in canonical order. - /// Never empty for a returned match. - public let matchedFields: [String] - - /// `true` when the secure-content detector flagged this view, which - /// is also why `title` / `subtitle` may be `null`. - public let secureContent: Bool -} - -/// Envelope for `hierarchy.find`. -/// -/// `totalMatchCount` counts every hit in the searched scope, so a caller -/// that receives `truncated: true` knows how much it is not seeing and -/// can narrow the query rather than paginate blindly. -public struct LKMCPBridgeSearchResult: Sendable, Codable { - public let matches: [LKMCPBridgeSearchMatch] - - /// Hits found before `limit` was applied. - public let totalMatchCount: Int - - /// `true` when `matches.count < totalMatchCount`. - public let truncated: Bool - - /// Nodes examined. Equals the whole tree unless the caller scoped the - /// search with `rootObjectIdentifier`. - public let searchedNodeCount: Int - - /// The fields actually searched, after defaulting. - public let searchedFields: [String] -} - -// MARK: - Selector listing - -/// Envelope for `selectors.list`. -/// -/// The server walks the entire class chain up to `NSObject`, so -/// `totalCount` on a UIKit / AppKit view runs into four digits. Callers -/// are expected to narrow with `nameFilter` rather than raise `limit`. -public struct LKMCPBridgeSelectorListResult: Sendable, Codable { - /// The class actually queried. Differs from the request when the - /// caller passed `objectIdentifier` and the service resolved the leaf - /// class on their behalf. - public let className: String - - /// Selector names in the order the target app produced them: by - /// class, most-derived first. Not sorted alphabetically — position is - /// the only signal about where a method came from. Coarse rather than - /// exact: within a single class the order is arbitrary. - public let selectors: [String] - - /// Matching selectors before `limit` was applied. - public let totalCount: Int - - /// `true` when `selectors.count < totalCount`. - public let truncated: Bool - - /// `true` when selectors taking arguments were included. When - /// `false`, every entry is callable through `invoke.method`. - public let includesArguments: Bool - - /// The resolved object's full class chain, present only when the - /// caller passed `objectIdentifier`. Tells the caller which classes - /// the flat `selectors` list spans. - public let classChain: [String]? -} - -// MARK: - Hierarchy refresh - -/// Envelope for `hierarchy.refresh`. -/// -/// The interesting field is `previousNodeCount`: LookInside only learns the -/// target's UI changed when someone asks it to look, so a caller has no way -/// to tell a refresh that found a whole new screen from one that found the -/// same screen it already had. Reporting both counts answers that without a -/// follow-up `hierarchy.read`. -public struct LKMCPBridgeRefreshResult: Sendable, Codable { - /// Echo of the requested target. - public let targetIdentifier: String - - /// Top-level windows / scenes after the reload. Usable directly as - /// `hierarchy.read`'s `rootObjectIdentifier`. - public let rootObjectIdentifiers: [String] - - /// Nodes in the tree after the reload. - public let nodeCount: Int - - /// Nodes in the tree before it. Equal counts do not prove the UI is - /// unchanged — the same number of views can be an entirely different - /// screen — but a difference does prove it changed. - public let previousNodeCount: Int - - /// Wall-clock cost of the round-trip, so callers can judge how - /// expensive repeating this would be. - public let durationMilliseconds: Int -} - - diff --git a/LookInside/MCPBridge/LKMCPBridgeInspectionService.swift b/LookInside/MCPBridge/LKMCPBridgeInspectionService.swift deleted file mode 100644 index 1a29b26f..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeInspectionService.swift +++ /dev/null @@ -1,355 +0,0 @@ -// LKMCPBridgeInspectionService.swift -// -// Routes MCPBridge inspection requests to the host's per-window -// `LookinLiveDocument` instances and converts the results to wire DTOs. -// -// All access to `NSDocumentController` and `LookinLiveDocument` happens on -// the main thread; the public entry point is `@MainActor` so the bridge -// connection handlers can `await` it from a background queue and get the -// hop for free. - -import AppKit -import CoreGraphics -import Foundation -import os - -@MainActor -public final class LKMCPBridgeInspectionService { - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Inspection") - - public init() {} - - /// Routes a decoded request frame to the appropriate inspection method. - /// Falls through to `dispatch.unknownMethod` for any verb the host does - /// not implement so the wire layer behaves predictably for new clients. - public func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - switch request.method { - case "targets.list": - return handleTargetsList(identifier: request.identifier) - case "hierarchy.read": - return handleHierarchyRead(identifier: request.identifier, parameters: request.parameters) - case "attributes.read": - return handleAttributesRead(identifier: request.identifier, parameters: request.parameters) - default: - return .failure(identifier: request.identifier, error: .unknownMethod) - } - } - - // MARK: - targets.list - - private func handleTargetsList(identifier: String) -> LKMCPBridgeResponse { - let documents = LKMCPBridgeLiveDocumentLookup.enumerateLiveDocuments() - let infos = documents.compactMap(makeTargetInfo(for:)) - do { - let payload = try encodeAsJSONValue(infos) - return .success(identifier: identifier, result: .object(["targets": payload])) - } catch { - Self.logger.error("targets.list encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - private func makeTargetInfo(for document: LookinLiveDocument) -> LKMCPBridgeTargetInfo? { - guard let appInfo = document.inspectableApp.appInfo else { return nil } - return LKMCPBridgeTargetInfo( - targetIdentifier: String(appInfo.appInfoIdentifier), - applicationName: appInfo.appName, - bundleIdentifier: appInfo.appBundleIdentifier, - deviceDescription: appInfo.deviceDescription, - operatingSystemDescription: appInfo.osDescription, - deviceKind: deviceKindString(for: appInfo.deviceType), - serverVersion: Int(appInfo.serverVersion), - licenseState: "licensed" - ) - } - - private func deviceKindString(for kind: LookinAppInfoDevice) -> String { - switch kind { - case .simulator: return "simulator" - case .iPad: return "iPad" - case .others: return "device" - case .mac: return "mac" - case .macCatalyst: return "macCatalyst" - @unknown default: return "unknown" - } - } - - // MARK: - hierarchy.read - - private func handleHierarchyRead( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) -> LKMCPBridgeResponse { - guard let parameters = parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - - let rootObjectIdentifier: String? - if case .string(let raw)? = parameters["rootObjectIdentifier"] { - rootObjectIdentifier = raw - } else { - rootObjectIdentifier = nil - } - - let depth: Int? - if case .integer(let raw)? = parameters["depth"] { - depth = Int(raw) - } else if case .double(let raw)? = parameters["depth"] { - depth = Int(raw) - } else { - depth = nil - } - - let includeLayoutGuides: Bool - if case .bool(let raw)? = parameters["includeLayoutGuides"] { - includeLayoutGuides = raw - } else { - includeLayoutGuides = false - } - - let includeCells: Bool - if case .bool(let raw)? = parameters["includeCells"] { - includeCells = raw - } else { - includeCells = false - } - - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - - guard let dataSource = document.hierarchyDataSource else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has not loaded a hierarchy yet." - ) - ) - } - - let rootItems: [LookinDisplayItem] - if let rootObjectIdentifier { - guard let scopedRoot = LKMCPBridgeLiveDocumentLookup.findDisplayItem( - amongRoots: dataSource.rawFlatItems ?? [], - matchingObjectIdentifier: rootObjectIdentifier - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.objectNotFound", - message: "Object identifier \(rootObjectIdentifier) is not present in this target's hierarchy." - ) - ) - } - rootItems = [scopedRoot] - } else { - rootItems = LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document) - } - - let nodes = rootItems.map { makeViewNode(from: $0, remainingDepth: depth, includeLayoutGuides: includeLayoutGuides, includeCells: includeCells) } - do { - let payload = try encodeAsJSONValue(nodes) - return .success(identifier: identifier, result: .object(["roots": payload])) - } catch { - Self.logger.error("hierarchy.read encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - private static func nodeKindString(for item: LookinDisplayItem) -> String { - switch item.resolvedNodeKind() { - case .layer: return "layer" - case .view: return "view" - case .window: return "window" - case .windowScene: return "windowScene" - case .custom: return "custom" - case .layoutGuide: return "layoutGuide" - case .cell: return "cell" - case .unspecified: return "view" - @unknown default: return "view" - } - } - - private func makeViewNode(from item: LookinDisplayItem, remainingDepth: Int?, includeLayoutGuides: Bool, includeCells: Bool) -> LKMCPBridgeViewNode { - let identity = LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: item) - let className = item.displayingObject()?.classChainList?.first ?? "" - let frame = LKMCPBridgeRect(cgRect: LKMCPBridgeLiveDocumentLookup.rootSpaceFrame(for: item)) - var subitems = item.subitems ?? [] - if includeLayoutGuides == false { - // Default-off so existing agents' structural paths and snapshot - // diff baselines do not drift when guide nodes enter the tree. - subitems = subitems.filter { $0.resolvedNodeKind() != .layoutGuide } - } - if includeCells == false { - // Same reasoning as includeLayoutGuides, for cell nodes. - subitems = subitems.filter { $0.resolvedNodeKind() != .cell } - } - let childIdentifiers = subitems.map(LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for:)) - - let inlinedChildren: [LKMCPBridgeViewNode]? - if let remainingDepth, remainingDepth <= 1 { - inlinedChildren = nil - } else { - let nextDepth = remainingDepth.map { $0 - 1 } - inlinedChildren = subitems.map { makeViewNode(from: $0, remainingDepth: nextDepth, includeLayoutGuides: includeLayoutGuides, includeCells: includeCells) } - } - - return LKMCPBridgeViewNode( - objectIdentifier: identity, - className: className, - nodeKind: Self.nodeKindString(for: item), - frame: frame, - isHidden: item.isHidden, - alpha: Double(item.alpha), - representsKeyWindow: item.representedAsKeyWindow, - childObjectIdentifiers: childIdentifiers, - children: inlinedChildren - ) - } - - // MARK: - attributes.read - - private func handleAttributesRead( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) -> LKMCPBridgeResponse { - guard let parameters = parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"], - case .string(let objectIdentifier)? = parameters["objectIdentifier"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - - let includeUserCustom: Bool - if case .bool(let raw)? = parameters["includeUserCustom"] { - includeUserCustom = raw - } else { - includeUserCustom = true - } - - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - - guard document.hierarchyDataSource != nil else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has not loaded a hierarchy yet." - ) - ) - } - - guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( - amongRoots: LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document), - matchingObjectIdentifier: objectIdentifier - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.objectNotFound", - message: "Object identifier \(objectIdentifier) is not present in this target's hierarchy." - ) - ) - } - - var rawGroups: [LookinAttributesGroup] = [] - if let inbuiltGroups = displayItem.attributesGroupList { - rawGroups.append(contentsOf: inbuiltGroups) - } - if includeUserCustom, let customGroups = displayItem.customAttrGroupList { - rawGroups.append(contentsOf: customGroups) - } - - // Evaluate the secure-content gate once per display item so every - // attribute we emit for this view shares the same redaction - // decision (a UITextField/NSSecureTextField can't have its - // `placeholder` leak when its `text` is redacted, for example). - let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: displayItem) - - let encodedGroups = rawGroups.map { group in - return encodeGroup(group, redactingSecureContent: redactSecureContent) - } - - do { - let payload = try encodeAsJSONValue(encodedGroups) - // Annotate whether the host has actually fetched per-item - // details for this view. v2 reads the cache as-is; agents that - // see an empty `groups` array should know to ask the user to - // open this view in the host inspector first. `secureContent` - // tells the agent why textual values may be `null` here. - let hasCachedDetails = displayItem.attributesGroupList?.isEmpty == false - return .success( - identifier: identifier, - result: .object([ - "groups": payload, - "detailsCached": .bool(hasCachedDetails), - "secureContent": .bool(redactSecureContent), - ]) - ) - } catch { - Self.logger.error("attributes.read encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - private func encodeGroup( - _ group: LookinAttributesGroup, - redactingSecureContent: Bool - ) -> LKMCPBridgeAttributeGroup { - let identifier = group.userCustomTitle ?? group.identifier - let sections = (group.attrSections ?? []).map { section in - return encodeSection(section, redactingSecureContent: redactingSecureContent) - } - return LKMCPBridgeAttributeGroup( - identifier: identifier ?? "", - isUserCustom: group.userCustomTitle != nil, - isSwiftUIGroup: group.isSwiftUIGroup, - sections: sections - ) - } - - private func encodeSection( - _ section: LookinAttributesSection, - redactingSecureContent: Bool - ) -> LKMCPBridgeAttributeSection { - let attributes = (section.attributes ?? []).map { attribute in - return LKMCPBridgeAttributeEncoder.encode( - attribute, - redactingSecureContent: redactingSecureContent - ) - } - return LKMCPBridgeAttributeSection( - identifier: section.identifier ?? "", - attributes: attributes - ) - } - - // MARK: - Encoding helpers - - /// Encodes any `Encodable` value into the loose `LKMCPBridgeJSONValue` - /// tree used inside response frame `result` containers. Round-trips - /// through `JSONEncoder` / `JSONDecoder` so non-trivial nested types - /// (arrays, optionals, etc.) preserve their wire shape. - private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeInvocationService.swift b/LookInside/MCPBridge/LKMCPBridgeInvocationService.swift deleted file mode 100644 index 076ba77e..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeInvocationService.swift +++ /dev/null @@ -1,312 +0,0 @@ -// LKMCPBridgeInvocationService.swift -// -// Handles the `invoke.method` bridge route: arbitrary zero-argument -// Objective-C selector invocation on a view in an attached target app. -// This is the bridge's first mutating route — it goes over Peertalk -// (RPC 206 `LookinRequestType_InvokeMethod`) and can change the -// inspected app's state. Read-only inspection routes live in -// `LKMCPBridgeInspectionService`; they share the live-document / -// display-item lookup through `LKMCPBridgeLiveDocumentLookup`. -// -// Selector blacklist mirrors `LKConsoleDataSource` (rejects selectors -// containing `:` and `.`). The server enforces a hard zero-argument -// limit on top of that. Anything else — method visibility, naming -// conventions, side-effect ack — is left to the agent and tool-level -// `destructive` hint. - -import AppKit -import Foundation -import os - -@MainActor -public final class LKMCPBridgeInvocationService { - - // MARK: - Constants pulled from upstream LookinDefines.h - // - // These are intentionally re-declared here rather than imported via - // the bridging header: pulling LookinDefines.h into the bridging - // header would touch every Swift compilation in the host target. - // The values are part of the wire contract and changing them - // requires bumping LookinServer's RPC compatibility band, so the - // duplication cost is low and the blast-radius cost of importing is - // much higher. - - /// `LookinErrCode_ObjectNotFound` from `LookinDefines.h:139`. - /// Server returns this when (a) the oid is no longer in - /// `LKS_ObjectRegistry`, (b) the selector string did not bind, or - /// (c) the target object does not respond to the selector. - private static let lookinErrCodeObjectNotFound = -500 - - /// `LookinErrCode_Inner` from `LookinDefines.h:124`. For - /// `invoke.method` specifically the server emits this when the - /// selector exists but has arguments or has no method signature. - private static let lookinErrCodeInner = -401 - - /// `LookinErrCode_LicenseRequired` from `LookinDefines.h:153`. - private static let lookinErrCodeLicenseRequired = -408 - - /// `LookinErrCode_NoConnect` from `LookinDefines.h:128`. - private static let lookinErrCodeNoConnect = -403 - - /// `LookinErrCode_Timeout` from `LookinDefines.h:132`. - private static let lookinErrCodeTimeout = -405 - - /// `LookinStringFlag_VoidReturn` from `LookinDefines.h:115`. - /// Server-side sentinel placed in the response's `description` key - /// when the invoked method's declared return type is `void`. The - /// bridge translates this into `returnedVoid: true, description: nil`. - private static let lookinVoidReturnMarker = "LOOKIN_TAG_RETURN_VALUE_VOID" - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Invocation") - - public init() {} - - // MARK: - Entry point - - public func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - guard request.method == "invoke.method" else { - return .failure(identifier: request.identifier, error: .unknownMethod) - } - return await handleInvokeMethod( - identifier: request.identifier, - parameters: request.parameters - ) - } - - // MARK: - invoke.method - - private func handleInvokeMethod( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) async -> LKMCPBridgeResponse { - guard let parameters = parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"], - case .string(let objectIdentifier)? = parameters["objectIdentifier"], - case .string(let selector)? = parameters["selector"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - - if selector.isEmpty { - return .failure(identifier: identifier, error: .invalidParameters) - } - - // Mirror LKConsoleDataSource's selector blacklist (rejects - // anything that wouldn't go through RPC 206's zero-argument - // gate on the server side). Centralizing this here gives a - // single explicit error code with a hint instead of relying on - // the server's generic `LookinErrCode_Inner`. - if selector.contains(":") { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "invoke.unsupportedSelector", - message: "Multi-argument selectors (containing ':') are not supported in this release. Use a zero-argument selector or wait for a future bridge method." - ) - ) - } - if selector.contains(".") { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "invoke.unsupportedSelector", - message: "Dotted access (containing '.') is not supported; pass a single method or property name." - ) - ) - } - - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - - guard document.hierarchyDataSource != nil else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has not loaded a hierarchy yet." - ) - ) - } - - guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( - amongRoots: LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document), - matchingObjectIdentifier: objectIdentifier - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.objectNotFound", - message: "Object identifier \(objectIdentifier) is not present in this target's hierarchy." - ) - ) - } - - // The wire oid is hex-encoded; LKInspectableApp accepts the raw - // unsigned-long value. Re-derive it from the display item we - // just resolved so we don't have to parse the wire string — - // the display item already holds the canonical numeric oid. - guard let nativeOid = displayItem.displayingObject()?.oid, nativeOid != 0 else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.objectNotFound", - message: "Display item \(objectIdentifier) does not carry a live object identifier." - ) - ) - } - - let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: displayItem) - - // Run the RPC. `rawInvokeMethod(withOid:text:)` keeps the raw - // server-side error codes intact (no localized NSError remap), - // so the catch block below can build precise bridge error codes. - // The ObjC implementation itself returns `LookinErr_NoConnect` - // when `channel` is nil, which we map to `invoke.disconnected` - // below — so we don't pre-check the channel from Swift (the - // property is wrapped via FrameworkToolbox @dynamicMemberLookup - // and isn't reachable on a key-path that we own). - guard let signal = document.inspectableApp.rawInvokeMethod(withOid: nativeOid, text: selector) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "invoke.internalError", - message: "The target's inspectable app returned no signal for the invocation." - ) - ) - } - let rawDictionary: NSDictionary - do { - rawDictionary = try await LKMCPBridgeRACBridge.awaitFirstValue(of: signal, as: NSDictionary.self) - } catch RACBridgeError.completedWithoutValue { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "invoke.disconnected", - message: "The target app did not return a response. The channel may have been closed mid-invocation." - ) - ) - } catch RACBridgeError.cancelled { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "invoke.cancelled", - message: "The invocation was cancelled before the target app produced a result." - ) - ) - } catch let error as NSError { - // Must stay below the RACBridgeError clauses. Every Swift error - // bridges to NSError, so this pattern matches everything -- above - // them it silently swallows both, and they become dead code. - return .failure(identifier: identifier, error: mapInvocationError(error)) - } catch { - Self.logger.error("invoke.method bridge error: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - - let invocationResult = makeInvocationResult( - from: rawDictionary, - redactSecureContent: redactSecureContent - ) - - do { - let payload = try encodeAsJSONValue(invocationResult) - return .success(identifier: identifier, result: payload) - } catch { - Self.logger.error("invoke.method encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - // MARK: - Response shaping - - private func makeInvocationResult( - from dictionary: NSDictionary, - redactSecureContent: Bool - ) -> LKMCPBridgeInvocationResult { - let rawDescription = dictionary["description"] as? String - let returnedVoid = rawDescription == Self.lookinVoidReturnMarker - - let surfacedDescription: String? - if redactSecureContent { - surfacedDescription = nil - } else if returnedVoid { - surfacedDescription = nil - } else { - surfacedDescription = rawDescription - } - - let returnObject = makeReturnedObject(from: dictionary["object"]) - - return LKMCPBridgeInvocationResult( - description: surfacedDescription, - returnedVoid: returnedVoid, - returnObject: returnObject, - secureContent: redactSecureContent - ) - } - - private func makeReturnedObject(from rawObject: Any?) -> LKMCPBridgeReturnedObject? { - guard let lookinObject = rawObject as? LookinObject else { return nil } - let oidString = String(format: "0x%lx", lookinObject.oid) - return LKMCPBridgeReturnedObject( - objectIdentifier: oidString, - memoryAddress: lookinObject.memoryAddress ?? "", - classChainList: lookinObject.classChainList ?? [], - specialTrace: lookinObject.specialTrace - ) - } - - // MARK: - Error mapping - - private func mapInvocationError(_ error: NSError) -> LKMCPBridgeErrorPayload { - switch error.code { - case Self.lookinErrCodeObjectNotFound: - return LKMCPBridgeErrorPayload( - code: "invoke.objectNotFound", - message: "The target app could not find an object for this identifier or it does not respond to the selector. The object may have been deallocated; try reloading the inspector." - ) - case Self.lookinErrCodeInner: - return LKMCPBridgeErrorPayload( - code: "invoke.invalidSelector", - message: "The target app rejected the selector — most likely it requires arguments or has no method signature. Only zero-argument selectors are supported." - ) - case Self.lookinErrCodeLicenseRequired: - return .licenseRequired - case Self.lookinErrCodeNoConnect: - return LKMCPBridgeErrorPayload( - code: "invoke.disconnected", - message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." - ) - case Self.lookinErrCodeTimeout: - return LKMCPBridgeErrorPayload( - code: "invoke.timeout", - message: "The target app did not respond within the request timeout. Check whether it is paused in Xcode or blocked on the main thread." - ) - default: - Self.logger.error("invoke.method received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") - return LKMCPBridgeErrorPayload( - code: "invoke.internalError", - message: "The target app reported an unexpected error (code \(error.code))." - ) - } - } - - // MARK: - Encoding helper (duplicated from InspectionService) - // - // The two services share the same JSON round-trip helper but they - // belong to different actor-isolated types, so we keep one copy per - // service rather than introducing a shared protocol just for two - // identical four-line helpers. - - private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeListenSocket.swift b/LookInside/MCPBridge/LKMCPBridgeListenSocket.swift deleted file mode 100644 index cb5914e7..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeListenSocket.swift +++ /dev/null @@ -1,157 +0,0 @@ -// LKMCPBridgeListenSocket.swift -// -// Creation of the MCPBridge listening Unix domain socket, split out of -// `LKMCPBridgeServer` so it can be exercised on its own. -// -// The split exists for a reason beyond tidiness: the socket's blocking -// mode is a correctness property that is invisible at the call site and -// impossible to test through the server, which drags in the whole -// inspection stack (AppKit, NSDocumentController, the ObjC bridge). -// Everything here depends on Darwin and Foundation only, so -// `Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift` can compile it -// with a bare `swiftc` invocation the way the other host-side tests do. -// -// Why non-blocking matters: the server drives accepts from a -// `DispatchSourceRead` and drains them in a `while true` loop that -// expects `EAGAIN` to signal "no more pending connections". On a -// blocking descriptor that call never returns `EAGAIN` — it parks the -// thread until the *next* client shows up. Since the accept handler -// runs on the server's serial state queue, the first connected client -// would leave that queue occupied indefinitely, and everything else -// scheduled on it (`stop()`, connection bookkeeping) would simply never -// run. - -import Darwin -import Foundation - -enum LKMCPBridgeListenSocket { - - /// File permissions for the socket itself: owner-only, matching the - /// per-user trust model (any process running as this user may - /// connect; nothing else may). - static let socketFilePermissions: mode_t = 0o600 - - /// Binds and listens on a Unix domain socket at `socketPath`, - /// returning a non-blocking descriptor ready for `accept(2)`. - /// - /// Any existing socket file at the path is unlinked first — a stale - /// file from a crashed host would otherwise make `bind` fail with - /// `EADDRINUSE` forever. - /// - /// - Throws: `LKMCPBridgeListenSocketError` when any step fails. The - /// descriptor is closed before throwing, so a failed call leaks - /// nothing. - static func makeListening( - atPath socketPath: String, - backlog: Int32 - ) throws -> Int32 { - let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) - guard descriptor >= 0 else { - throw LKMCPBridgeListenSocketError(message: "socket(AF_UNIX) failed (errno \(errno))") - } - - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(socketPath.utf8) - let pathCapacity = MemoryLayout.size(ofValue: address.sun_path) - if pathBytes.count >= pathCapacity { - Darwin.close(descriptor) - throw LKMCPBridgeListenSocketError( - message: "Socket path is longer than the kernel's sun_path limit (\(pathCapacity) bytes)." - ) - } - withUnsafeMutableBytes(of: &address.sun_path) { pathBuffer in - for index in 0.. Int32 in - return addressPointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - return Darwin.bind(descriptor, rebound, socklen_t(MemoryLayout.size)) - } - } - if bindResult != 0 { - let savedErrorNumber = errno - Darwin.close(descriptor) - throw LKMCPBridgeListenSocketError(message: "bind() failed (errno \(savedErrorNumber))") - } - - // Best-effort: a permissive umask would otherwise leave the - // socket group/world accessible. Not fatal — the parent - // directory is already 0700. - _ = chmod(socketPath, socketFilePermissions) - - if listen(descriptor, backlog) != 0 { - let savedErrorNumber = errno - Darwin.close(descriptor) - unlink(socketPath) - throw LKMCPBridgeListenSocketError(message: "listen() failed (errno \(savedErrorNumber))") - } - - // The accept drain loop's termination condition. See the file - // header for what happens without it. - if fcntl(descriptor, F_SETFL, O_NONBLOCK) != 0 { - let savedErrorNumber = errno - Darwin.close(descriptor) - unlink(socketPath) - throw LKMCPBridgeListenSocketError( - message: "fcntl(F_SETFL, O_NONBLOCK) failed (errno \(savedErrorNumber))" - ) - } - - return descriptor - } - - /// Accepts one pending connection, returning a **blocking** - /// descriptor, or a negative value when there is nothing to accept - /// (`errno` is `EAGAIN` in that case) or the call failed. - /// - /// The blocking mode is the whole reason this wraps `accept(2)`. - /// On BSD-derived systems — macOS included — the accepted socket - /// inherits `O_NONBLOCK` from the listener, which is the opposite - /// of Linux and the opposite of what POSIX requires. Since the - /// listener has to be non-blocking (see `makeListening`), every - /// connection would silently come out non-blocking too. - /// - /// That matters because `LKMCPBridgeConnection` writes responses - /// with a blocking `write(2)` loop and treats `EAGAIN` as fatal. - /// On a non-blocking descriptor, any response larger than the - /// socket send buffer hits `EAGAIN` partway through and the - /// connection is torn down mid-frame. Small responses still work, - /// so the symptom is selective and confusing: hierarchy reads - /// succeed, screenshots drop the connection. - static func acceptConnection(listenDescriptor: Int32) -> Int32 { - var clientAddress = sockaddr_un() - var addressLength = socklen_t(MemoryLayout.size) - let descriptor = withUnsafeMutablePointer(to: &clientAddress) { addressPointer -> Int32 in - return addressPointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - return accept(listenDescriptor, rebound, &addressLength) - } - } - guard descriptor >= 0 else { return descriptor } - - let flags = fcntl(descriptor, F_GETFL) - if flags >= 0 && (flags & O_NONBLOCK) != 0 { - _ = fcntl(descriptor, F_SETFL, flags & ~O_NONBLOCK) - } - return descriptor - } - - /// Whether `descriptor` is in non-blocking mode. Exists so the - /// property can be asserted directly rather than inferred from - /// timing. - static func isNonBlocking(descriptor: Int32) -> Bool { - let flags = fcntl(descriptor, F_GETFL) - guard flags >= 0 else { return false } - return (flags & O_NONBLOCK) != 0 - } -} - -struct LKMCPBridgeListenSocketError: LocalizedError { - let message: String - var errorDescription: String? { return message } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeLiveDocumentLookup.swift b/LookInside/MCPBridge/LKMCPBridgeLiveDocumentLookup.swift deleted file mode 100644 index 377d4aaa..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeLiveDocumentLookup.swift +++ /dev/null @@ -1,99 +0,0 @@ -// LKMCPBridgeLiveDocumentLookup.swift -// -// Shared lookup helpers that translate the bridge wire identifiers -// (`targetIdentifier`, `objectIdentifier`) into the host's live runtime -// objects (`LookinLiveDocument`, `LookinDisplayItem`). Both the inspection -// service (read-only queries) and the invocation service (round-trips to -// the server over Peertalk) share this surface so the wire shape — hex -// `oid` strings, decimal `appInfoIdentifier` strings — stays one -// canonical implementation. -// -// All entry points are `@MainActor`: `NSDocumentController` and -// `LookinLiveDocument` require main-thread isolation, and the bridge -// connection handlers already hop here before invoking lookup. - -import AppKit -import Foundation - -@MainActor -enum LKMCPBridgeLiveDocumentLookup { - - /// Enumerates every currently-open `LookinLiveDocument` known to the - /// host. Static (archived `.lookin`) documents are excluded because - /// they have no attached app to inspect / invoke against. - static func enumerateLiveDocuments() -> [LookinLiveDocument] { - let allDocuments = NSDocumentController.shared.documents - return allDocuments.compactMap { $0 as? LookinLiveDocument } - } - - /// Resolves a wire `targetIdentifier` (decimal string form of - /// `LookinAppInfo.appInfoIdentifier`) to the matching - /// `LookinLiveDocument`. Returns `nil` when the string is not a valid - /// unsigned integer or no open live document carries that - /// `appInfoIdentifier`. - static func findLiveDocument(targetIdentifier: String) -> LookinLiveDocument? { - guard let identifierValue = UInt(targetIdentifier) else { return nil } - return enumerateLiveDocuments().first { document in - return document.inspectableApp.appInfo?.appInfoIdentifier == identifierValue - } - } - - /// Breadth-first search across the supplied root display items for an - /// item whose wire `objectIdentifier` matches `identifier`. The wire - /// form is the hex-encoded `LookinObject.oid`, prefixed with `0x` - /// (e.g. `0x600000abc123`). - static func findDisplayItem( - amongRoots roots: [LookinDisplayItem], - matchingObjectIdentifier identifier: String - ) -> LookinDisplayItem? { - var queue: [LookinDisplayItem] = roots - while queue.isEmpty == false { - let current = queue.removeFirst() - if objectIdentifierString(for: current) == identifier { - return current - } - if let subitems = current.subitems { - queue.append(contentsOf: subitems) - } - } - return nil - } - - /// Top-level display items (windows / scenes) inside a live document. - /// These are the seed points for `findDisplayItem(amongRoots:matchingObjectIdentifier:)` - /// when the caller didn't pre-narrow the search space. - static func topLevelDisplayItems(in document: LookinLiveDocument) -> [LookinDisplayItem] { - guard let dataSource = document.hierarchyDataSource, - let flatItems = dataSource.rawFlatItems - else { return [] } - // Top-level items (UIWindow / NSWindow) report indentLevel == 0; - // every nested view has indentLevel >= 1. We avoid `superItem` - // here because the Objective-C importer renames it into a Swift - // keyword collision. - return flatItems.filter { $0.indentLevel() == 0 } - } - - /// Canonical hex-encoded `objectIdentifier` string for a display item. - /// Stays in sync with the form returned by `hierarchy.read` so - /// callers can round-trip identifiers across bridge methods. - static func objectIdentifierString(for item: LookinDisplayItem) -> String { - let oid = item.displayingObject()?.oid ?? 0 - return String(format: "0x%lx", oid) - } - - /// The item's rectangle in the root coordinate space. - /// - /// `LookinDisplayItem.frame` is *parent*-relative -- `calculateFrameToRoot` - /// exists precisely because composing it up the tree is non-trivial (it - /// has to subtract each ancestor's `bounds` origin and flip for AppKit - /// superitems). The bridge reports root-space rectangles so an agent can - /// line a node up against a screenshot without walking the tree itself. - /// - /// Falls back to the raw frame when the item has no usable geometry: - /// `hasValidFrameToRoot` is a check on `frame` itself, so in that case - /// both values are equally meaningless and the raw one at least matches - /// what the host inspector displays. - static func rootSpaceFrame(for item: LookinDisplayItem) -> CGRect { - return item.hasValidFrameToRoot() ? item.calculateFrameToRoot() : item.frame - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeModificationService.swift b/LookInside/MCPBridge/LKMCPBridgeModificationService.swift deleted file mode 100644 index 414ac8a0..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeModificationService.swift +++ /dev/null @@ -1,396 +0,0 @@ -// LKMCPBridgeModificationService.swift -// -// Handles the `attribute.modify` bridge route: mutates one inspected -// attribute on a view in the attached target app (RPC 204 -// `InbuiltAttrModification`) and returns the post-layout effective -// value alongside the value the agent requested. -// -// This is the second mutating bridge route (after `invoke.method`). -// It shares the same Peertalk-round-trip plumbing — selector lookup -// happens against `LookinDashboardBlueprint`; the polymorphic wire -// value is decoded into the right ObjC type by -// `LKMCPBridgeAttributeValueDecoder`; the round-trip uses -// `LKMCPBridgeRACBridge` to await the response; -// `LKMCPBridgeAttributeEncoder` re-encodes the effective post-layout -// attribute so the caller can compare against what they sent. -// -// The bridge does NOT pre-compute settability — it asks -// `LookinDashboardBlueprint setterWithAttrID:` for the setter SEL and -// surfaces `modify.readOnly` when the blueprint returns nil. This -// matches the host inspector's existing modification gate. - -import AppKit -import CoreGraphics -import Foundation -import os - -@MainActor -public final class LKMCPBridgeModificationService { - - // MARK: - Error code constants from LookinDefines.h - // - // See LKMCPBridgeInvocationService for the duplication rationale — - // pulling LookinDefines.h into the bridging header would touch - // every Swift compile in the host target. - - private static let lookinErrCodeObjectNotFound = -500 - private static let lookinErrCodeInner = -401 - private static let lookinErrCodeException = -502 - private static let lookinErrCodeLicenseRequired = -408 - private static let lookinErrCodeNoConnect = -403 - private static let lookinErrCodeTimeout = -405 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Modification") - - public init() {} - - // MARK: - Entry point - - public func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - guard request.method == "attribute.modify" else { - return .failure(identifier: request.identifier, error: .unknownMethod) - } - return await handleAttributeModify( - identifier: request.identifier, - parameters: request.parameters - ) - } - - // MARK: - attribute.modify - - private func handleAttributeModify( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) async -> LKMCPBridgeResponse { - // Parameter extraction - guard let parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"], - case .string(let objectIdentifier)? = parameters["objectIdentifier"], - case .string(let attributeIdentifier)? = parameters["attributeIdentifier"], - case .object(let valueObject)? = parameters["value"], - case .string(let wireKind)? = valueObject["kind"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - let wireData = valueObject["data"] - - // Live-document / display-item lookup - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - - guard document.hierarchyDataSource != nil else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has not loaded a hierarchy yet." - ) - ) - } - - guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( - amongRoots: LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document), - matchingObjectIdentifier: objectIdentifier - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.objectNotFound", - message: "Object identifier \(objectIdentifier) is not present in this target's hierarchy." - ) - ) - } - - // Find the attribute in the currently-cached detail - guard let attribute = findAttribute( - withIdentifier: attributeIdentifier, - in: displayItem.attributesGroupList ?? [] - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.attributeNotFound", - message: "Attribute \(attributeIdentifier) is not present in this view's cached detail. Open the view in the LookInside inspector (or call read_attributes first) to populate its detail, then retry." - ) - ) - } - - // Setter lookup. `setter(withAttrID:)` returns nil for read-only - // attributes (e.g. computed Relation, class chain, AutoLayout - // constraint summaries). - guard let setterSelector = LookinDashboardBlueprint.setter(withAttrID: attributeIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.readOnly", - message: "Attribute \(attributeIdentifier) is read-only; there is no registered setter for it." - ) - ) - } - - // Native oid - guard let nativeOid = displayItem.displayingObject()?.oid, nativeOid != 0 else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.objectNotFound", - message: "Display item \(objectIdentifier) does not carry a live object identifier." - ) - ) - } - - // Decode wire value into the polymorphic id LookinAttributeModification expects. - let nativeValue: Any - do { - nativeValue = try LKMCPBridgeAttributeValueDecoder.decode( - wireKind: wireKind, - wireData: wireData, - expectedAttrType: attribute.attrType - ) - } catch LKMCPBridgeAttributeValueDecoder.DecodeError.unsupportedKind(let kind) { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.unsupportedKind", - message: "Wire kind '\(kind)' is not supported for modification in this release. Supported kinds: integer, double, bool, string, selector, class, point, vector, size, rect, transform, edgeInsets, offset, color, enum." - ) - ) - } catch LKMCPBridgeAttributeValueDecoder.DecodeError.kindMismatch(let wireKind, let expectedAttrType) { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.valueKindMismatch", - message: "Wire kind '\(wireKind)' does not match attribute's declared LookinAttrType (\(expectedAttrType.rawValue))." - ) - ) - } catch LKMCPBridgeAttributeValueDecoder.DecodeError.shapeInvalid(let reason) { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.valueShapeInvalid", - message: reason - ) - ) - } catch { - Self.logger.error("Unexpected decode error: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - - // Build the LookinAttributeModification. - let modification = LookinAttributeModification() - modification.targetOid = nativeOid - modification.setterSelector = setterSelector - modification.attrType = attribute.attrType - modification.value = nativeValue - modification.clientReadableVersion = LKHelper.lookinReadableVersion() - - // Round-trip through Peertalk. Raw entry preserves the - // server-side error codes so we can map them precisely below. - // ObjC `rawSubmitInbuiltModification:` has no preposition, so - // the Swift importer keeps the full name with `_:` label. - guard let signal = document.inspectableApp.rawSubmitInbuiltModification(modification) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.internalError", - message: "The target's inspectable app returned no signal for the modification." - ) - ) - } - let detail: LookinDisplayItemDetail - do { - detail = try await LKMCPBridgeRACBridge.awaitFirstValue( - of: signal, - as: LookinDisplayItemDetail.self - ) - } catch RACBridgeError.completedWithoutValue { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.disconnected", - message: "The target app did not return a response. The channel may have been closed mid-modification." - ) - ) - } catch RACBridgeError.cancelled { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.cancelled", - message: "The modification was cancelled before the target app produced a result." - ) - ) - } catch let error as NSError { - // Must stay below the RACBridgeError clauses. Every Swift error - // bridges to NSError, so this pattern matches everything -- above - // them it silently swallows both, and they become dead code. - return .failure(identifier: identifier, error: mapModificationError(error)) - } catch { - Self.logger.error("attribute.modify bridge error: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - - // Merge the post-modification detail back into the host's cache, the - // same way `details.read` and `screenshot.read` do. Without this the - // inspector window keeps showing the pre-modification value, and a - // follow-up `attributes.read` serves the stale cached groups -- which - // directly contradicts the `effectiveAttribute` this call is about to - // return, with nothing to tell the agent which one is current. - // - // The host's own modification path additionally re-fetches a - // screenshot (`LKDashboardViewController.m`). That is deliberately - // not mirrored here: screenshots have their own route, and issuing - // one per attribute write would put a render round-trip on the - // critical path of every modification. - document.hierarchyDataSource?.modify(with: detail) - - // Find the effective post-layout attribute in the response. - guard let effectiveAttribute = findAttribute( - withIdentifier: attributeIdentifier, - in: detail.attributesGroupList ?? [] - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "modify.internalError", - message: "Effective attribute disappeared from the server's post-modification detail. This indicates a server-side bug." - ) - ) - } - - // Encode the effective attribute through the same encoder - // the read route uses, so its shape matches read_attributes. - let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: displayItem) - let encodedEffective = LKMCPBridgeAttributeEncoder.encode( - effectiveAttribute, - redactingSecureContent: redactSecureContent - ) - - let requestedWire = LKMCPBridgeAttributeValueWire(kind: wireKind, data: wireData) - let effectiveWire = LKMCPBridgeAttributeValueWire( - kind: encodedEffective.kind, - data: encodedEffective.value - ) - let matches = !redactSecureContent - && requestedWire.kind == effectiveWire.kind - && jsonValuesEqual(requestedWire.data, effectiveWire.data) - - let frame = (detail.frameValue?.rectValue).map { CGRect(x: $0.origin.x, y: $0.origin.y, width: $0.size.width, height: $0.size.height) } ?? .zero - let bounds = (detail.boundsValue?.rectValue).map { CGRect(x: $0.origin.x, y: $0.origin.y, width: $0.size.width, height: $0.size.height) } ?? .zero - let isHidden = detail.hiddenValue?.boolValue ?? false - let alpha = detail.alphaValue?.doubleValue ?? 1.0 - - let result = LKMCPBridgeModificationResult( - attributeIdentifier: attributeIdentifier, - requestedValue: requestedWire, - effectiveAttribute: encodedEffective, - effectiveMatchesRequested: matches, - frame: LKMCPBridgeRect(cgRect: frame), - bounds: LKMCPBridgeRect(cgRect: bounds), - isHidden: isHidden, - alpha: alpha, - secureContent: redactSecureContent - ) - - do { - let payload = try encodeAsJSONValue(result) - return .success(identifier: identifier, result: payload) - } catch { - Self.logger.error("attribute.modify encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - // MARK: - Helpers - - private func findAttribute( - withIdentifier identifier: String, - in groups: [LookinAttributesGroup] - ) -> LookinAttribute? { - for group in groups { - for section in group.attrSections ?? [] { - for attribute in section.attributes ?? [] { - if attribute.identifier == identifier { - return attribute - } - } - } - } - return nil - } - - /// Equality on `LKMCPBridgeJSONValue` by canonical JSON encoding. - /// Uses `.sortedKeys` so object key order does not affect the result. - /// This is intentionally strict — no float epsilon — because layout - /// pass adjustments are a real signal we want to surface to the - /// agent rather than absorb. - private func jsonValuesEqual( - _ lhs: LKMCPBridgeJSONValue?, - _ rhs: LKMCPBridgeJSONValue? - ) -> Bool { - switch (lhs, rhs) { - case (nil, nil): - return true - case (nil, _?), (_?, nil): - return false - case (let lhsValue?, let rhsValue?): - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - let lhsData = (try? encoder.encode(lhsValue)) ?? Data() - let rhsData = (try? encoder.encode(rhsValue)) ?? Data() - return lhsData == rhsData - } - } - - private func mapModificationError(_ error: NSError) -> LKMCPBridgeErrorPayload { - switch error.code { - case Self.lookinErrCodeObjectNotFound: - return LKMCPBridgeErrorPayload( - code: "modify.objectNotFound", - message: "The target app could not find an object for this identifier. The object may have been deallocated; try reloading the inspector." - ) - case Self.lookinErrCodeInner: - return LKMCPBridgeErrorPayload( - code: "modify.invalidSetter", - message: "The target app rejected the modification — the setter signature was missing or the value type did not unbox cleanly." - ) - case Self.lookinErrCodeException: - // NSException thrown by the setter; the server stuffs the - // exception's reason into the NSLocalizedDescriptionKey. - let reason = (error.userInfo[NSLocalizedDescriptionKey] as? String) ?? "no further detail" - return LKMCPBridgeErrorPayload( - code: "modify.exception", - message: "The setter raised an NSException in the target app: \(reason)" - ) - case Self.lookinErrCodeLicenseRequired: - return .licenseRequired - case Self.lookinErrCodeNoConnect: - return LKMCPBridgeErrorPayload( - code: "modify.disconnected", - message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." - ) - case Self.lookinErrCodeTimeout: - return LKMCPBridgeErrorPayload( - code: "modify.timeout", - message: "The target app did not respond within the request timeout. Check whether it is paused in Xcode or blocked on the main thread." - ) - default: - Self.logger.error("attribute.modify received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") - return LKMCPBridgeErrorPayload( - code: "modify.internalError", - message: "The target app reported an unexpected error (code \(error.code))." - ) - } - } - - private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift b/LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift deleted file mode 100644 index ff778d94..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift +++ /dev/null @@ -1,89 +0,0 @@ -// LKMCPBridgeObjectIdentifierList.swift -// -// Parses and normalizes the `objectIdentifiers` array that `details.read` -// accepts off the wire. -// -// This lives apart from LKMCPBridgeDetailsService for the same reason -// LKMCPBridgeSearchQuery lives apart from LKMCPBridgeSearchService: the -// service imports AppKit and the ObjC bridge, so it cannot be compiled into -// the standalone binaries that Scripts/test-mcp-bridge.sh builds, while the -// list handling itself needs nothing but Foundation. -// -// What makes it worth extracting rather than inlining: a repeated identifier -// used to reach `Dictionary(uniqueKeysWithValues:)` further down the route, -// which traps -- taking the entire host app down rather than failing the one -// request. The caller is an LLM-driven MCP client, so a list that repeats an -// identifier is an ordinary thing to receive, not a malformed request, and -// the cost of getting it wrong is far out of proportion to the mistake. - -import Foundation - -enum LKMCPBridgeObjectIdentifierList { - - /// Why a wire payload was rejected. - enum ParseFailure: Error, Equatable { - /// At least one entry was not a JSON string. Loose typing is fine - /// for primitives elsewhere, but an identifier that is not a string - /// cannot address anything, so it is rejected rather than skipped. - case notAllStrings - /// The array carried no entries at all. - case empty - /// More *distinct* identifiers than the route accepts. - case tooMany(distinctCount: Int, limit: Int) - } - - struct Parsed: Equatable { - /// Distinct identifiers, in the order they were first seen. Order is - /// preserved because the response lists details in request order and - /// an agent correlates them positionally. - let identifiers: [String] - - /// How many entries were dropped because an equal string had already - /// been seen. Surfaced so the host can log it; the agent is not told, - /// because repeating an identifier is not an error and the response - /// it gets back is exactly what it asked for. - let droppedDuplicateCount: Int - } - - /// Materializes the wire array into distinct identifiers. - /// - /// - Parameters: - /// - wireValues: The raw `objectIdentifiers` array. - /// - limit: Maximum number of distinct identifiers the route accepts. - static func parse( - wireValues: [LKMCPBridgeJSONValue], - limit: Int - ) -> Result { - var identifiers: [String] = [] - identifiers.reserveCapacity(wireValues.count) - var seenIdentifiers: Set = [] - var droppedDuplicateCount = 0 - - for entry in wireValues { - guard case .string(let value) = entry else { - return .failure(.notAllStrings) - } - if seenIdentifiers.insert(value).inserted { - identifiers.append(value) - } else { - droppedDuplicateCount += 1 - } - } - - guard identifiers.isEmpty == false else { - return .failure(.empty) - } - - // The cap is applied after de-duplication because it exists to bound - // how many objects the host actually fetches from the target app. A - // repeated identifier costs nothing downstream, so rejecting a list - // for a length it does not really have would be a false refusal. - guard identifiers.count <= limit else { - return .failure(.tooMany(distinctCount: identifiers.count, limit: limit)) - } - - return .success( - Parsed(identifiers: identifiers, droppedDuplicateCount: droppedDuplicateCount) - ) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeRACBridge.swift b/LookInside/MCPBridge/LKMCPBridgeRACBridge.swift deleted file mode 100644 index a140901b..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeRACBridge.swift +++ /dev/null @@ -1,179 +0,0 @@ -// LKMCPBridgeRACBridge.swift -// -// Bridge between the host's ReactiveObjC `RACSignal *` based RPC APIs and -// modern Swift structured concurrency. The MCPBridge inspection routes -// today only read cached state on the main actor, but routes that need -// to round-trip to the inspected target over Peertalk (invocation, -// attribute mutation, screenshot fetch) consume RACSignals returned by -// `LKInspectableApp` / `LKConnectionManager`. This file is the one place -// that knows how to await a single-value signal as an `async throws` -// call. -// -// Semantics: -// - Awaits the FIRST `next` value emitted by the signal, then disposes. -// LookInside's RPC signals all follow the "emit once, then complete" -// convention, so first-value semantics match the signal's own. -// - Forwards `error` events to the `async throws` channel. -// - Coalesces "completed without emitting" into a structured -// `RACBridgeError.completedWithoutValue`. -// - Cooperatively cancels the signal subscription when the awaiting -// Task is cancelled. (Cancellation does NOT propagate back to the -// Peertalk request itself — that requires explicit push-304-cancel -// plumbing on the server side; see invoke.* error mapping for the -// timeout path.) - -import Foundation - -// MARK: - Errors - -/// Errors surfaced by the RAC → async bridge itself, distinct from any -/// errors the source RACSignal may emit. Bridge methods that want to -/// translate these into structured wire error codes can switch on the -/// case. -enum RACBridgeError: Error, LocalizedError { - /// The source signal completed without ever emitting a `next` - /// value. LookInside RPC signals do not normally do this — the - /// only known path is a deliberate cancellation race. - case completedWithoutValue - /// The awaiting Task was cancelled before the signal produced a - /// value or an error. - case cancelled - - var errorDescription: String? { - switch self { - case .completedWithoutValue: - return "The source signal completed without producing a value." - case .cancelled: - return "The await on the source signal was cancelled." - } - } -} - -// MARK: - Awaiters - -enum LKMCPBridgeRACBridge { - - /// Awaits ALL `next` values emitted by an `RACSignal` and returns - /// them as a flat `[Element]`, in emission order. Suitable for - /// streaming RPCs whose Host-side `RACSignal` emits one frame at a - /// time (e.g. RPC 203 `HierarchyDetails`, which emits one - /// `[LookinDisplayItemDetail]` per `LookinStaticAsyncUpdateTasksPackage`). - /// - /// Internally relies on `RACSignal.collect()`, which aggregates the - /// `next` events into a single `NSArray` published on `completed`, - /// then bridges via `awaitFirstValue`. If any emitted event is not - /// an `Element`-compatible type the function throws - /// `RACBridgeError.completedWithoutValue`. - static func awaitAllValues( - of signal: RACSignal, - as elementType: Element.Type = Element.self - ) async throws -> [Element] { - // RACSignal's Swift generic is type-erased at runtime; .collect() - // narrows the static type to RACSignal but the underlying - // class is the same. Re-erase so we can reuse awaitFirstValue. - let collected = signal.collect() as AnyObject as! RACSignal - let bag = try await awaitFirstValue(of: collected, as: NSArray.self) - var values: [Element] = [] - values.reserveCapacity(bag.count) - for entry in bag { - guard let typed = entry as? Element else { - throw RACBridgeError.completedWithoutValue - } - values.append(typed) - } - return values - } - - /// Awaits the first `next` value of an `RACSignal`, cast to the - /// expected type. Throws when the signal emits an `error`, completes - /// before producing a value, or returns a value that does not bridge - /// to the requested type. - /// - /// The signal's subscription is torn down as soon as a value, error, - /// or completion is observed; the caller's Task does not retain the - /// disposable beyond the await. - static func awaitFirstValue( - of signal: RACSignal, - as type: Value.Type = Value.self - ) async throws -> Value { - // Continuations resume exactly once; the state box guards - // against the "next then completed" race where ReactiveObjC - // synchronously completes after emitting a value. - let stateBox = RACContinuationState() - - return try await withTaskCancellationHandler( - operation: { - try await withCheckedThrowingContinuation { continuation in - let disposable: RACDisposable? = signal.subscribeNext({ value in - guard stateBox.markResolved() else { return } - if let typed = value as? Value { - continuation.resume(returning: typed) - } else { - continuation.resume(throwing: RACBridgeError.completedWithoutValue) - } - }, error: { error in - guard stateBox.markResolved() else { return } - continuation.resume(throwing: error ?? RACBridgeError.completedWithoutValue) - }, completed: { - guard stateBox.markResolved() else { return } - continuation.resume(throwing: RACBridgeError.completedWithoutValue) - }) - stateBox.disposable = disposable - if stateBox.isCancelled { - // Cancellation arrived before subscribeNext returned; - // dispose immediately and fail the continuation. - disposable?.dispose() - if stateBox.markResolved() { - continuation.resume(throwing: RACBridgeError.cancelled) - } - } - } - }, - onCancel: { - stateBox.cancel() - } - ) - } -} - -// MARK: - Private state box - -/// Thread-safe one-shot guard around the RACSignal → continuation hand-off. -/// Tracks both "did I resume the continuation yet?" and "did the Task get -/// cancelled?". `NSLock` is sufficient — there's no contention on the hot -/// path, just a few callbacks per await. -private final class RACContinuationState: @unchecked Sendable { - private let lock = NSLock() - private var resolved = false - private var cancelled = false - var disposable: RACDisposable? - - var isCancelled: Bool { - lock.lock() - defer { lock.unlock() } - return cancelled - } - - /// Atomically claims the right to resume the continuation. Returns - /// `true` the first time it's called, `false` on every subsequent - /// call so duplicate signal events become no-ops. - func markResolved() -> Bool { - lock.lock() - defer { lock.unlock() } - if resolved { return false } - resolved = true - return true - } - - /// Marks cancellation and disposes the active subscription. Safe to - /// call from any thread; the dispose happens outside the lock to - /// avoid reentrancy into ReactiveObjC internals. - func cancel() { - lock.lock() - cancelled = true - let pendingDisposable = disposable - disposable = nil - lock.unlock() - pendingDisposable?.dispose() - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeRefreshService.swift b/LookInside/MCPBridge/LKMCPBridgeRefreshService.swift deleted file mode 100644 index 172ad99c..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeRefreshService.swift +++ /dev/null @@ -1,247 +0,0 @@ -// LKMCPBridgeRefreshService.swift -// -// Handles the `hierarchy.refresh` bridge route: re-fetching the target -// app's whole view tree (RPC 202) and handing it to the host's data -// source, which is exactly what the inspector's toolbar reload button -// does. -// -// Why this needs to exist at all: LookInside is a manual-refresh -// inspector by design. Nothing polls the target and nothing pushes "the -// UI changed" — the host's tree is whatever the last fetch produced. A -// human closes that gap by clicking reload; before this route an agent -// had no equivalent and could only read a snapshot that silently went -// stale the moment the app navigated. -// -// This service is deliberately NOT folded into `LKMCPBridgeInspectionService`. -// That service is pure cached reads with no RPC; refresh round-trips to -// the target and mutates host state, and merging them would quietly -// retire the "the read surface never emits RPC" property. -// -// The actual reload is not reimplemented here. It runs through -// `-[LKStaticWindowController reloadHierarchySignal]`, the same method -// the toolbar button now calls, so both paths share one re-entrancy gate -// and one fetch chain. Duplicating the chain in Swift would have left two -// copies to drift apart, and writing the window controller's private -// `isFetchingHierarchy` flag from here would have meant this service -// silently driving the host's toolbar enablement. - -import AppKit -import Foundation -import os - -@MainActor -public final class LKMCPBridgeRefreshService { - - // MARK: - Constants pulled from upstream LookinDefines.h - // - // Re-declared rather than imported, matching the other RPC-emitting - // services: pulling LookinDefines.h into the bridging header would - // touch every Swift compilation in the target. - - /// `LookinErrCode_NoConnect` from `LookinDefines.h:128`. - private static let lookinErrCodeNoConnect = -403 - - /// `LookinErrCode_Timeout` from `LookinDefines.h:132`. - private static let lookinErrCodeTimeout = -405 - - /// `LookinErrCode_LicenseRequired` from `LookinDefines.h:153`. - private static let lookinErrCodeLicenseRequired = -408 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Refresh") - - public init() {} - - // MARK: - Entry point - - public func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - guard request.method == "hierarchy.refresh" else { - return .failure(identifier: request.identifier, error: .unknownMethod) - } - return await handleHierarchyRefresh( - identifier: request.identifier, - parameters: request.parameters - ) - } - - // MARK: - hierarchy.refresh - - private func handleHierarchyRefresh( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) async -> LKMCPBridgeResponse { - guard let parameters = parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - - guard let windowController = document.staticWindowController else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has no inspector window yet." - ) - ) - } - - // Read the old size before starting: the data source is replaced - // in place during the reload, so after the fact there is nothing - // left to compare against. - let previousNodeCount = document.hierarchyDataSource?.rawFlatItems?.count ?? 0 - - // Stamps `lastReloadInitiator` on the window controller, which is - // what lets the event publisher label the resulting - // `hierarchy.reloaded` event as the caller's own echo rather than - // as news about the app. - guard let signal = windowController.reloadHierarchySignal(with: .agent) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "refresh.internalError", - message: "The inspector window returned no signal for the hierarchy reload." - ) - ) - } - - let startInstant = ContinuousClock.now - do { - // The value is discarded: by the time it arrives the window - // controller has already handed it to the data source, and - // everything reported below is read back from there so the - // response describes the host's settled state rather than the - // wire payload. - _ = try await LKMCPBridgeRACBridge.awaitFirstValue(of: signal, as: AnyObject.self) - } catch RACBridgeError.completedWithoutValue { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "refresh.disconnected", - message: "The target app did not return a hierarchy. The channel may have been closed mid-request." - ) - ) - } catch RACBridgeError.cancelled { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "refresh.cancelled", - message: "The refresh was cancelled before the target app produced a hierarchy." - ) - ) - } catch let error as NSError { - // Must stay below the RACBridgeError clauses. Every Swift error - // bridges to NSError, so this pattern matches everything -- above - // them it silently swallows both, and they become dead code. - return .failure(identifier: identifier, error: mapRefreshError(error)) - } catch { - Self.logger.error("hierarchy.refresh bridge error: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - let elapsedComponents = (ContinuousClock.now - startInstant).components - let durationMilliseconds = Int(elapsedComponents.seconds) * 1000 - + Int(elapsedComponents.attoseconds / 1_000_000_000_000_000) - - let rootIdentifiers = LKMCPBridgeLiveDocumentLookup - .topLevelDisplayItems(in: document) - .map { LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: $0) } - - let result = LKMCPBridgeRefreshResult( - targetIdentifier: targetIdentifier, - rootObjectIdentifiers: rootIdentifiers, - nodeCount: document.hierarchyDataSource?.rawFlatItems?.count ?? 0, - previousNodeCount: previousNodeCount, - durationMilliseconds: durationMilliseconds - ) - - do { - let payload = try encodeAsJSONValue(result) - return .success(identifier: identifier, result: payload) - } catch { - Self.logger.error("hierarchy.refresh encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - // MARK: - Error mapping - - private func mapRefreshError(_ error: NSError) -> LKMCPBridgeErrorPayload { - // Two domains reach here. The window controller's own domain means - // the host declined to start; `LookinErrorDomain` means the target - // app failed the fetch. Keeping them apart matters to the caller: - // the first is worth retrying in a moment, the second usually is - // not. - if error.domain == LKStaticWindowControllerReloadErrorDomain { - switch error.code { - case LKStaticWindowControllerReloadErrorCode.alreadyInProgress.rawValue: - return LKMCPBridgeErrorPayload( - code: "refresh.alreadyInProgress", - message: "A hierarchy reload is already running for this target — either a user clicked reload or another refresh call is still in flight. Retry once it settles." - ) - case LKStaticWindowControllerReloadErrorCode.detailSyncInProgress.rawValue: - return LKMCPBridgeErrorPayload( - code: "refresh.detailSyncInProgress", - message: "The inspector is still syncing view details. Reloading now would discard that work; wait for it to finish." - ) - case LKStaticWindowControllerReloadErrorCode.noInspectableApp.rawValue: - return LKMCPBridgeErrorPayload( - code: "refresh.notAttached", - message: "The inspector window is not attached to an app. Attach a target in LookInside and try again." - ) - case LKStaticWindowControllerReloadErrorCode.windowClosed.rawValue: - return LKMCPBridgeErrorPayload( - code: "refresh.windowClosed", - message: "The inspector window closed while the hierarchy was being fetched, so the result was discarded." - ) - case LKStaticWindowControllerReloadErrorCode.noResponse.rawValue: - return LKMCPBridgeErrorPayload( - code: "refresh.disconnected", - message: "The target app finished the hierarchy request without returning a hierarchy. The channel may have been closed mid-request." - ) - default: - Self.logger.error("hierarchy.refresh received unmapped host refusal \(error.code, privacy: .public)") - return LKMCPBridgeErrorPayload( - code: "refresh.internalError", - message: "The inspector declined the reload for an unexpected reason (code \(error.code))." - ) - } - } - - switch error.code { - case Self.lookinErrCodeLicenseRequired: - return .licenseRequired - case Self.lookinErrCodeNoConnect: - return LKMCPBridgeErrorPayload( - code: "refresh.disconnected", - message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." - ) - case Self.lookinErrCodeTimeout: - return LKMCPBridgeErrorPayload( - code: "refresh.timeout", - message: "The target app did not return its hierarchy within the request timeout. Check whether it is paused in Xcode or blocked on the main thread." - ) - default: - Self.logger.error("hierarchy.refresh received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") - return LKMCPBridgeErrorPayload( - code: "refresh.internalError", - message: "The target app reported an unexpected error (code \(error.code))." - ) - } - } - - // MARK: - Encoding helper (duplicated from InspectionService) - - private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeScreenshotService.swift b/LookInside/MCPBridge/LKMCPBridgeScreenshotService.swift deleted file mode 100644 index 46c72085..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeScreenshotService.swift +++ /dev/null @@ -1,544 +0,0 @@ -// LKMCPBridgeScreenshotService.swift -// -// Handles the `screenshot.read` bridge route: renders one display item — -// either on its own (`solo`) or together with its whole subtree -// (`group`) — and returns it as PNG bytes. -// -// Why every mode goes through RPC 203 rather than RPC 201 `App`: -// RPC 201 with `needImages=YES` also carries a whole-app screenshot -// (`LookinAppInfo.screenshot`), but reaching it means driving -// `LKAppsManager.fetchAppInfosWithImage:localInfos:`, which re-scans -// *every* Peertalk port and rebuilds a fresh `LKInspectableApp` list -// for all connected apps. That is a heavyweight, side-effecting -// operation to perform for one picture, and the resulting screenshot -// is whatever the app looked like when the scan ran. Taking a `group` -// screenshot of the key window instead produces the same picture, -// freshly rendered, over the same code path as every other mode. -// Callers who want "the whole screen" simply omit `objectIdentifier` -// and get the key window's group screenshot. -// -// Caching: the host already holds `soloScreenshot` / `groupScreenshot` -// on `LookinDisplayItem` whenever the inspector UI has rendered that -// view. Those are served directly (`fromCache: true`) so repeat calls -// stay cheap. On a miss the service issues RPC 203 with the matching -// task type, merges the response back through -// `LKStaticHierarchyDataSource.modifyWithDisplayItemDetail:` (same -// side-effect `details.read` relies on) and encodes the fresh image. -// -// Redaction: screenshots are pixels, and the host's secure-content -// redactor only ever operated on attribute *strings*. A screenshot is -// therefore returned unredacted, and the result carries -// `containsSecureContent` so the agent — and the human reading its -// transcript — can see when the frame covered a secure input view. -// Blacking out secure regions is deliberately deferred; see -// docs/monorepo/mcp-design.md §E.3. - -import AppKit -import CoreGraphics -import Foundation -import os - -@MainActor -public final class LKMCPBridgeScreenshotService { - - // MARK: - Configuration - - /// Default cap on the longest side of the returned image, in pixels. - /// Screenshots land directly in an LLM context window, where a full - /// 3x device screenshot costs a meaningful share of the budget for - /// detail no agent uses. 1024 keeps interface text legible while - /// holding a typical PNG in the low hundreds of kilobytes. - private static let defaultMaximumPixelDimension = 1024 - - /// Hard ceiling for `maximumPixelDimension`. Above this the base64 - /// payload starts to rival the connection's 8 MB frame cap for no - /// practical gain. - private static let hardMaximumPixelDimension = 4096 - - /// Lower bound, purely to reject nonsense input (0, negatives). - private static let minimumPixelDimension = 16 - - // MARK: - Error code constants from LookinDefines.h - // - // See LKMCPBridgeInvocationService for the duplication rationale. - - private static let lookinErrCodeObjectNotFound = -500 - private static let lookinErrCodeInner = -401 - private static let lookinErrCodeLicenseRequired = -408 - private static let lookinErrCodeNoConnect = -403 - private static let lookinErrCodeTimeout = -405 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Screenshot") - - public init() {} - - // MARK: - Screenshot mode - - /// Which of the two `LookinDisplayItemDetail` screenshot slots the - /// caller wants. Mirrors `LookinStaticAsyncUpdateTaskType` minus its - /// `NoScreenshot` case, which has no meaning on this route. - private enum ScreenshotMode: String { - /// The view alone, with its subviews hidden during capture. - case solo - /// The view together with everything it contains. - case group - - var taskType: LookinStaticAsyncUpdateTaskType { - switch self { - case .solo: return .soloScreenshot - case .group: return .groupScreenshot - } - } - - func cachedImage(on displayItem: LookinDisplayItem) -> NSImage? { - switch self { - case .solo: return displayItem.soloScreenshot - case .group: return displayItem.groupScreenshot - } - } - - func image(in detail: LookinDisplayItemDetail) -> NSImage? { - switch self { - case .solo: return detail.soloScreenshot - case .group: return detail.groupScreenshot - } - } - } - - // MARK: - Entry point - - public func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - guard request.method == "screenshot.read" else { - return .failure(identifier: request.identifier, error: .unknownMethod) - } - return await handleScreenshotRead( - identifier: request.identifier, - parameters: request.parameters - ) - } - - // MARK: - screenshot.read - - private func handleScreenshotRead( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) async -> LKMCPBridgeResponse { - guard let parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - - let requestedObjectIdentifier: String? - if case .string(let raw)? = parameters["objectIdentifier"] { - requestedObjectIdentifier = raw - } else { - requestedObjectIdentifier = nil - } - - let mode: ScreenshotMode - if case .string(let raw)? = parameters["mode"] { - guard let parsed = ScreenshotMode(rawValue: raw) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.invalidMode", - message: "Unsupported screenshot mode \"\(raw)\". Valid modes are \"solo\" and \"group\"." - ) - ) - } - mode = parsed - } else { - mode = .group - } - - let maximumPixelDimension: Int - switch parameters["maximumPixelDimension"] { - case .integer(let raw)?: - maximumPixelDimension = Int(raw) - case .double(let raw)?: - maximumPixelDimension = Int(raw) - case .none, .some(.null): - maximumPixelDimension = Self.defaultMaximumPixelDimension - default: - return .failure(identifier: identifier, error: .invalidParameters) - } - guard maximumPixelDimension >= Self.minimumPixelDimension, - maximumPixelDimension <= Self.hardMaximumPixelDimension - else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.invalidPixelDimension", - message: "maximumPixelDimension must be between \(Self.minimumPixelDimension) and \(Self.hardMaximumPixelDimension); received \(maximumPixelDimension)." - ) - ) - } - - // Live-document lookup - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - guard document.hierarchyDataSource != nil else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has not loaded a hierarchy yet." - ) - ) - } - - // Resolve which display item to capture. - let roots = LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document) - let displayItem: LookinDisplayItem - if let requestedObjectIdentifier { - guard let found = LKMCPBridgeLiveDocumentLookup.findDisplayItem( - amongRoots: roots, - matchingObjectIdentifier: requestedObjectIdentifier - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.objectNotFound", - message: "Object identifier \(requestedObjectIdentifier) is not present in this target's hierarchy. Refresh via get_hierarchy and retry." - ) - ) - } - displayItem = found - } else { - // No explicit target: capture the key window, which is what - // "show me the screen" means. Fall back to the first root - // when no window reports itself as key (can happen briefly - // during app launch or when the app is backgrounded). - guard let keyWindowItem = roots.first(where: { $0.representedAsKeyWindow }) ?? roots.first else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.objectNotFound", - message: "This target's hierarchy has no top-level window to capture." - ) - ) - } - displayItem = keyWindowItem - } - - let resolvedObjectIdentifier = LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: displayItem) - let containsSecureContent = Self.subtreeContainsSecureContent(rootDisplayItem: displayItem) - - // Fast path: the inspector UI already rendered this view, so the - // image is sitting on the display item. - if let cachedImage = mode.cachedImage(on: displayItem) { - return encodeResponse( - identifier: identifier, - image: cachedImage, - objectIdentifier: resolvedObjectIdentifier, - mode: mode, - displayItem: displayItem, - maximumPixelDimension: maximumPixelDimension, - containsSecureContent: containsSecureContent, - servedFromCache: true - ) - } - - // Slow path: ask the target app to render it now. - guard let nativeObjectIdentifier = displayItem.displayingObject()?.oid, - nativeObjectIdentifier != 0 - else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.objectNotFound", - message: "The resolved display item has no live object behind it; it was probably deallocated. Refresh via get_hierarchy and retry." - ) - ) - } - - let task = LookinStaticAsyncUpdateTask() - task.oid = nativeObjectIdentifier - task.taskType = mode.taskType - // Attributes are `read_attributes` / `read_view_details` territory; - // asking for them here would make the target app build attribute - // groups it is about to throw away. - task.attrRequest = .notNeed - task.needBasisVisualInfo = false - task.needSubitems = false - task.clientReadableVersion = LKHelper.lookinReadableVersion() - - let package = LookinStaticAsyncUpdateTasksPackage() - package.tasks = [task] - - guard let signal = document.inspectableApp.rawFetchHierarchyDetail(withTaskPackages: [package]) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.internalError", - message: "The target's inspectable app returned no signal for the fetch." - ) - ) - } - - let frames: [NSArray] - do { - frames = try await LKMCPBridgeRACBridge.awaitAllValues(of: signal, as: NSArray.self) - } catch RACBridgeError.completedWithoutValue { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.notAvailable", - message: "The target app completed the render request without returning an image." - ) - ) - } catch RACBridgeError.cancelled { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.cancelled", - message: "The render was cancelled before the target app returned an image." - ) - ) - } catch let error as NSError { - // Must stay below the RACBridgeError clauses. Every Swift error - // bridges to NSError, so this pattern matches everything -- above - // them it silently swallows both, and they become dead code. - return .failure(identifier: identifier, error: mapScreenshotError(error)) - } catch { - Self.logger.error("screenshot.read bridge error: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - - // Find our detail in the streamed frames. We sent exactly one - // task, so at most one detail can match. - var matchingDetail: LookinDisplayItemDetail? - for frame in frames { - for entry in frame { - guard let detail = entry as? LookinDisplayItemDetail, - detail.displayItemOid == nativeObjectIdentifier - else { continue } - matchingDetail = detail - break - } - if matchingDetail != nil { break } - } - - guard let detail = matchingDetail else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.notAvailable", - message: "The target app did not return a detail for the requested object." - ) - ) - } - if detail.failureCode == -1 { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.objectNotFound", - message: "The target app could not find the requested object; it may have been deallocated. Refresh via get_hierarchy and retry." - ) - ) - } - - // Merge into the host cache so the inspector UI and any repeat - // call see the same image without another round-trip. - document.hierarchyDataSource?.modify(with: detail) - - guard let renderedImage = mode.image(in: detail) else { - // The server has real paths that return a detail with no - // image — notably `solo` on an AppKit view that is not - // layer-backed and has no subviews, where neither capture - // branch fires. Tell the agent to retry in `group` rather - // than leaving it to guess. - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.notAvailable", - message: "The target app returned no \(mode.rawValue) screenshot for this object. Some views cannot be captured in \"solo\" mode; retry with mode \"group\"." - ) - ) - } - - return encodeResponse( - identifier: identifier, - image: renderedImage, - objectIdentifier: resolvedObjectIdentifier, - mode: mode, - displayItem: displayItem, - maximumPixelDimension: maximumPixelDimension, - containsSecureContent: containsSecureContent, - servedFromCache: false - ) - } - - // MARK: - Encoding - - private func encodeResponse( - identifier: String, - image: NSImage, - objectIdentifier: String, - mode: ScreenshotMode, - displayItem: LookinDisplayItem, - maximumPixelDimension: Int, - containsSecureContent: Bool, - servedFromCache: Bool - ) -> LKMCPBridgeResponse { - guard let encoded = Self.encodePNG(from: image, maximumPixelDimension: maximumPixelDimension) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "screenshot.encodingFailed", - message: "The captured image could not be encoded as PNG." - ) - ) - } - - let result = LKMCPBridgeScreenshotResult( - objectIdentifier: objectIdentifier, - mode: mode.rawValue, - imageData: encoded.pngData.base64EncodedString(), - mimeType: "image/png", - pixelWidth: encoded.pixelWidth, - pixelHeight: encoded.pixelHeight, - sourcePixelWidth: encoded.sourcePixelWidth, - sourcePixelHeight: encoded.sourcePixelHeight, - byteCount: encoded.pngData.count, - frame: LKMCPBridgeRect(cgRect: LKMCPBridgeLiveDocumentLookup.rootSpaceFrame(for: displayItem)), - servedFromCache: servedFromCache, - containsSecureContent: containsSecureContent - ) - - do { - let payload = try encodeAsJSONValue(result) - return .success(identifier: identifier, result: payload) - } catch { - Self.logger.error("screenshot.read encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - /// Encodes an `NSImage` as PNG, downscaling so the longest side does - /// not exceed `maximumPixelDimension`. Works in pixels rather than - /// points throughout: the source is a Retina capture whose point size - /// is half (or a third) of what actually matters for payload size. - private static func encodePNG( - from image: NSImage, - maximumPixelDimension: Int - ) -> (pngData: Data, pixelWidth: Int, pixelHeight: Int, sourcePixelWidth: Int, sourcePixelHeight: Int)? { - guard let sourceImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return nil - } - let sourcePixelWidth = sourceImage.width - let sourcePixelHeight = sourceImage.height - guard sourcePixelWidth > 0, sourcePixelHeight > 0 else { return nil } - - let longestSourceSide = max(sourcePixelWidth, sourcePixelHeight) - let renderedImage: CGImage - let targetPixelWidth: Int - let targetPixelHeight: Int - - if longestSourceSide > maximumPixelDimension { - let scaleFactor = Double(maximumPixelDimension) / Double(longestSourceSide) - targetPixelWidth = max(1, Int((Double(sourcePixelWidth) * scaleFactor).rounded())) - targetPixelHeight = max(1, Int((Double(sourcePixelHeight) * scaleFactor).rounded())) - guard let context = CGContext( - data: nil, - width: targetPixelWidth, - height: targetPixelHeight, - bitsPerComponent: 8, - bytesPerRow: 0, - space: CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - return nil - } - context.interpolationQuality = .high - context.draw( - sourceImage, - in: CGRect(x: 0, y: 0, width: targetPixelWidth, height: targetPixelHeight) - ) - guard let scaledImage = context.makeImage() else { return nil } - renderedImage = scaledImage - } else { - targetPixelWidth = sourcePixelWidth - targetPixelHeight = sourcePixelHeight - renderedImage = sourceImage - } - - let bitmapRepresentation = NSBitmapImageRep(cgImage: renderedImage) - guard let pngData = bitmapRepresentation.representation(using: .png, properties: [:]) else { - return nil - } - return ( - pngData: pngData, - pixelWidth: targetPixelWidth, - pixelHeight: targetPixelHeight, - sourcePixelWidth: sourcePixelWidth, - sourcePixelHeight: sourcePixelHeight - ) - } - - // MARK: - Helpers - - /// Walks a display item and everything under it looking for a view - /// the secure-content detector flags. A `group` screenshot renders - /// the whole subtree, so a secure field nested three levels down - /// still ends up in the returned pixels. - private static func subtreeContainsSecureContent(rootDisplayItem: LookinDisplayItem) -> Bool { - var pendingItems: [LookinDisplayItem] = [rootDisplayItem] - while pendingItems.isEmpty == false { - let currentItem = pendingItems.removeFirst() - if LKMCPBridgeSecureContentDetector.isSecure(displayItem: currentItem) { - return true - } - if let subitems = currentItem.subitems { - pendingItems.append(contentsOf: subitems) - } - } - return false - } - - private func mapScreenshotError(_ error: NSError) -> LKMCPBridgeErrorPayload { - switch error.code { - case Self.lookinErrCodeObjectNotFound: - return LKMCPBridgeErrorPayload( - code: "screenshot.objectNotFound", - message: "The target app could not find the requested object. It may have been deallocated; refresh via get_hierarchy and retry." - ) - case Self.lookinErrCodeInner: - return LKMCPBridgeErrorPayload( - code: "screenshot.internalError", - message: "The target app rejected the render request with a generic inner error." - ) - case Self.lookinErrCodeLicenseRequired: - return .licenseRequired - case Self.lookinErrCodeNoConnect: - return LKMCPBridgeErrorPayload( - code: "screenshot.disconnected", - message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." - ) - case Self.lookinErrCodeTimeout: - return LKMCPBridgeErrorPayload( - code: "screenshot.timeout", - message: "The target app did not render within the request timeout. Check whether the target is paused in Xcode." - ) - default: - Self.logger.error("screenshot.read received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") - return LKMCPBridgeErrorPayload( - code: "screenshot.internalError", - message: "The target app reported an unexpected error (code \(error.code))." - ) - } - } - - private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift b/LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift deleted file mode 100644 index 39e82897..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift +++ /dev/null @@ -1,162 +0,0 @@ -// LKMCPBridgeSearchQuery.swift -// -// Pure matching core behind the `hierarchy.find` bridge route. -// -// Deliberately kept free of AppKit and of the Objective-C hierarchy model: -// which fields a query looks at, and how a hit is reported back, is the -// part of search whose correctness is invisible at the call site (a field -// silently never matching looks exactly like "nothing in the tree matched"). -// Splitting it out lets `Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift` -// compile it standalone under the host repo's `swiftc`-single-binary test -// pattern, the same reason `LKMCPBridgeListenSocket` was split out. -// -// Matching is case-insensitive substring containment. That is intentionally -// dumber than the host inspector's own search: an agent issues a query it -// composed from a class name it already read out of `hierarchy.read`, so -// fuzzy scoring would trade predictability for a relevance ranking nobody -// asked for. Callers that want precision narrow `fields` instead. - -import Foundation - -public struct LKMCPBridgeSearchQuery: Sendable { - - // MARK: - Fields - - /// The searchable projections of a hierarchy node. Wire names are the - /// raw values; `hierarchy.find` rejects anything outside this set - /// rather than silently ignoring a misspelled field. - public enum Field: String, Sendable, Codable, CaseIterable { - /// Leaf class only — the head of the class chain. Use this to - /// avoid matching every `UIView` subclass on a query of "UIView". - case className - - /// Any entry in the class chain, so a query of "UIControl" - /// also finds `UIButton` instances. - case classChain - - /// The row label the host inspector shows, usually the demangled - /// class name but overridable by the in-app `lookin_customDebugInfos` - /// hook. - case title - - /// The host inspector's secondary label: owning view controller, - /// ivar trace, or a custom subtitle. - case subtitle - - /// Hex memory address of the underlying view / layer / window - /// object, so an address copied out of a crash log or the Xcode - /// debugger can be located in the tree. - case memoryAddress - } - - /// One hierarchy node reduced to the strings this type knows how to - /// match. Built by the service layer so this type never touches - /// `LookinDisplayItem`. - public struct Candidate: Sendable { - public let className: String - public let classChain: [String] - public let title: String? - public let subtitle: String? - public let memoryAddresses: [String] - - public init( - className: String, - classChain: [String], - title: String?, - subtitle: String?, - memoryAddresses: [String] - ) { - self.className = className - self.classChain = classChain - self.title = title - self.subtitle = subtitle - self.memoryAddresses = memoryAddresses - } - } - - // MARK: - State - - /// The query exactly as the caller supplied it, kept for echoing back - /// in the response. - public let rawQuery: String - - /// Fields to consider, in canonical `Field.allCases` order regardless - /// of the order the caller listed them, so `matchedFields` is stable - /// across requests that ask for the same set. - public let fields: [Field] - - private let normalizedQuery: String - - // MARK: - Construction - - /// - Parameters: - /// - rawQuery: caller-supplied needle. Callers are responsible for - /// rejecting the empty string; an empty query here matches - /// everything, which is a legitimate thing for a test to assert - /// but never a useful thing for an agent to ask for. - /// - fields: which projections to search. Duplicates are collapsed; - /// an empty array means every field. - public init(rawQuery: String, fields: [Field] = Field.allCases) { - self.rawQuery = rawQuery - self.normalizedQuery = rawQuery.lowercased() - let requested = Set(fields.isEmpty ? Field.allCases : fields) - self.fields = Field.allCases.filter { requested.contains($0) } - } - - /// Translates wire field names into `Field` values, preserving the - /// canonical order. Returns `nil` for the first unrecognized name so - /// the caller can name it in the error message — silently dropping it - /// would turn a typo into "no results found", which reads as a fact - /// about the app rather than as a mistake in the request. - public static func fields(fromWireNames names: [String]) -> [Field]? { - var resolved: Set = [] - for name in names { - guard let field = Field(rawValue: name) else { return nil } - resolved.insert(field) - } - return Field.allCases.filter { resolved.contains($0) } - } - - /// The first wire name in `names` that is not a valid field, for use - /// in the caller's error message. - public static func firstUnrecognizedFieldName(in names: [String]) -> String? { - return names.first { Field(rawValue: $0) == nil } - } - - // MARK: - Matching - - /// Every field of `candidate` that contains the query, in canonical - /// order. Empty means no match. - /// - /// The list — rather than a bare `Bool` — is what lets the response - /// tell an agent *why* a node came back. A hit on `memoryAddress` and - /// a hit on `title` warrant very different follow-up actions, and - /// without the reason the agent has to re-derive it by inspecting - /// each result. - public func matchedFields(in candidate: Candidate) -> [Field] { - return fields.filter { field in - switch field { - case .className: - return contains(candidate.className) - case .classChain: - return candidate.classChain.contains(where: contains) - case .title: - return contains(candidate.title) - case .subtitle: - return contains(candidate.subtitle) - case .memoryAddress: - return candidate.memoryAddresses.contains(where: contains) - } - } - } - - /// Convenience predicate for callers that only need a yes / no. - public func matches(_ candidate: Candidate) -> Bool { - return matchedFields(in: candidate).isEmpty == false - } - - private func contains(_ haystack: String?) -> Bool { - guard let haystack, haystack.isEmpty == false else { return false } - return haystack.lowercased().contains(normalizedQuery) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeSearchService.swift b/LookInside/MCPBridge/LKMCPBridgeSearchService.swift deleted file mode 100644 index 584dee5f..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeSearchService.swift +++ /dev/null @@ -1,318 +0,0 @@ -// LKMCPBridgeSearchService.swift -// -// Handles the `hierarchy.find` bridge route: locating views in an -// attached target by class name, label, or memory address. -// -// Entirely cache-resident. Unlike `details.read` / `screenshot.read`, -// this route issues no Peertalk RPC — it walks the flat display-item list -// the host already holds. That makes it the cheapest way for an agent to -// go from "somewhere in this app there is a play button" to an object -// identifier it can hand to `attributes.read` / `invoke.method`, without -// first dumping a few thousand nodes of `hierarchy.read` into its context. -// -// It deliberately does NOT call the host's own -// `-[LKHierarchyDataSource searchWithString:]`. That method drives the -// inspector UI: it flips the data source into its search state, expands -// every ancestor of every hit, clears the user's current selection, and -// republishes the visible row list. Running it for a bridge request would -// reach through the socket and rearrange the window the user is looking -// at. Only the pure per-item predicate is reused in spirit, reimplemented -// over a wider field set in `LKMCPBridgeSearchQuery`. - -import AppKit -import Foundation -import os - -@MainActor -public final class LKMCPBridgeSearchService { - - /// Default number of matches returned when the caller doesn't ask. - /// Sized so a typical response stays well inside an agent's context - /// budget while still showing enough of a broad query (say, every - /// `UILabel`) for the agent to judge whether to narrow it. - private static let defaultMatchLimit = 50 - - /// Ceiling on `limit`. A caller that wants more than this is really - /// asking for the whole tree and should use `hierarchy.read`. - private static let maximumMatchLimit = 500 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Search") - - public init() {} - - // MARK: - Entry point - - public func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - guard request.method == "hierarchy.find" else { - return .failure(identifier: request.identifier, error: .unknownMethod) - } - return handleHierarchyFind( - identifier: request.identifier, - parameters: request.parameters - ) - } - - // MARK: - hierarchy.find - - private func handleHierarchyFind( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) -> LKMCPBridgeResponse { - guard let parameters = parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"], - case .string(let rawQuery)? = parameters["query"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - - // An empty needle matches every node, which would silently turn a - // search into a full tree dump — the exact outcome this route - // exists to avoid. - let trimmedQuery = rawQuery.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmedQuery.isEmpty { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "search.emptyQuery", - message: "`query` must be a non-empty search string. To list the tree instead of searching it, use hierarchy.read." - ) - ) - } - - let fields: [LKMCPBridgeSearchQuery.Field] - if case .array(let rawFields)? = parameters["matchFields"] { - let names: [String] = rawFields.compactMap { value in - if case .string(let name) = value { return name } - return nil - } - guard names.count == rawFields.count else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "search.invalidMatchField", - message: "`matchFields` must be an array of strings. Valid values: \(Self.validFieldList)." - ) - ) - } - guard let resolved = LKMCPBridgeSearchQuery.fields(fromWireNames: names) else { - let unrecognized = LKMCPBridgeSearchQuery.firstUnrecognizedFieldName(in: names) ?? "?" - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "search.invalidMatchField", - message: "`\(unrecognized)` is not a searchable field. Valid values: \(Self.validFieldList)." - ) - ) - } - fields = resolved.isEmpty ? LKMCPBridgeSearchQuery.Field.allCases : resolved - } else { - fields = LKMCPBridgeSearchQuery.Field.allCases - } - - let matchLimit: Int - switch parameters["limit"] { - case .integer(let raw)?: - matchLimit = Int(raw) - case .double(let raw)?: - matchLimit = Int(raw) - case nil: - matchLimit = Self.defaultMatchLimit - default: - return .failure(identifier: identifier, error: .invalidParameters) - } - guard matchLimit >= 1, matchLimit <= Self.maximumMatchLimit else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "search.invalidLimit", - message: "`limit` must be between 1 and \(Self.maximumMatchLimit); received \(matchLimit)." - ) - ) - } - - let includeHidden: Bool - if case .bool(let raw)? = parameters["includeHidden"] { - includeHidden = raw - } else { - includeHidden = true - } - - let scopeObjectIdentifier: String? - if case .string(let raw)? = parameters["rootObjectIdentifier"] { - scopeObjectIdentifier = raw - } else { - scopeObjectIdentifier = nil - } - - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - - guard let dataSource = document.hierarchyDataSource, - let allItems = dataSource.rawFlatItems - else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has not loaded a hierarchy yet." - ) - ) - } - - let searchScope: [LookinDisplayItem] - if let scopeObjectIdentifier { - guard let scopeRoot = LKMCPBridgeLiveDocumentLookup.findDisplayItem( - amongRoots: allItems, - matchingObjectIdentifier: scopeObjectIdentifier - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.objectNotFound", - message: "Object identifier \(scopeObjectIdentifier) is not present in this target's hierarchy." - ) - ) - } - var subtree: [LookinDisplayItem] = [] - scopeRoot.enumerateSelfAndChildren { subtree.append($0) } - searchScope = subtree - } else { - searchScope = allItems - } - - let query = LKMCPBridgeSearchQuery(rawQuery: trimmedQuery, fields: fields) - - // The whole scope is scanned even after `limit` is reached: an - // agent that only learns "here are 50" cannot tell a complete - // answer from the tip of an iceberg, and the scan is a cheap - // in-memory string compare over a list the host already holds. - var matches: [LKMCPBridgeSearchMatch] = [] - var totalMatchCount = 0 - var searchedNodeCount = 0 - - for item in searchScope { - if includeHidden == false, item.isHidden { - continue - } - searchedNodeCount += 1 - let matchedFields = query.matchedFields(in: makeCandidate(from: item)) - if matchedFields.isEmpty { - continue - } - totalMatchCount += 1 - if matches.count < matchLimit { - matches.append(makeMatch(from: item, matchedFields: matchedFields)) - } - } - - let result = LKMCPBridgeSearchResult( - matches: matches, - totalMatchCount: totalMatchCount, - truncated: matches.count < totalMatchCount, - searchedNodeCount: searchedNodeCount, - searchedFields: fields.map(\.rawValue) - ) - - do { - let payload = try encodeAsJSONValue(result) - return .success(identifier: identifier, result: payload) - } catch { - Self.logger.error("hierarchy.find encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - // MARK: - Candidate / match construction - - private func makeCandidate(from item: LookinDisplayItem) -> LKMCPBridgeSearchQuery.Candidate { - let displayingObject = item.displayingObject() - let classChain = displayingObject?.classChainList ?? [] - - // Every object flavour the item can represent contributes its - // address, mirroring the host's own search: an address copied out - // of a debugger may name the layer even when the row represents - // the view, and vice versa. - var memoryAddresses: [String] = [] - for candidateObject in [item.viewObject, item.layerObject, item.windowObject, item.kindObject] { - if let address = candidateObject?.memoryAddress, address.isEmpty == false { - memoryAddresses.append(address) - } - } - - return LKMCPBridgeSearchQuery.Candidate( - className: classChain.first ?? "", - classChain: classChain, - title: item.title(), - subtitle: item.subtitle(), - memoryAddresses: memoryAddresses - ) - } - - private func makeMatch( - from item: LookinDisplayItem, - matchedFields: [LKMCPBridgeSearchQuery.Field] - ) -> LKMCPBridgeSearchMatch { - // Titles and subtitles can carry app-supplied strings through the - // in-app `lookin_customDebugInfos` hook, so they go through the - // same redaction gate as `attributes.read`'s textual values. - let redactSecureContent = LKMCPBridgeSecureContentDetector.isSecure(displayItem: item) - let title = redactSecureContent ? nil : item.title() - let subtitle = redactSecureContent ? nil : item.subtitle() - - var ancestorIdentifiers: [String] = [] - var ancestorTitles: [String] = [] - item.enumerateAncestors { ancestor, _ in - ancestorIdentifiers.append(LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: ancestor)) - ancestorTitles.append(Self.pathComponentTitle(for: ancestor)) - } - // `enumerateAncestors` walks upward; the wire order is root first - // so the list reads like a path. - ancestorIdentifiers.reverse() - ancestorTitles.reverse() - - let pathComponents = ancestorTitles + [Self.pathComponentTitle(for: item)] - - return LKMCPBridgeSearchMatch( - objectIdentifier: LKMCPBridgeLiveDocumentLookup.objectIdentifierString(for: item), - className: item.displayingObject()?.classChainList?.first ?? "", - title: title, - subtitle: subtitle, - frame: LKMCPBridgeRect(cgRect: LKMCPBridgeLiveDocumentLookup.rootSpaceFrame(for: item)), - isHidden: item.isHidden, - alpha: Double(item.alpha), - depth: ancestorIdentifiers.count, - ancestorObjectIdentifiers: ancestorIdentifiers, - pathDescription: pathComponents.joined(separator: " > "), - matchedFields: matchedFields.map(\.rawValue), - secureContent: redactSecureContent - ) - } - - /// Path components use the class name rather than the display title: - /// the title can be an app-supplied custom string, and a path is for - /// orientation, not for reading back app content. - private static func pathComponentTitle(for item: LookinDisplayItem) -> String { - let className = item.displayingObject()?.classChainList?.first ?? "" - return className.isEmpty ? "?" : className - } - - private static let validFieldList: String = { - return LKMCPBridgeSearchQuery.Field.allCases - .map(\.rawValue) - .joined(separator: ", ") - }() - - // MARK: - Encoding helper (duplicated from InspectionService) - - private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeSecureContentDetector.swift b/LookInside/MCPBridge/LKMCPBridgeSecureContentDetector.swift deleted file mode 100644 index 9cd5b5a2..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeSecureContentDetector.swift +++ /dev/null @@ -1,46 +0,0 @@ -// LKMCPBridgeSecureContentDetector.swift -// -// Decides whether an inspected display item carries secure user content -// (passwords, OTPs, payment fields) so callers can redact the textual -// values before they cross the wire. -// -// The detector is intentionally conservative: better to over-redact than -// to leak a credential into an MCP transcript that an AI agent or its log -// store will keep indefinitely. The current safety floor matches the -// reference implementation in upstream PR #26 (LookInside repo) by -// catching every AppKit `NSSecureTextField` and its subclasses via the -// inspected object's class chain. -// -// Known gap (tracked for a follow-up server-side change): -// UIKit `UITextField` with `isSecureTextEntry = YES` is NOT detected. -// LookinServer does not currently surface `isSecureTextEntry` as a -// `LookinAttr_*`, so the bridge has no reliable way to read its value. -// PR #26 references the attribute identifier `isSecureTextEntry` but -// LookinServer's identifier table has no matching entry — so that branch -// silently never fires there either. The right fix is to add the -// property to `LookinAttrIdentifiers.h` plus the iOS-side reflection in -// `LookinServer/Server/Category/UITextField+LookinServer.m`, after which -// this detector can opt the UIKit case in. - -import Foundation - -enum LKMCPBridgeSecureContentDetector { - - /// Returns `true` when the given display item represents a view that - /// the host should treat as carrying secure user input. When `true`, - /// callers must replace any user-visible text strings on the item's - /// attributes with `null` (or a `kind: "redacted"` marker) before - /// emitting them on the bridge socket. - static func isSecure(displayItem: LookinDisplayItem) -> Bool { - guard let classChain = displayItem.viewObject?.classChainList else { - return false - } - // NSSecureTextField and any subclass: catching the whole chain - // also redacts cases where the inspected view is a private subclass - // (e.g. `_NSSecureTextField_FooBar`) without losing protection. - if classChain.contains("NSSecureTextField") { - return true - } - return false - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeSelectorService.swift b/LookInside/MCPBridge/LKMCPBridgeSelectorService.swift deleted file mode 100644 index 7fb39832..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeSelectorService.swift +++ /dev/null @@ -1,327 +0,0 @@ -// LKMCPBridgeSelectorService.swift -// -// Handles the `selectors.list` bridge route: enumerating the Objective-C -// methods a class in the target app responds to (RPC 213 -// `LookinRequestTypeAllSelectorNames`). -// -// This route exists to make `invoke.method` usable. Without it an agent -// has to guess selector names from the class name and discover its -// mistakes one `invoke.invalidSelector` at a time; with it the agent can -// read the actual method table first. The two routes are kept in separate -// services because this one is read-only and `invoke.method` is not — the -// tool-level `destructive` hint on the MCP side follows that same split. -// -// Sizing note: the server walks the class chain all the way to `NSObject` -// and returns one flat, de-duplicated array. For a `UIView` subclass that -// is comfortably four digits of selectors, which is why this route -// defaults to a filtered, truncated view and always reports `totalCount`. - -import AppKit -import Foundation -import os - -@MainActor -public final class LKMCPBridgeSelectorService { - - // MARK: - Constants pulled from upstream LookinDefines.h - // - // Re-declared rather than imported for the same reason as in - // `LKMCPBridgeInvocationService`: pulling LookinDefines.h into the - // bridging header would touch every Swift compilation in the target. - - /// `LookinErrCode_Inner` from `LookinDefines.h:124`. RPC 213 emits - /// this for exactly one condition — `NSClassFromString` returned nil, - /// i.e. the class does not exist in the target process. - private static let lookinErrCodeInner = -401 - - /// `LookinErrCode_LicenseRequired` from `LookinDefines.h:153`. - private static let lookinErrCodeLicenseRequired = -408 - - /// `LookinErrCode_NoConnect` from `LookinDefines.h:128`. - private static let lookinErrCodeNoConnect = -403 - - /// `LookinErrCode_Timeout` from `LookinDefines.h:132`. - private static let lookinErrCodeTimeout = -405 - - /// Default cap on returned selectors. Deliberately far below the - /// thousands a framework class yields: the useful answer to "what can - /// I call on this" is nearly always reachable with a `nameFilter`, - /// and an unfiltered dump would cost more context than it is worth. - private static let defaultSelectorLimit = 200 - - /// Ceiling on `limit`. - private static let maximumSelectorLimit = 2000 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Selectors") - - public init() {} - - // MARK: - Entry point - - public func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - guard request.method == "selectors.list" else { - return .failure(identifier: request.identifier, error: .unknownMethod) - } - return await handleSelectorsList( - identifier: request.identifier, - parameters: request.parameters - ) - } - - // MARK: - selectors.list - - private func handleSelectorsList( - identifier: String, - parameters: [String: LKMCPBridgeJSONValue]? - ) async -> LKMCPBridgeResponse { - guard let parameters = parameters, - case .string(let targetIdentifier)? = parameters["targetIdentifier"] - else { - return .failure(identifier: identifier, error: .invalidParameters) - } - - let requestedClassName: String? - if case .string(let raw)? = parameters["className"] { - requestedClassName = raw - } else { - requestedClassName = nil - } - - let requestedObjectIdentifier: String? - if case .string(let raw)? = parameters["objectIdentifier"] { - requestedObjectIdentifier = raw - } else { - requestedObjectIdentifier = nil - } - - if requestedClassName == nil, requestedObjectIdentifier == nil { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "selectors.missingSubject", - message: "Supply either `className` or `objectIdentifier` to name the class whose selectors you want." - ) - ) - } - - let includeArguments: Bool - if case .bool(let raw)? = parameters["includeArguments"] { - includeArguments = raw - } else { - includeArguments = false - } - - let nameFilter: String? - if case .string(let raw)? = parameters["nameFilter"] { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - nameFilter = trimmed.isEmpty ? nil : trimmed - } else { - nameFilter = nil - } - - let selectorLimit: Int - switch parameters["limit"] { - case .integer(let raw)?: - selectorLimit = Int(raw) - case .double(let raw)?: - selectorLimit = Int(raw) - case nil: - selectorLimit = Self.defaultSelectorLimit - default: - return .failure(identifier: identifier, error: .invalidParameters) - } - guard selectorLimit >= 1, selectorLimit <= Self.maximumSelectorLimit else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "selectors.invalidLimit", - message: "`limit` must be between 1 and \(Self.maximumSelectorLimit); received \(selectorLimit)." - ) - ) - } - - guard let document = LKMCPBridgeLiveDocumentLookup.findLiveDocument(targetIdentifier: targetIdentifier) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.targetNotFound", - message: "No live inspection document found for target identifier \(targetIdentifier)." - ) - ) - } - - // Resolve the subject class. `objectIdentifier` wins when both are - // present: it is the more specific request, and it lets the - // response carry the object's whole class chain. - let resolvedClassName: String - let resolvedClassChain: [String]? - if let requestedObjectIdentifier { - guard document.hierarchyDataSource != nil else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.notReady", - message: "Live document has not loaded a hierarchy yet." - ) - ) - } - guard let displayItem = LKMCPBridgeLiveDocumentLookup.findDisplayItem( - amongRoots: LKMCPBridgeLiveDocumentLookup.topLevelDisplayItems(in: document), - matchingObjectIdentifier: requestedObjectIdentifier - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "hierarchy.objectNotFound", - message: "Object identifier \(requestedObjectIdentifier) is not present in this target's hierarchy." - ) - ) - } - let classChain = displayItem.displayingObject()?.classChainList ?? [] - guard let leafClassName = classChain.first, leafClassName.isEmpty == false else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "selectors.classNotFound", - message: "Display item \(requestedObjectIdentifier) does not report a class name." - ) - ) - } - resolvedClassName = leafClassName - resolvedClassChain = classChain - } else { - let className = requestedClassName ?? "" - guard className.isEmpty == false else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "selectors.missingSubject", - message: "`className` must be a non-empty class name." - ) - ) - } - resolvedClassName = className - resolvedClassChain = nil - } - - // `hasArg` on the wire means "do not filter out selectors that - // take arguments", so it maps straight onto `includeArguments`. - guard let signal = document.inspectableApp.fetchSelectorNames( - withClass: resolvedClassName, - hasArg: includeArguments - ) else { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "selectors.internalError", - message: "The target's inspectable app returned no signal for the selector query." - ) - ) - } - - let rawSelectors: NSArray - do { - rawSelectors = try await LKMCPBridgeRACBridge.awaitFirstValue(of: signal, as: NSArray.self) - } catch RACBridgeError.completedWithoutValue { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "selectors.disconnected", - message: "The target app did not return a selector list. The channel may have been closed mid-request." - ) - ) - } catch RACBridgeError.cancelled { - return .failure( - identifier: identifier, - error: LKMCPBridgeErrorPayload( - code: "selectors.cancelled", - message: "The selector query was cancelled before the target app produced a result." - ) - ) - } catch let error as NSError { - // Must stay below the RACBridgeError clauses. Every Swift error - // bridges to NSError, so this pattern matches everything -- above - // them it silently swallows both, and they become dead code. - return .failure( - identifier: identifier, - error: mapSelectorError(error, className: resolvedClassName) - ) - } catch { - Self.logger.error("selectors.list bridge error: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - - // Server order is class-chain order, most-derived first (it walks - // `superclass` upward and de-duplicates on first sight). Preserved - // rather than sorted: position is the only hint about which class - // a selector came from, and alphabetizing would bury the app's own - // overrides among NSObject's. Measured on NSClipView (1554 - // zero-argument selectors): its own land in the first 4%, - // NSObject's in the last 20%. Within one class the order is - // whatever `class_copyMethodList` returns, so this is a coarse - // ranking rather than an attribution. - var selectorNames = rawSelectors.compactMap { $0 as? String } - if let nameFilter { - let normalizedFilter = nameFilter.lowercased() - selectorNames = selectorNames.filter { $0.lowercased().contains(normalizedFilter) } - } - let totalCount = selectorNames.count - let truncatedSelectors = Array(selectorNames.prefix(selectorLimit)) - - let result = LKMCPBridgeSelectorListResult( - className: resolvedClassName, - selectors: truncatedSelectors, - totalCount: totalCount, - truncated: truncatedSelectors.count < totalCount, - includesArguments: includeArguments, - classChain: resolvedClassChain - ) - - do { - let payload = try encodeAsJSONValue(result) - return .success(identifier: identifier, result: payload) - } catch { - Self.logger.error("selectors.list encode failed: \(error.localizedDescription, privacy: .public)") - return .failure(identifier: identifier, error: .internalError) - } - } - - // MARK: - Error mapping - - private func mapSelectorError(_ error: NSError, className: String) -> LKMCPBridgeErrorPayload { - switch error.code { - case Self.lookinErrCodeInner: - // RPC 213's only `Inner` path is a failed NSClassFromString, - // so this is specific enough to name the cause outright. - return LKMCPBridgeErrorPayload( - code: "selectors.classNotFound", - message: "The target app has no class named `\(className)`. Swift types need their module prefix (for example `MyApp.ContentView`); check the `classChain` reported by hierarchy.read." - ) - case Self.lookinErrCodeLicenseRequired: - return .licenseRequired - case Self.lookinErrCodeNoConnect: - return LKMCPBridgeErrorPayload( - code: "selectors.disconnected", - message: "The target app is no longer connected. Re-attach from the LookInside inspector and try again." - ) - case Self.lookinErrCodeTimeout: - return LKMCPBridgeErrorPayload( - code: "selectors.timeout", - message: "The target app did not respond within the request timeout. Check whether it is paused in Xcode or blocked on the main thread." - ) - default: - Self.logger.error("selectors.list received unmapped error code \(error.code, privacy: .public): \(error.localizedDescription, privacy: .public)") - return LKMCPBridgeErrorPayload( - code: "selectors.internalError", - message: "The target app reported an unexpected error (code \(error.code))." - ) - } - } - - // MARK: - Encoding helper (duplicated from InspectionService) - - private func encodeAsJSONValue(_ value: some Encodable) throws -> LKMCPBridgeJSONValue { - let data = try JSONEncoder().encode(value) - return try JSONDecoder().decode(LKMCPBridgeJSONValue.self, from: data) - } -} diff --git a/LookInside/MCPBridge/LKMCPBridgeServer.swift b/LookInside/MCPBridge/LKMCPBridgeServer.swift deleted file mode 100644 index f3ae5ce8..00000000 --- a/LookInside/MCPBridge/LKMCPBridgeServer.swift +++ /dev/null @@ -1,441 +0,0 @@ -// LKMCPBridgeServer.swift -// -// The MCPBridge server is the GPL-side surface that exposes LookInside's -// inspection state over a local Unix domain socket. Proprietary consumers -// (currently only the `lookinside-mcp` MCP shim, but the surface is designed -// to also serve future CI bridges, remote inspectors, and automation runners) -// connect to this socket and exchange newline-delimited JSON frames. -// -// Process model: the server runs inside the LookInside.app host process. -// Activation is keyed off `applicationDidFinishLaunching:` and shutdown is -// keyed off `applicationWillTerminate:`. The server is intentionally a -// process-global singleton (the host has exactly one inspection state). -// -// Socket location: a single per-user UNIX socket inside the user's -// Application Support directory, with `0o600` permissions and a `0o700` -// parent directory. The path is stable across host restarts. - -import Darwin -import Dispatch -import Foundation -import os - -@objc(LKMCPBridgeServer) -public final class LKMCPBridgeServer: NSObject { - - // MARK: - Singleton - - @objc public static let sharedInstance = LKMCPBridgeServer() - - // MARK: - Configuration - - /// Maximum number of pending connections in the kernel accept queue. - private static let listenBacklog: Int32 = 16 - - private static let logger = Logger(subsystem: "com.lookinside.app", category: "MCPBridge.Server") - - // MARK: - State - - private let stateQueue = DispatchQueue(label: "com.lookinside.mcp-bridge.state") - - /// Shared parent for every connection's private serial queue. Kept - /// concurrent so separate connections make progress independently; - /// each `LKMCPBridgeConnection` targets its own serial queue at this - /// one, which is what keeps a single connection's frames from - /// interleaving. Do not hand this queue to a connection as its - /// working queue directly. - private let ioQueue = DispatchQueue( - label: "com.lookinside.mcp-bridge.io", - qos: .userInitiated, - attributes: .concurrent - ) - - private var listenFileDescriptor: Int32 = -1 - private var acceptSource: DispatchSourceRead? - private var activeConnections: Set = [] - private var openConnections: [ObjectIdentifier: LKMCPBridgeConnection] = [:] - private var isRunning = false - - /// Routes inspection-method requests (`targets.list`, `hierarchy.read`, - /// …) to the host's per-window inspection state. Lazily constructed on - /// the main actor on first use because `NSDocumentController` access - /// requires main-thread isolation. - private var inspectionService: LKMCPBridgeInspectionService? - - /// Routes mutating / RPC-emitting methods (`invoke.method`). Kept - /// separate from `inspectionService` so the inspection surface stays - /// read-only even as new mutating verbs land; lazily constructed on - /// the main actor on first use. - private var invocationService: LKMCPBridgeInvocationService? - - /// Routes `attribute.modify` (RPC 204 InbuiltAttrModification). - /// Kept distinct from `invocationService` so each route's setter - /// lookup / value decoding code stays self-contained. - private var modificationService: LKMCPBridgeModificationService? - - /// Routes `details.read` (batch RPC 203 HierarchyDetails prefetch). - /// Kept distinct from the read-only inspection service because - /// details.read actively pumps the Peertalk channel rather than - /// reading cached state. - private var detailsService: LKMCPBridgeDetailsService? - - /// Routes `screenshot.read` (RPC 203 with a Solo/Group task type). - /// Separate from `detailsService` because the two ask the same RPC - /// for entirely different payloads — attribute groups versus image - /// bytes — and their error vocabularies differ accordingly. - private var screenshotService: LKMCPBridgeScreenshotService? - - /// Routes `hierarchy.find`. Kept apart from `inspectionService` - /// even though both only read cached state, because search owns a - /// parameter vocabulary of its own (match fields, limits) and its - /// own error namespace. - private var searchService: LKMCPBridgeSearchService? - - /// Routes `selectors.list` (RPC 213 AllSelectorNames). Read-only, - /// so kept out of `invocationService` even though the two serve the - /// same workflow — discovering a method, then calling it. - private var selectorService: LKMCPBridgeSelectorService? - - /// Routes `hierarchy.refresh` (RPC 202 Hierarchy). Kept out of the - /// inspection service because refreshing round-trips to the target - /// and replaces host state, which would break that service's - /// "cached reads only, never emits RPC" property. - private var refreshService: LKMCPBridgeRefreshService? - - /// Translates host state changes into pushed event frames. Main-actor - /// isolated (every signal it watches is published there) and owned - /// here so its lifetime matches the socket's. - @MainActor private static var eventPublisher: LKMCPBridgeEventPublisher? - - // MARK: - Lifecycle - - @objc public func start() { - stateQueue.async { [weak self] in - guard let self else { return } - guard self.isRunning == false else { return } - do { - try self.bindAndListen() - self.isRunning = true - Self.logger.notice("MCPBridge started at \(LKMCPBridgeServer.socketURL.path, privacy: .public)") - self.startEventPublisher() - } catch { - Self.logger.error("MCPBridge failed to start: \(error.localizedDescription, privacy: .public)") - self.cleanUpListenSocket() - } - } - } - - @objc public func stop() { - stateQueue.async { [weak self] in - guard let self else { return } - guard self.isRunning else { return } - self.isRunning = false - - // Cancelling hands the descriptor to the source's cancel handler, - // which closes it. That handler is scheduled on this very queue, - // so it cannot run until this block returns -- closing here as - // well would be a double close, and by the time the second one - // lands the number may have been recycled onto an unrelated file. - self.acceptSource?.cancel() - self.acceptSource = nil - self.listenFileDescriptor = -1 - unlink(Self.socketURL.path) - - for connection in self.openConnections.values { - connection.close(reason: "server shutdown") - } - self.openConnections.removeAll() - self.activeConnections.removeAll() - Self.logger.notice("MCPBridge stopped") - Task { @MainActor in - Self.eventPublisher?.stop() - Self.eventPublisher = nil - } - } - } - - // MARK: - Event push - - /// Pushes an unsolicited frame to every open connection. - /// - /// No subscription filtering happens here on purpose. The host has no - /// idea how many bridge clients are attached or what any of them care - /// about, and teaching it would drag Model Context Protocol concepts - /// into this GPL layer. Clients filter; the host broadcasts. - public func broadcast(event: LKMCPBridgeEvent) { - stateQueue.async { [weak self] in - guard let self, self.isRunning else { return } - for connection in self.openConnections.values { - connection.send(event: event) - } - } - } - - private func startEventPublisher() { - Task { @MainActor in - if Self.eventPublisher == nil { - Self.eventPublisher = LKMCPBridgeEventPublisher(broadcast: { event in - LKMCPBridgeServer.sharedInstance.broadcast(event: event) - }) - } - Self.eventPublisher?.start() - } - } - - // MARK: - Bind / Listen / Accept - - private func bindAndListen() throws { - let socketURL = Self.socketURL - try ensureRuntimeDirectory(at: socketURL.deletingLastPathComponent()) - - // Socket creation lives in LKMCPBridgeListenSocket so its - // blocking mode — which `acceptPendingConnections()` depends on - // for loop termination — can be asserted by a test that does not - // need the whole inspection stack. See that file's header. - let descriptor = try LKMCPBridgeListenSocket.makeListening( - atPath: socketURL.path, - backlog: Self.listenBacklog - ) - listenFileDescriptor = descriptor - - let source = DispatchSource.makeReadSource(fileDescriptor: descriptor, queue: stateQueue) - source.setEventHandler { [weak self] in - self?.acceptPendingConnections() - } - source.setCancelHandler { - close(descriptor) - } - acceptSource = source - source.resume() - } - - private func acceptPendingConnections() { - while true { - // Goes through LKMCPBridgeListenSocket because the accepted - // descriptor has to be put back into blocking mode; see that - // method's documentation. - let clientDescriptor = LKMCPBridgeListenSocket.acceptConnection( - listenDescriptor: listenFileDescriptor - ) - if clientDescriptor < 0 { - let errorNumber = errno - if errorNumber == EAGAIN || errorNumber == EWOULDBLOCK { - return - } - Self.logger.error("accept() failed (errno \(errorNumber))") - return - } - let connection = LKMCPBridgeConnection( - fileDescriptor: clientDescriptor, - queue: ioQueue, - requestHandler: { [weak self] request in - return await self?.handle(request: request) ?? .failure( - identifier: request.identifier, - error: .internalError - ) - }, - onClose: { [weak self] closingConnection in - self?.removeConnection(closingConnection) - } - ) - let key = ObjectIdentifier(connection) - openConnections[key] = connection - activeConnections.insert(key) - Self.logger.notice("Accepted connection (fd=\(clientDescriptor))") - connection.start() - } - } - - private func removeConnection(_ connection: LKMCPBridgeConnection) { - stateQueue.async { [weak self] in - guard let self else { return } - let key = ObjectIdentifier(connection) - self.openConnections.removeValue(forKey: key) - self.activeConnections.remove(key) - } - } - - // MARK: - Request dispatch - - private func handle(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - // `ping` stays as a transport-level smoke test that doesn't require - // the inspection state to be initialized; mutating verbs route to - // the invocation service; everything else routes to the (read-only) - // inspection service. The split keeps the read surface free of - // any Peertalk-round-trip work so cached reads stay snappy. - switch request.method { - case "ping": - return .success( - identifier: request.identifier, - result: .object([ - "pong": .bool(true), - "serverVersion": .string(currentHostMarketingVersion()), - ]) - ) - case "invoke.method": - return await invocationDispatch(request: request) - case "attribute.modify": - return await modificationDispatch(request: request) - case "details.read": - return await detailsDispatch(request: request) - case "screenshot.read": - return await screenshotDispatch(request: request) - case "hierarchy.find": - return await searchDispatch(request: request) - case "selectors.list": - return await selectorDispatch(request: request) - case "hierarchy.refresh": - return await refreshDispatch(request: request) - default: - return await inspectionDispatch(request: request) - } - } - - private func inspectionDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - let service = await MainActor.run { () -> LKMCPBridgeInspectionService in - if let existing = self.inspectionService { - return existing - } - let created = LKMCPBridgeInspectionService() - self.inspectionService = created - return created - } - return await service.handle(request: request) - } - - private func invocationDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - let service = await MainActor.run { () -> LKMCPBridgeInvocationService in - if let existing = self.invocationService { - return existing - } - let created = LKMCPBridgeInvocationService() - self.invocationService = created - return created - } - return await service.handle(request: request) - } - - private func modificationDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - let service = await MainActor.run { () -> LKMCPBridgeModificationService in - if let existing = self.modificationService { - return existing - } - let created = LKMCPBridgeModificationService() - self.modificationService = created - return created - } - return await service.handle(request: request) - } - - private func detailsDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - let service = await MainActor.run { () -> LKMCPBridgeDetailsService in - if let existing = self.detailsService { - return existing - } - let created = LKMCPBridgeDetailsService() - self.detailsService = created - return created - } - return await service.handle(request: request) - } - - private func screenshotDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - let service = await MainActor.run { () -> LKMCPBridgeScreenshotService in - if let existing = self.screenshotService { - return existing - } - let created = LKMCPBridgeScreenshotService() - self.screenshotService = created - return created - } - return await service.handle(request: request) - } - - private func searchDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - let service = await MainActor.run { () -> LKMCPBridgeSearchService in - if let existing = self.searchService { - return existing - } - let created = LKMCPBridgeSearchService() - self.searchService = created - return created - } - return await service.handle(request: request) - } - - private func selectorDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - let service = await MainActor.run { () -> LKMCPBridgeSelectorService in - if let existing = self.selectorService { - return existing - } - let created = LKMCPBridgeSelectorService() - self.selectorService = created - return created - } - return await service.handle(request: request) - } - - private func refreshDispatch(request: LKMCPBridgeRequest) async -> LKMCPBridgeResponse { - let service = await MainActor.run { () -> LKMCPBridgeRefreshService in - if let existing = self.refreshService { - return existing - } - let created = LKMCPBridgeRefreshService() - self.refreshService = created - return created - } - return await service.handle(request: request) - } - - // MARK: - Helpers - - /// Failed-start cleanup only. - /// - /// This is the one path where no accept source exists to own the - /// descriptor, so closing it here is correct. `stop()` deliberately does - /// not call this: once the source exists, its cancel handler is the only - /// thing allowed to close the descriptor. - private func cleanUpListenSocket() { - dispatchPrecondition(condition: .onQueue(stateQueue)) - if acceptSource == nil, listenFileDescriptor >= 0 { - close(listenFileDescriptor) - } - listenFileDescriptor = -1 - unlink(Self.socketURL.path) - } - - private func ensureRuntimeDirectory(at directoryURL: URL) throws { - let fileManager = FileManager.default - try fileManager.createDirectory( - at: directoryURL, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700] - ) - // createDirectory ignores posixPermissions on pre-existing dirs; - // re-apply 0o700 to lock down any inherited permissive state. - _ = chmod(directoryURL.path, 0o700) - } - - private func currentHostMarketingVersion() -> String { - return (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "unknown" - } - - // MARK: - Socket location - - /// Canonical socket location: `~/Library/Application Support/LookInside/Host/run/lookinside-host-mcp.sock`. - public static let socketURL: URL = { - let fileManager = FileManager.default - let applicationSupportURL = (try? fileManager.url( - for: .applicationSupportDirectory, - in: .userDomainMask, - appropriateFor: nil, - create: true - )) ?? fileManager.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support", isDirectory: true) - return applicationSupportURL - .appendingPathComponent("LookInside", isDirectory: true) - .appendingPathComponent("Host", isDirectory: true) - .appendingPathComponent("run", isDirectory: true) - .appendingPathComponent("lookinside-host-mcp.sock", isDirectory: false) - }() -} diff --git a/LookInside/Manager/LKAppMenuManager.h b/LookInside/Manager/LKAppMenuManager.h deleted file mode 100644 index 4efeb48b..00000000 --- a/LookInside/Manager/LKAppMenuManager.h +++ /dev/null @@ -1,37 +0,0 @@ -// -// LKAppMenuManager.h -// Lookin -// -// Created by Li Kai on 2019/3/20. -// https://lookin.work -// - -#import - -@class LKAppMenuManager, LKWindowController; - -@protocol LKAppMenuManagerDelegate - -@optional - -- (void)appMenuManagerDidSelectReload; -- (void)appMenuManagerDidSelectDimension; -- (void)appMenuManagerDidSelectZoomIn; -- (void)appMenuManagerDidSelectZoomOut; -- (void)appMenuManagerDidSelectDecreaseInterspace; -- (void)appMenuManagerDidSelectIncreaseInterspace; -- (void)appMenuManagerDidSelectExpansionIndex:(NSUInteger)index; -- (void)appMenuManagerDidSelectFilter; - -- (void)appMenuManagerDidSelectExport; -- (void)appMenuManagerDidSelectOpenInNewWindow; - -@end - -@interface LKAppMenuManager : NSObject - -+ (instancetype)sharedInstance; - -- (void)setup; - -@end diff --git a/LookInside/Manager/LKAppMenuManager.m b/LookInside/Manager/LKAppMenuManager.m deleted file mode 100644 index 43bf987f..00000000 --- a/LookInside/Manager/LKAppMenuManager.m +++ /dev/null @@ -1,585 +0,0 @@ -// -// LKAppMenuManager.m -// Lookin -// -// Created by Li Kai on 2019/3/20. -// https://lookin.work -// - -#import "LKAppMenuManager.h" -#import "LKNavigationManager.h" -#import "LKPreferenceManager.h" -#import "LKWindowController.h" -#import "LookInside-Swift.h" -#import - -static NSUInteger const kTag_About = 11; -static NSUInteger const kTag_Preferences = 12; -static NSUInteger const kTag_CheckUpdates = 13; -static NSUInteger const kTag_PrivateDiscriminatorSettings = 20; -static NSUInteger const kTag_ActivateSwiftUISupport = 14; -static NSUInteger const kTag_SwiftUISupportLicense = 15; -static NSUInteger const kTag_RefreshSwiftUISupportLicense = 16; -static NSUInteger const kTag_PurchaseSwiftUISupport = 17; -static NSUInteger const kTag_SwiftUISupportCustomerSupport = 18; -static NSUInteger const kTag_SwiftUISupportSubmenu = 19; - -static NSString *const kSwiftUISupportPurchaseURL = @"https://lookinside-app.com/purchase"; -static NSString *const kSwiftUISupportCustomerSupportURL = @"mailto:support@lookinside-app.com"; - -static NSUInteger const kTag_Reload = 21; -static NSUInteger const kTag_Dimension = 22; -static NSUInteger const kTag_ZoomIn = 23; -static NSUInteger const kTag_ZoomOut = 24; -static NSUInteger const kTag_DecreaseInterspace = 25; -static NSUInteger const kTag_IncreaseInterspace = 26; -static NSUInteger const kTag_Expansion = 27; -static NSUInteger const kTag_Filter = 28; -static NSUInteger const kTag_OpenInNewWindow = 31; -static NSUInteger const kTag_Export = 32; -static NSUInteger const kTag_NewInspection = 33; -static NSUInteger const kTag_AttachToRunningApp = 34; -static NSUInteger const kTag_AttachToAppOnDevice = 35; - -static NSUInteger const kTag_GitHub = 57; -static NSUInteger const kTag_Acknowledgements = 72; - -static NSMenuItem *LKMenuItem(NSString *title, SEL action, NSString *keyEquivalent, NSEventModifierFlags modifiers, NSInteger tag) { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:action keyEquivalent:keyEquivalent]; - item.keyEquivalentModifierMask = modifiers; - item.tag = tag; - return item; -} - -static NSMenuItem *LKSubmenuItem(NSString *title, NSMenu *submenu, NSInteger tag) { - NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:nil keyEquivalent:@""]; - item.tag = tag; - item.submenu = submenu; - return item; -} - -@interface LKAppMenuManager () - -@property(nonatomic, copy) NSDictionary *delegatingTagToSelMap; -@property(nonatomic, strong) NSMenu *recentDocumentsMenu; -@property(nonatomic, strong) SPUStandardUpdaterController *updaterController; - -@end - -@implementation LKAppMenuManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKAppMenuManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -- (NSString *)_applicationName { - NSString *bundleDisplayName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]; - if (bundleDisplayName.length > 0) { - return bundleDisplayName; - } - NSString *bundleName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"]; - if (bundleName.length > 0) { - return bundleName; - } - return [NSProcessInfo processInfo].processName ?: @"LookInside"; -} - -- (NSMenu *)_buildApplicationMenu { - NSString *appName = [self _applicationName]; - NSMenu *menu = [[NSMenu alloc] initWithTitle:appName]; - [menu addItem:LKMenuItem([NSString stringWithFormat:@"About %@", appName], nil, @"", 0, kTag_About)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(@"Preferences…", nil, @",", NSEventModifierFlagCommand, kTag_Preferences)]; - [menu addItem:LKMenuItem(@"Private Discriminator Settings…", nil, @"", 0, kTag_PrivateDiscriminatorSettings)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(@"Check for Updates…", nil, @"", 0, kTag_CheckUpdates)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem([NSString stringWithFormat:@"Hide %@", appName], @selector(hide:), @"h", NSEventModifierFlagCommand, 0)]; - [menu addItem:LKMenuItem(@"Hide Others", @selector(hideOtherApplications:), @"h", NSEventModifierFlagCommand | NSEventModifierFlagOption, 0)]; - [menu addItem:LKMenuItem(@"Show All", @selector(unhideAllApplications:), @"", 0, 0)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem([NSString stringWithFormat:@"Quit %@", appName], @selector(terminate:), @"q", NSEventModifierFlagCommand, 0)]; - return menu; -} - -- (void)_reloadRecentDocumentsMenu { - [self.recentDocumentsMenu removeAllItems]; - - NSArray *recentURLs = [NSDocumentController sharedDocumentController].recentDocumentURLs; - if (recentURLs.count == 0) { - NSMenuItem *emptyItem = LKMenuItem(@"No Recent Documents", nil, @"", 0, 0); - emptyItem.enabled = NO; - [self.recentDocumentsMenu addItem:emptyItem]; - return; - } - - [recentURLs enumerateObjectsUsingBlock:^(NSURL * _Nonnull url, NSUInteger idx, BOOL * _Nonnull stop) { - NSString *title = url.lastPathComponent.length > 0 ? url.lastPathComponent : url.path; - NSMenuItem *item = LKMenuItem(title, @selector(_handleOpenRecentDocument:), @"", 0, 0); - item.target = self; - item.representedObject = url; - item.toolTip = url.path; - [self.recentDocumentsMenu addItem:item]; - }]; - - [self.recentDocumentsMenu addItem:[NSMenuItem separatorItem]]; - NSMenuItem *clearItem = LKMenuItem(@"Clear Menu", @selector(clearRecentDocuments:), @"", 0, 0); - clearItem.target = [NSDocumentController sharedDocumentController]; - [self.recentDocumentsMenu addItem:clearItem]; -} - -- (NSMenu *)_buildFileMenu { - NSMenu *menu = [[NSMenu alloc] initWithTitle:@"File"]; - - // Phase E: New Inspection… brings up the Launch window's app picker so a - // fresh Live Doc can be created from any state (no Doc, Live Doc, or Archive - // Doc focused). - [menu addItem:LKMenuItem(@"New Inspection…", nil, @"n", NSEventModifierFlagCommand, kTag_NewInspection)]; - [menu addItem:LKMenuItem(@"Attach to Running App…", nil, @"", 0, kTag_AttachToRunningApp)]; - // The same thing for an app on a connected iPhone or iPad. A separate entry - // rather than a tab inside the existing picker: that picker's Applications - // list enumerates this Mac, and showing it beside a phone's process list - // would invite picking a pid from the wrong machine. - [menu addItem:LKMenuItem(@"Attach to App on Device…", nil, @"", 0, kTag_AttachToAppOnDevice)]; - [menu addItem:[NSMenuItem separatorItem]]; - - [menu addItem:LKMenuItem(@"Open…", @selector(openDocument:), @"o", NSEventModifierFlagCommand, 0)]; - - self.recentDocumentsMenu = [[NSMenu alloc] initWithTitle:@"Open Recent"]; - self.recentDocumentsMenu.autoenablesItems = NO; - self.recentDocumentsMenu.delegate = self; - [self _reloadRecentDocumentsMenu]; - [menu addItem:LKSubmenuItem(@"Open Recent", self.recentDocumentsMenu, 0)]; - - [menu addItem:[NSMenuItem separatorItem]]; - // Phase E: standard NSDocument actions; -performClose: targets the key - // window, -saveDocumentAs: is auto-validated by NSDocument against - // -writableTypesForSaveOperation:. - [menu addItem:LKMenuItem(@"Close", @selector(performClose:), @"w", NSEventModifierFlagCommand, 0)]; - [menu addItem:LKMenuItem(@"Save As…", @selector(saveDocumentAs:), @"s", NSEventModifierFlagCommand | NSEventModifierFlagShift, 0)]; - - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(@"Copy to New Window…", nil, @"", 0, kTag_OpenInNewWindow)]; - [menu addItem:LKMenuItem(@"Export…", nil, @"", 0, kTag_Export)]; - return menu; -} - -- (NSMenu *)_buildEditMenu { - NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Edit"]; - [menu addItem:LKMenuItem(@"Cut", @selector(cut:), @"x", NSEventModifierFlagCommand, 0)]; - [menu addItem:LKMenuItem(@"Copy", @selector(copy:), @"c", NSEventModifierFlagCommand, 0)]; - [menu addItem:LKMenuItem(@"Paste", @selector(paste:), @"v", NSEventModifierFlagCommand, 0)]; - [menu addItem:LKMenuItem(@"Select All", @selector(selectAll:), @"a", NSEventModifierFlagCommand, 0)]; - return menu; -} - -- (NSMenu *)_buildViewMenu { - NSMenu *menu = [[NSMenu alloc] initWithTitle:@"View"]; - [menu addItem:LKMenuItem(@"Reload", nil, @"r", NSEventModifierFlagCommand, kTag_Reload)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(@"Filter", nil, @"f", NSEventModifierFlagCommand, kTag_Filter)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(@"2D / 3D", nil, @"\\", NSEventModifierFlagCommand, kTag_Dimension)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(@"Zoom In", nil, @"+", NSEventModifierFlagCommand, kTag_ZoomIn)]; - [menu addItem:LKMenuItem(@"Zoom Out", nil, @"-", NSEventModifierFlagCommand, kTag_ZoomOut)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(@"Decrease Item Separation", nil, @"[", NSEventModifierFlagCommand, kTag_DecreaseInterspace)]; - [menu addItem:LKMenuItem(@"Increase Item Separation", nil, @"]", NSEventModifierFlagCommand, kTag_IncreaseInterspace)]; - [menu addItem:[NSMenuItem separatorItem]]; - - NSMenu *expansionMenu = [[NSMenu alloc] initWithTitle:@"Hierarchy Depth"]; - [expansionMenu addItem:LKMenuItem(@"Level 1 (Collapse All)", nil, @"1", NSEventModifierFlagCommand, 271)]; - [expansionMenu addItem:LKMenuItem(@"Level 2", nil, @"2", NSEventModifierFlagCommand, 272)]; - [expansionMenu addItem:LKMenuItem(@"Level 3", nil, @"3", NSEventModifierFlagCommand, 273)]; - [expansionMenu addItem:LKMenuItem(@"Level 4", nil, @"4", NSEventModifierFlagCommand, 274)]; - [expansionMenu addItem:LKMenuItem(@"Level 5 (Expand All)", nil, @"5", NSEventModifierFlagCommand, 275)]; - [menu addItem:LKSubmenuItem(@"Hierarchy Depth", expansionMenu, kTag_Expansion)]; - - return menu; -} - -- (NSMenu *)_buildSwiftUIPluginSubmenu { - NSMenu *menu = [[NSMenu alloc] initWithTitle:@"LookInside Pro"]; - [self _populateSwiftUIPluginSubmenu:menu]; - return menu; -} - -- (void)_populateSwiftUIPluginSubmenu:(NSMenu *)menu { - [menu removeAllItems]; - - LKSwiftUISupportActivationState state = [LKSwiftUISupportGatekeeper sharedInstance].activationState; - - if (state == LKSwiftUISupportActivationStateActivated) { - [menu addItem:LKMenuItem(NSLocalizedString(@"LookInside Pro License…", nil), nil, @"", 0, kTag_SwiftUISupportLicense)]; - [menu addItem:LKMenuItem(NSLocalizedString(@"Refresh License Status", nil), nil, @"", 0, kTag_RefreshSwiftUISupportLicense)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(NSLocalizedString(@"Customer Support…", nil), nil, @"", 0, kTag_SwiftUISupportCustomerSupport)]; - } else { - [menu addItem:LKMenuItem(NSLocalizedString(@"Activate LookInside Pro…", nil), nil, @"", 0, kTag_ActivateSwiftUISupport)]; - [menu addItem:[NSMenuItem separatorItem]]; - [menu addItem:LKMenuItem(NSLocalizedString(@"Purchase…", nil), nil, @"", 0, kTag_PurchaseSwiftUISupport)]; - } - - [self _wireSwiftUIPluginSubmenuActions:menu]; -} - -- (void)_wireSwiftUIPluginSubmenuActions:(NSMenu *)menu { - NSMenuItem *activateItem = [menu itemWithTag:kTag_ActivateSwiftUISupport]; - activateItem.target = self; - activateItem.action = @selector(_handleActivateSwiftUISupport); - - NSMenuItem *licenseItem = [menu itemWithTag:kTag_SwiftUISupportLicense]; - licenseItem.target = self; - licenseItem.action = @selector(_handleSwiftUISupportLicense); - - NSMenuItem *refreshItem = [menu itemWithTag:kTag_RefreshSwiftUISupportLicense]; - refreshItem.target = self; - refreshItem.action = @selector(_handleRefreshSwiftUISupportLicense); - - NSMenuItem *purchaseItem = [menu itemWithTag:kTag_PurchaseSwiftUISupport]; - purchaseItem.target = self; - purchaseItem.action = @selector(_handlePurchaseSwiftUISupport); - - NSMenuItem *customerSupportItem = [menu itemWithTag:kTag_SwiftUISupportCustomerSupport]; - customerSupportItem.target = self; - customerSupportItem.action = @selector(_handleSwiftUISupportCustomerSupport); -} - -- (NSMenu *)_buildPluginsMenu { - NSMenu *menu = [[NSMenu alloc] initWithTitle:NSLocalizedString(@"Plugins", nil)]; - [menu addItem:LKSubmenuItem(NSLocalizedString(@"LookInside Pro", nil), [self _buildSwiftUIPluginSubmenu], kTag_SwiftUISupportSubmenu)]; - return menu; -} - -- (NSMenu *)_buildWindowMenu { - NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Window"]; - // Phase E: ⌘W lives in File menu (NSDocument convention); Window menu keeps - // standard window manipulators. - [menu addItem:LKMenuItem(@"Minimize", @selector(performMiniaturize:), @"m", NSEventModifierFlagCommand, 0)]; - [menu addItem:LKMenuItem(@"Zoom", @selector(performZoom:), @"", 0, 0)]; - return menu; -} - -- (NSMenu *)_buildHelpMenu { - NSMenu *menu = [[NSMenu alloc] initWithTitle:@"Help"]; - [menu addItem:LKMenuItem(@"Source Code at GitHub", nil, @"", 0, kTag_GitHub)]; - [menu addItem:LKMenuItem(@"Acknowledgements", nil, @"", 0, kTag_Acknowledgements)]; - return menu; -} - -- (void)_installMainMenu { - NSString *appName = [self _applicationName]; - NSMenu *mainMenu = [[NSMenu alloc] initWithTitle:@"Main Menu"]; - NSMenu *windowMenu = [self _buildWindowMenu]; - NSMenu *helpMenu = [self _buildHelpMenu]; - - [mainMenu addItem:LKSubmenuItem(appName, [self _buildApplicationMenu], 0)]; - [mainMenu addItem:LKSubmenuItem(@"File", [self _buildFileMenu], 0)]; - [mainMenu addItem:LKSubmenuItem(@"Edit", [self _buildEditMenu], 0)]; - [mainMenu addItem:LKSubmenuItem(@"View", [self _buildViewMenu], 0)]; - [mainMenu addItem:LKSubmenuItem(@"Plugins", [self _buildPluginsMenu], 0)]; - [mainMenu addItem:LKSubmenuItem(@"Window", windowMenu, 0)]; - [mainMenu addItem:LKSubmenuItem(@"Help", helpMenu, 0)]; - - NSApp.mainMenu = mainMenu; - NSApp.windowsMenu = windowMenu; - if ([NSApp respondsToSelector:@selector(setHelpMenu:)]) { - [NSApp setHelpMenu:helpMenu]; - } -} - -// Debug builds leave SPARKLE_PUBLIC_ED_KEY empty -- only the release workflow -// injects the real key -- so SUPublicEDKey resolves to an empty string and -// Sparkle refuses to start, putting up an "Unable to Check For Updates" alert -// on every launch. Skip the updater entirely in that case; dropping a key into -// Configuration/custom.xcconfig restores the real update flow locally. -- (BOOL)_shouldStartSparkleUpdater { -#if DEBUG - NSString *publicKey = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"SUPublicEDKey"]; - return publicKey.length > 0; -#else - return YES; -#endif -} - -- (void)setup { - if ([self _shouldStartSparkleUpdater]) { - self.updaterController = [[SPUStandardUpdaterController alloc] initWithStartingUpdater:YES updaterDelegate:nil userDriverDelegate:nil]; - } - [self _installMainMenu]; - - self.delegatingTagToSelMap = @{ - @(kTag_Reload):NSStringFromSelector(@selector(appMenuManagerDidSelectReload)), - @(kTag_Dimension):NSStringFromSelector(@selector(appMenuManagerDidSelectDimension)), - @(kTag_ZoomIn):NSStringFromSelector(@selector(appMenuManagerDidSelectZoomIn)), - @(kTag_ZoomOut):NSStringFromSelector(@selector(appMenuManagerDidSelectZoomOut)), - @(kTag_DecreaseInterspace):NSStringFromSelector(@selector(appMenuManagerDidSelectDecreaseInterspace)), - @(kTag_IncreaseInterspace):NSStringFromSelector(@selector(appMenuManagerDidSelectIncreaseInterspace)), - @(kTag_Expansion):NSStringFromSelector(@selector(appMenuManagerDidSelectExpansionIndex:)), - @(kTag_Export):NSStringFromSelector(@selector(appMenuManagerDidSelectExport)), - @(kTag_OpenInNewWindow):NSStringFromSelector(@selector(appMenuManagerDidSelectOpenInNewWindow)), - @(kTag_Filter):NSStringFromSelector(@selector(appMenuManagerDidSelectFilter)), - }; - - NSMenu *menu = [NSApp mainMenu]; - - NSMenu *menu_lookin = [menu itemAtIndex:0].submenu; - menu_lookin.autoenablesItems = NO; - menu_lookin.delegate = self; - - NSMenuItem *menuItem_about = [menu_lookin itemWithTag:kTag_About]; - menuItem_about.target = self; - menuItem_about.action = @selector(_handleAbout); - - NSMenuItem *menuItem_preferences = [menu_lookin itemWithTag:kTag_Preferences]; - menuItem_preferences.target = self; - menuItem_preferences.action = @selector(_handlePreferences); - - NSMenuItem *menuItem_privateDiscriminatorSettings = [menu_lookin itemWithTag:kTag_PrivateDiscriminatorSettings]; - menuItem_privateDiscriminatorSettings.target = self; - menuItem_privateDiscriminatorSettings.action = @selector(_handlePrivateDiscriminatorSettings); - - NSMenuItem *menuItem_checkUpdates = [menu_lookin itemWithTag:kTag_CheckUpdates]; - menuItem_checkUpdates.target = self; - menuItem_checkUpdates.action = @selector(_handleCheckUpdates); - - NSMenu *menu_file = [menu itemAtIndex:1].submenu; - menu_file.autoenablesItems = NO; - menu_file.delegate = self; - - NSMenuItem *menuItem_newInspection = [menu_file itemWithTag:kTag_NewInspection]; - menuItem_newInspection.target = self; - menuItem_newInspection.action = @selector(_handleNewInspection); - - NSMenuItem *menuItem_attachToRunningApp = [menu_file itemWithTag:kTag_AttachToRunningApp]; - menuItem_attachToRunningApp.target = self; - menuItem_attachToRunningApp.action = @selector(_handleAttachToRunningApp); - - NSMenuItem *menuItem_attachToAppOnDevice = [menu_file itemWithTag:kTag_AttachToAppOnDevice]; - menuItem_attachToAppOnDevice.target = self; - menuItem_attachToAppOnDevice.action = @selector(_handleAttachToAppOnDevice); - - NSMenu *menu_view = [menu itemAtIndex:3].submenu; - menu_view.autoenablesItems = NO; - menu_view.delegate = self; - - NSMenu *menu_plugins = [menu itemAtIndex:4].submenu; - menu_plugins.autoenablesItems = NO; - NSMenu *menu_plugins_swiftUI = [menu_plugins itemAtIndex:0].submenu; - menu_plugins_swiftUI.autoenablesItems = NO; - menu_plugins_swiftUI.delegate = self; - [self _wireSwiftUIPluginSubmenuActions:menu_plugins_swiftUI]; - - NSMenu *menu_help = [menu itemAtIndex:6].submenu; - menu_help.autoenablesItems = YES; - menu_help.delegate = self; - - NSMenuItem *githubItem = [menu_help itemWithTag:kTag_GitHub]; - githubItem.target = self; - githubItem.action = @selector(_handleShowGitHub); - - { - NSMenuItem *item = [menu_help itemWithTag:kTag_Acknowledgements]; - item.target = self; - item.action = @selector(_handleAcknowledgements); - } - - NSArray *itemArray = [menu_file.itemArray arrayByAddingObjectsFromArray:menu_view.itemArray]; - [itemArray enumerateObjectsUsingBlock:^(NSMenuItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSString *selString = self.delegatingTagToSelMap[@(obj.tag)]; - if (selString) { - if (obj.hasSubmenu) { - if (obj.tag == kTag_Expansion) { - [obj.submenu.itemArray enumerateObjectsUsingBlock:^(NSMenuItem * _Nonnull expansionSubItem, NSUInteger idx, BOOL * _Nonnull stop) { - expansionSubItem.target = self; - expansionSubItem.representedObject = @(idx); - expansionSubItem.action = @selector(_handleExpansion:); - }]; - } - } else { - obj.target = self; - obj.action = @selector(_handleDelegateItem:); - } - } - }]; -} - -- (void)menuNeedsUpdate:(NSMenu *)menu { - if (menu == self.recentDocumentsMenu) { - [self _reloadRecentDocumentsMenu]; - return; - } - - if ([menu.title isEqualToString:@"LookInside Pro"]) { - [[LKSwiftUISupportGatekeeper sharedInstance] refreshActivationStateInBackground]; - [self _populateSwiftUIPluginSubmenu:menu]; - return; - } - - LKWindowController *wc = [LKNavigationManager sharedInstance].currentKeyWindowController; - - [menu.itemArray enumerateObjectsUsingBlock:^(NSMenuItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSString *selString = self.delegatingTagToSelMap[@(obj.tag)]; - if (selString) { - SEL delegateSel = NSSelectorFromString(selString); - obj.enabled = [wc respondsToSelector:delegateSel]; - return; - } - if (obj.tag == kTag_CheckUpdates) { - // Nil whenever the build carries no Sparkle public key; see - // -_shouldStartSparkleUpdater. - obj.enabled = self.updaterController != nil; - return; - } - // Phase E: NSDocument-aware validation for standard responder actions. - if (obj.action == @selector(saveDocumentAs:)) { - NSDocument *doc = [[NSDocumentController sharedDocumentController] documentForWindow:NSApp.keyWindow]; - obj.enabled = doc != nil && [doc writableTypesForSaveOperation:NSSaveAsOperation].count > 0; - return; - } - if (obj.action == @selector(performClose:)) { - obj.enabled = NSApp.keyWindow != nil; - return; - } - obj.enabled = YES; - }]; -} - -- (void)_handlePreferences { - [[LKNavigationManager sharedInstance] showPreference]; -} - -- (void)_handlePrivateDiscriminatorSettings { - [LKPrivateDiscriminatorSettingsWindowController showSettingsWindow]; -} - -- (void)_handleOpenRecentDocument:(NSMenuItem *)item { - NSURL *url = item.representedObject; - if (url == nil) { - return; - } - - [[NSDocumentController sharedDocumentController] openDocumentWithContentsOfURL:url display:YES completionHandler:^(NSDocument * _Nullable document, BOOL documentWasAlreadyOpen, NSError * _Nullable error) { - if (error) { - [NSApp presentError:error]; - } - }]; -} - -- (void)_handleDelegateItem:(NSMenuItem *)item { - NSString *selString = self.delegatingTagToSelMap[@(item.tag)]; - SEL sel = NSSelectorFromString(selString); - if (!sel) { - NSAssert(NO, @""); - return; - } - LKWindowController *wc = [LKNavigationManager sharedInstance].currentKeyWindowController; - if (![wc respondsToSelector:sel]) { - NSAssert(NO, @""); - return; - } - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[wc methodSignatureForSelector:sel]]; - [invocation setTarget:wc]; - [invocation setSelector:sel]; - [invocation invoke]; -} - -- (void)_handleExpansion:(NSMenuItem *)item { - NSNumber *idxNum = item.representedObject; - if (idxNum == nil) { - NSAssert(NO, @""); - return; - } - NSUInteger index = idxNum.unsignedIntegerValue; - - LKWindowController *wc = [LKNavigationManager sharedInstance].currentKeyWindowController; - if (![wc respondsToSelector:@selector(appMenuManagerDidSelectExpansionIndex:)]) { - NSAssert(NO, @""); - return; - } - [wc appMenuManagerDidSelectExpansionIndex:index]; -} - -- (void)_handleCheckUpdates { - [self.updaterController checkForUpdates:nil]; -} - -- (void)_handleAbout { - [[LKNavigationManager sharedInstance] showAbout]; -} - -- (void)_handleActivateSwiftUISupport { - [[LKSwiftUISupportGatekeeper sharedInstance] showActivationWindow]; -} - -- (void)_handleSwiftUISupportLicense { - [[LKSwiftUISupportGatekeeper sharedInstance] showLicenseWindow]; -} - -- (void)_handleRefreshSwiftUISupportLicense { - [[LKSwiftUISupportGatekeeper sharedInstance] refreshLicenseStatus]; -} - -- (void)_handlePurchaseSwiftUISupport { - NSURL *url = [NSURL URLWithString:kSwiftUISupportPurchaseURL]; - if (url) { - [[NSWorkspace sharedWorkspace] openURL:url]; - } -} - -- (void)_handleSwiftUISupportCustomerSupport { - NSURL *url = [NSURL URLWithString:kSwiftUISupportCustomerSupportURL]; - if (url) { - [[NSWorkspace sharedWorkspace] openURL:url]; - } -} - -- (void)_handleShowGitHub { - [LKHelper openProjectGitHubRepository]; -} - -- (void)_handleAcknowledgements { - [LKHelper openProjectREADME]; -} - -- (void)_handleNewInspection { - // Phase E: File > New Inspection… reuses the Launch window's app picker - // so a fresh Live Doc can be created from any state. - [[LKNavigationManager sharedInstance] showLaunch]; -} - -- (void)_handleAttachToRunningApp { - // File > Attach to Running App… opens the running-process picker and - // injects LookInsideServer.framework into the chosen pid via the - // privileged lookinside-injector daemon. Driven from LKInjectionFlow on - // the Swift side; we just hand it the current key window for sheet - // attachment. - if (![[LKSwiftUISupportGatekeeper sharedInstance] allowProtectedFeatureAccessForWindow:NSApp.keyWindow]) { - return; - } - [[LKInjectionFlow sharedInstance] startFromWindow:NSApp.keyWindow]; -} - -- (void)_handleAttachToAppOnDevice { - // File > Attach to App on Device… asks the LookInside Injector running on a - // connected iPhone or iPad for its list of running apps and has *it* do the - // injecting — there is no daemon and no framework download on this side, - // because the device carries its own payload. Driven from - // LKDeviceInjectionFlow on the Swift side. - if (![[LKSwiftUISupportGatekeeper sharedInstance] allowProtectedFeatureAccessForWindow:NSApp.keyWindow]) { - return; - } - [[LKDeviceInjectionFlow sharedInstance] startFromWindow:NSApp.keyWindow]; -} - -@end diff --git a/LookInside/Manager/LKAppMenuManager.swift b/LookInside/Manager/LKAppMenuManager.swift new file mode 100644 index 00000000..e47b8e82 --- /dev/null +++ b/LookInside/Manager/LKAppMenuManager.swift @@ -0,0 +1,502 @@ +// +// LKAppMenuManager.swift +// LookInside +// +// Created by Li Kai on 2019/3/20. +// https://lookin.work +// +// Builds the main menu and handles its commands. Window commands (reload, +// zoom, export…) go to the key window's controller through +// LKAppMenuManagerDelegate and are enabled only when that controller +// implements them. +// + +import AppKit +import Sparkle + +/// The menu sends each command to the key window's controller, and enables +/// the item only when the controller implements the method. +@objc(LKAppMenuManagerDelegate) +@MainActor +protocol LKAppMenuManagerDelegate: NSObjectProtocol { + @objc optional func appMenuManagerDidSelectReload() + @objc optional func appMenuManagerDidSelectDimension() + @objc optional func appMenuManagerDidSelectZoomIn() + @objc optional func appMenuManagerDidSelectZoomOut() + @objc optional func appMenuManagerDidSelectDecreaseInterspace() + @objc optional func appMenuManagerDidSelectIncreaseInterspace() + @objc(appMenuManagerDidSelectExpansionIndex:) + optional func appMenuManagerDidSelectExpansionIndex(_ index: UInt) + @objc optional func appMenuManagerDidSelectFilter() + + @objc optional func appMenuManagerDidSelectExport() + @objc optional func appMenuManagerDidSelectOpenInNewWindow() +} + +@objc(LKAppMenuManager) +@MainActor +final class LKAppMenuManager: NSObject, NSMenuDelegate { + static let shared = LKAppMenuManager() + + override private init() { + super.init() + } + + private enum Tag { + static let about = 11 + static let preferences = 12 + static let checkUpdates = 13 + static let activateSwiftUISupport = 14 + static let swiftUISupportLicense = 15 + static let refreshSwiftUISupportLicense = 16 + static let purchaseSwiftUISupport = 17 + static let swiftUISupportCustomerSupport = 18 + static let swiftUISupportSubmenu = 19 + static let privateDiscriminatorSettings = 20 + + static let reload = 21 + static let dimension = 22 + static let zoomIn = 23 + static let zoomOut = 24 + static let decreaseInterspace = 25 + static let increaseInterspace = 26 + static let expansion = 27 + static let filter = 28 + static let openInNewWindow = 31 + static let export = 32 + static let newInspection = 33 + static let attachToRunningApp = 34 + static let attachToAppOnDevice = 35 + + static let gitHub = 57 + static let acknowledgements = 72 + } + + private static let swiftUISupportPurchaseURL = "https://lookinside-app.com/purchase" + private static let swiftUISupportCustomerSupportURL = "mailto:support@lookinside-app.com" + /// The title `menuNeedsUpdate(_:)` recognizes the LookInside Pro submenu by. + private static let swiftUISupportMenuTitle = "LookInside Pro" + + /// The window commands: menu item tag → LKAppMenuManagerDelegate method. + private static let delegatingSelectors: [Int: Selector] = [ + Tag.reload: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectReload), + Tag.dimension: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectDimension), + Tag.zoomIn: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectZoomIn), + Tag.zoomOut: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectZoomOut), + Tag.decreaseInterspace: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectDecreaseInterspace), + Tag.increaseInterspace: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectIncreaseInterspace), + Tag.expansion: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectExpansionIndex(_:)), + Tag.export: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectExport), + Tag.openInNewWindow: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectOpenInNewWindow), + Tag.filter: #selector(LKAppMenuManagerDelegate.appMenuManagerDidSelectFilter), + ] + + private var recentDocumentsMenu: NSMenu? + private var updaterController: SPUStandardUpdaterController? + + // MARK: - Setup + + /// Starts the updater when the build can use it, and installs the main + /// menu. + func setup() { + if shouldStartSparkleUpdater { + updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil) + } + installMainMenu() + } + + /// Only the release and internal-snapshot workflows inject + /// SPARKLE_PUBLIC_ED_KEY (both refuse to build without it). Every other + /// build -- Debug, or a local Release build, signed or not -- leaves + /// SUPublicEDKey empty, and Sparkle then refuses to start and puts up a + /// modal "Unable to Check For Updates" alert on every launch. Skip the + /// updater entirely in that case; dropping a key into + /// Configuration/custom.xcconfig restores the real update flow locally. + private var shouldStartSparkleUpdater: Bool { + let publicKey = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String + return !(publicKey ?? "").isEmpty + } + + private var applicationName: String { + for key in ["CFBundleDisplayName", "CFBundleName"] { + if let name = Bundle.main.object(forInfoDictionaryKey: key) as? String, !name.isEmpty { + return name + } + } + return ProcessInfo.processInfo.processName + } + + private func installMainMenu() { + let appName = applicationName + let mainMenu = NSMenu(title: "Main Menu") + let windowMenu = buildWindowMenu() + let helpMenu = buildHelpMenu() + + mainMenu.addItem(Self.submenuItem(appName, buildApplicationMenu(appName: appName))) + mainMenu.addItem(Self.submenuItem("File", buildFileMenu())) + mainMenu.addItem(Self.submenuItem("Edit", buildEditMenu())) + mainMenu.addItem(Self.submenuItem("View", buildViewMenu())) + mainMenu.addItem(Self.submenuItem("Plugins", buildPluginsMenu())) + mainMenu.addItem(Self.submenuItem("Window", windowMenu)) + mainMenu.addItem(Self.submenuItem("Help", helpMenu)) + + NSApp.mainMenu = mainMenu + NSApp.windowsMenu = windowMenu + NSApp.helpMenu = helpMenu + } + + // MARK: - Menus + + private func buildApplicationMenu(appName: String) -> NSMenu { + let menu = NSMenu(title: appName) + menu.autoenablesItems = false + menu.delegate = self + menu.addItem(item("About \(appName)", action: #selector(handleAbout), tag: Tag.about)) + menu.addItem(.separator()) + menu.addItem(item("Preferences…", action: #selector(handlePreferences), key: ",", modifiers: .command, tag: Tag.preferences)) + menu.addItem(item("Private Discriminator Settings…", action: #selector(handlePrivateDiscriminatorSettings), tag: Tag.privateDiscriminatorSettings)) + menu.addItem(.separator()) + menu.addItem(item("Check for Updates…", action: #selector(handleCheckUpdates), tag: Tag.checkUpdates)) + menu.addItem(.separator()) + menu.addItem(Self.standardItem("Hide \(appName)", action: #selector(NSApplication.hide(_:)), key: "h", modifiers: .command)) + menu.addItem(Self.standardItem("Hide Others", action: #selector(NSApplication.hideOtherApplications(_:)), key: "h", modifiers: [.command, .option])) + menu.addItem(Self.standardItem("Show All", action: #selector(NSApplication.unhideAllApplications(_:)))) + menu.addItem(.separator()) + menu.addItem(Self.standardItem("Quit \(appName)", action: #selector(NSApplication.terminate(_:)), key: "q", modifiers: .command)) + return menu + } + + private func buildFileMenu() -> NSMenu { + let menu = NSMenu(title: "File") + menu.autoenablesItems = false + menu.delegate = self + + // New Inspection… brings up the Launch window's app picker so a fresh + // live document can be created from any state. + menu.addItem(item("New Inspection…", action: #selector(handleNewInspection), key: "n", modifiers: .command, tag: Tag.newInspection)) + menu.addItem(item("Attach to Running App…", action: #selector(handleAttachToRunningApp), tag: Tag.attachToRunningApp)) + // The same thing for an app on a connected iPhone or iPad. A separate + // entry rather than a tab inside the existing picker: that picker's + // Applications list enumerates this Mac, and showing it beside a + // phone's process list would invite picking a pid from the wrong + // machine. + menu.addItem(item("Attach to App on Device…", action: #selector(handleAttachToAppOnDevice), tag: Tag.attachToAppOnDevice)) + menu.addItem(.separator()) + + menu.addItem(Self.standardItem("Open…", action: #selector(NSDocumentController.openDocument(_:)), key: "o", modifiers: .command)) + + let recentDocumentsMenu = NSMenu(title: "Open Recent") + recentDocumentsMenu.autoenablesItems = false + recentDocumentsMenu.delegate = self + self.recentDocumentsMenu = recentDocumentsMenu + reloadRecentDocumentsMenu() + menu.addItem(Self.submenuItem("Open Recent", recentDocumentsMenu)) + + menu.addItem(.separator()) + // Standard NSDocument actions: performClose: targets the key window, + // saveDocumentAs: is validated against writableTypes(for:). + menu.addItem(Self.standardItem("Close", action: #selector(NSWindow.performClose(_:)), key: "w", modifiers: .command)) + menu.addItem(Self.standardItem("Save As…", action: #selector(NSDocument.saveAs(_:)), key: "s", modifiers: [.command, .shift])) + + menu.addItem(.separator()) + menu.addItem(delegatingItem("Copy to New Window…", tag: Tag.openInNewWindow)) + menu.addItem(delegatingItem("Export…", tag: Tag.export)) + return menu + } + + private func buildEditMenu() -> NSMenu { + let menu = NSMenu(title: "Edit") + menu.addItem(Self.standardItem("Cut", action: #selector(NSText.cut(_:)), key: "x", modifiers: .command)) + menu.addItem(Self.standardItem("Copy", action: #selector(NSText.copy(_:)), key: "c", modifiers: .command)) + menu.addItem(Self.standardItem("Paste", action: #selector(NSText.paste(_:)), key: "v", modifiers: .command)) + menu.addItem(Self.standardItem("Select All", action: #selector(NSText.selectAll(_:)), key: "a", modifiers: .command)) + return menu + } + + private func buildViewMenu() -> NSMenu { + let menu = NSMenu(title: "View") + menu.autoenablesItems = false + menu.delegate = self + menu.addItem(delegatingItem("Reload", key: "r", tag: Tag.reload)) + menu.addItem(.separator()) + menu.addItem(delegatingItem("Filter", key: "f", tag: Tag.filter)) + menu.addItem(.separator()) + menu.addItem(delegatingItem("2D / 3D", key: "\\", tag: Tag.dimension)) + menu.addItem(.separator()) + menu.addItem(delegatingItem("Zoom In", key: "+", tag: Tag.zoomIn)) + menu.addItem(delegatingItem("Zoom Out", key: "-", tag: Tag.zoomOut)) + menu.addItem(.separator()) + menu.addItem(delegatingItem("Decrease Item Separation", key: "[", tag: Tag.decreaseInterspace)) + menu.addItem(delegatingItem("Increase Item Separation", key: "]", tag: Tag.increaseInterspace)) + menu.addItem(.separator()) + + let expansionMenu = NSMenu(title: "Hierarchy Depth") + let levels = ["Level 1 (Collapse All)", "Level 2", "Level 3", "Level 4", "Level 5 (Expand All)"] + for (index, title) in levels.enumerated() { + let levelItem = item(title, action: #selector(handleExpansion(_:)), key: "\(index + 1)", modifiers: .command, tag: 271 + index) + levelItem.representedObject = NSNumber(value: index) + expansionMenu.addItem(levelItem) + } + menu.addItem(Self.submenuItem("Hierarchy Depth", expansionMenu, tag: Tag.expansion)) + return menu + } + + private func buildPluginsMenu() -> NSMenu { + let menu = NSMenu(title: NSLocalizedString("Plugins", comment: "")) + menu.autoenablesItems = false + + let swiftUIMenu = NSMenu(title: Self.swiftUISupportMenuTitle) + swiftUIMenu.autoenablesItems = false + swiftUIMenu.delegate = self + populateSwiftUIPluginSubmenu(swiftUIMenu) + menu.addItem(Self.submenuItem(NSLocalizedString("LookInside Pro", comment: ""), swiftUIMenu, tag: Tag.swiftUISupportSubmenu)) + return menu + } + + private func populateSwiftUIPluginSubmenu(_ menu: NSMenu) { + menu.removeAllItems() + if LKSwiftUISupportGatekeeper.sharedInstance().activationState == .activated { + menu.addItem(item(NSLocalizedString("LookInside Pro License…", comment: ""), action: #selector(handleSwiftUISupportLicense), tag: Tag.swiftUISupportLicense)) + menu.addItem(item(NSLocalizedString("Refresh License Status", comment: ""), action: #selector(handleRefreshSwiftUISupportLicense), tag: Tag.refreshSwiftUISupportLicense)) + menu.addItem(.separator()) + menu.addItem(item(NSLocalizedString("Customer Support…", comment: ""), action: #selector(handleSwiftUISupportCustomerSupport), tag: Tag.swiftUISupportCustomerSupport)) + } else { + menu.addItem(item(NSLocalizedString("Activate LookInside Pro…", comment: ""), action: #selector(handleActivateSwiftUISupport), tag: Tag.activateSwiftUISupport)) + menu.addItem(.separator()) + menu.addItem(item(NSLocalizedString("Purchase…", comment: ""), action: #selector(handlePurchaseSwiftUISupport), tag: Tag.purchaseSwiftUISupport)) + } + } + + private func buildWindowMenu() -> NSMenu { + let menu = NSMenu(title: "Window") + // ⌘W lives in the File menu (NSDocument convention); the Window menu + // keeps the standard window manipulators. + menu.addItem(Self.standardItem("Minimize", action: #selector(NSWindow.performMiniaturize(_:)), key: "m", modifiers: .command)) + menu.addItem(Self.standardItem("Zoom", action: #selector(NSWindow.performZoom(_:)))) + return menu + } + + private func buildHelpMenu() -> NSMenu { + let menu = NSMenu(title: "Help") + menu.autoenablesItems = true + menu.delegate = self + menu.addItem(item("Source Code at GitHub", action: #selector(handleShowGitHub), tag: Tag.gitHub)) + menu.addItem(item("Acknowledgements", action: #selector(handleAcknowledgements), tag: Tag.acknowledgements)) + return menu + } + + private func reloadRecentDocumentsMenu() { + guard let menu = recentDocumentsMenu else { return } + menu.removeAllItems() + + let recentURLs = NSDocumentController.shared.recentDocumentURLs + guard !recentURLs.isEmpty else { + let emptyItem = Self.standardItem("No Recent Documents", action: nil) + emptyItem.isEnabled = false + menu.addItem(emptyItem) + return + } + + for url in recentURLs { + let title = url.lastPathComponent.isEmpty ? url.path : url.lastPathComponent + let recentItem = item(title, action: #selector(handleOpenRecentDocument(_:))) + recentItem.representedObject = url + recentItem.toolTip = url.path + menu.addItem(recentItem) + } + + menu.addItem(.separator()) + let clearItem = Self.standardItem("Clear Menu", action: #selector(NSDocumentController.clearRecentDocuments(_:))) + clearItem.target = NSDocumentController.shared + menu.addItem(clearItem) + } + + // MARK: - Items + + /// An item with no target: AppKit sends `action` up the responder chain. + private static func standardItem( + _ title: String, + action: Selector?, + key: String = "", + modifiers: NSEvent.ModifierFlags = [] + ) -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: key) + item.keyEquivalentModifierMask = modifiers + return item + } + + /// An item handled by this manager. + private func item( + _ title: String, + action: Selector, + key: String = "", + modifiers: NSEvent.ModifierFlags = [], + tag: Int = 0 + ) -> NSMenuItem { + let item = Self.standardItem(title, action: action, key: key, modifiers: modifiers) + item.target = self + item.tag = tag + return item + } + + /// A window command; `tag` picks its delegate method. + private func delegatingItem(_ title: String, key: String = "", tag: Int) -> NSMenuItem { + item(title, action: #selector(handleDelegateItem(_:)), key: key, modifiers: key.isEmpty ? [] : .command, tag: tag) + } + + private static func submenuItem(_ title: String, _ submenu: NSMenu, tag: Int = 0) -> NSMenuItem { + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") + item.tag = tag + item.submenu = submenu + return item + } + + // MARK: - NSMenuDelegate + + func menuNeedsUpdate(_ menu: NSMenu) { + if menu === recentDocumentsMenu { + reloadRecentDocumentsMenu() + return + } + + if menu.title == Self.swiftUISupportMenuTitle { + LKSwiftUISupportGatekeeper.sharedInstance().refreshActivationStateInBackground() + populateSwiftUIPluginSubmenu(menu) + return + } + + let windowController = LKNavigationManager.shared.currentKeyWindowController() + for menuItem in menu.items { + if let selector = Self.delegatingSelectors[menuItem.tag] { + menuItem.isEnabled = windowController?.responds(to: selector) ?? false + } else if menuItem.tag == Tag.checkUpdates { + // Nil whenever the build carries no Sparkle public key; see + // shouldStartSparkleUpdater. + menuItem.isEnabled = updaterController != nil + } else if menuItem.action == #selector(NSDocument.saveAs(_:)) { + let document = NSApp.keyWindow.flatMap { NSDocumentController.shared.document(for: $0) } + menuItem.isEnabled = document.map { !$0.writableTypes(for: .saveAsOperation).isEmpty } ?? false + } else if menuItem.action == #selector(NSWindow.performClose(_:)) { + menuItem.isEnabled = NSApp.keyWindow != nil + } else { + menuItem.isEnabled = true + } + } + } + + // MARK: - Actions + + @objc private func handleDelegateItem(_ item: NSMenuItem) { + guard let selector = Self.delegatingSelectors[item.tag] else { + assertionFailure("no window command for tag \(item.tag)") + return + } + guard let windowController = LKNavigationManager.shared.currentKeyWindowController(), + windowController.responds(to: selector) + else { + assertionFailure("the key window does not handle \(selector)") + return + } + windowController.perform(selector) + } + + @objc private func handleExpansion(_ item: NSMenuItem) { + guard let index = (item.representedObject as? NSNumber)?.uintValue else { + assertionFailure("expansion item without an index") + return + } + guard let windowController = LKNavigationManager.shared.currentKeyWindowController(), + let handler = (windowController as LKAppMenuManagerDelegate).appMenuManagerDidSelectExpansionIndex + else { + assertionFailure("the key window does not handle expansion") + return + } + handler(index) + } + + @objc private func handleOpenRecentDocument(_ item: NSMenuItem) { + guard let url = item.representedObject as? URL else { return } + NSDocumentController.shared.openDocument(withContentsOf: url, display: true) { _, _, error in + if let error { + NSApp.presentError(error) + } + } + } + + @objc private func handleAbout() { + LKNavigationManager.shared.showAbout() + } + + @objc private func handlePreferences() { + LKNavigationManager.shared.showPreference() + } + + @objc private func handlePrivateDiscriminatorSettings() { + LKPrivateDiscriminatorSettingsWindowController.showSettingsWindow() + } + + @objc private func handleCheckUpdates() { + updaterController?.checkForUpdates(nil) + } + + @objc private func handleActivateSwiftUISupport() { + LKSwiftUISupportGatekeeper.sharedInstance().showActivationWindow() + } + + @objc private func handleSwiftUISupportLicense() { + LKSwiftUISupportGatekeeper.sharedInstance().showLicenseWindow() + } + + @objc private func handleRefreshSwiftUISupportLicense() { + LKSwiftUISupportGatekeeper.sharedInstance().refreshLicenseStatus() + } + + @objc private func handlePurchaseSwiftUISupport() { + if let url = URL(string: Self.swiftUISupportPurchaseURL) { + NSWorkspace.shared.open(url) + } + } + + @objc private func handleSwiftUISupportCustomerSupport() { + if let url = URL(string: Self.swiftUISupportCustomerSupportURL) { + NSWorkspace.shared.open(url) + } + } + + @objc private func handleShowGitHub() { + LKHelper.openProjectGitHubRepository() + } + + @objc private func handleAcknowledgements() { + LKHelper.openProjectREADME() + } + + /// File > New Inspection… reuses the Launch window's app picker, so a + /// fresh live document can be created from any state. + @objc private func handleNewInspection() { + LKNavigationManager.shared.showLaunch() + } + + /// File > Attach to Running App… opens the running-process picker and + /// injects LookInsideServer.framework into the chosen pid through the + /// privileged injector daemon (LKInjectionFlow); sheets attach to the + /// key window. + @objc private func handleAttachToRunningApp() { + guard LKSwiftUISupportGatekeeper.sharedInstance().allowProtectedFeatureAccess(for: NSApp.keyWindow) else { + return + } + LKInjectionFlow.shared.startFromWindow(NSApp.keyWindow) + } + + /// File > Attach to App on Device… asks the LookInside Injector running + /// on a connected iPhone or iPad for its list of running apps and has + /// *it* do the injecting — there is no daemon and no framework download + /// on this side, because the device carries its own payload + /// (LKDeviceInjectionFlow). + @objc private func handleAttachToAppOnDevice() { + guard LKSwiftUISupportGatekeeper.sharedInstance().allowProtectedFeatureAccess(for: NSApp.keyWindow) else { + return + } + LKDeviceInjectionFlow.shared.startFromWindow(NSApp.keyWindow) + } +} diff --git a/LookInside/Manager/LKHelper.h b/LookInside/Manager/LKHelper.h deleted file mode 100644 index 529c0305..00000000 --- a/LookInside/Manager/LKHelper.h +++ /dev/null @@ -1,118 +0,0 @@ -// -// LKHelper.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import -#import "LookinDefines.h" - -// Phase F: the legacy `InspectingApp` shorthand for -// `[LKAppsManager sharedInstance].inspectingApp` has been removed along -// with the single-slot global it pointed at. Call sites now resolve the -// inspectable app via the owning `LookinLiveDocument` (e.g. via -// `+[LookinLiveDocument documentInWindow:]` on a per-window lookup). -#define CurrentTime [[NSDate date] timeIntervalSince1970] -#define CurrentKeyWindow [NSApplication sharedApplication].keyWindow - -#define NSImageMake(imageName) [NSImage imageNamed:imageName] -#define NSFontMake(fontSize) [NSFont systemFontOfSize:fontSize] - -#define DashboardCardValueColor LookinColorMake(223, 223, 223) -#define DashboardCardControlBackgroundColor LookinColorMake(40, 40, 40) -#define DashboardCardControlBorderColor LookinColorMake(87, 87, 87) - -#define SeparatorLightModeColor LookinColorMake(215, 215, 215) -#define SeparatorDarkModeColor LookinColorMake(67, 67, 69) - -#define NSSizeMax NSMakeSize(CGFLOAT_MAX, CGFLOAT_MAX) - -#define NSColorBlack LookinColorMake(13, 20, 30) -#define NSColorWhite LookinColorMake(250, 251, 252) -#define NSColorGray0 LookinColorMake(33, 40, 50) -#define NSColorGray1 LookinColorMake(53, 60, 70) -#define NSColorGray9 LookinColorMake(216, 220, 228) - -extern const CGFloat HierarchyMinWidth; - -extern const CGFloat MeasureViewWidth; - -extern const CGFloat DashboardViewWidth; -extern const CGFloat DashboardAttrItemHorInterspace; -extern const CGFloat DashboardAttrItemVerInterspace; -extern const CGFloat DashboardHorInset; -extern const CGFloat DashboardCardControlCornerRadius; -extern const CGFloat DashboardSectionMarginTop; -extern const CGFloat DashboardCardCornerRadius; -extern const CGFloat DashboardSearchCardInset; - -extern const CGFloat ConsoleInsetLeft; -extern const CGFloat ConsoleInsetRight; - -extern const CGFloat ZoomSliderMaxValue; - -typedef struct { - CGFloat left, right; -} HorizontalMargins; - -CG_INLINE HorizontalMargins HorizontalMarginsMake(CGFloat left, CGFloat right) { - HorizontalMargins margins = {left, right}; - return margins; -} - -#define AlertError(targetError, targetWindow) if (targetError.code != LookinErrCode_Discard) {[[NSAlert alertWithError:targetError] beginSheetModalForWindow:targetWindow completionHandler:nil];} - -#define AlertErrorText(errorTitle, errorDetail, targetWindow) AlertError(LookinErrorMake(errorTitle, errorDetail), targetWindow) - -#define IsEnglish [LKHelper isEnglish] - -@class LookinAppInfo; - -@interface LKHelper : NSObject - -+ (instancetype)sharedInstance; - -+ (NSFont *)italicFontOfSize:(CGFloat)fontSize; - -+ (NSString *)lookinReadableVersion; - -+ (void)showDisabledExternalLinkAlertWithMessage:(NSString *)message; - -+ (void)openLookinWebsiteWithPath:(NSString *)path; - -+ (void)openLookinOfficialWebsite; - -+ (void)openCustomConfigWebsite; - -+ (void)openProjectGitHubRepository; - -+ (void)openProjectREADME; - -+ (BOOL)isEnglish; - -+ (BOOL)appInfoLooksLikeMacTarget:(LookinAppInfo *)appInfo; - -/// The name of the view class the inspected app actually uses: @"NSView" for AppKit -/// targets, @"UIView" otherwise. UI copy must never hardcode either name, because -/// the host is always compiled for macOS while the inspected app may be either. -+ (NSString *)viewClassNameForMacTarget:(BOOL)isMacTarget; - -/// Convenience wrapper combining +appInfoLooksLikeMacTarget: and +viewClassNameForMacTarget:. -+ (NSString *)viewClassNameForAppInfo:(LookinAppInfo *)appInfo; - -/// macOS 10.14 及以后返回用户的系统主题色,旧版本系统返回蓝色 -+ (NSColor *)accentColor; - -+ (NSArray *)bestMatchesInCandidates:(NSArray *)candidates input:(NSString *)input maxResultsCount:(NSUInteger)maxResultsCount; - -/// 使用 UIImageView 的 “使用预览打开该图片” 功能时会创建临时图片文件,它们的路径会保存在这里,Lookin 退出时应当删除这些临时文件 -/// 创建图片的相关逻辑见 LKDashboardAttributeOpenImageView.m -@property(nonatomic, strong) NSMutableArray *tempImageFiles; - -/// macOS 10.14 及之后返回系统的 [NSTextView scrollableTextView]。10.13 由该方法自己实现。 -/// 当使用自己实现的版本时(即 10.13 系统),需要业务自己负责 textView(即 scrollableTextView.documentVie)的布局 -+ (NSScrollView *)scrollableTextView; - -@end diff --git a/LookInside/Manager/LKHelper.m b/LookInside/Manager/LKHelper.m deleted file mode 100644 index c462f28e..00000000 --- a/LookInside/Manager/LKHelper.m +++ /dev/null @@ -1,230 +0,0 @@ -// -// LKHelper.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKHelper.h" -#import "NSString+Score.h" -#import "LookinAppInfo.h" - -const CGFloat HierarchyMinWidth = 200; -const CGFloat MeasureViewWidth = 240; -const CGFloat DashboardViewWidth = 260; -const CGFloat DashboardHorInset = 10; -const CGFloat DashboardAttrItemHorInterspace = 10; -const CGFloat DashboardAttrItemVerInterspace = 9; -const CGFloat DashboardCardControlCornerRadius = 4; -const CGFloat DashboardSectionMarginTop = 10; -const CGFloat DashboardCardCornerRadius = 6; -const CGFloat DashboardSearchCardInset = 6; -const CGFloat ConsoleInsetLeft = 10; -const CGFloat ConsoleInsetRight = 26; -const CGFloat ZoomSliderMaxValue = 2.8; - -@implementation LKHelper - -+ (void)showDisabledExternalLinkAlertWithMessage:(NSString *)message { - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = NSLocalizedString(@"Link Disabled", nil); - alert.informativeText = message; - [alert addButtonWithTitle:NSLocalizedString(@"OK", nil)]; - [alert runModal]; -} - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKHelper *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -+ (NSFont *)italicFontOfSize:(CGFloat)fontSize { - NSFontDescriptor *fontDescriptor = [NSFontMake(fontSize).fontDescriptor fontDescriptorWithSymbolicTraits:NSFontDescriptorTraitItalic]; - NSFont *font = [NSFont fontWithDescriptor:fontDescriptor size:fontSize]; - return font; -} - -+ (NSString *)lookinReadableVersion { - NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary]; - NSString *string = [infoDictionary objectForKey:@"CFBundleShortVersionString"]; - return string ? : @""; -} - -+ (void)openLookinWebsiteWithPath:(NSString *)path { - [self showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"Legacy website links are disabled in this community build. See the local repository README for current documentation.", nil)]; -} - -+ (void)openLookinOfficialWebsite { - [self showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"A public project website is not configured for this build yet.", nil)]; -} - -+ (void)openCustomConfigWebsite { - [self showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"Legacy documentation links are disabled in this community build.", nil)]; -} - -+ (void)openProjectGitHubRepository { - NSURL *url = [NSURL URLWithString:@"https://github.com/Lakr233/LookInside"]; - if (url) { - [[NSWorkspace sharedWorkspace] openURL:url]; - } -} - -+ (void)openProjectREADME { - NSString *readmePath = [NSFileManager.defaultManager.currentDirectoryPath stringByAppendingPathComponent:@"README.md"]; - if (![NSFileManager.defaultManager fileExistsAtPath:readmePath]) { - [self showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"README.md was not found in the current repository checkout.", nil)]; - return; - } - [[NSWorkspace sharedWorkspace] openURL:[NSURL fileURLWithPath:readmePath]]; -} - -+ (BOOL)isEnglish { - static dispatch_once_t onceToken; - static BOOL isEnglish = YES; - dispatch_once(&onceToken,^{ - NSArray *languages = [NSLocale preferredLanguages]; - NSString *language = [languages objectAtIndex:0]; - isEnglish = ![language hasPrefix:@"zh"]; - }); - return isEnglish; -} - -+ (BOOL)appInfoLooksLikeMacTarget:(LookinAppInfo *)appInfo { - if (!appInfo) { - return NO; - } - if (appInfo.deviceType == LookinAppInfoDeviceMac) { - return YES; - } - if (appInfo.deviceType == LookinAppInfoDeviceMacCatalyst) { - // Catalyst runs on a Mac but draws UIKit views, and this predicate is about the view - // framework, not the hardware. Answer NO here rather than falling through: the - // heuristics below match on the Mac's own computer name ("JH's Mac Studio Ultra"), - // which a Catalyst app reports as its deviceDescription. - return NO; - } - - NSString *osDescription = appInfo.osDescription.lowercaseString ?: @""; - NSString *deviceDescription = appInfo.deviceDescription.lowercaseString ?: @""; - return [osDescription containsString:@"macos"] || - [osDescription containsString:@"os x"] || - [deviceDescription containsString:@"mac"]; -} - -+ (NSString *)viewClassNameForMacTarget:(BOOL)isMacTarget { - return isMacTarget ? @"NSView" : @"UIView"; -} - -+ (NSString *)viewClassNameForAppInfo:(LookinAppInfo *)appInfo { - return [self viewClassNameForMacTarget:[self appInfoLooksLikeMacTarget:appInfo]]; -} - -+ (NSColor *)accentColor { - return [NSColor controlAccentColor]; -} - -+ (NSArray *)bestMatchesInCandidates:(NSArray *)candidates input:(NSString *)input maxResultsCount:(NSUInteger)maxResultsCount { - // {"string":"abc", "score":@(0.75)} - NSMutableArray *topResults = [NSMutableArray arrayWithCapacity:maxResultsCount]; - __block CGFloat lowestScore = 1; - - input = [input lowercaseString]; - [candidates enumerateObjectsUsingBlock:^(NSString * _Nonnull candidate, NSUInteger idx, BOOL * _Nonnull stop) { - if (lowestScore >= 1 && topResults.count >= maxResultsCount) { - *stop = YES; - return; - } - - NSString *candidate_lowercase = [candidate lowercaseString]; - CGFloat score; - if ([candidate_lowercase containsString:input]) { - score = 1; - } else { - score = [input scoreAgainst:candidate_lowercase fuzziness:@(.5) options:NSStringScoreOptionNone]; - } - - if (topResults.count < maxResultsCount) { - [topResults addObject:@{@"string": candidate, @"score":@(score)}]; - - if (score < lowestScore) { - lowestScore = score; - } - return; - } - - if (score > lowestScore) { - NSUInteger idxToDelete = [self _indexOfSmallestNumberInArray:[topResults lookin_map:^id(NSUInteger idx, NSDictionary *value) { - return value[@"score"]; - }]]; - [topResults removeObjectAtIndex:idxToDelete]; - [topResults addObject:@{@"string": candidate, @"score":@(score)}]; - lowestScore = [self _smallestNumberInArray:[topResults lookin_map:^id(NSUInteger idx, NSDictionary *value) { - return value[@"score"]; - }]]; - } - }]; - - [topResults sortUsingComparator:^NSComparisonResult(NSDictionary *obj1, NSDictionary *obj2) { - CGFloat score1 = ((NSNumber *)obj1[@"score"]).doubleValue; - CGFloat score2 = ((NSNumber *)obj2[@"score"]).doubleValue; - if (score1 > score2) { - return NSOrderedAscending; - } else if (score1 < score2) { - return NSOrderedDescending; - } else { - return NSOrderedSame; - } - }]; - - NSArray *resultStrings = [topResults lookin_map:^id(NSUInteger idx, NSDictionary *value) { - return value[@"string"]; - }]; - return resultStrings; -} - -+ (NSUInteger)_indexOfSmallestNumberInArray:(NSArray *)array { - if (array.count == 0) { - NSAssert(NO, @"_indexOfSmallestNumberInArray"); - return NSNotFound; - } - __block NSUInteger index = NSNotFound; - __block CGFloat smallestNumber = CGFLOAT_MAX; - [array enumerateObjectsUsingBlock:^(NSNumber *obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.doubleValue < smallestNumber) { - smallestNumber = obj.doubleValue; - index = idx; - } - }]; - return index; -} - -+ (CGFloat)_smallestNumberInArray:(NSArray *)array { - if (!array.count) { - NSAssert(NO, @"_smallestNumberInArray"); - return 0; - } - __block CGFloat smallestNumber = CGFLOAT_MAX; - [array enumerateObjectsUsingBlock:^(NSNumber * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.doubleValue < smallestNumber) { - smallestNumber = obj.doubleValue; - } - }]; - return smallestNumber; -} - -+ (NSScrollView *)scrollableTextView { - return [NSTextView scrollableTextView]; -} - -// Frame validation moved into LookinCore as LookinIsUsableRect, next to the -// calculateFrameToRoot walk that consumes it. -@end diff --git a/LookInside/Manager/LKHelper.swift b/LookInside/Manager/LKHelper.swift new file mode 100644 index 00000000..1c0ceee1 --- /dev/null +++ b/LookInside/Manager/LKHelper.swift @@ -0,0 +1,182 @@ +// +// LKHelper.swift +// Lookin +// +// Created by Li Kai on 2018/8/4. +// https://lookin.work +// + +import AppKit +import LookInsideHostCore + +let HierarchyMinWidth: CGFloat = 200 +let MeasureViewWidth: CGFloat = 240 +let DashboardViewWidth: CGFloat = 260 +let DashboardHorInset: CGFloat = 10 +let DashboardAttrItemHorInterspace: CGFloat = 10 +let DashboardAttrItemVerInterspace: CGFloat = 9 +let DashboardCardControlCornerRadius: CGFloat = 4 +let DashboardSectionMarginTop: CGFloat = 10 +let DashboardCardCornerRadius: CGFloat = 6 +let DashboardSearchCardInset: CGFloat = 6 +let ConsoleInsetLeft: CGFloat = 10 +let ConsoleInsetRight: CGFloat = 26 +let ZoomSliderMaxValue: CGFloat = 2.8 + +struct HorizontalMargins { + var left: CGFloat + var right: CGFloat +} + +final class LKHelper { + private static let shared = LKHelper() + + static func sharedInstance() -> LKHelper { + shared + } + + private init() {} + + /// 使用 UIImageView 的 “使用预览打开该图片” 功能时会创建临时图片文件,它们的路径会保存在这里,Lookin 退出时应当删除这些临时文件 + /// 创建图片的相关逻辑见 LKDashboardAttributeReadOnlyViews.swift + var tempImageFiles: [String] = [] + + static func showDisabledExternalLinkAlert(withMessage message: String) { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Link Disabled", comment: "") + alert.informativeText = message + alert.addButton(withTitle: NSLocalizedString("OK", comment: "")) + alert.runModal() + } + + static func italicFont(ofSize fontSize: CGFloat) -> NSFont? { + let fontDescriptor = NSFont.systemFont(ofSize: fontSize).fontDescriptor.withSymbolicTraits(.italic) + return NSFont(descriptor: fontDescriptor, size: fontSize) + } + + static func lookinReadableVersion() -> String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + } + + static func openLookinWebsite(withPath _: String) { + showDisabledExternalLinkAlert(withMessage: NSLocalizedString("Legacy website links are disabled in this community build. See the local repository README for current documentation.", comment: "")) + } + + static func openLookinOfficialWebsite() { + showDisabledExternalLinkAlert(withMessage: NSLocalizedString("A public project website is not configured for this build yet.", comment: "")) + } + + static func openCustomConfigWebsite() { + showDisabledExternalLinkAlert(withMessage: NSLocalizedString("Legacy documentation links are disabled in this community build.", comment: "")) + } + + static func openProjectGitHubRepository() { + if let url = URL(string: "https://github.com/Lakr233/LookInside") { + NSWorkspace.shared.open(url) + } + } + + static func openProjectREADME() { + let readmePath = (FileManager.default.currentDirectoryPath as NSString).appendingPathComponent("README.md") + guard FileManager.default.fileExists(atPath: readmePath) else { + showDisabledExternalLinkAlert(withMessage: NSLocalizedString("README.md was not found in the current repository checkout.", comment: "")) + return + } + NSWorkspace.shared.open(URL(fileURLWithPath: readmePath)) + } + + private static let isEnglishLanguage: Bool = { + guard let language = Locale.preferredLanguages.first else { + return true + } + return !language.hasPrefix("zh") + }() + + static func isEnglish() -> Bool { + isEnglishLanguage + } + + static func appInfoLooksLikeMacTarget(_ appInfo: LookinAppInfo?) -> Bool { + guard let appInfo else { + return false + } + if appInfo.deviceType == .mac { + return true + } + if appInfo.deviceType == .macCatalyst { + // Catalyst runs on a Mac but draws UIKit views, and this predicate is about the view + // framework, not the hardware. Answer false here rather than falling through: the + // heuristics below match on the Mac's own computer name ("JH's Mac Studio Ultra"), + // which a Catalyst app reports as its deviceDescription. + return false + } + + let osDescription = (appInfo.osDescription as NSString?)?.lowercased ?? "" + let deviceDescription = (appInfo.deviceDescription as NSString?)?.lowercased ?? "" + return osDescription.contains("macos") + || osDescription.contains("os x") + || deviceDescription.contains("mac") + } + + /// The name of the view class the inspected app actually uses: "NSView" for AppKit + /// targets, "UIView" otherwise. UI copy must never hardcode either name, because + /// the host is always compiled for macOS while the inspected app may be either. + static func viewClassName(forMacTarget isMacTarget: Bool) -> String { + isMacTarget ? "NSView" : "UIView" + } + + /// Convenience wrapper combining appInfoLooksLikeMacTarget(_:) and viewClassName(forMacTarget:). + static func viewClassName(for appInfo: LookinAppInfo?) -> String { + viewClassName(forMacTarget: appInfoLooksLikeMacTarget(appInfo)) + } + + /// 返回用户的系统主题色 + static func accentColor() -> NSColor { + .controlAccentColor + } + + static func bestMatches(inCandidates candidates: [String], input: String, maxResultsCount: Int) -> [String] { + var topResults: [(string: String, score: CGFloat)] = [] + var lowestScore: CGFloat = 1 + + let input = (input as NSString).lowercased + for candidate in candidates { + if lowestScore >= 1, topResults.count >= maxResultsCount { + break + } + + let candidateLowercase = (candidate as NSString).lowercased + let score: CGFloat + if (candidateLowercase as NSString).contains(input) { + score = 1 + } else { + score = (input as NSString).score(against: candidateLowercase, fuzziness: 0.5, options: StringScore.Options.none.rawValue) + } + + if topResults.count < maxResultsCount { + topResults.append((candidate, score)) + if score < lowestScore { + lowestScore = score + } + continue + } + + if score > lowestScore, let indexToDelete = topResults.indices.min(by: { topResults[$0].score < topResults[$1].score }) { + topResults.remove(at: indexToDelete) + topResults.append((candidate, score)) + lowestScore = topResults.map(\.score).min() ?? 0 + } + } + + topResults.sort { $0.score > $1.score } + return topResults.map(\.string) + } + + /// 返回系统的 NSTextView.scrollableTextView() + static func scrollableTextView() -> NSScrollView { + NSTextView.scrollableTextView() + } + + // Frame validation moved into LookinCore as LookinIsUsableRect, next to the + // calculateFrameToRoot walk that consumes it. +} diff --git a/LookInside/Manager/LKNavigationManager.h b/LookInside/Manager/LKNavigationManager.h deleted file mode 100644 index 2efbf633..00000000 --- a/LookInside/Manager/LKNavigationManager.h +++ /dev/null @@ -1,43 +0,0 @@ -// -// LKNavigationManager.h -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import - -@class LKLaunchWindowController, LKDynamicWindowController, LKWindowController, LKReadWindowController, LookinHierarchyInfo, LookinHierarchyFile; - -@interface LKNavigationManager : NSObject - -+ (instancetype)sharedInstance; - -- (void)showLaunch; - -- (void)showLaunchAllowingAutoEnter:(BOOL)allowAutoEnter; - -- (void)closeLaunch; - -- (void)showPreference; - -- (void)showAbout; - -- (void)showJsonWindow:(NSString *)json; - -/// Phase C 后:thin wrapper,内部走 NSDocumentController openDocumentWithContentsOfURL。 -/// 返回值始终为 YES(被异步打开行为接管),错误通过 NSApp presentError: 异步弹出。 -- (BOOL)showReaderWithFilePath:(NSString *)filePath error:(NSError **)error; - -/// Phase C 后:用一个内存中的 LookinHierarchyFile 创建一个 untitled LookinArchiveDocument, -/// 注册到 NSDocumentController,并显示 reader 窗口。Save As 会弹出落盘对话框。 -- (void)showReaderWithHierarchyFile:(LookinHierarchyFile *)file title:(NSString *)title; - -@property(nonatomic, strong, readonly) LKLaunchWindowController *launchWindowController; - -- (LKWindowController *)currentKeyWindowController; - -@property(nonatomic, assign) CGFloat windowTitleBarHeight; - -@end diff --git a/LookInside/Manager/LKNavigationManager.m b/LookInside/Manager/LKNavigationManager.m deleted file mode 100644 index 79658083..00000000 --- a/LookInside/Manager/LKNavigationManager.m +++ /dev/null @@ -1,144 +0,0 @@ -// -// LKNavigationManager.m -// Lookin -// -// Created by Li Kai on 2018/11/3. -// https://lookin.work -// - -#import "LKNavigationManager.h" -#import "LKLaunchWindowController.h" -#import "LKPreferenceWindowController.h" -#import "LKPreviewController.h" -#import "LKPreviewController.h" -#import "LKAppsManager.h" -#import "LookinHierarchyFile.h" -#import "LKReadWindowController.h" -#import "LKConsoleViewController.h" -#import "LKPreferenceManager.h" -#import "LKAboutWindowController.h" -#import "LKJSONAttributeWindowController.h" -#import "LKJSONAttributeViewController.h" -#import "LookinArchiveDocument.h" - -@interface LKNavigationManager () - -@property(nonatomic, strong) LKPreferenceWindowController *preferenceWindowController; -@property(nonatomic, strong) LKJSONAttributeWindowController *jsonWindowController; -@property(nonatomic, strong) LKAboutWindowController *aboutWindowController; - -@end - -@implementation LKNavigationManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKNavigationManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -- (void)showLaunch { - [self showLaunchAllowingAutoEnter:NO]; -} - -- (void)showLaunchAllowingAutoEnter:(BOOL)allowAutoEnter { - _launchWindowController = [[LKLaunchWindowController alloc] initWithAutoEnterOnInitialReload:allowAutoEnter]; - [self.launchWindowController showWindow:self]; -} - -- (void)closeLaunch { - [self.launchWindowController close]; - _launchWindowController = nil; -} - -- (void)showPreference { - if (!self.preferenceWindowController) { - self.preferenceWindowController = [LKPreferenceWindowController new]; - self.preferenceWindowController.window.delegate = self; - } - [self.preferenceWindowController showWindow:self]; -} - -- (void)showAbout { - if (!self.aboutWindowController) { - _aboutWindowController = [[LKAboutWindowController alloc] init]; - self.aboutWindowController.window.delegate = self; - } - [self.aboutWindowController showWindow:self]; -} - -- (LKWindowController *)currentKeyWindowController { - NSWindow *keyWindow = [NSApplication sharedApplication].keyWindow; - if ([keyWindow.windowController isKindOfClass:[LKWindowController class]]) { - return keyWindow.windowController; - } - return nil; -} - -- (BOOL)showReaderWithFilePath:(NSString *)filePath error:(NSError **)error { - // Phase C: route through NSDocumentController so .lookin archives integrate with - // Recent Documents, Open Recent, proxy icon dragging, and de-duplication of - // already-open files. Errors are surfaced asynchronously via -presentError:. - NSURL *fileURL = [NSURL fileURLWithPath:filePath]; - [[NSDocumentController sharedDocumentController] openDocumentWithContentsOfURL:fileURL - display:YES - completionHandler:^(NSDocument *_Nullable document, - BOOL alreadyOpen, - NSError *_Nullable openError) { - if (!document && openError) { - [NSApp presentError:openError]; - } - }]; - return YES; -} - -- (void)showReaderWithHierarchyFile:(LookinHierarchyFile *)file title:(NSString *)title { - // Phase C: in-memory hierarchy is wrapped in an untitled LookinArchiveDocument so - // it gets the same NSDocument lifecycle (Save As, Versions, Recent on save) as - // a file-backed archive. - LookinArchiveDocument *document = [[LookinArchiveDocument alloc] init]; - document.hierarchyFile = file; - [[NSDocumentController sharedDocumentController] addDocument:document]; - [document makeWindowControllers]; - [document showWindows]; - - if (title.length) { - document.windowControllers.firstObject.window.title = title; - } -} - - -- (void)showJsonWindow:(NSString *)json { - if (!self.jsonWindowController) { - self.jsonWindowController = [LKJSONAttributeWindowController new]; - self.jsonWindowController.window.delegate = self; - } - [((LKJSONAttributeViewController *)self.jsonWindowController.contentViewController) renderWithJSON:json]; - [self.jsonWindowController showWindow:self]; -} - -#pragma mark - - -- (void)windowWillClose:(NSNotification *)notification { - NSWindow *closingWindow = notification.object; - - if (closingWindow == self.preferenceWindowController.window) { - _preferenceWindowController = nil; - - } else if (closingWindow == self.aboutWindowController.window) { - self.aboutWindowController = nil; - } - // Phase F: Live Doc and Archive Doc windows are both owned by - // NSDocumentController, and Live Doc windows persist their frame - // (`LKWindowSizeName_Static`) themselves, so no manual cleanup runs - // here for inspection windows. -} - -@end diff --git a/LookInside/Manager/LKNavigationManager.swift b/LookInside/Manager/LKNavigationManager.swift new file mode 100644 index 00000000..7a66fde2 --- /dev/null +++ b/LookInside/Manager/LKNavigationManager.swift @@ -0,0 +1,129 @@ +// +// LKNavigationManager.swift +// LookInside +// +// Created by Li Kai on 2018/11/3. +// https://lookin.work +// +// Opens the app's standalone windows: launch, preferences, about, the JSON +// viewer, and readers for `.lookin` files or in-memory hierarchies. +// + +import AppKit + +@objc(LKNavigationManager) +@MainActor +final class LKNavigationManager: NSObject, NSWindowDelegate { + static let shared = LKNavigationManager() + + override private init() { + super.init() + } + + @objc private(set) var launchWindowController: LKLaunchWindowController? + + // Read by name (KVC) by the DEBUG UI snapshots. + @objc private(set) var preferenceWindowController: LKPreferenceWindowController? + @objc private(set) var aboutWindowController: LKAboutWindowController? + private var jsonWindowController: LKJSONAttributeWindowController? + + /// Set by the hierarchy view from its top inset. + @objc var windowTitleBarHeight: CGFloat = 0 + + @objc func showLaunch() { + showLaunch(allowingAutoEnter: false) + } + + @objc(showLaunchAllowingAutoEnter:) + func showLaunch(allowingAutoEnter allowAutoEnter: Bool) { + let controller = LKLaunchWindowController(autoEnterOnInitialReload: allowAutoEnter) + launchWindowController = controller + controller.showWindow(self) + } + + @objc func closeLaunch() { + launchWindowController?.close() + launchWindowController = nil + } + + @objc func showPreference() { + let controller = preferenceWindowController ?? { + let controller = LKPreferenceWindowController() + controller.window?.delegate = self + return controller + }() + preferenceWindowController = controller + controller.showWindow(self) + } + + @objc func showAbout() { + let controller = aboutWindowController ?? { + let controller = LKAboutWindowController() + controller.window?.delegate = self + return controller + }() + aboutWindowController = controller + controller.showWindow(self) + } + + @objc(showJsonWindow:) + func showJSONWindow(_ json: String?) { + let controller = jsonWindowController ?? { + let controller = LKJSONAttributeWindowController() + controller.window?.delegate = self + return controller + }() + jsonWindowController = controller + (controller.contentViewController as? LKJSONAttributeViewController)?.render(json: json) + controller.showWindow(self) + } + + /// The window controller of the key window, when it is one of ours. + @objc func currentKeyWindowController() -> LKWindowController? { + NSApplication.shared.keyWindow?.windowController as? LKWindowController + } + + /// Opens the file through `NSDocumentController`, so `.lookin` archives + /// get Recent Documents, Open Recent, proxy icon dragging and the reuse + /// of an already open file. Opening is asynchronous; an error is + /// presented with `NSApp.presentError`, so this never throws. + @objc(showReaderWithFilePath:error:) + func showReader(withFilePath filePath: String) throws { + let fileURL = URL(fileURLWithPath: filePath) + NSDocumentController.shared.openDocument(withContentsOf: fileURL, display: true) { document, _, openError in + if document == nil, let openError { + NSApp.presentError(openError) + } + } + } + + /// Wraps an in-memory hierarchy in an untitled `LookinArchiveDocument`, + /// so it gets the same document life cycle (Save As, Versions, Recent on + /// save) as a file-backed archive, and shows its window. + @objc(showReaderWithHierarchyFile:title:) + func showReader(with file: LookinHierarchyFile?, title: String?) { + let document = LookinArchiveDocument() + document.hierarchyFile = file + NSDocumentController.shared.addDocument(document) + document.makeWindowControllers() + document.showWindows() + + if let title, !title.isEmpty { + document.windowControllers.first?.window?.title = title + } + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + let closingWindow = notification.object as? NSWindow + if closingWindow == preferenceWindowController?.window { + preferenceWindowController = nil + } else if closingWindow == aboutWindowController?.window { + aboutWindowController = nil + } + // Live and archive document windows belong to NSDocumentController, + // and live windows save their own frame (`LKWindowSizeName_Static`), + // so inspection windows need nothing here. + } +} diff --git a/LookInside/Manager/LKPreferenceManager.h b/LookInside/Manager/LKPreferenceManager.h deleted file mode 100644 index e077fd42..00000000 --- a/LookInside/Manager/LKPreferenceManager.h +++ /dev/null @@ -1,147 +0,0 @@ -// -// LKPreferenceManager.h -// Lookin -// -// Created by Li Kai on 2019/1/8. -// https://lookin.work -// - -#import -#import "LookinAttributesGroup.h" -#import "LKExportManager.h" -#import "LookinMsgAttribute.h" - -extern NSString *const LKWindowSizeName_Dynamic; -extern NSString *const LKWindowSizeName_Static; - -/// 初始的 preview scale -extern const CGFloat LKInitialPreviewScale; - -/// 默认的层级请求超时时间,单位秒。 -extern const NSTimeInterval LKDefaultHierarchyRequestTimeoutInterval; -extern const NSTimeInterval LKDefaultLicenseHandshakeTimeoutInterval; - -typedef NS_ENUM(NSInteger, LookinPreferredAppeanranceType) { - LookinPreferredAppeanranceTypeDark, - LookinPreferredAppeanranceTypeLight, - LookinPreferredAppeanranceTypeSystem -}; - - -typedef NS_ENUM(NSInteger, LookinDoubleClickBehavior) { - LookinDoubleClickBehaviorCollapse, - LookinDoubleClickBehaviorFocus -}; - -typedef NS_ENUM(NSInteger, LookinPreferredCallStackType) { - LookinPreferredCallStackTypeDefault, // 格式化 + 简略 - LookinPreferredCallStackTypeFormattedCompletely, // 格式化 + 完整 - LookinPreferredCallStackTypeRaw // 原始堆栈 -}; - -typedef NS_ENUM(NSInteger, LookinMeasureState) { - LookinMeasureState_no, // 没有处于测距模式 - LookinMeasureState_unlocked, // 处于测距模式,但未锁定,此时松开手指就会导致退出测距模式 - LookinMeasureState_locked // 处于测距模式,且锁定,此时松开手指不会退出测距模式 -}; - -@interface LKPreferenceManager : NSObject - -+ (instancetype)mainManager; - -/// 默认为 NO -@property(nonatomic, assign) BOOL shouldStoreToLocal; - -/// 仅在 macOS 10.14 及以后上生效 -@property(nonatomic, assign) LookinPreferredAppeanranceType appearanceType; - -@property(nonatomic, assign) LookinDoubleClickBehavior doubleClickBehavior; - -/// 有效值为 0 ~ 4 -@property(nonatomic, assign) NSInteger expansionIndex; - -@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *showOutline; - -@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *showHiddenItems; - -/// 是否在层级树里显示系统自动创建的 LayoutGuide(safe area、layout margins 等)。 -/// 默认显示;关闭只是宿主端过滤,数据仍在。用户自建 guide 不受此开关影响。 -@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *showSystemLayoutGuides; - -/// 是否显示 view 的 backing layer 子树(backing-layer-toggle 提案)。 -/// 默认关闭。开启后像 Xcode 的 Show Layers:每个 view 节点下展开完整 layer 子树, -/// 内容显示在真正渲染它的 layer 上,view 节点变为线框。切换时重新拉取层级。 -@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *showBackingLayers; - -// 范围是 0 ~ 1 -@property(nonatomic, strong, readonly) LookinDoubleMsgAttribute *zInterspace; - -@property(nonatomic, assign) BOOL rgbaFormat; - -/// 0 ~ 2 -@property(nonatomic, assign) NSInteger imageContrastLevel; - -/// 是否自动将选中的 UIView/CALayer 作为控制台的目标对象 -@property(nonatomic, assign) BOOL syncConsoleTarget; - -/// 是否按目标 App bundle id 持久化层级折叠状态。默认 YES。 -/// 关闭后已存储的折叠状态保留在磁盘上,仅停止读取和写入。 -@property(nonatomic, assign) BOOL rememberExpansionState; - -/// 读取某 bundle id 下记录的展开/折叠状态。key 为结构路径,value 为 @(YES) 表示展开、@(NO) 表示折叠。 -/// 未命中返回空字典。为兼容早期仅记录 expanded paths 的存档(NSArray), -/// 读到旧格式时会自动转换为 dict 形式(全部视为 @(YES))。 -- (NSDictionary *)expansionStateForBundleIdentifier:(NSString *)bundleIdentifier; - -/// 写入某 bundle id 下记录的展开/折叠状态,并将该 bundle id 提升到 LRU 队首。 -/// 当 LRU 超过容量上限时,最旧的 bundle id 会被静默移除(其对应的展开状态键也会被清理)。 -/// 若 rememberExpansionState 为 NO 或 bundleIdentifier 为空,则不做任何操作。 -- (void)setExpansionState:(NSDictionary *)expansionState forBundleIdentifier:(NSString *)bundleIdentifier; - -/// 将一个已记录的 bundle id 提升到 LRU 队首。若该 bundle id 未被记录则不做任何操作。 -/// 当 rememberExpansionState 为 NO 或 bundleIdentifier 为空时同样不做任何操作。 -- (void)bumpExpansionStateBundleIdentifierToMostRecent:(NSString *)bundleIdentifier; - -// 被折叠的 AttrGroup -@property(nonatomic, copy) NSArray *collapsedAttrGroups; - -@property(nonatomic, assign) CGFloat preferredExportCompression; - -@property(nonatomic, assign) NSTimeInterval hierarchyRequestTimeoutInterval; - -@property(nonatomic, assign) NSTimeInterval licenseHandshakeTimeoutInterval; - -@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *freeRotation; - -@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *fastMode; - -/// 上次接收到 iOS app 里传过来的 color config 和 collapsedClasses 信息的时间,用来统计 -@property(nonatomic, assign) NSTimeInterval receivingConfigTime_Color; -@property(nonatomic, assign) NSTimeInterval receivingConfigTime_Class; - -/// 返回某个 section 是否应该被显示在主界面上 -- (BOOL)isSectionShowing:(LookinAttrSectionIdentifier)secID; -/// 把某个 section 显示在主界面上 -- (void)showSection:(LookinAttrSectionIdentifier)secID; -/// 把某个 section 从主界面上移除 -- (void)hideSection:(LookinAttrSectionIdentifier)secID; -/// 当某个 section 被添加或移除时,会发出该通知 -extern NSString *const NotificationName_DidChangeSectionShowing; - -#pragma mark - 以下属性不会持久化 - -@property(nonatomic, assign) LookinPreferredCallStackType callStackType; - -@property(nonatomic, strong, readonly) LookinIntegerMsgAttribute *previewDimension; - -@property(nonatomic, strong, readonly) LookinDoubleMsgAttribute *previewScale; - -/// 参数是 LookinMeasureState -@property(nonatomic, strong, readonly) LookinIntegerMsgAttribute *measureState; - -/// 是否用户正在按住 cmd 键而处于快速选择模式 -@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *isQuickSelecting; - -- (void)reset; - -@end diff --git a/LookInside/Manager/LKPreferenceManager.m b/LookInside/Manager/LKPreferenceManager.m deleted file mode 100644 index 1d3850d6..00000000 --- a/LookInside/Manager/LKPreferenceManager.m +++ /dev/null @@ -1,848 +0,0 @@ -// -// LKPreferenceManager.m -// Lookin -// -// Created by Li Kai on 2019/1/8. -// https://lookin.work -// - -#import "LKPreferenceManager.h" -#import "LookinDashboardBlueprint.h" -#import "LKPreviewView.h" - -NSString *const NotificationName_DidChangeSectionShowing = @"NotificationName_DidChangeSectionShowing"; - -NSString *const LKWindowSizeName_Dynamic = @"LKWindowSizeName_Dynamic"; -NSString *const LKWindowSizeName_Static = @"LKWindowSizeName_Static"; - -const CGFloat LKInitialPreviewScale = 0.27; -const NSTimeInterval LKDefaultHierarchyRequestTimeoutInterval = 15; -const NSTimeInterval LKDefaultLicenseHandshakeTimeoutInterval = 5; - -static NSString * const Key_PreviousClientVersion = @"preVer"; -static NSString * const Key_ShowOutline = @"showOutline"; -static NSString * const Key_ShowHiddenItems = @"showHiddenItems"; -static NSString * const Key_ShowSystemLayoutGuides = @"showSystemLayoutGuides"; -static NSString * const Key_ShowBackingLayers = @"showBackingLayers"; -static NSString * const Key_RgbaFormat = @"egbaFormat"; -static NSString * const Key_ZInterspace = @"zInterspace_v095"; -static NSString * const Key_AppearanceType = @"appearanceType"; -static NSString * const Key_DoubleClickBehavior = @"doubleClickBehavior"; -static NSString * const Key_ExpansionIndex = @"expansionIndex"; -static NSString * const Key_ContrastLevel = @"contrastLevel"; -static NSString * const Key_SectionsShow = @"ss"; -static NSString * const Key_CollapsedGroups = @"collapsedGroups_918"; -static NSString * const Key_PreferredExportCompression = @"preferredExportCompression"; -static NSString * const Key_HierarchyRequestTimeoutInterval = @"hierarchyRequestTimeoutInterval"; -static NSString * const Key_LicenseHandshakeTimeoutInterval = @"licenseHandshakeTimeoutInterval"; -static NSString * const Key_CallStackType = @"callStackType"; -static NSString * const Key_SyncConsoleTarget = @"syncConsoleTarget"; -static NSString * const Key_FreeRotation = @"FreeRotation"; -static NSString * const Key_FastMode = @"fastMode"; -static NSString * const Key_ReceivingConfigTime_Color = @"ConfigTime_Color"; -static NSString * const Key_ReceivingConfigTime_Class = @"ConfigTime_Class"; -static NSString * const Key_RememberExpansionState = @"rememberExpansionState"; -static NSString * const Key_ExpansionStateBundleLRU = @"ExpansionStateBundleLRU"; -static NSString * const KeyPrefix_ExpansionStateForBundle = @"ExpansionState."; -static const NSUInteger MaxRememberedExpansionStateBundles = 20; - -@interface LKPreferenceManager () - -@property(nonatomic, strong) NSMutableDictionary *storedSectionShowConfig; - -@end - -@implementation LKPreferenceManager - -+ (instancetype)mainManager { - static dispatch_once_t onceToken; - static LKPreferenceManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - instance.shouldStoreToLocal = YES; - }); - return instance; -} - -- (instancetype)init { - if (self = [super init]) { - _previewScale = [LookinDoubleMsgAttribute attributeWithDouble:LKInitialPreviewScale]; - _previewDimension = [LookinIntegerMsgAttribute attributeWithInteger:LookinPreviewDimension3D]; - _measureState = [LookinIntegerMsgAttribute attributeWithInteger:LookinMeasureState_no]; - _isQuickSelecting = [LookinBOOLMsgAttribute attributeWithBOOL:NO]; - - NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults]; - - NSInteger prevClientVersion = [userDefaults integerForKey:Key_PreviousClientVersion]; - if (prevClientVersion != LOOKIN_CLIENT_VERSION) { - [[NSUserDefaults standardUserDefaults] setInteger:LOOKIN_CLIENT_VERSION forKey:Key_PreviousClientVersion]; - } - - NSNumber *obj_showOutline = [userDefaults objectForKey:Key_ShowOutline]; - if (obj_showOutline != nil) { - _showOutline = [LookinBOOLMsgAttribute attributeWithBOOL:[obj_showOutline boolValue]]; - } else { - _showOutline = [LookinBOOLMsgAttribute attributeWithBOOL:YES]; - [userDefaults setObject:@(YES) forKey:Key_ShowOutline]; - } - [self.showHiddenItems subscribe:self action:@selector(_handleShowOutlineDidChange:) relatedObject:nil]; - - NSNumber *obj_showHiddenItems = [userDefaults objectForKey:Key_ShowHiddenItems]; - if (obj_showHiddenItems != nil) { - _showHiddenItems = [LookinBOOLMsgAttribute attributeWithBOOL:[obj_showHiddenItems boolValue]]; - } else { - _showHiddenItems = [LookinBOOLMsgAttribute attributeWithBOOL:NO]; - [userDefaults setObject:@(NO) forKey:Key_ShowHiddenItems]; - } - [self.showHiddenItems subscribe:self action:@selector(_handleShowHiddenItemsChange:) relatedObject:nil]; - - NSNumber *obj_showSystemLayoutGuides = [userDefaults objectForKey:Key_ShowSystemLayoutGuides]; - if (obj_showSystemLayoutGuides != nil) { - _showSystemLayoutGuides = [LookinBOOLMsgAttribute attributeWithBOOL:[obj_showSystemLayoutGuides boolValue]]; - } else { - _showSystemLayoutGuides = [LookinBOOLMsgAttribute attributeWithBOOL:YES]; - [userDefaults setObject:@(YES) forKey:Key_ShowSystemLayoutGuides]; - } - [self.showSystemLayoutGuides subscribe:self action:@selector(_handleShowSystemLayoutGuidesChange:) relatedObject:nil]; - - NSNumber *obj_showBackingLayers = [userDefaults objectForKey:Key_ShowBackingLayers]; - if (obj_showBackingLayers != nil) { - _showBackingLayers = [LookinBOOLMsgAttribute attributeWithBOOL:[obj_showBackingLayers boolValue]]; - } else { - _showBackingLayers = [LookinBOOLMsgAttribute attributeWithBOOL:NO]; - [userDefaults setObject:@(NO) forKey:Key_ShowBackingLayers]; - } - [self.showBackingLayers subscribe:self action:@selector(_handleShowBackingLayersChange:) relatedObject:nil]; - - NSNumber *obj_doubleClickBehavior = [userDefaults objectForKey:Key_DoubleClickBehavior]; - if (obj_doubleClickBehavior) { - _doubleClickBehavior = [obj_doubleClickBehavior intValue]; - } else { - _doubleClickBehavior = LookinDoubleClickBehaviorCollapse; - [userDefaults setObject:@(_doubleClickBehavior) forKey:Key_DoubleClickBehavior]; - } - - NSNumber *obj_rgbaFormat = [userDefaults objectForKey:Key_RgbaFormat]; - if (obj_rgbaFormat != nil) { - _rgbaFormat = [obj_rgbaFormat boolValue]; - } else { - _rgbaFormat = YES; - [userDefaults setObject:@(_rgbaFormat) forKey:Key_RgbaFormat]; - } - - double zInterspaceValue; - NSNumber *obj_zInterspace = [userDefaults objectForKey:Key_ZInterspace]; - if (obj_zInterspace != nil) { - zInterspaceValue = [obj_zInterspace doubleValue]; - } else { - /// 默认值为 0.22 - zInterspaceValue = .22; - [userDefaults setObject:@(zInterspaceValue) forKey:Key_ZInterspace]; - } - zInterspaceValue = MAX(MIN(zInterspaceValue, LookinPreviewMaxZInterspace), LookinPreviewMinZInterspace); - _zInterspace = [LookinDoubleMsgAttribute attributeWithDouble:zInterspaceValue]; - [self.zInterspace subscribe:self action:@selector(_handleZInterspaceDidChange:) relatedObject:nil]; - - NSNumber *obj_appearanceType = [userDefaults objectForKey:Key_AppearanceType]; - if (obj_appearanceType != nil) { - _appearanceType = [obj_appearanceType integerValue]; - } else { - _appearanceType = LookinPreferredAppeanranceTypeSystem; - [userDefaults setObject:@(_appearanceType) forKey:Key_AppearanceType]; - } - - NSNumber *obj_expansionIndex = [userDefaults objectForKey:Key_ExpansionIndex]; - if (obj_expansionIndex != nil) { - _expansionIndex = [obj_expansionIndex integerValue]; - } else { - _expansionIndex = 3; - [userDefaults setObject:@(_expansionIndex) forKey:Key_ExpansionIndex]; - } - - NSNumber *obj_contrastLevel = [userDefaults objectForKey:Key_ContrastLevel]; - if (obj_contrastLevel != nil) { - _imageContrastLevel = [obj_contrastLevel integerValue]; - } else { - _imageContrastLevel = 0; - [userDefaults setObject:@(_imageContrastLevel) forKey:Key_ContrastLevel]; - } - - NSNumber *obj_syncConsoleTarget = [userDefaults objectForKey:Key_SyncConsoleTarget]; - if (obj_syncConsoleTarget != nil) { - _syncConsoleTarget = [obj_syncConsoleTarget boolValue]; - } else { - _syncConsoleTarget = YES; - [userDefaults setObject:@(_syncConsoleTarget) forKey:Key_SyncConsoleTarget]; - } - - NSNumber *obj_freeRotation = [userDefaults objectForKey:Key_FreeRotation]; - if (obj_freeRotation != nil) { - _freeRotation = [LookinBOOLMsgAttribute attributeWithBOOL:obj_freeRotation.boolValue]; - } else { - _freeRotation = [LookinBOOLMsgAttribute attributeWithBOOL:YES]; - [userDefaults setObject:@(_freeRotation.currentBOOLValue) forKey:Key_FreeRotation]; - } - [self.freeRotation subscribe:self action:@selector(_handleFreeRotationDidChange:) relatedObject:nil]; - - NSNumber *obj_fastMode = [userDefaults objectForKey:Key_FastMode]; - if (obj_fastMode != nil) { - _fastMode = [LookinBOOLMsgAttribute attributeWithBOOL:obj_fastMode.boolValue]; - } else { - _fastMode = [LookinBOOLMsgAttribute attributeWithBOOL:NO]; - [userDefaults setObject:@(_fastMode.currentBOOLValue) forKey:Key_FastMode]; - } - [self.fastMode subscribe:self action:@selector(_handleFastModeDidChange:) relatedObject:nil]; - - self.storedSectionShowConfig = [[userDefaults objectForKey:Key_SectionsShow] mutableCopy]; - if (!self.storedSectionShowConfig) { - self.storedSectionShowConfig = [NSMutableDictionary dictionary]; - } - - _collapsedAttrGroups = [userDefaults objectForKey:Key_CollapsedGroups]; - if (!_collapsedAttrGroups) { - _collapsedAttrGroups = @[LookinAttrGroup_Class]; - } - - NSNumber *obj_preferredExportCompression = [userDefaults objectForKey:Key_PreferredExportCompression]; - if (obj_preferredExportCompression != nil) { - _preferredExportCompression = [obj_preferredExportCompression doubleValue]; - } else { - /// 这里的默认值需要在 LKExportAccessory.m 里定义的选项里面 - _preferredExportCompression = .5; - [userDefaults setObject:@(_preferredExportCompression) forKey:Key_PreferredExportCompression]; - } - - NSNumber *obj_hierarchyRequestTimeoutInterval = [userDefaults objectForKey:Key_HierarchyRequestTimeoutInterval]; - if (obj_hierarchyRequestTimeoutInterval != nil && [obj_hierarchyRequestTimeoutInterval doubleValue] > 0) { - _hierarchyRequestTimeoutInterval = [obj_hierarchyRequestTimeoutInterval doubleValue]; - } else { - _hierarchyRequestTimeoutInterval = LKDefaultHierarchyRequestTimeoutInterval; - [userDefaults setObject:@(_hierarchyRequestTimeoutInterval) forKey:Key_HierarchyRequestTimeoutInterval]; - } - - NSNumber *obj_licenseHandshakeTimeoutInterval = [userDefaults objectForKey:Key_LicenseHandshakeTimeoutInterval]; - if (obj_licenseHandshakeTimeoutInterval != nil && [obj_licenseHandshakeTimeoutInterval doubleValue] > 0) { - _licenseHandshakeTimeoutInterval = [obj_licenseHandshakeTimeoutInterval doubleValue]; - } else { - _licenseHandshakeTimeoutInterval = LKDefaultLicenseHandshakeTimeoutInterval; - [userDefaults setObject:@(_licenseHandshakeTimeoutInterval) forKey:Key_LicenseHandshakeTimeoutInterval]; - } - - _receivingConfigTime_Color = [userDefaults doubleForKey:Key_ReceivingConfigTime_Color]; - _receivingConfigTime_Class = [userDefaults doubleForKey:Key_ReceivingConfigTime_Class]; - - NSNumber *obj_rememberExpansionState = [userDefaults objectForKey:Key_RememberExpansionState]; - if (obj_rememberExpansionState != nil) { - _rememberExpansionState = [obj_rememberExpansionState boolValue]; - } else { - _rememberExpansionState = YES; - [userDefaults setObject:@(_rememberExpansionState) forKey:Key_RememberExpansionState]; - } - } - return self; -} - -- (void)setRememberExpansionState:(BOOL)rememberExpansionState { - if (_rememberExpansionState == rememberExpansionState) { - return; - } - _rememberExpansionState = rememberExpansionState; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(rememberExpansionState) forKey:Key_RememberExpansionState]; - } -} - -- (NSString *)_expansionStateKeyForBundleIdentifier:(NSString *)bundleIdentifier { - return [KeyPrefix_ExpansionStateForBundle stringByAppendingString:bundleIdentifier]; -} - -/// Rebuilds the LRU array with `bundleIdentifier` at index 0, dropping any prior entry -/// for the same id and skipping malformed (non-string, empty) entries left over by -/// a corrupted plist. -- (NSMutableArray *)_lruArrayWithBundleIdentifier:(NSString *)bundleIdentifier - movedToFrontOf:(id)rawLRU { - NSMutableArray *lru = [NSMutableArray array]; - if ([rawLRU isKindOfClass:[NSArray class]]) { - for (id entry in (NSArray *)rawLRU) { - if ([entry isKindOfClass:[NSString class]] && [(NSString *)entry length] > 0 && ![entry isEqualToString:bundleIdentifier]) { - [lru addObject:entry]; - } - } - } - [lru insertObject:bundleIdentifier atIndex:0]; - return lru; -} - -- (NSDictionary *)expansionStateForBundleIdentifier:(NSString *)bundleIdentifier { - if (bundleIdentifier.length == 0) { - return @{}; - } - id stored = [[NSUserDefaults standardUserDefaults] objectForKey:[self _expansionStateKeyForBundleIdentifier:bundleIdentifier]]; - if ([stored isKindOfClass:[NSDictionary class]]) { - NSDictionary *storedDict = (NSDictionary *)stored; - if (storedDict.count == 0) { - return @{}; - } - NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:storedDict.count]; - [storedDict enumerateKeysAndObjectsUsingBlock:^(id key, id value, BOOL *stop) { - if ([key isKindOfClass:[NSString class]] && [(NSString *)key length] > 0 && [value isKindOfClass:[NSNumber class]]) { - result[key] = @([(NSNumber *)value boolValue]); - } - }]; - return result.copy; - } - // Legacy format: NSArray recorded only the expanded paths. Treat - // every entry as expanded; absent paths fall through to expansionIndex defaults. - if ([stored isKindOfClass:[NSArray class]]) { - NSArray *storedArray = (NSArray *)stored; - if (storedArray.count == 0) { - return @{}; - } - NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:storedArray.count]; - for (id entry in storedArray) { - if ([entry isKindOfClass:[NSString class]] && [(NSString *)entry length] > 0) { - result[entry] = @(YES); - } - } - return result.copy; - } - return @{}; -} - -- (void)setExpansionState:(NSDictionary *)expansionState forBundleIdentifier:(NSString *)bundleIdentifier { - if (bundleIdentifier.length == 0) { - return; - } - if (!self.rememberExpansionState) { - return; - } - if (!self.shouldStoreToLocal) { - return; - } - - NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults]; - - NSMutableArray *lru = [self _lruArrayWithBundleIdentifier:bundleIdentifier - movedToFrontOf:[userDefaults objectForKey:Key_ExpansionStateBundleLRU]]; - - if (lru.count > MaxRememberedExpansionStateBundles) { - NSRange evictedRange = NSMakeRange(MaxRememberedExpansionStateBundles, lru.count - MaxRememberedExpansionStateBundles); - for (NSString *evictedBundleId in [lru subarrayWithRange:evictedRange]) { - [userDefaults removeObjectForKey:[self _expansionStateKeyForBundleIdentifier:evictedBundleId]]; - } - [lru removeObjectsInRange:evictedRange]; - } - - NSDictionary *sanitized = expansionState ?: @{}; - [userDefaults setObject:sanitized forKey:[self _expansionStateKeyForBundleIdentifier:bundleIdentifier]]; - [userDefaults setObject:lru.copy forKey:Key_ExpansionStateBundleLRU]; -} - -- (void)bumpExpansionStateBundleIdentifierToMostRecent:(NSString *)bundleIdentifier { - if (bundleIdentifier.length == 0) { - return; - } - if (!self.rememberExpansionState) { - return; - } - if (!self.shouldStoreToLocal) { - return; - } - - NSUserDefaults *userDefaults = [NSUserDefaults standardUserDefaults]; - NSArray *rawLRU = [userDefaults objectForKey:Key_ExpansionStateBundleLRU]; - if (![rawLRU isKindOfClass:[NSArray class]] || ![rawLRU containsObject:bundleIdentifier]) { - // Absent → the first setExpansionState:... call will record it. - return; - } - if ([rawLRU.firstObject isEqualToString:bundleIdentifier]) { - return; - } - - NSMutableArray *lru = [self _lruArrayWithBundleIdentifier:bundleIdentifier movedToFrontOf:rawLRU]; - [userDefaults setObject:lru.copy forKey:Key_ExpansionStateBundleLRU]; -} - -- (void)_handleShowOutlineDidChange:(LookinMsgActionParams *)param { - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(param.boolValue) forKey:Key_ShowOutline]; - } -} - -- (void)_handleShowHiddenItemsChange:(LookinMsgActionParams *)param { - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(param.boolValue) forKey:Key_ShowHiddenItems]; - } -} - -- (void)_handleShowSystemLayoutGuidesChange:(LookinMsgActionParams *)param { - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(param.boolValue) forKey:Key_ShowSystemLayoutGuides]; - } -} - -- (void)_handleShowBackingLayersChange:(LookinMsgActionParams *)param { - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(param.boolValue) forKey:Key_ShowBackingLayers]; - } -} - -- (void)setRgbaFormat:(BOOL)rgbaFormat { - if (_rgbaFormat == rgbaFormat) { - return; - } - _rgbaFormat = rgbaFormat; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(rgbaFormat) forKey:Key_RgbaFormat]; - } -} - -- (void)setDoubleClickBehavior:(LookinDoubleClickBehavior)doubleClickBehavior { - _doubleClickBehavior = doubleClickBehavior; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(doubleClickBehavior) forKey:Key_DoubleClickBehavior]; - } -} - -- (void)setAppearanceType:(LookinPreferredAppeanranceType)appearanceType { - if (_appearanceType == appearanceType) { - return; - } - _appearanceType = appearanceType; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(appearanceType) forKey:Key_AppearanceType]; - } -} - -- (void)setExpansionIndex:(NSInteger)expansionIndex { - if (_expansionIndex == expansionIndex) { - return; - } - _expansionIndex = expansionIndex; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(expansionIndex) forKey:Key_ExpansionIndex]; - } -} - -- (void)setImageContrastLevel:(NSInteger)imageContrastLevel { - if (_imageContrastLevel == imageContrastLevel) { - return; - } - _imageContrastLevel = imageContrastLevel; - - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(imageContrastLevel) forKey:Key_ContrastLevel]; - } -} - -- (void)setCollapsedAttrGroups:(NSArray *)collapsedAttrGroups { - _collapsedAttrGroups = collapsedAttrGroups.copy; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:collapsedAttrGroups forKey:Key_CollapsedGroups]; - } -} - -- (void)setPreferredExportCompression:(CGFloat)preferredExportCompression { - if (_preferredExportCompression == preferredExportCompression) { - return; - } - _preferredExportCompression = preferredExportCompression; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(preferredExportCompression) forKey:Key_PreferredExportCompression]; - } -} - -- (void)setHierarchyRequestTimeoutInterval:(NSTimeInterval)hierarchyRequestTimeoutInterval { - if (hierarchyRequestTimeoutInterval <= 0) { - hierarchyRequestTimeoutInterval = LKDefaultHierarchyRequestTimeoutInterval; - } - if (_hierarchyRequestTimeoutInterval == hierarchyRequestTimeoutInterval) { - return; - } - _hierarchyRequestTimeoutInterval = hierarchyRequestTimeoutInterval; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(hierarchyRequestTimeoutInterval) forKey:Key_HierarchyRequestTimeoutInterval]; - } -} - -- (void)setLicenseHandshakeTimeoutInterval:(NSTimeInterval)licenseHandshakeTimeoutInterval { - if (licenseHandshakeTimeoutInterval <= 0) { - licenseHandshakeTimeoutInterval = LKDefaultLicenseHandshakeTimeoutInterval; - } - if (_licenseHandshakeTimeoutInterval == licenseHandshakeTimeoutInterval) { - return; - } - _licenseHandshakeTimeoutInterval = licenseHandshakeTimeoutInterval; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(licenseHandshakeTimeoutInterval) forKey:Key_LicenseHandshakeTimeoutInterval]; - } -} - -- (void)setCallStackType:(LookinPreferredCallStackType)callStackType { - if (callStackType < 0 || callStackType > 2) { - NSAssert(NO, @""); - callStackType = 0; - } - _callStackType = callStackType; -} - -- (void)setSyncConsoleTarget:(BOOL)syncConsoleTarget { - if (_syncConsoleTarget == syncConsoleTarget) { - return; - } - _syncConsoleTarget = syncConsoleTarget; - if (self.shouldStoreToLocal) { - [[NSUserDefaults standardUserDefaults] setObject:@(syncConsoleTarget) forKey:Key_SyncConsoleTarget]; - } -} - -- (void)setReceivingConfigTime_Class:(NSTimeInterval)receivingConfigTime_Class { - _receivingConfigTime_Class = receivingConfigTime_Class; - [[NSUserDefaults standardUserDefaults] setDouble:receivingConfigTime_Class forKey:Key_ReceivingConfigTime_Class]; -} - -- (void)setReceivingConfigTime_Color:(NSTimeInterval)receivingConfigTime_Color { - _receivingConfigTime_Color = receivingConfigTime_Color; - [[NSUserDefaults standardUserDefaults] setDouble:receivingConfigTime_Color forKey:Key_ReceivingConfigTime_Color]; -} - -- (void)_handleFreeRotationDidChange:(LookinMsgActionParams *)param { - if (!self.shouldStoreToLocal) { - return; - } - BOOL boolValue = param.boolValue; - [[NSUserDefaults standardUserDefaults] setObject:@(boolValue) forKey:Key_FreeRotation]; -} - -- (void)_handleFastModeDidChange:(LookinMsgActionParams *)param { - if (!self.shouldStoreToLocal) { - return; - } - BOOL boolValue = param.boolValue; - [[NSUserDefaults standardUserDefaults] setObject:@(boolValue) forKey:Key_FastMode]; -} - - -- (void)_handleZInterspaceDidChange:(LookinMsgActionParams *)param { - if (!self.shouldStoreToLocal) { - return; - } - double doubleValue = param.doubleValue; - [[NSUserDefaults standardUserDefaults] setObject:@(doubleValue) forKey:Key_ZInterspace]; -} - -/// 返回某个 section 是否应该被显示在主界面上 -- (BOOL)isSectionShowing:(LookinAttrSectionIdentifier)secID { - if (self.storedSectionShowConfig[secID] != nil) { - return [self.storedSectionShowConfig[secID] boolValue]; - } - NSSet *showingSecIDs = [self _showingSecIDsInDefault]; - if ([showingSecIDs containsObject:secID]) { - return YES; - } else { - return NO; - } -} - -/// 把某个 section 显示在主界面上 -- (void)showSection:(LookinAttrSectionIdentifier)secID { - if ([self isSectionShowing:secID]) { - NSAssert(NO, @""); - return; - } - self.storedSectionShowConfig[secID] = @(YES); - [[NSNotificationCenter defaultCenter] postNotificationName:NotificationName_DidChangeSectionShowing object:nil]; - [[NSUserDefaults standardUserDefaults] setObject:self.storedSectionShowConfig.copy forKey:Key_SectionsShow]; -} - -/// 把某个 section 从主界面上移除 -- (void)hideSection:(LookinAttrSectionIdentifier)secID { - if (![self isSectionShowing:secID]) { - NSAssert(NO, @""); - return; - } - self.storedSectionShowConfig[secID] = @(NO); - [[NSNotificationCenter defaultCenter] postNotificationName:NotificationName_DidChangeSectionShowing object:nil]; - [[NSUserDefaults standardUserDefaults] setObject:self.storedSectionShowConfig.copy forKey:Key_SectionsShow]; -} - -/// 返回默认情况下,哪些 section 应该被显示在主界面上 -- (NSSet *)_showingSecIDsInDefault { - static dispatch_once_t onceToken; - static NSSet *targetSet = nil; - dispatch_once(&onceToken,^{ - NSArray *array = @[LookinAttrSec_Class_Class, - - LookinAttrSec_Relation_Relation, - - LookinAttrSec_Layout_Frame, - LookinAttrSec_Layout_Bounds, - LookinAttrSec_Layout_CoordinateSpace, - - LookinAttrSec_AutoLayout_Hugging, - LookinAttrSec_AutoLayout_Resistance, - LookinAttrSec_AutoLayout_Constraints, - LookinAttrSec_AutoLayout_IntrinsicSize, - - LookinAttrSec_LayoutGuide_Identifier, - LookinAttrSec_LayoutGuide_LayoutFrame, - LookinAttrSec_LayoutGuide_OwningView, - - LookinAttrSec_NSCell_Cell, - LookinAttrSec_NSCell_Content, - LookinAttrSec_NSCell_Behavior, - LookinAttrSec_NSCell_ButtonCell, - LookinAttrSec_NSCell_TextFieldCell, - - LookinAttrSec_ViewLayer_Visibility, - LookinAttrSec_ViewLayer_InterationAndMasks, - LookinAttrSec_ViewLayer_Corner, - LookinAttrSec_ViewLayer_BgColor, - LookinAttrSec_ViewLayer_Border, - LookinAttrSec_ViewLayer_Shadow, - - LookinAttrSec_UIStackView_Axis, - LookinAttrSec_UIStackView_Alignment, - LookinAttrSec_UIStackView_Distribution, - LookinAttrSec_UIStackView_Spacing, - - LookinAttrSec_UIVisualEffectView_Style, - LookinAttrSec_UIVisualEffectView_QMUIForegroundColor, - - LookinAttrSec_UIImageView_Name, - LookinAttrSec_UIImageView_Open, - - LookinAttrSec_UILabel_Text, - LookinAttrSec_UILabel_Font, - LookinAttrSec_UILabel_NumberOfLines, - LookinAttrSec_UILabel_TextColor, - LookinAttrSec_UILabel_BreakMode, - LookinAttrSec_UILabel_Alignment, - - LookinAttrSec_UIControl_EnabledSelected, - LookinAttrSec_UIControl_QMUIOutsideEdge, - - LookinAttrSec_UIButton_ContentInsets, - - LookinAttrSec_UIScrollView_ContentInset, - LookinAttrSec_UIScrollView_AdjustedInset, - LookinAttrSec_UIScrollView_IndicatorInset, - LookinAttrSec_UIScrollView_Offset, - LookinAttrSec_UIScrollView_ContentSize, - LookinAttrSec_UIScrollView_Behavior, - - LookinAttrSec_UITableView_Style, - LookinAttrSec_UITableView_SectionsNumber, - LookinAttrSec_UITableView_RowsNumber, - - LookinAttrSec_UITextView_Text, - LookinAttrSec_UITextView_Font, - LookinAttrSec_UITextView_TextColor, - LookinAttrSec_UITextView_Alignment, - LookinAttrSec_UITextView_ContainerInset, - - LookinAttrSec_UITextField_Text, - LookinAttrSec_UITextField_Font, - LookinAttrSec_UITextField_TextColor, - LookinAttrSec_UITextField_Alignment, - - // NSWindow - LookinAttrSec_NSWindow_Title, - LookinAttrSec_NSWindow_Subtitle, - LookinAttrSec_NSWindow_State, - LookinAttrSec_NSWindow_Style, - LookinAttrSec_NSWindow_CollectionBehavior, - LookinAttrSec_NSWindow_Appearance, - LookinAttrSec_NSWindow_TitleVisibility, - LookinAttrSec_NSWindow_ToolbarStyle, - LookinAttrSec_NSWindow_TitlebarSeparatorStyle, - LookinAttrSec_NSWindow_Behavior, - LookinAttrSec_NSWindow_AnimationBehavior, - LookinAttrSec_NSWindow_Level, - LookinAttrSec_NSWindow_TabbingMode, - LookinAttrSec_NSWindow_Size, - LookinAttrSec_NSWindow_Info, - // NSImageView - LookinAttrSec_NSImageView_Name, - LookinAttrSec_NSImageView_Open, - LookinAttrSec_NSImageView_Scaling, - LookinAttrSec_NSImageView_Behavior, - LookinAttrSec_NSImageView_ContentTintColor, - // NSControl - LookinAttrSec_NSControl_State, - LookinAttrSec_NSControl_ControlSize, - LookinAttrSec_NSControl_Font, - LookinAttrSec_NSControl_Alignment, - LookinAttrSec_NSControl_Misc, - LookinAttrSec_NSControl_StringValue, - LookinAttrSec_NSControl_Value, - // NSButton - LookinAttrSec_NSButton_ButtonType, - LookinAttrSec_NSButton_Title, - LookinAttrSec_NSButton_BezelStyle, - LookinAttrSec_NSButton_Bordered, - LookinAttrSec_NSButton_BezelColor, - LookinAttrSec_NSButton_Misc, - // NSScrollView - LookinAttrSec_NSScrollView_ContentOffset, - LookinAttrSec_NSScrollView_ContentSize, - LookinAttrSec_NSScrollView_ContentInset, - LookinAttrSec_NSScrollView_BorderType, - LookinAttrSec_NSScrollView_Scroller, - LookinAttrSec_NSScrollView_Ruler, - LookinAttrSec_NSScrollView_LineScroll, - LookinAttrSec_NSScrollView_PageScroll, - LookinAttrSec_NSScrollView_ScrollElasiticity, - LookinAttrSec_NSScrollView_Misc, - LookinAttrSec_NSScrollView_Magnification, - // NSTableView - LookinAttrSec_NSTableView_RowHeight, - LookinAttrSec_NSTableView_AutomaticRowHeights, - LookinAttrSec_NSTableView_IntercellSpacing, - LookinAttrSec_NSTableView_Style, - LookinAttrSec_NSTableView_ColumnAutoresizingStyle, - LookinAttrSec_NSTableView_GridStyleMask, - LookinAttrSec_NSTableView_SelectionHighlightStyle, - LookinAttrSec_NSTableView_GridColor, - LookinAttrSec_NSTableView_RowSizeStyle, - LookinAttrSec_NSTableView_NumberOfRows, - LookinAttrSec_NSTableView_NumberOfColumns, - LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors, - LookinAttrSec_NSTableView_AllowsColumnReordering, - LookinAttrSec_NSTableView_AllowsColumnResizing, - LookinAttrSec_NSTableView_AllowsMultipleSelection, - LookinAttrSec_NSTableView_AllowsEmptySelection, - LookinAttrSec_NSTableView_AllowsColumnSelection, - LookinAttrSec_NSTableView_AllowsTypeSelect, - LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle, - LookinAttrSec_NSTableView_Autosave, - LookinAttrSec_NSTableView_FloatsGroupRows, - LookinAttrSec_NSTableView_RowActionsVisible, - LookinAttrSec_NSTableView_UsesStaticContents, - LookinAttrSec_NSTableView_UserInterfaceLayoutDirection, - LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag, - // NSTextField - LookinAttrSec_NSTextField_BezelStyle, - LookinAttrSec_NSTextField_Bordered, - LookinAttrSec_NSTextField_TextColor, - LookinAttrSec_NSTextField_Placeholder, - LookinAttrSec_NSTextField_LineBreakStrategy, - LookinAttrSec_NSTextField_PreferredMaxLayoutWidth, - // NSTextView - LookinAttrSec_NSTextView_String, - LookinAttrSec_NSTextView_Basic, - LookinAttrSec_NSTextView_Font, - LookinAttrSec_NSTextView_TextColor, - LookinAttrSec_NSTextView_Alignment, - LookinAttrSec_NSTextView_ContainerInset, - LookinAttrSec_NSTextView_BaseWritingDirection, - LookinAttrSec_NSTextView_Size, - LookinAttrSec_NSTextView_Resizable, - // NSVisualEffectView - LookinAttrSec_NSVisualEffectView_Material, - LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle, - LookinAttrSec_NSVisualEffectView_BlendingMode, - LookinAttrSec_NSVisualEffectView_State, - LookinAttrSec_NSVisualEffectView_Emphasized, - // NSStackView - LookinAttrSec_NSStackView_Orientation, - LookinAttrSec_NSStackView_EdgeInsets, - LookinAttrSec_NSStackView_DetachesHiddenViews, - LookinAttrSec_NSStackView_Distribution, - LookinAttrSec_NSStackView_Alignment, - LookinAttrSec_NSStackView_Spacing, - // NSSlider - LookinAttrSec_NSSlider_SliderType, - LookinAttrSec_NSSlider_Range, - LookinAttrSec_NSSlider_TickMark, - LookinAttrSec_NSSlider_Misc, - // NSProgressIndicator - LookinAttrSec_NSProgressIndicator_Style, - LookinAttrSec_NSProgressIndicator_Range, - LookinAttrSec_NSProgressIndicator_Misc, - // NSSegmentedControl - LookinAttrSec_NSSegmentedControl_SegmentCount, - LookinAttrSec_NSSegmentedControl_Selection, - LookinAttrSec_NSSegmentedControl_Style, - LookinAttrSec_NSSegmentedControl_Colors, - // NSPopUpButton - LookinAttrSec_NSPopUpButton_Behavior, - LookinAttrSec_NSPopUpButton_Selection, - LookinAttrSec_NSPopUpButton_Items, - // NSComboBox - LookinAttrSec_NSComboBox_Items, - LookinAttrSec_NSComboBox_Misc, - // NSStepper - LookinAttrSec_NSStepper_Range, - LookinAttrSec_NSStepper_Misc, - // NSColorWell - LookinAttrSec_NSColorWell_Color, - LookinAttrSec_NSColorWell_Misc, - // NSSwitch - LookinAttrSec_NSSwitch_State, - // NSDatePicker - LookinAttrSec_NSDatePicker_Style, - LookinAttrSec_NSDatePicker_Range, - LookinAttrSec_NSDatePicker_Misc, - // NSLevelIndicator - LookinAttrSec_NSLevelIndicator_Style, - LookinAttrSec_NSLevelIndicator_Range, - LookinAttrSec_NSLevelIndicator_TickMark, - // NSOutlineView - LookinAttrSec_NSOutlineView_Indentation, - LookinAttrSec_NSOutlineView_Misc, - // NSCollectionView - LookinAttrSec_NSCollectionView_Selection, - LookinAttrSec_NSCollectionView_Info, - LookinAttrSec_NSCollectionView_Colors, - // NSBox - LookinAttrSec_NSBox_Type, - LookinAttrSec_NSBox_Title, - LookinAttrSec_NSBox_Appearance, - LookinAttrSec_NSBox_Metrics, - // NSSplitView - LookinAttrSec_NSSplitView_Orientation, - LookinAttrSec_NSSplitView_Style, - LookinAttrSec_NSSplitView_Misc, - // NSTabView - LookinAttrSec_NSTabView_Type, - LookinAttrSec_NSTabView_Misc, - LookinAttrSec_NSTabView_Info, - // NSGridView - LookinAttrSec_NSGridView_Dimensions, - LookinAttrSec_NSGridView_Spacing, - LookinAttrSec_NSGridView_Placement, - // UIWindowScene - LookinAttrSec_UIWindowScene_State, - LookinAttrSec_UIWindowScene_Title, - LookinAttrSec_UIWindowScene_Orientation, - LookinAttrSec_UIWindowScene_Windows, - LookinAttrSec_UIWindowScene_Screen, - LookinAttrSec_UIWindowScene_StatusBar, - LookinAttrSec_UIWindowScene_Geometry, - LookinAttrSec_UIWindowScene_SizeRestrictions, - LookinAttrSec_UIWindowScene_WindowingBehaviors, - LookinAttrSec_UIWindowScene_Pointer, - LookinAttrSec_UIWindowScene_Protection, - LookinAttrSec_UIWindowScene_Traits, - LookinAttrSec_UIWindowScene_Session, - LookinAttrSec_UIWindowScene_Configuration, - LookinAttrSec_UIWindowScene_ActivationConditions, - // UITraitCollection - LookinAttrSec_UITraitCollection_Appearance, - LookinAttrSec_UITraitCollection_SizeClass, - LookinAttrSec_UITraitCollection_Display, - LookinAttrSec_UITraitCollection_Device, - LookinAttrSec_UITraitCollection_Layout, - LookinAttrSec_UITraitCollection_Content, - - ]; - targetSet = [NSSet setWithArray:array]; - }); - return targetSet; -} - -- (void)reset { -} - -@end diff --git a/LookInside/Manager/LKPreferenceManager.swift b/LookInside/Manager/LKPreferenceManager.swift new file mode 100644 index 00000000..636dd43b --- /dev/null +++ b/LookInside/Manager/LKPreferenceManager.swift @@ -0,0 +1,730 @@ +// +// LKPreferenceManager.swift +// LookInside +// +// Created by Li Kai on 2019/1/8. +// https://lookin.work +// +// The user's preferences. `mainManager` stores them in the standard user +// defaults under the original keys; other instances (the reader windows) +// start from the stored values and keep their changes in memory. +// +// Properties are KVO-observable; the LookinMsgAttribute ones notify their +// subscribers instead. +// + +import AppKit +import LookInsideHostCore + +let LKWindowSizeName_Dynamic = "LKWindowSizeName_Dynamic" +let LKWindowSizeName_Static = "LKWindowSizeName_Static" + +/// The initial preview scale. +let LKInitialPreviewScale: CGFloat = 0.27 + +/// The default hierarchy request timeout, in seconds. +let LKDefaultHierarchyRequestTimeoutInterval: TimeInterval = 15 +let LKDefaultLicenseHandshakeTimeoutInterval: TimeInterval = 5 + +/// Posted when a dashboard section is added or removed. +let NotificationName_DidChangeSectionShowing = "NotificationName_DidChangeSectionShowing" + +// The raw values of these enums are stored in the user defaults; never +// reorder the cases. + +@objc enum LookinPreferredAppeanranceType: Int { + case dark + case light + case system +} + +@objc enum LookinDoubleClickBehavior: Int { + case collapse + case focus +} + +@objc enum LookinPreferredCallStackType: Int { + /// Formatted and shortened. + case `default` + /// Formatted, complete. + case formattedCompletely + /// The raw call stack. + case raw +} + +@objc enum LookinMeasureState: Int { + /// Not measuring. + case no + /// Measuring until the key is released. + case unlocked + /// Measuring until the user turns it off. + case locked +} + +@objc(LKPreferenceManager) +final class LKPreferenceManager: NSObject { + /// User-defaults keys. Never rename: they hold existing users' settings. + private enum Key { + static let previousClientVersion = "preVer" + static let showOutline = "showOutline" + static let showHiddenItems = "showHiddenItems" + static let showSystemLayoutGuides = "showSystemLayoutGuides" + static let showBackingLayers = "showBackingLayers" + static let rgbaFormat = "egbaFormat" + static let zInterspace = "zInterspace_v095" + static let appearanceType = "appearanceType" + static let doubleClickBehavior = "doubleClickBehavior" + static let expansionIndex = "expansionIndex" + static let contrastLevel = "contrastLevel" + static let sectionsShow = "ss" + static let collapsedGroups = "collapsedGroups_918" + static let preferredExportCompression = "preferredExportCompression" + static let hierarchyRequestTimeoutInterval = "hierarchyRequestTimeoutInterval" + static let licenseHandshakeTimeoutInterval = "licenseHandshakeTimeoutInterval" + static let syncConsoleTarget = "syncConsoleTarget" + static let freeRotation = "FreeRotation" + static let fastMode = "fastMode" + static let receivingConfigTimeColor = "ConfigTime_Color" + static let receivingConfigTimeClass = "ConfigTime_Class" + static let rememberExpansionState = "rememberExpansionState" + } + + static let shared: LKPreferenceManager = { + let manager = LKPreferenceManager() + manager.shouldStoreToLocal = true + return manager + }() + + private var defaults: UserDefaults { + .standard + } + + /// 默认为 NO + @objc var shouldStoreToLocal = false + + @objc dynamic var appearanceType: LookinPreferredAppeanranceType { + didSet { + guard oldValue != appearanceType else { return } + store(appearanceType.rawValue, forKey: Key.appearanceType) + } + } + + @objc dynamic var doubleClickBehavior: LookinDoubleClickBehavior { + didSet { + store(doubleClickBehavior.rawValue, forKey: Key.doubleClickBehavior) + } + } + + /// 有效值为 0 ~ 4 + @objc dynamic var expansionIndex: Int { + didSet { + guard oldValue != expansionIndex else { return } + store(expansionIndex, forKey: Key.expansionIndex) + } + } + + /// Not stored: the subscription in the Objective-C version targeted + /// `showHiddenItems` before it existed, so changes were never written. + @objc let showOutline: LookinBOOLMsgAttribute + + @objc let showHiddenItems: LookinBOOLMsgAttribute + + /// 是否在层级树里显示系统自动创建的 LayoutGuide(safe area、layout margins 等)。 + /// 默认显示;关闭只是宿主端过滤,数据仍在。用户自建 guide 不受此开关影响。 + @objc let showSystemLayoutGuides: LookinBOOLMsgAttribute + + /// 是否显示 view 的 backing layer 子树(backing-layer-toggle 提案)。 + /// 默认关闭。开启后像 Xcode 的 Show Layers:每个 view 节点下展开完整 layer 子树, + /// 内容显示在真正渲染它的 layer 上,view 节点变为线框。切换时重新拉取层级。 + @objc let showBackingLayers: LookinBOOLMsgAttribute + + /// 范围是 0 ~ 1 + @objc let zInterspace: LookinDoubleMsgAttribute + + @objc dynamic var rgbaFormat: Bool { + didSet { + guard oldValue != rgbaFormat else { return } + store(rgbaFormat, forKey: Key.rgbaFormat) + } + } + + /// 0 ~ 2 + @objc dynamic var imageContrastLevel: Int { + didSet { + guard oldValue != imageContrastLevel else { return } + store(imageContrastLevel, forKey: Key.contrastLevel) + } + } + + /// 是否自动将选中的 UIView/CALayer 作为控制台的目标对象 + @objc dynamic var syncConsoleTarget: Bool { + didSet { + guard oldValue != syncConsoleTarget else { return } + store(syncConsoleTarget, forKey: Key.syncConsoleTarget) + } + } + + /// 是否按目标 App bundle id 持久化层级折叠状态。默认 YES。 + /// 关闭后已存储的折叠状态保留在磁盘上,仅停止读取和写入。 + @objc dynamic var rememberExpansionState: Bool { + didSet { + guard oldValue != rememberExpansionState else { return } + store(rememberExpansionState, forKey: Key.rememberExpansionState) + } + } + + /// 被折叠的 AttrGroup + @objc dynamic var collapsedAttrGroups: [String] { + didSet { + store(collapsedAttrGroups, forKey: Key.collapsedGroups) + } + } + + @objc dynamic var preferredExportCompression: CGFloat { + didSet { + guard oldValue != preferredExportCompression else { return } + store(Double(preferredExportCompression), forKey: Key.preferredExportCompression) + } + } + + private var storedHierarchyRequestTimeoutInterval: TimeInterval + + /// A value of 0 or less resets to the default. + @objc dynamic var hierarchyRequestTimeoutInterval: TimeInterval { + get { storedHierarchyRequestTimeoutInterval } + set { + let value = newValue <= 0 ? LKDefaultHierarchyRequestTimeoutInterval : newValue + guard storedHierarchyRequestTimeoutInterval != value else { return } + storedHierarchyRequestTimeoutInterval = value + store(value, forKey: Key.hierarchyRequestTimeoutInterval) + } + } + + private var storedLicenseHandshakeTimeoutInterval: TimeInterval + + /// A value of 0 or less resets to the default. + @objc dynamic var licenseHandshakeTimeoutInterval: TimeInterval { + get { storedLicenseHandshakeTimeoutInterval } + set { + let value = newValue <= 0 ? LKDefaultLicenseHandshakeTimeoutInterval : newValue + guard storedLicenseHandshakeTimeoutInterval != value else { return } + storedLicenseHandshakeTimeoutInterval = value + store(value, forKey: Key.licenseHandshakeTimeoutInterval) + } + } + + @objc let freeRotation: LookinBOOLMsgAttribute + + @objc let fastMode: LookinBOOLMsgAttribute + + /// 上次接收到 iOS app 里传过来的 color config 和 collapsedClasses 信息的时间,用来统计 + /// Always stored, even when `shouldStoreToLocal` is off. + @objc dynamic var receivingConfigTime_Color: TimeInterval { + didSet { + defaults.set(receivingConfigTime_Color, forKey: Key.receivingConfigTimeColor) + } + } + + @objc dynamic var receivingConfigTime_Class: TimeInterval { + didSet { + defaults.set(receivingConfigTime_Class, forKey: Key.receivingConfigTimeClass) + } + } + + private var storedSectionShowConfig: [String: Bool] + + // MARK: - Not stored + + private var storedCallStackType: LookinPreferredCallStackType = .default + + @objc dynamic var callStackType: LookinPreferredCallStackType { + get { storedCallStackType } + set { + if newValue.rawValue < 0 || newValue.rawValue > 2 { + assertionFailure("callStackType out of range: \(newValue.rawValue)") + storedCallStackType = .default + } else { + storedCallStackType = newValue + } + } + } + + /// 参数是 LookinPreviewDimension + @objc let previewDimension: LookinIntegerMsgAttribute + + @objc let previewScale: LookinDoubleMsgAttribute + + /// 参数是 LookinMeasureState + @objc let measureState: LookinIntegerMsgAttribute + + /// 是否用户正在按住 cmd 键而处于快速选择模式 + @objc let isQuickSelecting: LookinBOOLMsgAttribute + + // MARK: - Init + + /// Reads every stored value; a missing one is set to its default and + /// written back, as before. + override init() { + let defaults = UserDefaults.standard + + previewScale = LookinDoubleMsgAttribute(double: Double(LKInitialPreviewScale)) + previewDimension = LookinIntegerMsgAttribute(integer: Int(LookinPreviewDimension.dimension3D.rawValue)) + measureState = LookinIntegerMsgAttribute(integer: LookinMeasureState.no.rawValue) + isQuickSelecting = LookinBOOLMsgAttribute(bool: false) + + if defaults.integer(forKey: Key.previousClientVersion) != Int(LOOKIN_CLIENT_VERSION) { + defaults.set(Int(LOOKIN_CLIENT_VERSION), forKey: Key.previousClientVersion) + } + + // The stored number for `key`, or `fallback` written back. + func number(_ key: String, default fallback: NSNumber) -> NSNumber { + if let stored = Self.storedNumber(defaults.object(forKey: key)) { + return stored + } + defaults.set(fallback, forKey: key) + return fallback + } + + showOutline = LookinBOOLMsgAttribute(bool: number(Key.showOutline, default: true).boolValue) + showHiddenItems = LookinBOOLMsgAttribute(bool: number(Key.showHiddenItems, default: false).boolValue) + showSystemLayoutGuides = LookinBOOLMsgAttribute(bool: number(Key.showSystemLayoutGuides, default: true).boolValue) + showBackingLayers = LookinBOOLMsgAttribute(bool: number(Key.showBackingLayers, default: false).boolValue) + doubleClickBehavior = LookinDoubleClickBehavior(rawValue: number(Key.doubleClickBehavior, default: NSNumber(value: LookinDoubleClickBehavior.collapse.rawValue)).intValue) ?? .collapse + rgbaFormat = number(Key.rgbaFormat, default: true).boolValue + + // 默认值为 0.22 + let zInterspaceValue = number(Key.zInterspace, default: 0.22).doubleValue + zInterspace = LookinDoubleMsgAttribute(double: max(min(zInterspaceValue, Double(LookinPreviewMaxZInterspace)), Double(LookinPreviewMinZInterspace))) + + appearanceType = LookinPreferredAppeanranceType(rawValue: number(Key.appearanceType, default: NSNumber(value: LookinPreferredAppeanranceType.system.rawValue)).intValue) ?? .system + expansionIndex = number(Key.expansionIndex, default: 3).intValue + imageContrastLevel = number(Key.contrastLevel, default: 0).intValue + syncConsoleTarget = number(Key.syncConsoleTarget, default: true).boolValue + freeRotation = LookinBOOLMsgAttribute(bool: number(Key.freeRotation, default: true).boolValue) + fastMode = LookinBOOLMsgAttribute(bool: number(Key.fastMode, default: false).boolValue) + + var sectionShowConfig: [String: Bool] = [:] + if let stored = defaults.object(forKey: Key.sectionsShow) as? NSDictionary { + for case let (secID as String, value as NSNumber) in stored { + sectionShowConfig[secID] = value.boolValue + } + } + storedSectionShowConfig = sectionShowConfig + + collapsedAttrGroups = (defaults.object(forKey: Key.collapsedGroups) as? [String]) ?? [LookinAttrGroup_Class as String] + + // 这里的默认值需要在 LKExportAccessory.m 里定义的选项里面 + preferredExportCompression = CGFloat(number(Key.preferredExportCompression, default: 0.5).doubleValue) + + storedHierarchyRequestTimeoutInterval = Self.positiveInterval( + defaults, key: Key.hierarchyRequestTimeoutInterval, fallback: LKDefaultHierarchyRequestTimeoutInterval + ) + storedLicenseHandshakeTimeoutInterval = Self.positiveInterval( + defaults, key: Key.licenseHandshakeTimeoutInterval, fallback: LKDefaultLicenseHandshakeTimeoutInterval + ) + + receivingConfigTime_Color = defaults.double(forKey: Key.receivingConfigTimeColor) + receivingConfigTime_Class = defaults.double(forKey: Key.receivingConfigTimeClass) + + rememberExpansionState = number(Key.rememberExpansionState, default: true).boolValue + + super.init() + + showHiddenItems.subscribe(self, action: #selector(handleShowHiddenItemsChange(_:)), relatedObject: nil) + showSystemLayoutGuides.subscribe(self, action: #selector(handleShowSystemLayoutGuidesChange(_:)), relatedObject: nil) + showBackingLayers.subscribe(self, action: #selector(handleShowBackingLayersChange(_:)), relatedObject: nil) + zInterspace.subscribe(self, action: #selector(handleZInterspaceDidChange(_:)), relatedObject: nil) + freeRotation.subscribe(self, action: #selector(handleFreeRotationDidChange(_:)), relatedObject: nil) + fastMode.subscribe(self, action: #selector(handleFastModeDidChange(_:)), relatedObject: nil) + } + + /// A stored value read the way the Objective-C manager read it, with + /// -integerValue / -doubleValue / -boolValue: an NSNumber, or a string. + /// A `-key value` launch argument (the e2e scripts pass + /// `-appearanceType 0`) reaches the argument domain as a string. + private static func storedNumber(_ object: Any?) -> NSNumber? { + switch object { + case let number as NSNumber: + return number + case let string as NSString: + let value = string.doubleValue + if value == 0, string.boolValue { + return NSNumber(value: true) + } + return NSNumber(value: value) + default: + return nil + } + } + + /// A stored interval above 0, or `fallback` written back. + private static func positiveInterval(_ defaults: UserDefaults, key: String, fallback: TimeInterval) -> TimeInterval { + if let stored = storedNumber(defaults.object(forKey: key)), stored.doubleValue > 0 { + return stored.doubleValue + } + defaults.set(fallback, forKey: key) + return fallback + } + + /// Writes `value` when this manager stores its changes. + private func store(_ value: Any, forKey key: String) { + guard shouldStoreToLocal else { return } + defaults.set(value, forKey: key) + } + + // MARK: - Expansion state + + /// 读取某 bundle id 下记录的展开/折叠状态。key 为结构路径,value 为 @(YES) 表示展开、@(NO) 表示折叠。 + /// 未命中返回空字典。为兼容早期仅记录 expanded paths 的存档(NSArray), + /// 读到旧格式时会自动转换为 dict 形式(全部视为 @(YES))。 + @objc(expansionStateForBundleIdentifier:) + func expansionState(forBundleIdentifier bundleIdentifier: String?) -> [String: NSNumber] { + guard let bundleIdentifier, !bundleIdentifier.isEmpty else { + return [:] + } + let stored = defaults.object(forKey: ExpansionStatePolicy.stateKey(forBundleIdentifier: bundleIdentifier)) + return ExpansionStatePolicy.decodeState(stored).mapValues { NSNumber(value: $0) } + } + + /// 写入某 bundle id 下记录的展开/折叠状态,并将该 bundle id 提升到 LRU 队首。 + /// 当 LRU 超过容量上限时,最旧的 bundle id 会被静默移除(其对应的展开状态键也会被清理)。 + /// 若 rememberExpansionState 为 NO 或 bundleIdentifier 为空,则不做任何操作。 + @objc(setExpansionState:forBundleIdentifier:) + func setExpansionState(_ expansionState: [String: NSNumber]?, forBundleIdentifier bundleIdentifier: String?) { + guard let bundleIdentifier, !bundleIdentifier.isEmpty, rememberExpansionState, shouldStoreToLocal else { + return + } + let lru = ExpansionStatePolicy.lru(movingToFront: bundleIdentifier, in: defaults.object(forKey: ExpansionStatePolicy.lruKey)) + let (kept, evicted) = ExpansionStatePolicy.evicting(lru) + for evictedBundleIdentifier in evicted { + defaults.removeObject(forKey: ExpansionStatePolicy.stateKey(forBundleIdentifier: evictedBundleIdentifier)) + } + defaults.set(expansionState ?? [:], forKey: ExpansionStatePolicy.stateKey(forBundleIdentifier: bundleIdentifier)) + defaults.set(kept, forKey: ExpansionStatePolicy.lruKey) + } + + /// 将一个已记录的 bundle id 提升到 LRU 队首。若该 bundle id 未被记录则不做任何操作。 + /// 当 rememberExpansionState 为 NO 或 bundleIdentifier 为空时同样不做任何操作。 + @objc(bumpExpansionStateBundleIdentifierToMostRecent:) + func bumpExpansionStateBundleIdentifierToMostRecent(_ bundleIdentifier: String?) { + guard let bundleIdentifier, !bundleIdentifier.isEmpty, rememberExpansionState, shouldStoreToLocal else { + return + } + let storedLRU = defaults.object(forKey: ExpansionStatePolicy.lruKey) + // Absent → the first setExpansionState:... call will record it. + guard ExpansionStatePolicy.shouldBump(bundleIdentifier, in: storedLRU) else { + return + } + defaults.set(ExpansionStatePolicy.lru(movingToFront: bundleIdentifier, in: storedLRU), forKey: ExpansionStatePolicy.lruKey) + } + + // MARK: - Attribute subscribers + + @objc private func handleShowHiddenItemsChange(_ param: LookinMsgActionParams) { + store(param.boolValue, forKey: Key.showHiddenItems) + } + + @objc private func handleShowSystemLayoutGuidesChange(_ param: LookinMsgActionParams) { + store(param.boolValue, forKey: Key.showSystemLayoutGuides) + } + + @objc private func handleShowBackingLayersChange(_ param: LookinMsgActionParams) { + store(param.boolValue, forKey: Key.showBackingLayers) + } + + @objc private func handleFreeRotationDidChange(_ param: LookinMsgActionParams) { + store(param.boolValue, forKey: Key.freeRotation) + } + + @objc private func handleFastModeDidChange(_ param: LookinMsgActionParams) { + store(param.boolValue, forKey: Key.fastMode) + } + + @objc private func handleZInterspaceDidChange(_ param: LookinMsgActionParams) { + store(param.doubleValue, forKey: Key.zInterspace) + } + + // MARK: - Dashboard sections + + /// 返回某个 section 是否应该被显示在主界面上 + @objc func isSectionShowing(_ secID: String) -> Bool { + if let stored = storedSectionShowConfig[secID] { + return stored + } + return Self.sectionsShowingByDefault.contains(secID) + } + + /// 把某个 section 显示在主界面上 + @objc func showSection(_ secID: String) { + setSection(secID, showing: true) + } + + /// 把某个 section 从主界面上移除 + @objc func hideSection(_ secID: String) { + setSection(secID, showing: false) + } + + /// Always stored, even when `shouldStoreToLocal` is off. + private func setSection(_ secID: String, showing: Bool) { + guard isSectionShowing(secID) != showing else { + assertionFailure("section \(secID) is already \(showing ? "shown" : "hidden")") + return + } + storedSectionShowConfig[secID] = showing + NotificationCenter.default.post(name: NSNotification.Name(NotificationName_DidChangeSectionShowing), object: nil) + defaults.set(storedSectionShowConfig, forKey: Key.sectionsShow) + } + + @objc func reset() {} + + /// 返回默认情况下,哪些 section 应该被显示在主界面上 + private static let sectionsShowingByDefault: Set = Set(([ + LookinAttrSec_Class_Class, + LookinAttrSec_Relation_Relation, + LookinAttrSec_Layout_Frame, + LookinAttrSec_Layout_Bounds, + LookinAttrSec_Layout_CoordinateSpace, + LookinAttrSec_AutoLayout_Hugging, + LookinAttrSec_AutoLayout_Resistance, + LookinAttrSec_AutoLayout_Constraints, + LookinAttrSec_AutoLayout_IntrinsicSize, + LookinAttrSec_LayoutGuide_Identifier, + LookinAttrSec_LayoutGuide_LayoutFrame, + LookinAttrSec_LayoutGuide_OwningView, + LookinAttrSec_NSCell_Cell, + LookinAttrSec_NSCell_Content, + LookinAttrSec_NSCell_Behavior, + LookinAttrSec_NSCell_ButtonCell, + LookinAttrSec_NSCell_TextFieldCell, + LookinAttrSec_ViewLayer_Visibility, + LookinAttrSec_ViewLayer_InterationAndMasks, + LookinAttrSec_ViewLayer_Corner, + LookinAttrSec_ViewLayer_BgColor, + LookinAttrSec_ViewLayer_Border, + LookinAttrSec_ViewLayer_Shadow, + LookinAttrSec_UIStackView_Axis, + LookinAttrSec_UIStackView_Alignment, + LookinAttrSec_UIStackView_Distribution, + LookinAttrSec_UIStackView_Spacing, + LookinAttrSec_UIVisualEffectView_Style, + LookinAttrSec_UIVisualEffectView_QMUIForegroundColor, + LookinAttrSec_UIImageView_Name, + LookinAttrSec_UIImageView_Open, + LookinAttrSec_UILabel_Text, + LookinAttrSec_UILabel_Font, + LookinAttrSec_UILabel_NumberOfLines, + LookinAttrSec_UILabel_TextColor, + LookinAttrSec_UILabel_BreakMode, + LookinAttrSec_UILabel_Alignment, + LookinAttrSec_UIControl_EnabledSelected, + LookinAttrSec_UIControl_QMUIOutsideEdge, + LookinAttrSec_UIButton_ContentInsets, + LookinAttrSec_UIScrollView_ContentInset, + LookinAttrSec_UIScrollView_AdjustedInset, + LookinAttrSec_UIScrollView_IndicatorInset, + LookinAttrSec_UIScrollView_Offset, + LookinAttrSec_UIScrollView_ContentSize, + LookinAttrSec_UIScrollView_Behavior, + LookinAttrSec_UITableView_Style, + LookinAttrSec_UITableView_SectionsNumber, + LookinAttrSec_UITableView_RowsNumber, + LookinAttrSec_UITextView_Text, + LookinAttrSec_UITextView_Font, + LookinAttrSec_UITextView_TextColor, + LookinAttrSec_UITextView_Alignment, + LookinAttrSec_UITextView_ContainerInset, + LookinAttrSec_UITextField_Text, + LookinAttrSec_UITextField_Font, + LookinAttrSec_UITextField_TextColor, + LookinAttrSec_UITextField_Alignment, + // NSWindow + LookinAttrSec_NSWindow_Title, + LookinAttrSec_NSWindow_Subtitle, + LookinAttrSec_NSWindow_State, + LookinAttrSec_NSWindow_Style, + LookinAttrSec_NSWindow_CollectionBehavior, + LookinAttrSec_NSWindow_Appearance, + LookinAttrSec_NSWindow_TitleVisibility, + LookinAttrSec_NSWindow_ToolbarStyle, + LookinAttrSec_NSWindow_TitlebarSeparatorStyle, + LookinAttrSec_NSWindow_Behavior, + LookinAttrSec_NSWindow_AnimationBehavior, + LookinAttrSec_NSWindow_Level, + LookinAttrSec_NSWindow_TabbingMode, + LookinAttrSec_NSWindow_Size, + LookinAttrSec_NSWindow_Info, + // NSImageView + LookinAttrSec_NSImageView_Name, + LookinAttrSec_NSImageView_Open, + LookinAttrSec_NSImageView_Scaling, + LookinAttrSec_NSImageView_Behavior, + LookinAttrSec_NSImageView_ContentTintColor, + // NSControl + LookinAttrSec_NSControl_State, + LookinAttrSec_NSControl_ControlSize, + LookinAttrSec_NSControl_Font, + LookinAttrSec_NSControl_Alignment, + LookinAttrSec_NSControl_Misc, + LookinAttrSec_NSControl_StringValue, + LookinAttrSec_NSControl_Value, + // NSButton + LookinAttrSec_NSButton_ButtonType, + LookinAttrSec_NSButton_Title, + LookinAttrSec_NSButton_BezelStyle, + LookinAttrSec_NSButton_Bordered, + LookinAttrSec_NSButton_BezelColor, + LookinAttrSec_NSButton_Misc, + // NSScrollView + LookinAttrSec_NSScrollView_ContentOffset, + LookinAttrSec_NSScrollView_ContentSize, + LookinAttrSec_NSScrollView_ContentInset, + LookinAttrSec_NSScrollView_BorderType, + LookinAttrSec_NSScrollView_Scroller, + LookinAttrSec_NSScrollView_Ruler, + LookinAttrSec_NSScrollView_LineScroll, + LookinAttrSec_NSScrollView_PageScroll, + LookinAttrSec_NSScrollView_ScrollElasiticity, + LookinAttrSec_NSScrollView_Misc, + LookinAttrSec_NSScrollView_Magnification, + // NSTableView + LookinAttrSec_NSTableView_RowHeight, + LookinAttrSec_NSTableView_AutomaticRowHeights, + LookinAttrSec_NSTableView_IntercellSpacing, + LookinAttrSec_NSTableView_Style, + LookinAttrSec_NSTableView_ColumnAutoresizingStyle, + LookinAttrSec_NSTableView_GridStyleMask, + LookinAttrSec_NSTableView_SelectionHighlightStyle, + LookinAttrSec_NSTableView_GridColor, + LookinAttrSec_NSTableView_RowSizeStyle, + LookinAttrSec_NSTableView_NumberOfRows, + LookinAttrSec_NSTableView_NumberOfColumns, + LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors, + LookinAttrSec_NSTableView_AllowsColumnReordering, + LookinAttrSec_NSTableView_AllowsColumnResizing, + LookinAttrSec_NSTableView_AllowsMultipleSelection, + LookinAttrSec_NSTableView_AllowsEmptySelection, + LookinAttrSec_NSTableView_AllowsColumnSelection, + LookinAttrSec_NSTableView_AllowsTypeSelect, + LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle, + LookinAttrSec_NSTableView_Autosave, + LookinAttrSec_NSTableView_FloatsGroupRows, + LookinAttrSec_NSTableView_RowActionsVisible, + LookinAttrSec_NSTableView_UsesStaticContents, + LookinAttrSec_NSTableView_UserInterfaceLayoutDirection, + LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag, + // NSTextField + LookinAttrSec_NSTextField_BezelStyle, + LookinAttrSec_NSTextField_Bordered, + LookinAttrSec_NSTextField_TextColor, + LookinAttrSec_NSTextField_Placeholder, + LookinAttrSec_NSTextField_LineBreakStrategy, + LookinAttrSec_NSTextField_PreferredMaxLayoutWidth, + // NSTextView + LookinAttrSec_NSTextView_String, + LookinAttrSec_NSTextView_Basic, + LookinAttrSec_NSTextView_Font, + LookinAttrSec_NSTextView_TextColor, + LookinAttrSec_NSTextView_Alignment, + LookinAttrSec_NSTextView_ContainerInset, + LookinAttrSec_NSTextView_BaseWritingDirection, + LookinAttrSec_NSTextView_Size, + LookinAttrSec_NSTextView_Resizable, + // NSVisualEffectView + LookinAttrSec_NSVisualEffectView_Material, + LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle, + LookinAttrSec_NSVisualEffectView_BlendingMode, + LookinAttrSec_NSVisualEffectView_State, + LookinAttrSec_NSVisualEffectView_Emphasized, + // NSStackView + LookinAttrSec_NSStackView_Orientation, + LookinAttrSec_NSStackView_EdgeInsets, + LookinAttrSec_NSStackView_DetachesHiddenViews, + LookinAttrSec_NSStackView_Distribution, + LookinAttrSec_NSStackView_Alignment, + LookinAttrSec_NSStackView_Spacing, + // NSSlider + LookinAttrSec_NSSlider_SliderType, + LookinAttrSec_NSSlider_Range, + LookinAttrSec_NSSlider_TickMark, + LookinAttrSec_NSSlider_Misc, + // NSProgressIndicator + LookinAttrSec_NSProgressIndicator_Style, + LookinAttrSec_NSProgressIndicator_Range, + LookinAttrSec_NSProgressIndicator_Misc, + // NSSegmentedControl + LookinAttrSec_NSSegmentedControl_SegmentCount, + LookinAttrSec_NSSegmentedControl_Selection, + LookinAttrSec_NSSegmentedControl_Style, + LookinAttrSec_NSSegmentedControl_Colors, + // NSPopUpButton + LookinAttrSec_NSPopUpButton_Behavior, + LookinAttrSec_NSPopUpButton_Selection, + LookinAttrSec_NSPopUpButton_Items, + // NSComboBox + LookinAttrSec_NSComboBox_Items, + LookinAttrSec_NSComboBox_Misc, + // NSStepper + LookinAttrSec_NSStepper_Range, + LookinAttrSec_NSStepper_Misc, + // NSColorWell + LookinAttrSec_NSColorWell_Color, + LookinAttrSec_NSColorWell_Misc, + // NSSwitch + LookinAttrSec_NSSwitch_State, + // NSDatePicker + LookinAttrSec_NSDatePicker_Style, + LookinAttrSec_NSDatePicker_Range, + LookinAttrSec_NSDatePicker_Misc, + // NSLevelIndicator + LookinAttrSec_NSLevelIndicator_Style, + LookinAttrSec_NSLevelIndicator_Range, + LookinAttrSec_NSLevelIndicator_TickMark, + // NSOutlineView + LookinAttrSec_NSOutlineView_Indentation, + LookinAttrSec_NSOutlineView_Misc, + // NSCollectionView + LookinAttrSec_NSCollectionView_Selection, + LookinAttrSec_NSCollectionView_Info, + LookinAttrSec_NSCollectionView_Colors, + // NSBox + LookinAttrSec_NSBox_Type, + LookinAttrSec_NSBox_Title, + LookinAttrSec_NSBox_Appearance, + LookinAttrSec_NSBox_Metrics, + // NSSplitView + LookinAttrSec_NSSplitView_Orientation, + LookinAttrSec_NSSplitView_Style, + LookinAttrSec_NSSplitView_Misc, + // NSTabView + LookinAttrSec_NSTabView_Type, + LookinAttrSec_NSTabView_Misc, + LookinAttrSec_NSTabView_Info, + // NSGridView + LookinAttrSec_NSGridView_Dimensions, + LookinAttrSec_NSGridView_Spacing, + LookinAttrSec_NSGridView_Placement, + // UIWindowScene + LookinAttrSec_UIWindowScene_State, + LookinAttrSec_UIWindowScene_Title, + LookinAttrSec_UIWindowScene_Orientation, + LookinAttrSec_UIWindowScene_Windows, + LookinAttrSec_UIWindowScene_Screen, + LookinAttrSec_UIWindowScene_StatusBar, + LookinAttrSec_UIWindowScene_Geometry, + LookinAttrSec_UIWindowScene_SizeRestrictions, + LookinAttrSec_UIWindowScene_WindowingBehaviors, + LookinAttrSec_UIWindowScene_Pointer, + LookinAttrSec_UIWindowScene_Protection, + LookinAttrSec_UIWindowScene_Traits, + LookinAttrSec_UIWindowScene_Session, + LookinAttrSec_UIWindowScene_Configuration, + LookinAttrSec_UIWindowScene_ActivationConditions, + // UITraitCollection + LookinAttrSec_UITraitCollection_Appearance, + LookinAttrSec_UITraitCollection_SizeClass, + LookinAttrSec_UITraitCollection_Display, + LookinAttrSec_UITraitCollection_Device, + LookinAttrSec_UITraitCollection_Layout, + LookinAttrSec_UITraitCollection_Content, + ] as [NSString]).map { $0 as String }) +} diff --git a/LookInside/Measure/LKMeasureController.h b/LookInside/Measure/LKMeasureController.h deleted file mode 100644 index 9509ac5a..00000000 --- a/LookInside/Measure/LKMeasureController.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKMeasureController.h -// Lookin -// -// Created by Li Kai on 2019/10/18. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LKHierarchyDataSource, LKPreferenceManager; - -@interface LKMeasureController : LKBaseViewController - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource; - -@end diff --git a/LookInside/Measure/LKMeasureController.m b/LookInside/Measure/LKMeasureController.m deleted file mode 100644 index ec80ffae..00000000 --- a/LookInside/Measure/LKMeasureController.m +++ /dev/null @@ -1,218 +0,0 @@ -// -// LKMeasureController.m -// Lookin -// -// Created by Li Kai on 2019/10/18. -// https://lookin.work -// - -#import "LKMeasureController.h" -#import "LKHierarchyDataSource.h" -#import "LKPreferenceManager.h" -#import "LookinDisplayItem.h" -#import "LKTextFieldView.h" -#import "LKMeasureResultView.h" -#import "LKNavigationManager.h" - -@interface LKMeasureController () - -@property(nonatomic, strong) LKBaseView *placeholderView; -@property(nonatomic, strong) NSImageView *placeholderImageView; -@property(nonatomic, strong) LKLabel *placeholderTitleLabel; -@property(nonatomic, strong) LKLabel *placeholderSubtitleLabel; -@property(nonatomic, strong) LKMeasureResultView *resultView; -@property(nonatomic, strong) LKHierarchyDataSource *dataSource; -@property(nonatomic, strong) LKLabel *shortcutLabel; -@property(nonatomic, strong) NSButton *lockSwitchButton; - -@end - -@implementation LKMeasureController - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource { - if (self = [self initWithContainerView:nil]) { - self.dataSource = dataSource; - - self.placeholderView = [LKBaseView new]; - self.placeholderView.hasEffectedBackground = YES; - self.placeholderView.layer.cornerRadius = DashboardCardCornerRadius; - [self.view addSubview:self.placeholderView]; - - self.placeholderImageView = [NSImageView new]; - [self.placeholderView addSubview:self.placeholderImageView]; - - self.placeholderTitleLabel = [LKLabel new]; - self.placeholderTitleLabel.font = [NSFont boldSystemFontOfSize:14]; - self.placeholderTitleLabel.alignment = NSTextAlignmentCenter; - [self.placeholderView addSubview:self.placeholderTitleLabel]; - - self.placeholderSubtitleLabel = [LKLabel new]; - self.placeholderSubtitleLabel.font = NSFontMake(12); - self.placeholderSubtitleLabel.alignment = NSTextAlignmentCenter; - [self.placeholderView addSubview:self.placeholderSubtitleLabel]; - - self.resultView = [LKMeasureResultView new]; - [self.view addSubview:self.resultView]; - - [dataSource.preferenceManager.measureState subscribe:self action:@selector(_measureStatePropertyDidChange:) relatedObject:nil]; - - @weakify(self); - [[RACObserve(dataSource, selectedItem) combineLatestWith:RACObserve(dataSource, hoveredItem)] subscribeNext:^(id _Nullable x) { - @strongify(self); - [self _reRender]; - }]; - } - return self; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - - CGFloat titleHeight = [LKNavigationManager sharedInstance].windowTitleBarHeight; - - NSView *contentView = nil; - if (self.placeholderView.isVisible) { - $(self.placeholderView).fullWidth.height([self _placeholderHeightForWidth:self.view.bounds.size.width]).verAlign.offsetY(titleHeight / 2.0); - [self _layoutPlaceholderView]; - contentView = self.placeholderView; - } - if (self.resultView.isVisible) { - $(self.resultView).fullWidth.heightToFit.verAlign.offsetY(titleHeight / 2.0); - contentView = self.resultView; - } - if (self.shortcutLabel) { - $(self.shortcutLabel).sizeToFit.horAlign.y(contentView.$maxY + 5); - } - if (self.lockSwitchButton) { - $(self.lockSwitchButton).sizeToFit.horAlign.y(contentView.$maxY + 5); - } -} - -- (void)_measureStatePropertyDidChange:(LookinMsgActionParams *)params { - self.shortcutLabel.hidden = YES; - - LookinMeasureState state = params.integerValue; - switch (state) { - case LookinMeasureState_no: - self.lockSwitchButton.hidden = YES; - self.lockSwitchButton.state = NSControlStateValueOff; - break; - - case LookinMeasureState_unlocked: - // 由快捷键触发 - if (!self.lockSwitchButton) { - self.lockSwitchButton = [NSButton new]; - [self.lockSwitchButton setButtonType:NSButtonTypeSwitch]; - self.lockSwitchButton.font = NSFontMake(15); - self.lockSwitchButton.title = NSLocalizedString(@"Cancel measure after key up.", nil); - self.lockSwitchButton.target = self; - self.lockSwitchButton.action = @selector(handleLockSwitchButton); - [self.view addSubview:self.lockSwitchButton]; - } - self.lockSwitchButton.state = NSControlStateValueOn; - self.lockSwitchButton.hidden = NO; - break; - - case LookinMeasureState_locked: { - if (!self.shortcutLabel) { - self.shortcutLabel = [LKLabel new]; - self.shortcutLabel.stringValue = NSLocalizedString(@"shortcut: holding \"option\" key", nil); - self.shortcutLabel.textColor = [NSColor secondaryLabelColor]; - [self.view addSubview:self.shortcutLabel]; - } - self.shortcutLabel.hidden = NO; - break; - } - } - [self _reRender]; -} - -- (void)_reRender { - if (self.dataSource.preferenceManager.measureState.currentIntegerValue == LookinMeasureState_no) { - return; - } - if (!self.dataSource.selectedItem) { - return; - } - if (!self.dataSource.hoveredItem || (self.dataSource.selectedItem == self.dataSource.hoveredItem)) { - NSString *format = NSLocalizedString(@"to measure between it and selected %@.", nil); - NSString *subtitle = [NSString stringWithFormat:format, self.dataSource.selectedItem.title]; - - self.resultView.hidden = YES; - [self _renderPlaceholderWithImage:NSImageMake(@"measure_hover") title:NSLocalizedString(@"Hover on a layer", nil) subtitle:subtitle]; - [self.view setNeedsLayout:YES]; - return; - } - - NSString *sizeInvalidClass = nil; - NSString *sizeInvalidProperty = nil; - CGRect selectedItemFrame = [self.dataSource.selectedItem calculateFrameToRoot]; - CGRect hoveredItemFrame = [self.dataSource.hoveredItem calculateFrameToRoot]; - - if (selectedItemFrame.size.width <= 0) { - sizeInvalidClass = self.dataSource.selectedItem.title; - sizeInvalidProperty = @"width"; - } else if (selectedItemFrame.size.height <= 0) { - sizeInvalidClass = self.dataSource.selectedItem.title; - sizeInvalidProperty = @"height"; - } else if (hoveredItemFrame.size.width <= 0) { - sizeInvalidClass = self.dataSource.hoveredItem.title; - sizeInvalidProperty = @"width"; - } else if (hoveredItemFrame.size.height <= 0) { - sizeInvalidClass = self.dataSource.hoveredItem.title; - sizeInvalidProperty = @"height"; - } - if (sizeInvalidClass || sizeInvalidProperty) { - NSString *subtitleFormat = NSLocalizedString(@"Selected %@'s %@ is less than or equal to 0.", nil); - NSString *subtitle = [NSString stringWithFormat:subtitleFormat, sizeInvalidClass, sizeInvalidProperty]; - - self.resultView.hidden = YES; - [self _renderPlaceholderWithImage:NSImageMake(@"measure_info") title:NSLocalizedString(@"Invalid Size", nil) subtitle:subtitle]; - [self.view setNeedsLayout:YES]; - return; - } - - self.placeholderView.hidden = YES; - self.resultView.hidden = NO; - [self.resultView renderWithMainRect:selectedItemFrame mainImage:self.dataSource.selectedItem.groupScreenshot referRect:hoveredItemFrame referImage:self.dataSource.hoveredItem.groupScreenshot]; - [self.view setNeedsLayout:YES]; -} - -- (void)_renderPlaceholderWithImage:(NSImage *)image title:(NSString *)title subtitle:(NSString *)subtitle { - self.placeholderImageView.image = image; - self.placeholderTitleLabel.stringValue = title; - self.placeholderSubtitleLabel.stringValue = subtitle; - self.placeholderView.hidden = NO; -} - -- (CGFloat)_placeholderHeightForWidth:(CGFloat)width { - NSEdgeInsets insets = NSEdgeInsetsMake(15, 5, 12, 5); - CGFloat contentWidth = width - insets.left - insets.right; - CGFloat resultHeight = insets.top + insets.bottom; - resultHeight += self.placeholderImageView.image.size.height; - resultHeight += 11; - resultHeight += [self.placeholderTitleLabel heightForWidth:contentWidth]; - resultHeight += 6; - resultHeight += [self.placeholderSubtitleLabel heightForWidth:contentWidth]; - return resultHeight; -} - -- (void)_layoutPlaceholderView { - NSEdgeInsets insets = NSEdgeInsetsMake(15, 5, 12, 5); - $(self.placeholderImageView).sizeToFit.horAlign.y(insets.top); - $(self.placeholderTitleLabel).sizeToFit.horAlign.y(self.placeholderImageView.$maxY + 11); - $(self.placeholderSubtitleLabel).x(insets.left).toRight(insets.right).heightToFit.y(self.placeholderTitleLabel.$maxY + 6); -} - -- (void)handleLockSwitchButton { - if (!self.lockSwitchButton) { - return; - } - if (self.lockSwitchButton.state == NSControlStateValueOn) { - [self.dataSource.preferenceManager.measureState setIntegerValue:LookinMeasureState_unlocked ignoreSubscriber:self]; - } else { - [self.dataSource.preferenceManager.measureState setIntegerValue:LookinMeasureState_locked ignoreSubscriber:self]; - } -} - -@end diff --git a/LookInside/Measure/LKMeasureController.swift b/LookInside/Measure/LKMeasureController.swift new file mode 100644 index 00000000..476daf17 --- /dev/null +++ b/LookInside/Measure/LKMeasureController.swift @@ -0,0 +1,228 @@ +// +// LKMeasureController.swift +// LookInside +// +// The Measure panel: while measuring, it shows the distances between the +// selected item and the hovered item, or a placeholder that says what to do +// next (hover a layer) or why it cannot measure (a zero-sized frame). +// + +import AppKit +import LookInsideHostCore + +@objc(LKMeasureController) +final class LKMeasureController: LKBaseViewController { + private static let placeholderInsets = NSEdgeInsets(top: 15, left: 5, bottom: 12, right: 5) + + private let dataSource: LKHierarchyDataSource + private let placeholderView = LKBaseView() + private let placeholderImageView = NSImageView() + private let placeholderTitleLabel = LKLabel() + private let placeholderSubtitleLabel = LKLabel() + private let resultView = LKMeasureResultView() + private var shortcutLabel: LKLabel? + private var lockSwitchButton: NSButton? + private var itemObservations: [NSKeyValueObservation] = [] + + @objc(initWithDataSource:) + init(dataSource: LKHierarchyDataSource) { + self.dataSource = dataSource + super.init(containerView: nil) + + placeholderView.hasEffectedBackground = true + placeholderView.layer?.cornerRadius = DashboardCardCornerRadius + view.addSubview(placeholderView) + + placeholderView.addSubview(placeholderImageView) + + placeholderTitleLabel.font = .boldSystemFont(ofSize: 14) + placeholderTitleLabel.alignment = .center + placeholderView.addSubview(placeholderTitleLabel) + + placeholderSubtitleLabel.font = .systemFont(ofSize: 12) + placeholderSubtitleLabel.alignment = .center + placeholderView.addSubview(placeholderSubtitleLabel) + + view.addSubview(resultView) + + dataSource.preferenceManager().measureState.subscribe( + self, action: #selector(measureStateDidChange(_:)), relatedObject: nil + ) + + itemObservations = [ + dataSource.observe(\.selectedItem, options: [.new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.reRender() } + }, + dataSource.observe(\.hoveredItem, options: [.new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.reRender() } + }, + ] + reRender() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func viewDidLayout() { + super.viewDidLayout() + let titleHeight = LKNavigationManager.shared.windowTitleBarHeight + + var contentView: NSView? + if placeholderView.isVisible { + placeholderView.lkpFullWidth() + placeholderView.lkpSetHeight(placeholderHeight(forWidth: view.bounds.width)) + placeholderView.lkpVerAlign() + placeholderView.lkpOffsetY(titleHeight / 2) + layoutPlaceholderView() + contentView = placeholderView + } + if resultView.isVisible { + resultView.lkpFullWidth() + resultView.lkpHeightToFit() + resultView.lkpVerAlign() + resultView.lkpOffsetY(titleHeight / 2) + contentView = resultView + } + let belowContent = (contentView?.frame.maxY ?? 0) + 5 + if let shortcutLabel { + shortcutLabel.lkpSizeToFit() + shortcutLabel.lkpHorAlign() + shortcutLabel.lkpSetY(belowContent) + } + if let lockSwitchButton { + lockSwitchButton.lkpSizeToFit() + lockSwitchButton.lkpHorAlign() + lockSwitchButton.lkpSetY(belowContent) + } + } + + @objc private func measureStateDidChange(_ params: LookinMsgActionParams) { + shortcutLabel?.isHidden = true + + switch LookinMeasureState(rawValue: params.integerValue) { + case .no: + lockSwitchButton?.isHidden = true + lockSwitchButton?.state = .off + case .unlocked: + // Entered with the shortcut. + let button = lockSwitchButton ?? makeLockSwitchButton() + button.state = .on + button.isHidden = false + case .locked: + let label = shortcutLabel ?? makeShortcutLabel() + label.isHidden = false + default: + break + } + reRender() + } + + private func makeLockSwitchButton() -> NSButton { + let button = NSButton() + button.setButtonType(.switch) + button.font = .systemFont(ofSize: 15) + button.title = NSLocalizedString("Cancel measure after key up.", comment: "") + button.target = self + button.action = #selector(handleLockSwitchButton) + view.addSubview(button) + lockSwitchButton = button + return button + } + + private func makeShortcutLabel() -> LKLabel { + let label = LKLabel() + label.stringValue = NSLocalizedString("shortcut: holding \"option\" key", comment: "") + label.textColor = .secondaryLabelColor + view.addSubview(label) + shortcutLabel = label + return label + } + + private func reRender() { + let measureState: LookinIntegerMsgAttribute = dataSource.preferenceManager().measureState + guard measureState.currentIntegerValue != LookinMeasureState.no.rawValue, + let selectedItem = dataSource.selectedItem + else { return } + + guard let hoveredItem = dataSource.hoveredItem, hoveredItem !== selectedItem else { + let format = NSLocalizedString("to measure between it and selected %@.", comment: "") + resultView.isHidden = true + renderPlaceholder(image: NSImage(named: "measure_hover"), + title: NSLocalizedString("Hover on a layer", comment: ""), + subtitle: String(format: format, selectedItem.title())) + view.needsLayout = true + return + } + + let selectedFrame = selectedItem.calculateFrameToRoot() + let hoveredFrame = hoveredItem.calculateFrameToRoot() + let invalid: (item: LookinDisplayItem, property: String)? = + if selectedFrame.width <= 0 { + (selectedItem, "width") + } else if selectedFrame.height <= 0 { + (selectedItem, "height") + } else if hoveredFrame.width <= 0 { + (hoveredItem, "width") + } else if hoveredFrame.height <= 0 { + (hoveredItem, "height") + } else { + nil + } + if let invalid { + let format = NSLocalizedString("Selected %@'s %@ is less than or equal to 0.", comment: "") + resultView.isHidden = true + renderPlaceholder(image: NSImage(named: "measure_info"), + title: NSLocalizedString("Invalid Size", comment: ""), + subtitle: String(format: format, invalid.item.title(), invalid.property)) + view.needsLayout = true + return + } + + placeholderView.isHidden = true + resultView.isHidden = false + resultView.render(mainRect: selectedFrame, mainImage: selectedItem.groupScreenshot, + referRect: hoveredFrame, referImage: hoveredItem.groupScreenshot) + view.needsLayout = true + } + + private func renderPlaceholder(image: NSImage?, title: String, subtitle: String) { + placeholderImageView.image = image + placeholderTitleLabel.stringValue = title + placeholderSubtitleLabel.stringValue = subtitle + placeholderView.isHidden = false + } + + private func placeholderHeight(forWidth width: CGFloat) -> CGFloat { + let insets = Self.placeholderInsets + let contentWidth = width - insets.left - insets.right + let limit = NSSize(width: contentWidth, height: .greatestFiniteMagnitude) + return insets.top + insets.bottom + + (placeholderImageView.image?.size.height ?? 0) + 11 + + placeholderTitleLabel.sizeThatFits(limit).height + 6 + + placeholderSubtitleLabel.sizeThatFits(limit).height + } + + private func layoutPlaceholderView() { + let insets = Self.placeholderInsets + placeholderImageView.lkpSizeToFit() + placeholderImageView.lkpHorAlign() + placeholderImageView.lkpSetY(insets.top) + + placeholderTitleLabel.lkpSizeToFit() + placeholderTitleLabel.lkpHorAlign() + placeholderTitleLabel.lkpSetY(placeholderImageView.frame.maxY + 11) + + placeholderSubtitleLabel.lkpSetX(insets.left) + placeholderSubtitleLabel.lkpToRight(insets.right) + placeholderSubtitleLabel.lkpHeightToFit() + placeholderSubtitleLabel.lkpSetY(placeholderTitleLabel.frame.maxY + 6) + } + + @objc private func handleLockSwitchButton() { + guard let lockSwitchButton else { return } + let state: LookinMeasureState = lockSwitchButton.state == .on ? .unlocked : .locked + dataSource.preferenceManager().measureState.setIntegerValue(state.rawValue, ignoreSubscriber: self) + } +} diff --git a/LookInside/Measure/LKMeasureResultLineData.h b/LookInside/Measure/LKMeasureResultLineData.h deleted file mode 100644 index 9f8e45ff..00000000 --- a/LookInside/Measure/LKMeasureResultLineData.h +++ /dev/null @@ -1,33 +0,0 @@ -// -// LKMeasureResultHorLineData.h -// Lookin -// -// Created by Li Kai on 2019/10/24. -// https://lookin.work -// - -#import - -@interface LKMeasureResultHorLineData : NSObject - -+ (instancetype)dataWithStartX:(CGFloat)startX endX:(CGFloat)endX y:(CGFloat)y value:(CGFloat)value; - -@property(nonatomic, assign) CGFloat startX; -@property(nonatomic, assign) CGFloat endX; -@property(nonatomic, assign) CGFloat y; - -@property(nonatomic, assign) CGFloat displayValue; - -@end - -@interface LKMeasureResultVerLineData : NSObject - -+ (instancetype)dataWithStartY:(CGFloat)startY endY:(CGFloat)endY x:(CGFloat)x value:(CGFloat)value; - -@property(nonatomic, assign) CGFloat startY; -@property(nonatomic, assign) CGFloat endY; -@property(nonatomic, assign) CGFloat x; - -@property(nonatomic, assign) CGFloat displayValue; - -@end diff --git a/LookInside/Measure/LKMeasureResultLineData.m b/LookInside/Measure/LKMeasureResultLineData.m deleted file mode 100644 index cfe39fbc..00000000 --- a/LookInside/Measure/LKMeasureResultLineData.m +++ /dev/null @@ -1,35 +0,0 @@ -// -// LKMeasureResultHorLineData.m -// Lookin -// -// Created by Li Kai on 2019/10/24. -// https://lookin.work -// - -#import "LKMeasureResultLineData.h" - -@implementation LKMeasureResultHorLineData - -+ (instancetype)dataWithStartX:(CGFloat)startX endX:(CGFloat)endX y:(CGFloat)y value:(CGFloat)value { - LKMeasureResultHorLineData *data = [LKMeasureResultHorLineData new]; - data.startX = startX; - data.endX = endX; - data.y = y; - data.displayValue = value; - return data; -} - -@end - -@implementation LKMeasureResultVerLineData - -+ (instancetype)dataWithStartY:(CGFloat)startY endY:(CGFloat)endY x:(CGFloat)x value:(CGFloat)value { - LKMeasureResultVerLineData *data = [LKMeasureResultVerLineData new]; - data.startY = startY; - data.endY = endY; - data.x = x; - data.displayValue = value; - return data; -} - -@end diff --git a/LookInside/Measure/LKMeasureResultView.h b/LookInside/Measure/LKMeasureResultView.h deleted file mode 100644 index 7b10c827..00000000 --- a/LookInside/Measure/LKMeasureResultView.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// LKMeasureResultView.h -// Lookin -// -// Created by Li Kai on 2019/10/21. -// https://lookin.work -// - -#import "LKBaseView.h" - -@interface LKMeasureResultView : LKBaseView - -- (void)renderWithMainRect:(CGRect)mainRect mainImage:(LookinImage *)mainImage referRect:(CGRect)referRect referImage:(LookinImage *)referImage; - -@end diff --git a/LookInside/Measure/LKMeasureResultView.m b/LookInside/Measure/LKMeasureResultView.m deleted file mode 100644 index 0f9b8c80..00000000 --- a/LookInside/Measure/LKMeasureResultView.m +++ /dev/null @@ -1,496 +0,0 @@ -// -// LKMeasureResultView.m -// Lookin -// -// Created by Li Kai on 2019/10/21. -// https://lookin.work -// - -#import "LKMeasureResultView.h" -#import "LKNavigationManager.h" -#import "LKTextFieldView.h" -#import "LKMeasureResultLineData.h" - -#define compare(A, B) [self _compare:A with:B] -#define addHor(arg_array, arg_startX, arg_endX, arg_y, arg_value) [arg_array addObject:[LKMeasureResultHorLineData dataWithStartX:arg_startX endX:arg_endX y:arg_y value:arg_value]] -#define addVer(arg_array, arg_startY, arg_endY, arg_x, arg_value) [arg_array addObject:[LKMeasureResultVerLineData dataWithStartY:arg_startY endY:arg_endY x:arg_x value:arg_value]] - -typedef NS_ENUM(NSInteger, CompareResult) { - Bigger, - Same, - Smaller -}; - -@interface LKMeasureResultView () - -/// mainLayer 指 selectedLayer,referLayer 指 hovered layer -@property(nonatomic, strong) LKBaseView *contentView; - -@property(nonatomic, strong) NSImageView *mainImageView; -@property(nonatomic, strong) NSImageView *referImageView; - -/// 如果直接把 horSolidLinesLayer 作为 contentView.layer 的 sublayer 则经常出现 linesLayer 被 imageView.layer 盖住的情况,貌似 imageView.layer 是懒加载的且时机不好掌握,因此这里多包一层 view 吧确保顺序 -@property(nonatomic, strong) LKBaseView *linesContainerView; -@property(nonatomic, strong) CAShapeLayer *horSolidLinesLayer; -@property(nonatomic, strong) CAShapeLayer *verSolidLinesLayer; -/// imageView 重叠时会被半透明处理,我们又不想半透明 border,所以单独把 border 作为 layer -@property(nonatomic, strong) CALayer *mainImageViewBorderLayer; -@property(nonatomic, strong) CALayer *referImageViewBorderLayer; - -@property(nonatomic, strong) NSMutableArray *textFieldViews; - -@property(nonatomic, assign) CGRect originalMainFrame; -@property(nonatomic, assign) CGRect originalReferFrame; - -@property(nonatomic, assign) CGRect scaledMainFrame; -@property(nonatomic, assign) CGRect scaledReferFrame; - -@end - -@implementation LKMeasureResultView { - CGFloat _horInset; - CGFloat _verInset; - CGFloat _labelHeight; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _horInset = 20; - _verInset = 20; - _labelHeight = 18; - - self.hasEffectedBackground = YES; - self.layer.cornerRadius = DashboardCardCornerRadius; - - self.contentView = [LKBaseView new]; - // label 经常会超出范围 - self.contentView.layer.masksToBounds = NO; - [self addSubview:self.contentView]; - - self.mainImageView = [NSImageView new]; - self.mainImageView.imageScaling = NSImageScaleProportionallyUpOrDown; - [self.contentView addSubview:self.mainImageView]; - - self.referImageView = [NSImageView new]; - self.referImageView.imageScaling = NSImageScaleProportionallyUpOrDown; - [self.contentView addSubview:self.referImageView]; - - self.linesContainerView = [LKBaseView new]; - self.linesContainerView.layer.masksToBounds = NO; - [self.contentView addSubview:self.linesContainerView]; - - self.mainImageViewBorderLayer = [CALayer layer]; - self.mainImageViewBorderLayer.borderWidth = 1; - [self.mainImageViewBorderLayer lookin_removeImplicitAnimations]; - [self.linesContainerView.layer addSublayer:self.mainImageViewBorderLayer]; - - self.referImageViewBorderLayer = [CALayer layer]; - self.referImageViewBorderLayer.borderWidth = 1; - [self.referImageViewBorderLayer lookin_removeImplicitAnimations]; - [self.linesContainerView.layer addSublayer:self.referImageViewBorderLayer]; - - self.horSolidLinesLayer = [CAShapeLayer layer]; - self.horSolidLinesLayer.lineWidth = 1; - [self.horSolidLinesLayer lookin_removeImplicitAnimations]; - self.horSolidLinesLayer.strokeColor = LookinColorMake(10, 127, 251).CGColor; - [self.linesContainerView.layer addSublayer:self.horSolidLinesLayer]; - - self.verSolidLinesLayer = [CAShapeLayer layer]; - self.verSolidLinesLayer.lineWidth = 1; - [self.verSolidLinesLayer lookin_removeImplicitAnimations]; - self.verSolidLinesLayer.strokeColor = LookinColorMake(209, 120, 0).CGColor; - [self.linesContainerView.layer addSublayer:self.verSolidLinesLayer]; - - [self updateColors]; - } - return self; -} - -- (void)updateColors { - [super updateColors]; - NSColor *borderColor = self.isDarkMode ? LookinColorMake(123, 123, 123) : LookinColorMake(190, 190, 190); - self.referImageViewBorderLayer.borderColor = borderColor.CGColor; - self.mainImageViewBorderLayer.borderColor = borderColor.CGColor; -} - -- (void)layout { - [super layout]; - self.mainImageView.frame = self.scaledMainFrame; - self.referImageView.frame = self.scaledReferFrame; - - CGFloat contentWidth, contentHeight; - [self _getWidth:&contentWidth height:&contentHeight fromRectA:self.scaledMainFrame rectB:self.scaledReferFrame]; - $(self.contentView).width(contentWidth).height(contentHeight).centerAlign; - - self.linesContainerView.frame = self.contentView.bounds; - self.horSolidLinesLayer.frame = self.linesContainerView.bounds; - self.verSolidLinesLayer.frame = self.linesContainerView.bounds; - self.mainImageViewBorderLayer.frame = self.mainImageView.frame; - self.referImageViewBorderLayer.frame = self.referImageView.frame; - [self _renderLinesAndLabels]; -} - -- (void)renderWithMainRect:(CGRect)originalMainRect mainImage:(LookinImage *)mainImage referRect:(CGRect)originalReferRect referImage:(LookinImage *)referImage { - self.originalMainFrame = originalMainRect; - self.originalReferFrame = originalReferRect; - - self.mainImageView.image = mainImage; - self.referImageView.image = referImage; - - CGFloat scaleFactor = [self _calculateScaleFactorWithMainRect:originalMainRect referRect:originalReferRect]; - - // 这里的两个 frame 是稍后真正被 layout 使用的值 - CGRect mainFrame = [self _adjustRect:originalMainRect withScaleFactor:scaleFactor]; - CGRect referFrame = [self _adjustRect:originalReferRect withScaleFactor:scaleFactor]; - - CGFloat minX, minY; - [self _getMinX:&minX minY:&minY maxX:nil maxY:nil fromRectA:mainFrame rectB:referFrame]; - - // 下面两句使得两个 rect 作为整体被移动 origin 至 {0, 0} - self.scaledMainFrame = [self _adjustRect:mainFrame withOffsetX:minX offsetY:minY]; - self.scaledReferFrame = [self _adjustRect:referFrame withOffsetX:minX offsetY:minY]; - - [self setNeedsLayout:YES]; -} - -/// 画那些辅助线。调用该方法前,self.scaledMainFrame 和 self.scaledReferFrame 必须已经被正确设置完毕 -- (void)_renderLinesAndLabels { - [self _hideAllLabels]; - self.horSolidLinesLayer.hidden = YES; - self.verSolidLinesLayer.hidden = YES; - - // 为了方便,mainFrame 记为 A,referRect 记为 B - CGRect rectA = self.scaledMainFrame; - CGRect rectB = self.scaledReferFrame; - - // 包裹对方的那个 view 应该被半透明处理,因为此时辅助线基本都落在了被包裹的 view 之外、包裹对方的 view 之内 - if (CGRectContainsRect(rectA, rectB)) { - // A 包裹 B - self.mainImageView.alphaValue = .2; - self.referImageView.alphaValue = 1; - - } else if (CGRectContainsRect(rectB, rectA)) { - // B 包裹 A - self.mainImageView.alphaValue = 1; - self.referImageView.alphaValue = .2; - - } else if (CGRectIntersectsRect(rectA, rectB)) { - // 两个 view 只有部分是重叠的 - self.mainImageView.alphaValue = .2; - self.referImageView.alphaValue = .2; - } else { - // 两个 view 没有重叠 - self.mainImageView.alphaValue = 1; - self.referImageView.alphaValue = 1; - } - - CGFloat minX_A = CGRectGetMinX(rectA); - CGFloat midX_A = CGRectGetMidX(rectA); - CGFloat maxX_A = CGRectGetMaxX(rectA); - CGFloat minX_B = CGRectGetMinX(rectB); - CGFloat midX_B = CGRectGetMidX(rectB); - CGFloat maxX_B = CGRectGetMaxX(rectB); - - CGFloat minY_A = CGRectGetMinY(rectA); - CGFloat midY_A = CGRectGetMidY(rectA); - CGFloat maxY_A = CGRectGetMaxY(rectA); - CGFloat minY_B = CGRectGetMinY(rectB); - CGFloat midY_B = CGRectGetMidY(rectB); - CGFloat maxY_B = CGRectGetMaxY(rectB); - - /** 水平方向的线 START **/ - NSMutableArray *horDatas = [NSMutableArray array]; - - if (compare(minX_A, minX_B) == Smaller) { - - if (compare(maxX_A, minX_B) == Smaller) { - // right to left - addHor(horDatas, maxX_A, minX_B, midY_A, CGRectGetMinX(self.originalReferFrame) - CGRectGetMaxX(self.originalMainFrame)); - } else { - // maxX_A >= minX_B - if (compare(maxX_A, maxX_B) == Smaller) { - // right to right - addHor(horDatas, maxX_A, maxX_B, midY_A, CGRectGetMaxX(self.originalReferFrame) - CGRectGetMaxX(self.originalMainFrame)); - } else if (compare(maxX_A, maxX_B) == Same) { - // nothing - } else if (compare(maxX_A, maxX_B) == Bigger) { - // right to right - addHor(horDatas, maxX_B, maxX_A, midY_B, CGRectGetMaxX(self.originalMainFrame) - CGRectGetMaxX(self.originalReferFrame)); - // left to left - addHor(horDatas, minX_A, minX_B, midY_B, CGRectGetMinX(self.originalReferFrame) - CGRectGetMinX(self.originalMainFrame)); - } else { - NSAssert(NO, @""); - } - } - - } else if (compare(minX_A, minX_B) == Same) { - - if (compare(maxX_A, maxX_B) == Smaller) { - // right to right - addHor(horDatas, maxX_A, maxX_B, midY_A, CGRectGetMaxX(self.originalReferFrame) - CGRectGetMaxX(self.originalMainFrame)); - } else if (compare(maxX_A, maxX_B) == Same) { - // nothing - } else if (compare(maxX_A, maxX_B) == Bigger) { - // right to right - addHor(horDatas, maxX_B, maxX_A, midY_B, CGRectGetMaxX(self.originalMainFrame) - CGRectGetMaxX(self.originalReferFrame)); - } else { - NSAssert(NO, @""); - } - - } else if (compare(minX_A, minX_B) == Bigger) { - - if (compare(minX_A, maxX_B) == Bigger) { - // left to right - addHor(horDatas, minX_A, maxX_B, midY_A, CGRectGetMinX(self.originalMainFrame) - CGRectGetMaxX(self.originalReferFrame)); - } else { - // left to left - addHor(horDatas, minX_B, minX_A, midY_A, CGRectGetMinX(self.originalMainFrame) - CGRectGetMinX(self.originalReferFrame)); - - if (compare(maxX_A, maxX_B) == Smaller) { - // right to right - addHor(horDatas, maxX_A, maxX_B, midY_A, CGRectGetMaxX(self.originalReferFrame) - CGRectGetMaxX(self.originalMainFrame)); - } - } - } else { - NSAssert(NO, @""); - } - - /** 水平方向的线 END **/ - - /** 竖直方向的线 START **/ - NSMutableArray *verDatas = [NSMutableArray array]; - - if (compare(minY_A, minY_B) == Smaller) { - - if (compare(maxY_A, minY_B) == Smaller) { - // bottom to top - addVer(verDatas, maxY_A, minY_B, midX_A, CGRectGetMinY(self.originalReferFrame) - CGRectGetMaxY(self.originalMainFrame)); - } else { - // maxY_A >= minY_B - if (compare(maxY_A, maxY_B) == Smaller) { - // bottom to bottom - addVer(verDatas, maxY_A, maxY_B, midX_A, CGRectGetMaxY(self.originalReferFrame) - CGRectGetMaxY(self.originalMainFrame)); - } else if (compare(maxY_A, maxY_B) == Same) { - // nothing - } else if (compare(maxY_A, maxY_B) == Bigger) { - // bottom to bottom - addVer(verDatas, maxY_B, maxY_A, midX_B, CGRectGetMaxY(self.originalMainFrame) - CGRectGetMaxY(self.originalReferFrame)); - // top to top - addVer(verDatas, minY_A, minY_B, midX_B, CGRectGetMinY(self.originalReferFrame) - CGRectGetMinY(self.originalMainFrame)); - } else { - NSAssert(NO, @""); - } - } - - } else if (compare(minY_A, minY_B) == Same) { - - if (compare(maxY_A, maxY_B) == Smaller) { - // bottom to bottom - addVer(verDatas, maxY_A, maxY_B, midX_A, CGRectGetMaxY(self.originalReferFrame) - CGRectGetMaxY(self.originalMainFrame)); - } else if (compare(maxY_A, maxY_B) == Same) { - // nothing - } else if (compare(maxY_A, maxY_B) == Bigger) { - // bottom to bottom - addVer(verDatas, maxY_B, maxY_A, midX_B, CGRectGetMaxY(self.originalMainFrame) - CGRectGetMaxY(self.originalReferFrame)); - } else { - NSAssert(NO, @""); - } - - } else if (compare(minY_A, minY_B) == Bigger) { - - if (compare(minY_A, maxY_B) == Bigger) { - // top to bottom - addVer(verDatas, maxY_B, minY_A, midX_A, CGRectGetMinY(self.originalMainFrame) - CGRectGetMaxY(self.originalReferFrame)); - } else { - // top to top - addVer(verDatas, minY_B, minY_A, midX_A, CGRectGetMinY(self.originalMainFrame) - CGRectGetMinY(self.originalReferFrame)); - - if (compare(maxY_A, maxY_B) == Smaller) { - // bottom to bottom - addVer(verDatas, maxY_A, maxY_B, midX_A, CGRectGetMaxY(self.originalReferFrame) - CGRectGetMaxY(self.originalMainFrame)); - } - } - } else { - NSAssert(NO, @""); - } - - /** 竖直方向的线 END **/ - - CGMutablePathRef horPath = CGPathCreateMutable(); - CGMutablePathRef verPath = CGPathCreateMutable(); - CGFloat handlerLength = 3; - [horDatas enumerateObjectsUsingBlock:^(LKMeasureResultHorLineData * _Nonnull data, NSUInteger idx, BOOL * _Nonnull stop) { - CGPathMoveToPoint(horPath, NULL, data.startX, data.y); - CGPathAddLineToPoint(horPath, NULL, data.endX, data.y); - - // 线段两端的小把手 - CGPathMoveToPoint(horPath, NULL, data.startX + .5, data.y - handlerLength); - CGPathAddLineToPoint(horPath, NULL, data.startX + .5, data.y + handlerLength); - CGPathMoveToPoint(horPath, NULL, data.endX - .5, data.y - handlerLength); - CGPathAddLineToPoint(horPath, NULL, data.endX - .5, data.y + handlerLength); - - LKTextFieldView *labelView = [self _dequeueAvailableTextField]; - labelView.backgroundColor = NSColor.systemBlueColor; - labelView.textField.stringValue = [NSString lookin_stringFromDouble:data.displayValue decimal:2]; - $(labelView).sizeToFit.height(_labelHeight).midX(data.startX + (data.endX - data.startX) / 2.0).maxY(data.y - 5); - }]; - [verDatas enumerateObjectsUsingBlock:^(LKMeasureResultVerLineData * _Nonnull data, NSUInteger idx, BOOL * _Nonnull stop) { - CGPathMoveToPoint(verPath, NULL, data.x, data.startY); - CGPathAddLineToPoint(verPath, NULL, data.x, data.endY); - - // 线段两端的小把手 - CGPathMoveToPoint(verPath, NULL, data.x - handlerLength, data.startY + .5); - CGPathAddLineToPoint(verPath, NULL, data.x + handlerLength, data.startY + .5); - CGPathMoveToPoint(verPath, NULL, data.x - handlerLength, data.endY - .5); - CGPathAddLineToPoint(verPath, NULL, data.x + handlerLength, data.endY - .5); - - LKTextFieldView *labelView = [self _dequeueAvailableTextField]; - labelView.backgroundColor = LookinColorRGBAMake(209, 120, 0, 1.0); - labelView.textField.stringValue = [NSString lookin_stringFromDouble:data.displayValue decimal:2]; - $(labelView).sizeToFit.height(_labelHeight).midY(data.startY + (data.endY - data.startY) / 2.0).maxX(data.x - 5); - if ([self checkOverlapOfTargetLabelView:labelView]) { - $(labelView).x(data.x + 5); - // 给点透明度,至少能稍微看到后面重叠的文字 - labelView.backgroundColor = LookinColorRGBAMake(209, 120, 0, 0.5); - } - }]; - self.horSolidLinesLayer.path = horPath; - self.horSolidLinesLayer.hidden = NO; - self.verSolidLinesLayer.path = verPath; - self.verSolidLinesLayer.hidden = NO; - CGPathRelease(horPath); - CGPathRelease(verPath); -} - -- (BOOL)checkOverlapOfTargetLabelView:(NSView *)targetView { - for (NSView *otherView in self.textFieldViews) { - if (otherView == targetView) { - continue; - } - if (!otherView.isVisible) { - continue; - } - CGRect inter = CGRectIntersection(otherView.frame, targetView.frame); - if (!CGRectIsNull(inter) && inter.size.width * inter.size.height > 100) { -// NSLog(@"moss - %@", @(inter.size.width * inter.size.height)); - return YES; - } - } - return NO; -} - -- (NSSize)sizeThatFits:(NSSize)limitedSize { - CGFloat minY = MIN(CGRectGetMinY(self.scaledMainFrame), CGRectGetMinY(self.scaledReferFrame)); - CGFloat maxY = MAX(CGRectGetMaxY(self.scaledMainFrame), CGRectGetMaxY(self.scaledReferFrame)); - CGFloat height = maxY - minY; - NSSize size = NSMakeSize(MeasureViewWidth, height + _verInset * 2); - return size; -} - -/// 如果按照真实的 frame 渲染肯定是装不下的,所以这里计算缩放的倍率(这个倍率可能小于 1) -- (CGFloat)_calculateScaleFactorWithMainRect:(CGRect)mainRect referRect:(CGRect)referRect { - CGFloat maxContentWidth = MeasureViewWidth - _horInset * 2; - CGFloat maxContentHeight = MAX((self.window.frame.size.height - [LKNavigationManager sharedInstance].windowTitleBarHeight) * .8, 200); - - CGFloat contentWidth; - CGFloat contentHeight; - [self _getWidth:&contentWidth height:&contentHeight fromRectA:mainRect rectB:referRect]; - - CGFloat scaleFactor_x = contentWidth / maxContentWidth; - CGFloat scaleFactor_y = contentHeight / maxContentHeight; - CGFloat scaleFactor = MAX(scaleFactor_x, scaleFactor_y); - if (scaleFactor <= 0) { - scaleFactor = 1; - NSAssert(NO, @""); - } - return scaleFactor; -} - -/// 将 rectA 和 rectB 视为一个整体 rect(即取它们的并集),然后获取它的各个值 -- (void)_getMinX:(inout CGFloat *)minX minY:(inout CGFloat *)minY maxX:(inout CGFloat *)maxX maxY:(inout CGFloat *)maxY fromRectA:(CGRect)rectA rectB:(CGRect)rectB { - if (minX) { - *minX = MIN(CGRectGetMinX(rectA), CGRectGetMinX(rectB)); - } - if (minY) { - *minY = MIN(CGRectGetMinY(rectA), CGRectGetMinY(rectB)); - } - if (maxX) { - *maxX = MAX(CGRectGetMaxX(rectA), CGRectGetMaxX(rectB)); - } - if (maxY) { - *maxY = MAX(CGRectGetMaxY(rectA), CGRectGetMaxY(rectB)); - } -} - -/// 将 rectA 和 rectB 视为一个整体 rect(即取它们的并集),然后获取它的 width 和 height -- (void)_getWidth:(inout CGFloat *)width height:(inout CGFloat *)height fromRectA:(CGRect)rectA rectB:(CGRect)rectB { - CGFloat minX, minY, maxX, maxY; - [self _getMinX:&minX minY:&minY maxX:&maxX maxY:&maxY fromRectA:rectA rectB:rectB]; - if (width) { - *width = maxX - minX; - } - if (height) { - *height = maxY - minY; - } -} - -/// 缩放一个 rect -- (CGRect)_adjustRect:(CGRect)rect withScaleFactor:(CGFloat)factor { - if (factor == 0) { - NSAssert(NO, @""); - factor = 1; - } - rect.origin.x /= factor; - rect.origin.y /= factor; - rect.size.width /= factor; - rect.size.height /= factor; - return rect; -} - -// 移动一个 rect -- (CGRect)_adjustRect:(CGRect)rect withOffsetX:(CGFloat)offsetX offsetY:(CGFloat)offsetY { - rect.origin.x -= offsetX; - rect.origin.y -= offsetY; - return rect; -} - -- (void)_hideAllLabels { - [self.textFieldViews enumerateObjectsUsingBlock:^(LKTextFieldView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - view.hidden = YES; - }]; -} - -- (LKTextFieldView *)_dequeueAvailableTextField { - if (!self.textFieldViews) { - self.textFieldViews = [NSMutableArray array]; - } - LKTextFieldView *resultView = [self.textFieldViews lookin_firstFiltered:^BOOL(LKTextFieldView *view) { - return view.hidden; - }]; - if (!resultView) { - resultView = [LKTextFieldView labelView]; - resultView.insets = NSEdgeInsetsMake(0, 3, 0, 3); - resultView.textField.textColor = [NSColor whiteColor]; - resultView.textField.font = NSFontMake(13); - resultView.textField.alignment = NSTextAlignmentCenter; - resultView.layer.cornerRadius = _labelHeight / 2.0; - [self.contentView addSubview:resultView]; - [self.textFieldViews addObject:resultView]; - } - resultView.hidden = NO; - return resultView; -} - -/// 避免直接用 == 符号比较造成的精度问题 -- (CompareResult)_compare:(CGFloat)A with:(CGFloat)B { - if (ABS(A - B) < 0.00001) { - return Same; - } - if (A > B) { - return Bigger; - } else { - return Smaller; - } -} - -@end diff --git a/LookInside/Measure/LKMeasureResultView.swift b/LookInside/Measure/LKMeasureResultView.swift new file mode 100644 index 00000000..113a0ad3 --- /dev/null +++ b/LookInside/Measure/LKMeasureResultView.swift @@ -0,0 +1,218 @@ +// +// LKMeasureResultView.swift +// LookInside +// +// Draws the two measured items scaled into the Measure panel, with blue +// horizontal and orange vertical distance lines and their value labels. +// + +import AppKit +import LookInsideHostCore + +final class LKMeasureResultView: LKBaseView { + private static let horizontalInset: CGFloat = 20 + private static let verticalInset: CGFloat = 20 + private static let labelHeight: CGFloat = 18 + private static let handleLength: CGFloat = 3 + + private static let horizontalColor = NSColor(red: 10 / 255.0, green: 127 / 255.0, blue: 251 / 255.0, alpha: 1) + private static let verticalColor = NSColor(red: 209 / 255.0, green: 120 / 255.0, blue: 0, alpha: 1) + + private let contentView = LKBaseView() + private let mainImageView = NSImageView() + private let referImageView = NSImageView() + private let linesContainerView = LKBaseView() + private let horizontalLinesLayer = CAShapeLayer() + private let verticalLinesLayer = CAShapeLayer() + private let mainImageViewBorderLayer = CALayer() + private let referImageViewBorderLayer = CALayer() + private var labelViews: [LKTextFieldView] = [] + + private var originalMainFrame: CGRect = .zero + private var originalReferFrame: CGRect = .zero + private var scaledMainFrame: CGRect = .zero + private var scaledReferFrame: CGRect = .zero + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + hasEffectedBackground = true + layer?.cornerRadius = DashboardCardCornerRadius + + // The labels often stick out of the content. + contentView.layer?.masksToBounds = false + addSubview(contentView) + + mainImageView.imageScaling = .scaleProportionallyUpOrDown + contentView.addSubview(mainImageView) + + referImageView.imageScaling = .scaleProportionallyUpOrDown + contentView.addSubview(referImageView) + + linesContainerView.layer?.masksToBounds = false + contentView.addSubview(linesContainerView) + + for borderLayer in [mainImageViewBorderLayer, referImageViewBorderLayer] { + borderLayer.borderWidth = 1 + borderLayer.lookin_removeImplicitAnimations() + linesContainerView.layer?.addSublayer(borderLayer) + } + + horizontalLinesLayer.lineWidth = 1 + horizontalLinesLayer.lookin_removeImplicitAnimations() + horizontalLinesLayer.strokeColor = Self.horizontalColor.cgColor + linesContainerView.layer?.addSublayer(horizontalLinesLayer) + + verticalLinesLayer.lineWidth = 1 + verticalLinesLayer.lookin_removeImplicitAnimations() + verticalLinesLayer.strokeColor = Self.verticalColor.cgColor + linesContainerView.layer?.addSublayer(verticalLinesLayer) + + updateColors() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func updateColors() { + super.updateColors() + let borderColor = isDarkMode() + ? NSColor(red: 123 / 255.0, green: 123 / 255.0, blue: 123 / 255.0, alpha: 1) + : NSColor(red: 190 / 255.0, green: 190 / 255.0, blue: 190 / 255.0, alpha: 1) + referImageViewBorderLayer.borderColor = borderColor.cgColor + mainImageViewBorderLayer.borderColor = borderColor.cgColor + } + + override func layout() { + super.layout() + mainImageView.frame = scaledMainFrame + referImageView.frame = scaledReferFrame + + let contentSize = MeasureGuides.extent(scaledMainFrame, scaledReferFrame) + contentView.lkpSetWidth(contentSize.width) + contentView.lkpSetHeight(contentSize.height) + contentView.lkpCenterAlign() + + linesContainerView.frame = contentView.bounds + horizontalLinesLayer.frame = linesContainerView.bounds + verticalLinesLayer.frame = linesContainerView.bounds + mainImageViewBorderLayer.frame = mainImageView.frame + referImageViewBorderLayer.frame = referImageView.frame + renderLinesAndLabels() + } + + func render(mainRect: CGRect, mainImage: NSImage?, referRect: CGRect, referImage: NSImage?) { + originalMainFrame = mainRect + originalReferFrame = referRect + mainImageView.image = mainImage + referImageView.image = referImage + + let maxContentWidth = MeasureViewWidth - Self.horizontalInset * 2 + let windowHeight = window?.frame.height ?? 0 + let titleBarHeight = LKNavigationManager.shared.windowTitleBarHeight + let maxContentHeight = max((windowHeight - titleBarHeight) * 0.8, 200) + let factor = MeasureGuides.scaleFactor(main: mainRect, refer: referRect, + maxWidth: maxContentWidth, maxHeight: maxContentHeight) + // These are the frames the next layout uses. + (scaledMainFrame, scaledReferFrame) = MeasureGuides.scaledFrames(main: mainRect, refer: referRect, factor: factor) + needsLayout = true + } + + private func renderLinesAndLabels() { + for labelView in labelViews { + labelView.isHidden = true + } + horizontalLinesLayer.isHidden = true + verticalLinesLayer.isHidden = true + + // The frame that contains the other is dimmed: the lines then run + // outside the inner frame but inside the outer one. + let alphas = MeasureGuides.Overlap(main: scaledMainFrame, refer: scaledReferFrame).alphas + mainImageView.alphaValue = alphas.main + referImageView.alphaValue = alphas.refer + + let horizontalLines = MeasureGuides.horizontalLines(a: scaledMainFrame, b: scaledReferFrame, + originalA: originalMainFrame, originalB: originalReferFrame) + let verticalLines = MeasureGuides.verticalLines(a: scaledMainFrame, b: scaledReferFrame, + originalA: originalMainFrame, originalB: originalReferFrame) + let handle = Self.handleLength + + let horizontalPath = CGMutablePath() + for line in horizontalLines { + horizontalPath.move(to: CGPoint(x: line.startX, y: line.y)) + horizontalPath.addLine(to: CGPoint(x: line.endX, y: line.y)) + // Small handles at both ends. + horizontalPath.move(to: CGPoint(x: line.startX + 0.5, y: line.y - handle)) + horizontalPath.addLine(to: CGPoint(x: line.startX + 0.5, y: line.y + handle)) + horizontalPath.move(to: CGPoint(x: line.endX - 0.5, y: line.y - handle)) + horizontalPath.addLine(to: CGPoint(x: line.endX - 0.5, y: line.y + handle)) + + let labelView = dequeueLabelView() + labelView.backgroundColor = .systemBlue + labelView.textField.stringValue = NSString.lookin_string(from: Double(line.value), decimal: 2) + labelView.lkpSizeToFit() + labelView.lkpSetHeight(Self.labelHeight) + labelView.lkpSetMidX(line.startX + (line.endX - line.startX) / 2) + labelView.lkpSetMaxY(line.y - 5) + } + + let verticalPath = CGMutablePath() + for line in verticalLines { + verticalPath.move(to: CGPoint(x: line.x, y: line.startY)) + verticalPath.addLine(to: CGPoint(x: line.x, y: line.endY)) + // Small handles at both ends. + verticalPath.move(to: CGPoint(x: line.x - handle, y: line.startY + 0.5)) + verticalPath.addLine(to: CGPoint(x: line.x + handle, y: line.startY + 0.5)) + verticalPath.move(to: CGPoint(x: line.x - handle, y: line.endY - 0.5)) + verticalPath.addLine(to: CGPoint(x: line.x + handle, y: line.endY - 0.5)) + + let labelView = dequeueLabelView() + labelView.backgroundColor = Self.verticalColor + labelView.textField.stringValue = NSString.lookin_string(from: Double(line.value), decimal: 2) + labelView.lkpSizeToFit() + labelView.lkpSetHeight(Self.labelHeight) + labelView.lkpSetMidY(line.startY + (line.endY - line.startY) / 2) + labelView.lkpSetMaxX(line.x - 5) + if overlapsAnotherLabel(labelView) { + labelView.lkpSetX(line.x + 5) + // Let the covered text show through a little. + labelView.backgroundColor = Self.verticalColor.withAlphaComponent(0.5) + } + } + + horizontalLinesLayer.path = horizontalPath + horizontalLinesLayer.isHidden = false + verticalLinesLayer.path = verticalPath + verticalLinesLayer.isHidden = false + } + + private func overlapsAnotherLabel(_ target: NSView) -> Bool { + labelViews.contains { other in + guard other !== target, other.isVisible else { return false } + let intersection = other.frame.intersection(target.frame) + return !intersection.isNull && intersection.width * intersection.height > 100 + } + } + + override func sizeThatFits(_: NSSize) -> NSSize { + let height = MeasureGuides.extent(scaledMainFrame, scaledReferFrame).height + return NSSize(width: MeasureViewWidth, height: height + Self.verticalInset * 2) + } + + private func dequeueLabelView() -> LKTextFieldView { + if let hidden = labelViews.first(where: { $0.isHidden }) { + hidden.isHidden = false + return hidden + } + let labelView = LKTextFieldView.label() + labelView.insets = NSEdgeInsets(top: 0, left: 3, bottom: 0, right: 3) + labelView.textField.textColor = .white + labelView.textField.font = .systemFont(ofSize: 13) + labelView.textField.alignment = .center + labelView.layer?.cornerRadius = Self.labelHeight / 2 + contentView.addSubview(labelView) + labelViews.append(labelView) + return labelView + } +} diff --git a/LookInside/Other/LKVersionComparer.h b/LookInside/Other/LKVersionComparer.h deleted file mode 100644 index 4a0fbc0f..00000000 --- a/LookInside/Other/LKVersionComparer.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// LKVersionComparer.h -// LookinClient -// -// Created by likai.123 on 2023/10/30. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface LKVersionComparer : NSObject - -/// latest 指网上最高的 LookinServer 版本号,user 指用户真实的版本号。如果用户版本号低于最高版本号,则该方法返回 NO,此时应该提示用户升级。 -+ (BOOL)compareWithNewest:(NSString *)latest user:(NSString *)user; - -/// 如果 realVersion 等于或高于 expectedVersion,则该方法返回 YES(换句话说:用户的实际版本号满足我们的需要) -+ (BOOL)compareWithExpectedVersion:(NSString *)expectedVersion realVersion:(NSString *)realVersion; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Other/LKVersionComparer.m b/LookInside/Other/LKVersionComparer.m deleted file mode 100644 index 380669db..00000000 --- a/LookInside/Other/LKVersionComparer.m +++ /dev/null @@ -1,30 +0,0 @@ -// -// LKVersionComparer.m -// LookinClient -// -// Created by likai.123 on 2023/10/30. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKVersionComparer.h" - -@implementation LKVersionComparer - -+ (BOOL)compareWithNewest:(NSString *)latest user:(NSString *)user { - return [self compareWithExpectedVersion:latest realVersion:user]; -} - -+ (BOOL)compareWithExpectedVersion:(NSString *)expectedVersion realVersion:(NSString *)realVersion { - NSInteger expectedNumber = [expectedVersion lookin_numbericOSVersion]; - NSInteger realNumber = [realVersion lookin_numbericOSVersion]; - if (expectedNumber == 0 || realNumber == 0) { - NSAssert(NO, @""); - return NO; - } - if (realNumber >= expectedNumber) { - return YES; - } - return NO; -} - -@end diff --git a/LookInside/Other/LKVersionComparer.swift b/LookInside/Other/LKVersionComparer.swift new file mode 100644 index 00000000..878262c4 --- /dev/null +++ b/LookInside/Other/LKVersionComparer.swift @@ -0,0 +1,32 @@ +// +// LKVersionComparer.swift +// LookInside +// +// Created by likai.123 on 2023/10/30. +// Copyright © 2023 hughkli. All rights reserved. +// + +import Foundation +import LookInsideHostCore + +@objc(LKVersionComparer) +final class LKVersionComparer: NSObject { + /// latest 指网上最高的 LookinServer 版本号,user 指用户真实的版本号。如果用户版本号低于最高版本号,则该方法返回 NO,此时应该提示用户升级。 + @objc(compareWithNewest:user:) + static func compare(newest latest: String?, user: String?) -> Bool { + compare(expectedVersion: latest, realVersion: user) + } + + /// 如果 realVersion 等于或高于 expectedVersion,则该方法返回 YES(换句话说:用户的实际版本号满足我们的需要) + /// A nil version reads as unparseable. + @objc(compareWithExpectedVersion:realVersion:) + static func compare(expectedVersion: String?, realVersion: String?) -> Bool { + let expected = expectedVersion ?? "" + let real = realVersion ?? "" + guard let satisfied = ServerVersionComparison.satisfies(expectedVersion: expected, realVersion: real) else { + assertionFailure("unparseable version: expected \(expected), real \(real)") + return false + } + return satisfied + } +} diff --git a/LookInside/Preference/LKPreferenceView.swift b/LookInside/Preference/LKPreferenceView.swift index 0a997a12..09cf086e 100644 --- a/LookInside/Preference/LKPreferenceView.swift +++ b/LookInside/Preference/LKPreferenceView.swift @@ -211,6 +211,8 @@ private struct TimeoutEditor: View { }() } +/// Stays an ObservableObject: as an @Observable model the segmented pickers +/// laid out narrower than before (the ui-snapshots preferences scene). @MainActor private final class LKPreferenceViewModel: ObservableObject { @Published private var appearanceType: Int @@ -226,7 +228,7 @@ private final class LKPreferenceViewModel: ObservableObject { private let manager: LKPreferenceManager - init(manager: LKPreferenceManager = LKPreferenceManager.main()) { + init(manager: LKPreferenceManager = LKPreferenceManager.shared) { self.manager = manager appearanceType = manager.appearanceType.rawValue colorFormat = manager.rgbaFormat ? 0 : 1 diff --git a/LookInside/Preference/LKPreferenceViewController.h b/LookInside/Preference/LKPreferenceViewController.h deleted file mode 100644 index 860064cd..00000000 --- a/LookInside/Preference/LKPreferenceViewController.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKPreferenceViewController.h -// Lookin -// -// Created by Li Kai on 2019/1/4. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@interface LKPreferenceViewController : LKBaseViewController - -@end diff --git a/LookInside/Preference/LKPreferenceViewController.m b/LookInside/Preference/LKPreferenceViewController.m deleted file mode 100644 index 009cb533..00000000 --- a/LookInside/Preference/LKPreferenceViewController.m +++ /dev/null @@ -1,45 +0,0 @@ -// -// LKPreferenceViewController.m -// Lookin -// -// Created by Li Kai on 2019/1/4. -// https://lookin.work -// - -#import "LKPreferenceViewController.h" -#import "LookInside-Swift.h" - -@interface LKPreferenceViewController () - -@property(nonatomic, strong) NSViewController *hostingController; - -@end - -@implementation LKPreferenceViewController - -- (void)setView:(NSView *)view { - [super setView:view]; - [self _installSwiftUIPreferencesIfNeeded]; -} - -- (void)_installSwiftUIPreferencesIfNeeded { - if (self.hostingController || !self.view) { - return; - } - - LKPreferenceHostingController *hostingController = [LKPreferenceHostingController new]; - self.hostingController = hostingController; - - [self addChildViewController:hostingController]; - hostingController.view.translatesAutoresizingMaskIntoConstraints = NO; - [self.view addSubview:hostingController.view]; - - [NSLayoutConstraint activateConstraints:@[ - [hostingController.view.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], - [hostingController.view.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], - [hostingController.view.topAnchor constraintEqualToAnchor:self.view.topAnchor], - [hostingController.view.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], - ]]; -} - -@end diff --git a/LookInside/Preference/LKPreferenceWindowController.h b/LookInside/Preference/LKPreferenceWindowController.h deleted file mode 100644 index 98201289..00000000 --- a/LookInside/Preference/LKPreferenceWindowController.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKPreferenceWindowController.h -// Lookin -// -// Created by Li Kai on 2019/1/4. -// https://lookin.work -// - -#import "LKWindowController.h" - -@interface LKPreferenceWindowController : LKWindowController - -@end diff --git a/LookInside/Preference/LKPreferenceWindowController.m b/LookInside/Preference/LKPreferenceWindowController.m deleted file mode 100644 index b9fc66fd..00000000 --- a/LookInside/Preference/LKPreferenceWindowController.m +++ /dev/null @@ -1,34 +0,0 @@ -// -// LKPreferenceWindowController.m -// Lookin -// -// Created by Li Kai on 2019/1/4. -// https://lookin.work -// - -#import "LKPreferenceWindowController.h" -#import "LKPreferenceViewController.h" -#import "LKWindow.h" - -@implementation LKPreferenceWindowController - -- (instancetype)init { - CGFloat windowHeight = 455; -#if DEBUG - windowHeight = 520; -#endif - LKWindow *window = [[LKWindow alloc] initWithContentRect:NSMakeRect(0, 0, 660, windowHeight) styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskMiniaturizable backing:NSBackingStoreBuffered defer:YES]; - window.movableByWindowBackground = YES; - window.title = NSLocalizedString(@"Preferences", nil); - window.titlebarAppearsTransparent = YES; - [window center]; - - if (self = [self initWithWindow:window]) { - LKPreferenceViewController *vc = [LKPreferenceViewController new]; - window.contentView = vc.view; - self.contentViewController = vc; - } - return self; -} - -@end diff --git a/LookInside/Preference/LKPreferenceWindowController.swift b/LookInside/Preference/LKPreferenceWindowController.swift new file mode 100644 index 00000000..259d05de --- /dev/null +++ b/LookInside/Preference/LKPreferenceWindowController.swift @@ -0,0 +1,64 @@ +// +// LKPreferenceWindowController.swift +// LookInside +// +// The Preferences window. Its content is the SwiftUI preference form +// (LKPreferenceView.swift) hosted inside an LKBaseViewController. +// + +import AppKit + +@objc(LKPreferenceWindowController) +final class LKPreferenceWindowController: LKWindowController { + @objc init() { + #if DEBUG + let windowHeight: CGFloat = 520 + #else + let windowHeight: CGFloat = 455 + #endif + let window = LKWindow(contentRect: NSRect(x: 0, y: 0, width: 660, height: windowHeight), + styleMask: [.titled, .closable, .miniaturizable], + backing: .buffered, defer: true) + window.isMovableByWindowBackground = true + window.title = NSLocalizedString("Preferences", comment: "") + window.titlebarAppearsTransparent = true + window.center() + + super.init(window: window) + let viewController = LKPreferenceViewController() + window.contentView = viewController.view + contentViewController = viewController + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } +} + +@objc(LKPreferenceViewController) +final class LKPreferenceViewController: LKBaseViewController { + init() { + super.init(containerView: nil) + installPreferenceForm() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + private func installPreferenceForm() { + let hostingController = LKPreferenceHostingController() + addChild(hostingController) + let hostedView = hostingController.view + hostedView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostedView) + NSLayoutConstraint.activate([ + hostedView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostedView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostedView.topAnchor.constraint(equalTo: view.topAnchor), + hostedView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + } +} diff --git a/LookInside/PrefixHeaders.pch b/LookInside/PrefixHeaders.pch deleted file mode 100644 index 67634ee1..00000000 --- a/LookInside/PrefixHeaders.pch +++ /dev/null @@ -1,49 +0,0 @@ -// -// PrefixHeader.pch -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#ifndef PrefixHeader_pch -#define PrefixHeader_pch - -#import "TargetConditionals.h" -#import "ShortCocoa.h" -#import "ReactiveObjC.h" - -#import "LookinDefines.h" -#import "LookinTuple.h" -#import "NSColor+LookinClient.h" - -#import "LKBaseView.h" -#import "LKLabel.h" -#import "LKBaseControl.h" -#import "LKBaseViewController.h" -#import "LKImageControl.h" -#import "LKTextControl.h" -#import "LKHelper.h" -#import "LKTwoColors.h" - -#import "NSObject+Lookin.h" -#import "NSString+LookinClient.h" -#import "CALayer+Lookin.h" -#import "NSArray+Lookin.h" -#import "NSSet+Lookin.h" -#import "NSPointerArray+LookinClient.h" -#import "NSView+LookinClient.h" -#import "Image+Lookin.h" -#import "NSAppearance+LookinClient.h" -#import "NSString+Lookin.h" -#import "NSButton+LookinClient.h" -#import "NSControl+LookinClient.h" -#import "NSArray+LookinClient.h" -#import "LookinDisplayItem+LookinClient.h" -#import "LookinCustomDisplayItemInfo+LookinClient.h" -#import "LookinObject+LookinClient.h" - -#import "ShortCocoa+LookinClient.h" - - -#endif /* PrefixHeader_pch */ diff --git a/LookInside/PrivateDiscriminator/LKPrivateDiscriminatorSettingsWindowController.swift b/LookInside/PrivateDiscriminator/LKPrivateDiscriminatorSettingsWindowController.swift index aa110bb3..a8ea889e 100644 --- a/LookInside/PrivateDiscriminator/LKPrivateDiscriminatorSettingsWindowController.swift +++ b/LookInside/PrivateDiscriminator/LKPrivateDiscriminatorSettingsWindowController.swift @@ -29,13 +29,13 @@ final class LKPrivateDiscriminatorSettingsWindowController: NSWindowController { } @available(*, unavailable) - required init?(coder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } } private struct LKPrivateDiscriminatorSettingsRootView: View { - @ObservedObject private var store = LKPrivateDiscriminatorStore.shared + private var store = LKPrivateDiscriminatorStore.shared @State private var importError: String? var body: some View { diff --git a/LookInside/PrivateDiscriminator/LKPrivateDiscriminatorStore.swift b/LookInside/PrivateDiscriminator/LKPrivateDiscriminatorStore.swift index 2ec3dccc..1ffdf08f 100644 --- a/LookInside/PrivateDiscriminator/LKPrivateDiscriminatorStore.swift +++ b/LookInside/PrivateDiscriminator/LKPrivateDiscriminatorStore.swift @@ -1,7 +1,7 @@ import AppKit -import Combine import Foundation import LookInsidePrivateDiscriminator +import Observation struct LKPrivateDiscriminatorModuleStatus: Identifiable, Hashable { let module: String @@ -13,7 +13,9 @@ struct LKPrivateDiscriminatorModuleStatus: Identifiable, Hashable { let autosavedRecordCount: Int? let diagnostic: String? - var id: String { module } + var id: String { + module + } var sourceSummary: String { var parts: [String] = [] @@ -54,36 +56,43 @@ struct LKPrivateDiscriminatorInvalidDiagnostic: Identifiable, Hashable { let source: String let message: String - var id: String { module + ":" + source } + var id: String { + module + ":" + source + } } +/// The private-discriminator indexes and settings. The settings window +/// observes the state below through Observation. @objc(LKPrivateDiscriminatorStore) -final class LKPrivateDiscriminatorStore: NSObject, ObservableObject { +@Observable +final class LKPrivateDiscriminatorStore: NSObject { @objc(shared) static let shared = LKPrivateDiscriminatorStore() - @Published private(set) var featureEnabled = false - @Published private(set) var autosaveEnabled = false - @Published private(set) var moduleStatuses: [LKPrivateDiscriminatorModuleStatus] = [] - @Published private(set) var invalidDiagnostics: [LKPrivateDiscriminatorInvalidDiagnostic] = [] - @Published private(set) var lastSettingsError: String? - @Published private(set) var isUpdatingDefaultLibrary = false - @Published private(set) var lastDefaultLibraryUpdateMessage: String? + private(set) var featureEnabled = false + private(set) var autosaveEnabled = false + private(set) var moduleStatuses: [LKPrivateDiscriminatorModuleStatus] = [] + private(set) var invalidDiagnostics: [LKPrivateDiscriminatorInvalidDiagnostic] = [] + private(set) var lastSettingsError: String? + private(set) var isUpdatingDefaultLibrary = false + private(set) var lastDefaultLibraryUpdateMessage: String? - @objc var isFeatureEnabled: Bool { featureEnabled } + @objc var isFeatureEnabled: Bool { + featureEnabled + } let storageDirectoryURL: URL let importedDirectoryURL: URL let autosavedDirectoryURL: URL private let configURL: URL - private var configuration = Configuration() - private var loadedIndexesByModule: [String: [LoadedModuleIndex]] = [:] + @ObservationIgnored private var configuration = Configuration() + @ObservationIgnored private var loadedIndexesByModule: [String: [LoadedModuleIndex]] = [:] private let verificationCache = PrivateDiscriminatorVerificationCache(bucketCount: 20) - private var activeGuessTasksByID: [String: Process] = [:] - private var guessStatesByID: [String: GuessState] = [:] - private var cancelledGuessIDs: Set = [] + @ObservationIgnored private var activeGuessTasksByID: [String: Process] = [:] + @ObservationIgnored private var guessStatesByID: [String: GuessState] = [:] + @ObservationIgnored private var cancelledGuessIDs: Set = [] - private override init() { + override private init() { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support", isDirectory: true) storageDirectoryURL = appSupport.appendingPathComponent("LookInside/private_disc", isDirectory: true) @@ -241,7 +250,7 @@ final class LKPrivateDiscriminatorStore: NSObject, ObservableObject { } @objc(displayTitleForDisplayItem:fallback:) - func displayTitle(for item: LookinDisplayItem, fallback: String?) -> String? { + func displayTitle(for _: LookinDisplayItem, fallback: String?) -> String? { guard featureEnabled, let fallback, !fallback.isEmpty else { return fallback } @@ -451,10 +460,10 @@ final class LKPrivateDiscriminatorStore: NSObject, ObservableObject { builders[module, default: ModuleStatusBuilder(module: module)].sourceFolderPath = sourcePath } - collectCSVFiles(in: importedDirectoryURL).forEach { module, url in + for (module, url) in collectCSVFiles(in: importedDirectoryURL) { builders[module, default: ModuleStatusBuilder(module: module)].importedCSVPath = url.path } - collectCSVFiles(in: autosavedDirectoryURL).forEach { module, url in + for (module, url) in collectCSVFiles(in: autosavedDirectoryURL) { builders[module, default: ModuleStatusBuilder(module: module)].autosavedCSVPath = url.path } @@ -1001,7 +1010,7 @@ final class LKPrivateDiscriminatorStore: NSObject, ObservableObject { DefaultLibraryModule(name: "SwiftUICore"), ] - private func promptForModuleName(defaultModule: String?, window: NSWindow?) -> String? { + private func promptForModuleName(defaultModule: String?, window _: NSWindow?) -> String? { let alert = NSAlert() alert.messageText = NSLocalizedString("Import Private Discriminator Module", comment: "") alert.informativeText = NSLocalizedString("Enter the Swift module name for this source folder.", comment: "") @@ -1021,7 +1030,7 @@ final class LKPrivateDiscriminatorStore: NSObject, ObservableObject { return module.isEmpty ? nil : module } - private func promptForSwiftPDGuessRequest(parsed: ParsedDiscriminator, window: NSWindow?) -> GuessRequest? { + private func promptForSwiftPDGuessRequest(parsed: ParsedDiscriminator, window _: NSWindow?) -> GuessRequest? { let defaultWords = Self.defaultGuessWords(for: parsed) let alert = NSAlert() @@ -1133,7 +1142,7 @@ final class LKPrivateDiscriminatorStore: NSObject, ObservableObject { return nil } - private func presentSwiftPDGuessInstallAlert(window: NSWindow?) { + private func presentSwiftPDGuessInstallAlert(window _: NSWindow?) { let alert = NSAlert() alert.messageText = NSLocalizedString("swift-pd-guess is not installed", comment: "") alert.informativeText = NSLocalizedString( @@ -1238,6 +1247,7 @@ final class LKPrivateDiscriminatorStore: NSObject, ObservableObject { private static var dashboardTitle: String { NSLocalizedString("Private Discriminator", comment: "") } + private static let dashboardAttributeIdentifier = "lookinside.private_discriminator.field" private static let dashboardStateDidChangeNotification = Notification.Name("LKPrivateDiscriminatorDashboardStateDidChange") diff --git a/LookInside/ReactiveObjC/NSArray+RACSequenceAdditions.h b/LookInside/ReactiveObjC/NSArray+RACSequenceAdditions.h deleted file mode 100644 index 9cd56056..00000000 --- a/LookInside/ReactiveObjC/NSArray+RACSequenceAdditions.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// NSArray+RACSequenceAdditions.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import - -@class RACSequence<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSArray<__covariant ObjectType> (RACSequenceAdditions) - -/// Creates and returns a sequence corresponding to the receiver. -/// -/// Mutating the receiver will not affect the sequence after it's been created. -@property (nonatomic, copy, readonly) RACSequence *rac_sequence; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSArray+RACSequenceAdditions.m b/LookInside/ReactiveObjC/NSArray+RACSequenceAdditions.m deleted file mode 100644 index 4eaebb8a..00000000 --- a/LookInside/ReactiveObjC/NSArray+RACSequenceAdditions.m +++ /dev/null @@ -1,18 +0,0 @@ -// -// NSArray+RACSequenceAdditions.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "NSArray+RACSequenceAdditions.h" -#import "RACArraySequence.h" - -@implementation NSArray (RACSequenceAdditions) - -- (RACSequence *)rac_sequence { - return [RACArraySequence sequenceWithArray:self offset:0]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSControl+RACCommandSupport.h b/LookInside/ReactiveObjC/NSControl+RACCommandSupport.h deleted file mode 100644 index 6829df8f..00000000 --- a/LookInside/ReactiveObjC/NSControl+RACCommandSupport.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// NSControl+RACCommandSupport.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/3/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACCommand<__contravariant InputType, __covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSControl (RACCommandSupport) - -/// Sets the control's command. When the control is clicked, the command is -/// executed with the sender of the event. The control's enabledness is bound -/// to the command's `canExecute`. -/// -/// Note: this will reset the control's target and action. -@property (nonatomic, strong, nullable) RACCommand<__kindof NSControl *, id> *rac_command; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSControl+RACCommandSupport.m b/LookInside/ReactiveObjC/NSControl+RACCommandSupport.m deleted file mode 100644 index 1d8f6ba2..00000000 --- a/LookInside/ReactiveObjC/NSControl+RACCommandSupport.m +++ /dev/null @@ -1,57 +0,0 @@ -// -// NSControl+RACCommandSupport.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/3/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "NSControl+RACCommandSupport.h" -#import "RACCommand.h" -#import "RACScopedDisposable.h" -#import "RACSignal+Operations.h" -#import - -static void *NSControlRACCommandKey = &NSControlRACCommandKey; -static void *NSControlEnabledDisposableKey = &NSControlEnabledDisposableKey; - -@implementation NSControl (RACCommandSupport) - -- (RACCommand *)rac_command { - return objc_getAssociatedObject(self, NSControlRACCommandKey); -} - -- (void)setRac_command:(RACCommand *)command { - objc_setAssociatedObject(self, NSControlRACCommandKey, command, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - // Tear down any previous binding before setting up our new one, or else we - // might get assertion failures. - [objc_getAssociatedObject(self, NSControlEnabledDisposableKey) dispose]; - objc_setAssociatedObject(self, NSControlEnabledDisposableKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - if (command == nil) { - self.enabled = YES; - return; - } - - [self rac_hijackActionAndTargetIfNeeded]; - - RACScopedDisposable *disposable = [[command.enabled setKeyPath:@"enabled" onObject:self] asScopedDisposable]; - objc_setAssociatedObject(self, NSControlEnabledDisposableKey, disposable, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (void)rac_hijackActionAndTargetIfNeeded { - SEL hijackSelector = @selector(rac_commandPerformAction:); - if (self.target == self && self.action == hijackSelector) return; - - if (self.target != nil) NSLog(@"WARNING: NSControl.rac_command hijacks the control's existing target and action."); - - self.target = self; - self.action = hijackSelector; -} - -- (void)rac_commandPerformAction:(id)sender { - [self.rac_command execute:sender]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSControl+RACTextSignalSupport.h b/LookInside/ReactiveObjC/NSControl+RACTextSignalSupport.h deleted file mode 100644 index e4cdf4f7..00000000 --- a/LookInside/ReactiveObjC/NSControl+RACTextSignalSupport.h +++ /dev/null @@ -1,28 +0,0 @@ -// -// NSControl+RACTextSignalSupport.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-03-08. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSControl (RACTextSignalSupport) - -/// Observes a text-based control for changes. -/// -/// Using this method on a control without editable text is considered undefined -/// behavior. -/// -/// Returns a signal which sends the current string value of the receiver, then -/// the new value any time it changes. -- (RACSignal *)rac_textSignal; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSControl+RACTextSignalSupport.m b/LookInside/ReactiveObjC/NSControl+RACTextSignalSupport.m deleted file mode 100644 index 90ff1cf4..00000000 --- a/LookInside/ReactiveObjC/NSControl+RACTextSignalSupport.m +++ /dev/null @@ -1,38 +0,0 @@ -// -// NSControl+RACTextSignalSupport.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-03-08. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSControl+RACTextSignalSupport.h" -#import "RACEXTScope.h" -#import "NSObject+RACDescription.h" -#import "RACDisposable.h" -#import "RACSignal.h" -#import "RACSubscriber.h" - -@implementation NSControl (RACTextSignalSupport) - -- (RACSignal *)rac_textSignal { - @weakify(self); - return [[[[RACSignal - createSignal:^(id subscriber) { - @strongify(self); - id observer = [NSNotificationCenter.defaultCenter addObserverForName:NSControlTextDidChangeNotification object:self queue:nil usingBlock:^(NSNotification *note) { - [subscriber sendNext:note.object]; - }]; - - return [RACDisposable disposableWithBlock:^{ - [NSNotificationCenter.defaultCenter removeObserver:observer]; - }]; - }] - map:^(NSControl *control) { - return [control.stringValue copy]; - }] - startWith:[self.stringValue copy]] - setNameWithFormat:@"%@ -rac_textSignal", RACDescription(self)]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSData+RACSupport.h b/LookInside/ReactiveObjC/NSData+RACSupport.h deleted file mode 100644 index 5551fbb6..00000000 --- a/LookInside/ReactiveObjC/NSData+RACSupport.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// NSData+RACSupport.h -// ReactiveObjC -// -// Created by Josh Abernathy on 5/11/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACScheduler; -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSData (RACSupport) - -// Read the data at the URL using -[NSData initWithContentsOfURL:options:error:]. -// Sends the data or the error. -// -// scheduler - cannot be nil. -+ (RACSignal *)rac_readContentsOfURL:(nullable NSURL *)URL options:(NSDataReadingOptions)options scheduler:(RACScheduler *)scheduler; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSData+RACSupport.m b/LookInside/ReactiveObjC/NSData+RACSupport.m deleted file mode 100644 index 977ba4ae..00000000 --- a/LookInside/ReactiveObjC/NSData+RACSupport.m +++ /dev/null @@ -1,35 +0,0 @@ -// -// NSData+RACSupport.m -// ReactiveObjC -// -// Created by Josh Abernathy on 5/11/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "NSData+RACSupport.h" -#import "RACReplaySubject.h" -#import "RACScheduler.h" - -@implementation NSData (RACSupport) - -+ (RACSignal *)rac_readContentsOfURL:(NSURL *)URL options:(NSDataReadingOptions)options scheduler:(RACScheduler *)scheduler { - NSCParameterAssert(scheduler != nil); - - RACReplaySubject *subject = [RACReplaySubject subject]; - [subject setNameWithFormat:@"+rac_readContentsOfURL: %@ options: %lu scheduler: %@", URL, (unsigned long)options, scheduler]; - - [scheduler schedule:^{ - NSError *error = nil; - NSData *data = [[NSData alloc] initWithContentsOfURL:URL options:options error:&error]; - if (data == nil) { - [subject sendError:error]; - } else { - [subject sendNext:data]; - [subject sendCompleted]; - } - }]; - - return subject; -} - -@end diff --git a/LookInside/ReactiveObjC/NSDictionary+RACSequenceAdditions.h b/LookInside/ReactiveObjC/NSDictionary+RACSequenceAdditions.h deleted file mode 100644 index 84c7955b..00000000 --- a/LookInside/ReactiveObjC/NSDictionary+RACSequenceAdditions.h +++ /dev/null @@ -1,35 +0,0 @@ -// -// NSDictionary+RACSequenceAdditions.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import - -@class RACSequence<__covariant ValueType>; -@class RACTwoTuple<__covariant First, __covariant Second>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSDictionary<__covariant KeyType, __covariant ObjectType> (RACSequenceAdditions) - -/// Creates and returns a sequence of key/value tuples. -/// -/// Mutating the receiver will not affect the sequence after it's been created. -@property (nonatomic, copy, readonly) RACSequence *> *rac_sequence; - -/// Creates and returns a sequence corresponding to the keys in the receiver. -/// -/// Mutating the receiver will not affect the sequence after it's been created. -@property (nonatomic, copy, readonly) RACSequence *rac_keySequence; - -/// Creates and returns a sequence corresponding to the values in the receiver. -/// -/// Mutating the receiver will not affect the sequence after it's been created. -@property (nonatomic, copy, readonly) RACSequence *rac_valueSequence; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSDictionary+RACSequenceAdditions.m b/LookInside/ReactiveObjC/NSDictionary+RACSequenceAdditions.m deleted file mode 100644 index 2998b9a1..00000000 --- a/LookInside/ReactiveObjC/NSDictionary+RACSequenceAdditions.m +++ /dev/null @@ -1,34 +0,0 @@ -// -// NSDictionary+RACSequenceAdditions.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "NSDictionary+RACSequenceAdditions.h" -#import "NSArray+RACSequenceAdditions.h" -#import "RACSequence.h" -#import "RACTuple.h" - -@implementation NSDictionary (RACSequenceAdditions) - -- (RACSequence *)rac_sequence { - NSDictionary *immutableDict = [self copy]; - - // TODO: First class support for dictionary sequences. - return [immutableDict.allKeys.rac_sequence map:^(id key) { - id value = immutableDict[key]; - return RACTuplePack(key, value); - }]; -} - -- (RACSequence *)rac_keySequence { - return self.allKeys.rac_sequence; -} - -- (RACSequence *)rac_valueSequence { - return self.allValues.rac_sequence; -} - -@end diff --git a/LookInside/ReactiveObjC/NSEnumerator+RACSequenceAdditions.h b/LookInside/ReactiveObjC/NSEnumerator+RACSequenceAdditions.h deleted file mode 100644 index 82e779b0..00000000 --- a/LookInside/ReactiveObjC/NSEnumerator+RACSequenceAdditions.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// NSEnumerator+RACSequenceAdditions.h -// ReactiveObjC -// -// Created by Uri Baghin on 07/01/2013. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -@class RACSequence<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSEnumerator (RACSequenceAdditions) - -/// Creates and returns a sequence corresponding to the receiver. -/// -/// The receiver is exhausted lazily as the sequence is enumerated. -@property (nonatomic, copy, readonly) RACSequence *rac_sequence; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSEnumerator+RACSequenceAdditions.m b/LookInside/ReactiveObjC/NSEnumerator+RACSequenceAdditions.m deleted file mode 100644 index 59b63800..00000000 --- a/LookInside/ReactiveObjC/NSEnumerator+RACSequenceAdditions.m +++ /dev/null @@ -1,22 +0,0 @@ -// -// NSEnumerator+RACSequenceAdditions.m -// ReactiveObjC -// -// Created by Uri Baghin on 07/01/2013. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSEnumerator+RACSequenceAdditions.h" -#import "RACSequence.h" - -@implementation NSEnumerator (RACSequenceAdditions) - -- (RACSequence *)rac_sequence { - return [RACSequence sequenceWithHeadBlock:^{ - return [self nextObject]; - } tailBlock:^{ - return self.rac_sequence; - }]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSFileHandle+RACSupport.h b/LookInside/ReactiveObjC/NSFileHandle+RACSupport.h deleted file mode 100644 index 70d8ff3a..00000000 --- a/LookInside/ReactiveObjC/NSFileHandle+RACSupport.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// NSFileHandle+RACSupport.h -// ReactiveObjC -// -// Created by Josh Abernathy on 5/10/12. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import - -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSFileHandle (RACSupport) - -// Read any available data in the background and send it. Completes when data -// length is <= 0. -- (RACSignal *)rac_readInBackground; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSFileHandle+RACSupport.m b/LookInside/ReactiveObjC/NSFileHandle+RACSupport.m deleted file mode 100644 index 9d1c42f3..00000000 --- a/LookInside/ReactiveObjC/NSFileHandle+RACSupport.m +++ /dev/null @@ -1,40 +0,0 @@ -// -// NSFileHandle+RACSupport.m -// ReactiveObjC -// -// Created by Josh Abernathy on 5/10/12. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "NSFileHandle+RACSupport.h" -#import "NSNotificationCenter+RACSupport.h" -#import "NSObject+RACDescription.h" -#import "RACReplaySubject.h" -#import "RACDisposable.h" - -@implementation NSFileHandle (RACSupport) - -- (RACSignal *)rac_readInBackground { - RACReplaySubject *subject = [RACReplaySubject subject]; - [subject setNameWithFormat:@"%@ -rac_readInBackground", RACDescription(self)]; - - RACSignal *dataNotification = [[[NSNotificationCenter defaultCenter] rac_addObserverForName:NSFileHandleReadCompletionNotification object:self] map:^(NSNotification *note) { - return note.userInfo[NSFileHandleNotificationDataItem]; - }]; - - __block RACDisposable *subscription = [dataNotification subscribeNext:^(NSData *data) { - if (data.length > 0) { - [subject sendNext:data]; - [self readInBackgroundAndNotify]; - } else { - [subject sendCompleted]; - [subscription dispose]; - } - }]; - - [self readInBackgroundAndNotify]; - - return subject; -} - -@end diff --git a/LookInside/ReactiveObjC/NSIndexSet+RACSequenceAdditions.h b/LookInside/ReactiveObjC/NSIndexSet+RACSequenceAdditions.h deleted file mode 100644 index 5075f4a6..00000000 --- a/LookInside/ReactiveObjC/NSIndexSet+RACSequenceAdditions.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// NSIndexSet+RACSequenceAdditions.h -// ReactiveObjC -// -// Created by Sergey Gavrilyuk on 12/17/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -@class RACSequence<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSIndexSet (RACSequenceAdditions) - -/// Creates and returns a sequence of indexes (as `NSNumber`s) corresponding to -/// the receiver. -/// -/// Mutating the receiver will not affect the sequence after it's been created. -@property (nonatomic, copy, readonly) RACSequence *rac_sequence; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSIndexSet+RACSequenceAdditions.m b/LookInside/ReactiveObjC/NSIndexSet+RACSequenceAdditions.m deleted file mode 100644 index e2866b82..00000000 --- a/LookInside/ReactiveObjC/NSIndexSet+RACSequenceAdditions.m +++ /dev/null @@ -1,18 +0,0 @@ -// -// NSIndexSet+RACSequenceAdditions.m -// ReactiveObjC -// -// Created by Sergey Gavrilyuk on 12/17/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSIndexSet+RACSequenceAdditions.h" -#import "RACIndexSetSequence.h" - -@implementation NSIndexSet (RACSequenceAdditions) - -- (RACSequence *)rac_sequence { - return [RACIndexSetSequence sequenceWithIndexSet:self]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSInvocation+RACTypeParsing.h b/LookInside/ReactiveObjC/NSInvocation+RACTypeParsing.h deleted file mode 100644 index a7ac23b7..00000000 --- a/LookInside/ReactiveObjC/NSInvocation+RACTypeParsing.h +++ /dev/null @@ -1,56 +0,0 @@ -// -// NSInvocation+RACTypeParsing.h -// ReactiveObjC -// -// Created by Josh Abernathy on 11/17/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACTuple; - -// A private category of methods to handle wrapping and unwrapping of values. -@interface NSInvocation (RACTypeParsing) - -// Sets the argument for the invocation at the given index by unboxing the given -// object based on the type signature of the argument. -// -// This does not support C arrays or unions. -// -// Note that calling this on a char * or const char * argument can cause all -// arguments to be retained. -// -// object - The object to unbox and set as the argument. -// index - The index of the argument to set. -- (void)rac_setArgument:(id)object atIndex:(NSUInteger)index; - -// Gets the argument for the invocation at the given index based on the -// invocation's method signature. The value is then wrapped in the appropriate -// object type. -// -// This does not support C arrays or unions. -// -// index - The index of the argument to get. -// -// Returns the argument of the invocation, wrapped in an object. -- (id)rac_argumentAtIndex:(NSUInteger)index; - -// Arguments tuple for the invocation. -// -// The arguments tuple excludes implicit variables `self` and `_cmd`. -// -// See -rac_argumentAtIndex: and -rac_setArgumentAtIndex: for further -// description of the underlying behavior. -@property (nonatomic, copy) RACTuple *rac_argumentsTuple; - -// Gets the return value from the invocation based on the invocation's method -// signature. The value is then wrapped in the appropriate object type. -// -// This does not support C arrays or unions. -// -// Returns the return value of the invocation, wrapped in an object. Voids are -// returned as `RACUnit.defaultUnit`. -- (id)rac_returnValue; - -@end diff --git a/LookInside/ReactiveObjC/NSInvocation+RACTypeParsing.m b/LookInside/ReactiveObjC/NSInvocation+RACTypeParsing.m deleted file mode 100644 index 92b5b7d0..00000000 --- a/LookInside/ReactiveObjC/NSInvocation+RACTypeParsing.m +++ /dev/null @@ -1,232 +0,0 @@ -// -// NSInvocation+RACTypeParsing.m -// ReactiveObjC -// -// Created by Josh Abernathy on 11/17/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "NSInvocation+RACTypeParsing.h" -#import "RACTuple.h" -#import "RACUnit.h" -#import - -@implementation NSInvocation (RACTypeParsing) - -- (void)rac_setArgument:(id)object atIndex:(NSUInteger)index { -#define PULL_AND_SET(type, selector) \ - do { \ - type val = [object selector]; \ - [self setArgument:&val atIndex:(NSInteger)index]; \ - } while (0) - - const char *argType = [self.methodSignature getArgumentTypeAtIndex:index]; - // Skip const type qualifier. - if (argType[0] == 'r') { - argType++; - } - - if (strcmp(argType, @encode(id)) == 0 || strcmp(argType, @encode(Class)) == 0) { - [self setArgument:&object atIndex:(NSInteger)index]; - } else if (strcmp(argType, @encode(char)) == 0) { - PULL_AND_SET(char, charValue); - } else if (strcmp(argType, @encode(int)) == 0) { - PULL_AND_SET(int, intValue); - } else if (strcmp(argType, @encode(short)) == 0) { - PULL_AND_SET(short, shortValue); - } else if (strcmp(argType, @encode(long)) == 0) { - PULL_AND_SET(long, longValue); - } else if (strcmp(argType, @encode(long long)) == 0) { - PULL_AND_SET(long long, longLongValue); - } else if (strcmp(argType, @encode(unsigned char)) == 0) { - PULL_AND_SET(unsigned char, unsignedCharValue); - } else if (strcmp(argType, @encode(unsigned int)) == 0) { - PULL_AND_SET(unsigned int, unsignedIntValue); - } else if (strcmp(argType, @encode(unsigned short)) == 0) { - PULL_AND_SET(unsigned short, unsignedShortValue); - } else if (strcmp(argType, @encode(unsigned long)) == 0) { - PULL_AND_SET(unsigned long, unsignedLongValue); - } else if (strcmp(argType, @encode(unsigned long long)) == 0) { - PULL_AND_SET(unsigned long long, unsignedLongLongValue); - } else if (strcmp(argType, @encode(float)) == 0) { - PULL_AND_SET(float, floatValue); - } else if (strcmp(argType, @encode(double)) == 0) { - PULL_AND_SET(double, doubleValue); - } else if (strcmp(argType, @encode(BOOL)) == 0) { - PULL_AND_SET(BOOL, boolValue); - } else if (strcmp(argType, @encode(char *)) == 0) { - const char *cString = [object UTF8String]; - [self setArgument:&cString atIndex:(NSInteger)index]; - [self retainArguments]; - } else if (strcmp(argType, @encode(void (^)(void))) == 0) { - [self setArgument:&object atIndex:(NSInteger)index]; - } else { - NSCParameterAssert([object isKindOfClass:NSValue.class]); - - NSUInteger valueSize = 0; - NSGetSizeAndAlignment([object objCType], &valueSize, NULL); - -#if DEBUG - NSUInteger argSize = 0; - NSGetSizeAndAlignment(argType, &argSize, NULL); - NSCAssert(valueSize == argSize, @"Value size does not match argument size in -rac_setArgument: %@ atIndex: %lu", object, (unsigned long)index); -#endif - - unsigned char valueBytes[valueSize]; - [object getValue:valueBytes]; - - [self setArgument:valueBytes atIndex:(NSInteger)index]; - } - -#undef PULL_AND_SET -} - -- (id)rac_argumentAtIndex:(NSUInteger)index { -#define WRAP_AND_RETURN(type) \ - do { \ - type val = 0; \ - [self getArgument:&val atIndex:(NSInteger)index]; \ - return @(val); \ - } while (0) - - const char *argType = [self.methodSignature getArgumentTypeAtIndex:index]; - // Skip const type qualifier. - if (argType[0] == 'r') { - argType++; - } - - if (strcmp(argType, @encode(id)) == 0 || strcmp(argType, @encode(Class)) == 0) { - __autoreleasing id returnObj; - [self getArgument:&returnObj atIndex:(NSInteger)index]; - return returnObj; - } else if (strcmp(argType, @encode(char)) == 0) { - WRAP_AND_RETURN(char); - } else if (strcmp(argType, @encode(int)) == 0) { - WRAP_AND_RETURN(int); - } else if (strcmp(argType, @encode(short)) == 0) { - WRAP_AND_RETURN(short); - } else if (strcmp(argType, @encode(long)) == 0) { - WRAP_AND_RETURN(long); - } else if (strcmp(argType, @encode(long long)) == 0) { - WRAP_AND_RETURN(long long); - } else if (strcmp(argType, @encode(unsigned char)) == 0) { - WRAP_AND_RETURN(unsigned char); - } else if (strcmp(argType, @encode(unsigned int)) == 0) { - WRAP_AND_RETURN(unsigned int); - } else if (strcmp(argType, @encode(unsigned short)) == 0) { - WRAP_AND_RETURN(unsigned short); - } else if (strcmp(argType, @encode(unsigned long)) == 0) { - WRAP_AND_RETURN(unsigned long); - } else if (strcmp(argType, @encode(unsigned long long)) == 0) { - WRAP_AND_RETURN(unsigned long long); - } else if (strcmp(argType, @encode(float)) == 0) { - WRAP_AND_RETURN(float); - } else if (strcmp(argType, @encode(double)) == 0) { - WRAP_AND_RETURN(double); - } else if (strcmp(argType, @encode(BOOL)) == 0) { - WRAP_AND_RETURN(BOOL); - } else if (strcmp(argType, @encode(char *)) == 0) { - WRAP_AND_RETURN(const char *); - } else if (strcmp(argType, @encode(void (^)(void))) == 0) { - __unsafe_unretained id block = nil; - [self getArgument:&block atIndex:(NSInteger)index]; - return [block copy]; - } else { - NSUInteger valueSize = 0; - NSGetSizeAndAlignment(argType, &valueSize, NULL); - - unsigned char valueBytes[valueSize]; - [self getArgument:valueBytes atIndex:(NSInteger)index]; - - return [NSValue valueWithBytes:valueBytes objCType:argType]; - } - - return nil; - -#undef WRAP_AND_RETURN -} - -- (RACTuple *)rac_argumentsTuple { - NSUInteger numberOfArguments = self.methodSignature.numberOfArguments; - NSMutableArray *argumentsArray = [NSMutableArray arrayWithCapacity:numberOfArguments - 2]; - for (NSUInteger index = 2; index < numberOfArguments; index++) { - [argumentsArray addObject:[self rac_argumentAtIndex:index] ?: RACTupleNil.tupleNil]; - } - - return [RACTuple tupleWithObjectsFromArray:argumentsArray]; -} - -- (void)setRac_argumentsTuple:(RACTuple *)arguments { - NSCAssert(arguments.count == self.methodSignature.numberOfArguments - 2, @"Number of supplied arguments (%lu), does not match the number expected by the signature (%lu)", (unsigned long)arguments.count, (unsigned long)self.methodSignature.numberOfArguments - 2); - - NSUInteger index = 2; - for (id arg in arguments) { - [self rac_setArgument:(arg == RACTupleNil.tupleNil ? nil : arg) atIndex:index]; - index++; - } -} - -- (id)rac_returnValue { -#define WRAP_AND_RETURN(type) \ - do { \ - type val = 0; \ - [self getReturnValue:&val]; \ - return @(val); \ - } while (0) - - const char *returnType = self.methodSignature.methodReturnType; - // Skip const type qualifier. - if (returnType[0] == 'r') { - returnType++; - } - - if (strcmp(returnType, @encode(id)) == 0 || strcmp(returnType, @encode(Class)) == 0 || strcmp(returnType, @encode(void (^)(void))) == 0) { - __autoreleasing id returnObj; - [self getReturnValue:&returnObj]; - return returnObj; - } else if (strcmp(returnType, @encode(char)) == 0) { - WRAP_AND_RETURN(char); - } else if (strcmp(returnType, @encode(int)) == 0) { - WRAP_AND_RETURN(int); - } else if (strcmp(returnType, @encode(short)) == 0) { - WRAP_AND_RETURN(short); - } else if (strcmp(returnType, @encode(long)) == 0) { - WRAP_AND_RETURN(long); - } else if (strcmp(returnType, @encode(long long)) == 0) { - WRAP_AND_RETURN(long long); - } else if (strcmp(returnType, @encode(unsigned char)) == 0) { - WRAP_AND_RETURN(unsigned char); - } else if (strcmp(returnType, @encode(unsigned int)) == 0) { - WRAP_AND_RETURN(unsigned int); - } else if (strcmp(returnType, @encode(unsigned short)) == 0) { - WRAP_AND_RETURN(unsigned short); - } else if (strcmp(returnType, @encode(unsigned long)) == 0) { - WRAP_AND_RETURN(unsigned long); - } else if (strcmp(returnType, @encode(unsigned long long)) == 0) { - WRAP_AND_RETURN(unsigned long long); - } else if (strcmp(returnType, @encode(float)) == 0) { - WRAP_AND_RETURN(float); - } else if (strcmp(returnType, @encode(double)) == 0) { - WRAP_AND_RETURN(double); - } else if (strcmp(returnType, @encode(BOOL)) == 0) { - WRAP_AND_RETURN(BOOL); - } else if (strcmp(returnType, @encode(char *)) == 0) { - WRAP_AND_RETURN(const char *); - } else if (strcmp(returnType, @encode(void)) == 0) { - return RACUnit.defaultUnit; - } else { - NSUInteger valueSize = 0; - NSGetSizeAndAlignment(returnType, &valueSize, NULL); - - unsigned char valueBytes[valueSize]; - [self getReturnValue:valueBytes]; - - return [NSValue valueWithBytes:valueBytes objCType:returnType]; - } - - return nil; - -#undef WRAP_AND_RETURN -} - -@end diff --git a/LookInside/ReactiveObjC/NSNotificationCenter+RACSupport.h b/LookInside/ReactiveObjC/NSNotificationCenter+RACSupport.h deleted file mode 100644 index 49cd4fbf..00000000 --- a/LookInside/ReactiveObjC/NSNotificationCenter+RACSupport.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// NSNotificationCenter+RACSupport.h -// ReactiveObjC -// -// Created by Josh Abernathy on 5/10/12. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import - -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSNotificationCenter (RACSupport) - -// Sends the NSNotification every time the notification is posted. -- (RACSignal *)rac_addObserverForName:(nullable NSString *)notificationName object:(nullable id)object; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSNotificationCenter+RACSupport.m b/LookInside/ReactiveObjC/NSNotificationCenter+RACSupport.m deleted file mode 100644 index 57ab7e0d..00000000 --- a/LookInside/ReactiveObjC/NSNotificationCenter+RACSupport.m +++ /dev/null @@ -1,31 +0,0 @@ -// -// NSNotificationCenter+RACSupport.m -// ReactiveObjC -// -// Created by Josh Abernathy on 5/10/12. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "NSNotificationCenter+RACSupport.h" -#import "RACEXTScope.h" -#import "RACSignal.h" -#import "RACSubscriber.h" -#import "RACDisposable.h" - -@implementation NSNotificationCenter (RACSupport) - -- (RACSignal *)rac_addObserverForName:(NSString *)notificationName object:(id)object { - @unsafeify(object); - return [[RACSignal createSignal:^(id subscriber) { - @strongify(object); - id observer = [self addObserverForName:notificationName object:object queue:nil usingBlock:^(NSNotification *note) { - [subscriber sendNext:note]; - }]; - - return [RACDisposable disposableWithBlock:^{ - [self removeObserver:observer]; - }]; - }] setNameWithFormat:@"-rac_addObserverForName: %@ object: <%@: %p>", notificationName, [object class], object]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSObject+RACAppKitBindings.h b/LookInside/ReactiveObjC/NSObject+RACAppKitBindings.h deleted file mode 100644 index 895de575..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACAppKitBindings.h +++ /dev/null @@ -1,35 +0,0 @@ -// -// NSObject+RACAppKitBindings.h -// ReactiveObjC -// -// Created by Josh Abernathy on 4/17/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACChannelTerminal; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSObject (RACAppKitBindings) - -/// Invokes -rac_channelToBinding:options: without any options. -- (RACChannelTerminal *)rac_channelToBinding:(NSString *)binding; - -/// Applies a Cocoa binding to the receiver, then exposes a RACChannel-based -/// interface for manipulating it. -/// -/// Creating two of the same bindings on the same object will result in undefined -/// behavior. -/// -/// binding - The name of the binding. This must not be nil. -/// options - Any options to pass to Cocoa Bindings. This may be nil. -/// -/// Returns a RACChannelTerminal which will send future values from the receiver, -/// and update the receiver when values are sent to the terminal. -- (RACChannelTerminal *)rac_channelToBinding:(NSString *)binding options:(nullable NSDictionary *)options; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSObject+RACAppKitBindings.m b/LookInside/ReactiveObjC/NSObject+RACAppKitBindings.m deleted file mode 100644 index 79785822..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACAppKitBindings.m +++ /dev/null @@ -1,146 +0,0 @@ -// -// NSObject+RACAppKitBindings.m -// ReactiveObjC -// -// Created by Josh Abernathy on 4/17/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "NSObject+RACAppKitBindings.h" -#import "RACEXTKeyPathCoding.h" -#import "RACEXTScope.h" -#import "NSObject+RACDeallocating.h" -#import "RACChannel.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACKVOChannel.h" -#import "RACValueTransformer.h" -#import - -// Used as an object to bind to, so we can hide the object creation and just -// expose a RACChannel instead. -@interface RACChannelProxy : NSObject - -// The RACChannel used for this Cocoa binding. -@property (nonatomic, strong, readonly) RACChannel *channel; - -// The KVC- and KVO-compliant property to be read and written by the Cocoa -// binding. -// -// This should not be set manually. -@property (nonatomic, strong) id value; - -// The target of the Cocoa binding. -// -// This should be set to nil when the target deallocates. -@property (atomic, unsafe_unretained) id target; - -// The name of the Cocoa binding used. -@property (nonatomic, copy, readonly) NSString *bindingName; - -// Improves the performance of KVO on the receiver. -// -// See the documentation for for more information. -@property (atomic, assign) void *observationInfo; - -// Initializes the receiver and binds to the given target. -// -// target - The target of the Cocoa binding. This must not be nil. -// bindingName - The name of the Cocoa binding to use. This must not be nil. -// options - Any options to pass to the binding. This may be nil. -// -// Returns an initialized channel proxy. -- (instancetype)initWithTarget:(id)target bindingName:(NSString *)bindingName options:(NSDictionary *)options; - -@end - -@implementation NSObject (RACAppKitBindings) - -- (RACChannelTerminal *)rac_channelToBinding:(NSString *)binding { - return [self rac_channelToBinding:binding options:nil]; -} - -- (RACChannelTerminal *)rac_channelToBinding:(NSString *)binding options:(NSDictionary *)options { - NSCParameterAssert(binding != nil); - - RACChannelProxy *proxy = [[RACChannelProxy alloc] initWithTarget:self bindingName:binding options:options]; - return proxy.channel.leadingTerminal; -} - -@end - -@implementation RACChannelProxy - -#pragma mark Properties - -- (void)setValue:(id)value { - [self willChangeValueForKey:@keypath(self.value)]; - _value = value; - [self didChangeValueForKey:@keypath(self.value)]; -} - -#pragma mark Lifecycle - -- (instancetype)initWithTarget:(id)target bindingName:(NSString *)bindingName options:(NSDictionary *)options { - NSCParameterAssert(target != nil); - NSCParameterAssert(bindingName != nil); - - self = [super init]; - - _target = target; - _bindingName = [bindingName copy]; - _channel = [[RACChannel alloc] init]; - - @weakify(self); - - void (^cleanUp)(void) = ^{ - @strongify(self); - - id target = self.target; - if (target == nil) return; - - self.target = nil; - - [target unbind:bindingName]; - objc_setAssociatedObject(target, (__bridge void *)self, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - }; - - // When the channel terminates, tear down this proxy. - [self.channel.followingTerminal subscribeError:^(NSError *error) { - cleanUp(); - } completed:cleanUp]; - - [self.target bind:bindingName toObject:self withKeyPath:@keypath(self.value) options:options]; - - // Keep the proxy alive as long as the target, or until the property subject - // terminates. - objc_setAssociatedObject(self.target, (__bridge void *)self, self, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - [[self.target rac_deallocDisposable] addDisposable:[RACDisposable disposableWithBlock:^{ - @strongify(self); - [self.channel.followingTerminal sendCompleted]; - }]]; - - RACChannelTo(self, value, options[NSNullPlaceholderBindingOption]) = self.channel.followingTerminal; - return self; -} - -- (void)dealloc { - [self.channel.followingTerminal sendCompleted]; -} - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p>{ target: %@, binding: %@ }", self.class, self, self.target, self.bindingName]; -} - -#pragma mark NSKeyValueObserving - -+ (BOOL)automaticallyNotifiesObserversForKey:(NSString *)key { - // Generating manual notifications for `value` is simpler and more - // performant than having KVO swizzle our class and add its own logic. - return NO; -} - -@end diff --git a/LookInside/ReactiveObjC/NSObject+RACDeallocating.h b/LookInside/ReactiveObjC/NSObject+RACDeallocating.h deleted file mode 100644 index 2f845d7b..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACDeallocating.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// NSObject+RACDeallocating.h -// ReactiveObjC -// -// Created by Kazuo Koga on 2013/03/15. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -@class RACCompoundDisposable; -@class RACDisposable; -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSObject (RACDeallocating) - -/// The compound disposable which will be disposed of when the receiver is -/// deallocated. -@property (atomic, readonly, strong) RACCompoundDisposable *rac_deallocDisposable; - -/// Returns a signal that will complete immediately before the receiver is fully -/// deallocated. If already deallocated when the signal is subscribed to, -/// a `completed` event will be sent immediately. -- (RACSignal *)rac_willDeallocSignal; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSObject+RACDeallocating.m b/LookInside/ReactiveObjC/NSObject+RACDeallocating.m deleted file mode 100644 index 53c3feac..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACDeallocating.m +++ /dev/null @@ -1,103 +0,0 @@ -// -// NSObject+RACDeallocating.m -// ReactiveObjC -// -// Created by Kazuo Koga on 2013/03/15. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSObject+RACDeallocating.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACReplaySubject.h" -#import -#import - -static const void *RACObjectCompoundDisposable = &RACObjectCompoundDisposable; - -static NSMutableSet *swizzledClasses(void) { - static dispatch_once_t onceToken; - static NSMutableSet *swizzledClasses = nil; - dispatch_once(&onceToken, ^{ - swizzledClasses = [[NSMutableSet alloc] init]; - }); - - return swizzledClasses; -} - -static void swizzleDeallocIfNeeded(Class classToSwizzle) { - @synchronized (swizzledClasses()) { - NSString *className = NSStringFromClass(classToSwizzle); - if ([swizzledClasses() containsObject:className]) return; - - SEL deallocSelector = sel_registerName("dealloc"); - - __block void (*originalDealloc)(__unsafe_unretained id, SEL) = NULL; - - id newDealloc = ^(__unsafe_unretained id self) { - RACCompoundDisposable *compoundDisposable = objc_getAssociatedObject(self, RACObjectCompoundDisposable); - [compoundDisposable dispose]; - - if (originalDealloc == NULL) { - struct objc_super superInfo = { - .receiver = self, - .super_class = class_getSuperclass(classToSwizzle) - }; - - void (*msgSend)(struct objc_super *, SEL) = (__typeof__(msgSend))objc_msgSendSuper; - msgSend(&superInfo, deallocSelector); - } else { - originalDealloc(self, deallocSelector); - } - }; - - IMP newDeallocIMP = imp_implementationWithBlock(newDealloc); - - if (!class_addMethod(classToSwizzle, deallocSelector, newDeallocIMP, "v@:")) { - // The class already contains a method implementation. - Method deallocMethod = class_getInstanceMethod(classToSwizzle, deallocSelector); - - // We need to store original implementation before setting new implementation - // in case method is called at the time of setting. - originalDealloc = (__typeof__(originalDealloc))method_getImplementation(deallocMethod); - - // We need to store original implementation again, in case it just changed. - originalDealloc = (__typeof__(originalDealloc))method_setImplementation(deallocMethod, newDeallocIMP); - } - - [swizzledClasses() addObject:className]; - } -} - -@implementation NSObject (RACDeallocating) - -- (RACSignal *)rac_willDeallocSignal { - RACSignal *signal = objc_getAssociatedObject(self, _cmd); - if (signal != nil) return signal; - - RACReplaySubject *subject = [RACReplaySubject subject]; - - [self.rac_deallocDisposable addDisposable:[RACDisposable disposableWithBlock:^{ - [subject sendCompleted]; - }]]; - - objc_setAssociatedObject(self, _cmd, subject, OBJC_ASSOCIATION_RETAIN); - - return subject; -} - -- (RACCompoundDisposable *)rac_deallocDisposable { - @synchronized (self) { - RACCompoundDisposable *compoundDisposable = objc_getAssociatedObject(self, RACObjectCompoundDisposable); - if (compoundDisposable != nil) return compoundDisposable; - - swizzleDeallocIfNeeded(self.class); - - compoundDisposable = [RACCompoundDisposable compoundDisposable]; - objc_setAssociatedObject(self, RACObjectCompoundDisposable, compoundDisposable, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - return compoundDisposable; - } -} - -@end diff --git a/LookInside/ReactiveObjC/NSObject+RACDescription.h b/LookInside/ReactiveObjC/NSObject+RACDescription.h deleted file mode 100644 index 066e1027..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACDescription.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// NSObject+RACDescription.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-05-13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -// A simplified description of the object, which does not invoke -description -// (and thus should be much faster in many cases). -// -// This is for debugging purposes only, and will return a constant string -// unless the RAC_DEBUG_SIGNAL_NAMES environment variable is set. -NSString *RACDescription(id object); diff --git a/LookInside/ReactiveObjC/NSObject+RACDescription.m b/LookInside/ReactiveObjC/NSObject+RACDescription.m deleted file mode 100644 index 1b214277..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACDescription.m +++ /dev/null @@ -1,50 +0,0 @@ -// -// NSObject+RACDescription.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-05-13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSObject+RACDescription.h" -#import "RACTuple.h" - -@implementation NSValue (RACDescription) - -- (NSString *)rac_description { - return self.description; -} - -@end - -@implementation NSString (RACDescription) - -- (NSString *)rac_description { - return self.description; -} - -@end - -@implementation RACTuple (RACDescription) - -- (NSString *)rac_description { - if (getenv("RAC_DEBUG_SIGNAL_NAMES") != NULL) { - return self.allObjects.description; - } else { - return @"(description skipped)"; - } -} - -@end - -NSString *RACDescription(id object) { - if (getenv("RAC_DEBUG_SIGNAL_NAMES") != NULL) { - if ([object respondsToSelector:@selector(rac_description)]) { - return [object rac_description]; - } else { - return [[NSString alloc] initWithFormat:@"<%@: %p>", [object class], object]; - } - } else { - return @"(description skipped)"; - } -} diff --git a/LookInside/ReactiveObjC/NSObject+RACKVOWrapper.h b/LookInside/ReactiveObjC/NSObject+RACKVOWrapper.h deleted file mode 100644 index 48e6a040..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACKVOWrapper.h +++ /dev/null @@ -1,46 +0,0 @@ -// -// NSObject+RACKVOWrapper.h -// ReactiveObjC -// -// Created by Josh Abernathy on 10/11/11. -// Copyright (c) 2011 GitHub. All rights reserved. -// - -#import - -@class RACDisposable; -@class RACKVOTrampoline; - -// A private category providing a block based interface to KVO. -@interface NSObject (RACKVOWrapper) - -// Adds the given block as the callbacks for when the key path changes. -// -// Unlike direct KVO observation, this handles deallocation of `weak` properties -// by generating an appropriate notification. This will only occur if there is -// an `@property` declaration visible in the observed class, with the `weak` -// memory management attribute. -// -// The observation does not need to be explicitly removed. It will be removed -// when the observer or the receiver deallocate. -// -// keyPath - The key path to observe. Must not be nil. -// options - The KVO observation options. -// observer - The object that requested the observation. May be nil. -// block - The block called when the value at the key path changes. It is -// passed the current value of the key path and the extended KVO -// change dictionary including RAC-specific keys and values. Must not -// be nil. -// -// Returns a disposable that can be used to stop the observation. -- (RACDisposable *)rac_observeKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options observer:(__weak NSObject *)observer block:(void (^)(id value, NSDictionary *change, BOOL causedByDealloc, BOOL affectedOnlyLastComponent))block; - -@end - -typedef void (^RACKVOBlock)(id target, id observer, NSDictionary *change); - -@interface NSObject (RACUnavailableKVOWrapper) - -- (RACKVOTrampoline *)rac_addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(RACKVOBlock)block __attribute((unavailable("Use rac_observeKeyPath:options:observer:block: instead."))); - -@end diff --git a/LookInside/ReactiveObjC/NSObject+RACKVOWrapper.m b/LookInside/ReactiveObjC/NSObject+RACKVOWrapper.m deleted file mode 100644 index 382d5c1e..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACKVOWrapper.m +++ /dev/null @@ -1,200 +0,0 @@ -// -// NSObject+RACKVOWrapper.m -// ReactiveObjC -// -// Created by Josh Abernathy on 10/11/11. -// Copyright (c) 2011 GitHub. All rights reserved. -// - -#import "NSObject+RACKVOWrapper.h" -#import "RACEXTRuntimeExtensions.h" -#import "RACEXTScope.h" -#import "NSObject+RACDeallocating.h" -#import "NSString+RACKeyPathUtilities.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACKVOTrampoline.h" -#import "RACSerialDisposable.h" - -@implementation NSObject (RACKVOWrapper) - -- (RACDisposable *)rac_observeKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options observer:(__weak NSObject *)weakObserver block:(void (^)(id, NSDictionary *, BOOL, BOOL))block { - NSCParameterAssert(block != nil); - NSCParameterAssert(keyPath.rac_keyPathComponents.count > 0); - - keyPath = [keyPath copy]; - - NSObject *strongObserver = weakObserver; - - NSArray *keyPathComponents = keyPath.rac_keyPathComponents; - BOOL keyPathHasOneComponent = (keyPathComponents.count == 1); - NSString *keyPathHead = keyPathComponents[0]; - NSString *keyPathTail = keyPath.rac_keyPathByDeletingFirstKeyPathComponent; - - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - - // The disposable that groups all disposal necessary to clean up the callbacks - // added to the value of the first key path component. - RACSerialDisposable *firstComponentSerialDisposable = [RACSerialDisposable serialDisposableWithDisposable:[RACCompoundDisposable compoundDisposable]]; - RACCompoundDisposable * (^firstComponentDisposable)(void) = ^{ - return (RACCompoundDisposable *)firstComponentSerialDisposable.disposable; - }; - - [disposable addDisposable:firstComponentSerialDisposable]; - - BOOL shouldAddDeallocObserver = NO; - - objc_property_t property = class_getProperty(object_getClass(self), keyPathHead.UTF8String); - if (property != NULL) { - rac_propertyAttributes *attributes = rac_copyPropertyAttributes(property); - if (attributes != NULL) { - @onExit { - free(attributes); - }; - - BOOL isObject = attributes->objectClass != nil || strstr(attributes->type, @encode(id)) == attributes->type; - BOOL isProtocol = attributes->objectClass == NSClassFromString(@"Protocol"); - BOOL isBlock = strcmp(attributes->type, @encode(void (^)(void))) == 0; - BOOL isWeak = attributes->weak; - - // If this property isn't actually an object (or is a Class object), - // no point in observing the deallocation of the wrapper returned by - // KVC. - // - // If this property is an object, but not declared `weak`, we - // don't need to watch for it spontaneously being set to nil. - // - // Attempting to observe non-weak properties will result in - // broken behavior for dynamic getters, so don't even try. - shouldAddDeallocObserver = isObject && isWeak && !isBlock && !isProtocol; - } - } - - // Adds the callback block to the value's deallocation. Also adds the logic to - // clean up the callback to the firstComponentDisposable. - void (^addDeallocObserverToPropertyValue)(NSObject *) = ^(NSObject *value) { - if (!shouldAddDeallocObserver) return; - - // If a key path value is the observer, commonly when a key path begins - // with "self", we prevent deallocation triggered callbacks for any such key - // path components. Thus, the observer's deallocation is not considered a - // change to the key path. - if (value == weakObserver) return; - - NSDictionary *change = @{ - NSKeyValueChangeKindKey: @(NSKeyValueChangeSetting), - NSKeyValueChangeNewKey: NSNull.null, - }; - - RACCompoundDisposable *valueDisposable = value.rac_deallocDisposable; - RACDisposable *deallocDisposable = [RACDisposable disposableWithBlock:^{ - block(nil, change, YES, keyPathHasOneComponent); - }]; - - [valueDisposable addDisposable:deallocDisposable]; - [firstComponentDisposable() addDisposable:[RACDisposable disposableWithBlock:^{ - [valueDisposable removeDisposable:deallocDisposable]; - }]]; - }; - - // Adds the callback block to the remaining path components on the value. Also - // adds the logic to clean up the callbacks to the firstComponentDisposable. - void (^addObserverToValue)(NSObject *) = ^(NSObject *value) { - RACDisposable *observerDisposable = [value rac_observeKeyPath:keyPathTail options:(options & ~NSKeyValueObservingOptionInitial) observer:weakObserver block:block]; - [firstComponentDisposable() addDisposable:observerDisposable]; - }; - - // Observe only the first key path component, when the value changes clean up - // the callbacks on the old value, add callbacks to the new value and call the - // callback block as needed. - // - // Note this does not use NSKeyValueObservingOptionInitial so this only - // handles changes to the value, callbacks to the initial value must be added - // separately. - NSKeyValueObservingOptions trampolineOptions = (options | NSKeyValueObservingOptionPrior) & ~NSKeyValueObservingOptionInitial; - RACKVOTrampoline *trampoline = [[RACKVOTrampoline alloc] initWithTarget:self observer:strongObserver keyPath:keyPathHead options:trampolineOptions block:^(id trampolineTarget, id trampolineObserver, NSDictionary *change) { - // If this is a prior notification, clean up all the callbacks added to the - // previous value and call the callback block. Everything else is deferred - // until after we get the notification after the change. - if ([change[NSKeyValueChangeNotificationIsPriorKey] boolValue]) { - [firstComponentDisposable() dispose]; - - if ((options & NSKeyValueObservingOptionPrior) != 0) { - block([trampolineTarget valueForKeyPath:keyPath], change, NO, keyPathHasOneComponent); - } - - return; - } - - // From here the notification is not prior. - NSObject *value = [trampolineTarget valueForKey:keyPathHead]; - - // If the value has changed but is nil, there is no need to add callbacks to - // it, just call the callback block. - if (value == nil) { - block(nil, change, NO, keyPathHasOneComponent); - return; - } - - // From here the notification is not prior and the value is not nil. - - // Create a new firstComponentDisposable while getting rid of the old one at - // the same time, in case this is being called concurrently. - RACDisposable *oldFirstComponentDisposable = [firstComponentSerialDisposable swapInDisposable:[RACCompoundDisposable compoundDisposable]]; - [oldFirstComponentDisposable dispose]; - - addDeallocObserverToPropertyValue(value); - - // If there are no further key path components, there is no need to add the - // other callbacks, just call the callback block with the value itself. - if (keyPathHasOneComponent) { - block(value, change, NO, keyPathHasOneComponent); - return; - } - - // The value has changed, is not nil, and there are more key path components - // to consider. Add the callbacks to the value for the remaining key path - // components and call the callback block with the current value of the full - // key path. - addObserverToValue(value); - block([value valueForKeyPath:keyPathTail], change, NO, keyPathHasOneComponent); - }]; - - // Stop the KVO observation when this one is disposed of. - [disposable addDisposable:trampoline]; - - // Add the callbacks to the initial value if needed. - NSObject *value = [self valueForKey:keyPathHead]; - if (value != nil) { - addDeallocObserverToPropertyValue(value); - - if (!keyPathHasOneComponent) { - addObserverToValue(value); - } - } - - // Call the block with the initial value if needed. - if ((options & NSKeyValueObservingOptionInitial) != 0) { - id initialValue = [self valueForKeyPath:keyPath]; - NSDictionary *initialChange = @{ - NSKeyValueChangeKindKey: @(NSKeyValueChangeSetting), - NSKeyValueChangeNewKey: initialValue ?: NSNull.null, - }; - block(initialValue, initialChange, NO, keyPathHasOneComponent); - } - - - RACCompoundDisposable *observerDisposable = strongObserver.rac_deallocDisposable; - RACCompoundDisposable *selfDisposable = self.rac_deallocDisposable; - // Dispose of this observation if the receiver or the observer deallocate. - [observerDisposable addDisposable:disposable]; - [selfDisposable addDisposable:disposable]; - - return [RACDisposable disposableWithBlock:^{ - [disposable dispose]; - [observerDisposable removeDisposable:disposable]; - [selfDisposable removeDisposable:disposable]; - }]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSObject+RACLifting.h b/LookInside/ReactiveObjC/NSObject+RACLifting.h deleted file mode 100644 index 3c79260b..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACLifting.h +++ /dev/null @@ -1,51 +0,0 @@ -// -// NSObject+RACLifting.h -// ReactiveObjC -// -// Created by Josh Abernathy on 10/13/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACSignal<__covariant ValueType>; -@class RACTuple; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSObject (RACLifting) - -/// Lifts the selector on the receiver into the reactive world. The selector will -/// be invoked whenever any signal argument sends a value, but only after each -/// signal has sent an initial value. -/// -/// It will replay the most recently sent value to new subscribers. -/// -/// This does not support C arrays or unions. -/// -/// selector - The selector on self to invoke. -/// firstSignal - The signal corresponding to the first method argument. This -/// must not be nil. -/// ... - A list of RACSignals corresponding to the remaining arguments. -/// There must be a non-nil signal for each method argument. -/// -/// Examples -/// -/// [button rac_liftSelector:@selector(setTitleColor:forState:) withSignals:textColorSignal, [RACSignal return:@(UIControlStateNormal)], nil]; -/// -/// Returns a signal which sends the return value from each invocation of the -/// selector. If the selector returns void, it instead sends RACUnit.defaultUnit. -/// It completes only after all the signal arguments complete. -- (RACSignal *)rac_liftSelector:(SEL)selector withSignals:(RACSignal *)firstSignal, ... NS_REQUIRES_NIL_TERMINATION; - -/// Like -rac_liftSelector:withSignals:, but accepts an array instead of -/// a variadic list of arguments. -- (RACSignal *)rac_liftSelector:(SEL)selector withSignalsFromArray:(NSArray *)signals; - -/// Like -rac_liftSelector:withSignals:, but accepts a signal sending tuples of -/// arguments instead of a variadic list of arguments. -- (RACSignal *)rac_liftSelector:(SEL)selector withSignalOfArguments:(RACSignal *)arguments; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSObject+RACLifting.m b/LookInside/ReactiveObjC/NSObject+RACLifting.m deleted file mode 100644 index f0a9d252..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACLifting.m +++ /dev/null @@ -1,78 +0,0 @@ -// -// NSObject+RACLifting.m -// ReactiveObjC -// -// Created by Josh Abernathy on 10/13/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "NSObject+RACLifting.h" -#import "RACEXTScope.h" -#import "NSInvocation+RACTypeParsing.h" -#import "NSObject+RACDeallocating.h" -#import "NSObject+RACDescription.h" -#import "RACSignal+Operations.h" -#import "RACTuple.h" - -@implementation NSObject (RACLifting) - -- (RACSignal *)rac_liftSelector:(SEL)selector withSignalOfArguments:(RACSignal *)arguments { - NSCParameterAssert(selector != NULL); - NSCParameterAssert(arguments != nil); - - @unsafeify(self); - - NSMethodSignature *methodSignature = [self methodSignatureForSelector:selector]; - NSCAssert(methodSignature != nil, @"%@ does not respond to %@", self, NSStringFromSelector(selector)); - - return [[[[arguments - takeUntil:self.rac_willDeallocSignal] - map:^(RACTuple *arguments) { - @strongify(self); - - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature]; - invocation.selector = selector; - invocation.rac_argumentsTuple = arguments; - [invocation invokeWithTarget:self]; - - return invocation.rac_returnValue; - }] - replayLast] - setNameWithFormat:@"%@ -rac_liftSelector: %s withSignalsOfArguments: %@", RACDescription(self), sel_getName(selector), arguments]; -} - -- (RACSignal *)rac_liftSelector:(SEL)selector withSignalsFromArray:(NSArray *)signals { - NSCParameterAssert(signals != nil); - NSCParameterAssert(signals.count > 0); - - NSMethodSignature *methodSignature = [self methodSignatureForSelector:selector]; - NSCAssert(methodSignature != nil, @"%@ does not respond to %@", self, NSStringFromSelector(selector)); - - NSUInteger numberOfArguments __attribute__((unused)) = methodSignature.numberOfArguments - 2; - NSCAssert(numberOfArguments == signals.count, @"Wrong number of signals for %@ (expected %lu, got %lu)", NSStringFromSelector(selector), (unsigned long)numberOfArguments, (unsigned long)signals.count); - - return [[self - rac_liftSelector:selector withSignalOfArguments:[RACSignal combineLatest:signals]] - setNameWithFormat:@"%@ -rac_liftSelector: %s withSignalsFromArray: %@", RACDescription(self), sel_getName(selector), signals]; -} - -- (RACSignal *)rac_liftSelector:(SEL)selector withSignals:(RACSignal *)firstSignal, ... { - NSCParameterAssert(firstSignal != nil); - - NSMutableArray *signals = [NSMutableArray array]; - - va_list args; - va_start(args, firstSignal); - for (id currentSignal = firstSignal; currentSignal != nil; currentSignal = va_arg(args, id)) { - NSCAssert([currentSignal isKindOfClass:RACSignal.class], @"Argument %@ is not a RACSignal", currentSignal); - - [signals addObject:currentSignal]; - } - va_end(args); - - return [[self - rac_liftSelector:selector withSignalsFromArray:signals] - setNameWithFormat:@"%@ -rac_liftSelector: %s withSignals: %@", RACDescription(self), sel_getName(selector), signals]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSObject+RACPropertySubscribing.h b/LookInside/ReactiveObjC/NSObject+RACPropertySubscribing.h deleted file mode 100644 index 8e9c0c3a..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACPropertySubscribing.h +++ /dev/null @@ -1,120 +0,0 @@ -// -// NSObject+RACPropertySubscribing.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/2/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import -#import "RACEXTKeyPathCoding.h" -#import "RACmetamacros.h" - -/// Creates a signal which observes `KEYPATH` on `TARGET` for changes. -/// -/// In either case, the observation continues until `TARGET` _or self_ is -/// deallocated. If any intermediate object is deallocated instead, it will be -/// assumed to have been set to nil. -/// -/// Make sure to `@strongify(self)` when using this macro within a block! The -/// macro will _always_ reference `self`, which can silently introduce a retain -/// cycle within a block. As a result, you should make sure that `self` is a weak -/// reference (e.g., created by `@weakify` and `@strongify`) before the -/// expression that uses `RACObserve`. -/// -/// Examples -/// -/// // Observes self, and doesn't stop until self is deallocated. -/// RACSignal *selfSignal = RACObserve(self, arrayController.items); -/// -/// // Observes the array controller, and stops when self _or_ the array -/// // controller is deallocated. -/// RACSignal *arrayControllerSignal = RACObserve(self.arrayController, items); -/// -/// // Observes obj.arrayController, and stops when self _or_ the array -/// // controller is deallocated. -/// RACSignal *signal2 = RACObserve(obj.arrayController, items); -/// -/// @weakify(self); -/// RACSignal *signal3 = [anotherSignal flattenMap:^(NSArrayController *arrayController) { -/// // Avoids a retain cycle because of RACObserve implicitly referencing -/// // self. -/// @strongify(self); -/// return RACObserve(arrayController, items); -/// }]; -/// -/// Returns a signal which sends the current value of the key path on -/// subscription, then sends the new value every time it changes, and sends -/// completed if self or observer is deallocated. -#define _RACObserve(TARGET, KEYPATH) \ -({ \ - __weak id target_ = (TARGET); \ - [target_ rac_valuesForKeyPath:@keypath(TARGET, KEYPATH) observer:self]; \ -}) - -#if __clang__ && (__clang_major__ >= 8) -#define RACObserve(TARGET, KEYPATH) _RACObserve(TARGET, KEYPATH) -#else -#define RACObserve(TARGET, KEYPATH) \ -({ \ - _Pragma("clang diagnostic push") \ - _Pragma("clang diagnostic ignored \"-Wreceiver-is-weak\"") \ - _RACObserve(TARGET, KEYPATH) \ - _Pragma("clang diagnostic pop") \ -}) -#endif - -@class RACDisposable; -@class RACTwoTuple<__covariant First, __covariant Second>; -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSObject (RACPropertySubscribing) - -/// Creates a signal to observe the value at the given key path. -/// -/// The initial value is sent on subscription, the subsequent values are sent -/// from whichever thread the change occured on, even if it doesn't have a valid -/// scheduler. -/// -/// Returns a signal that immediately sends the receiver's current value at the -/// given keypath, then any changes thereafter. -#if OS_OBJECT_HAVE_OBJC_SUPPORT -- (RACSignal *)rac_valuesForKeyPath:(NSString *)keyPath observer:(__weak NSObject *)observer; -#else -// Swift builds with OS_OBJECT_HAVE_OBJC_SUPPORT=0 for Playgrounds and LLDB :( -- (RACSignal *)rac_valuesForKeyPath:(NSString *)keyPath observer:(NSObject *)observer; -#endif - -/// Creates a signal to observe the changes of the given key path. -/// -/// The initial value is sent on subscription if `NSKeyValueObservingOptionInitial` is set. -/// The subsequent values are sent from whichever thread the change occured on, -/// even if it doesn't have a valid scheduler. -/// -/// Returns a signal that sends tuples containing the current value at the key -/// path and the change dictionary for each KVO callback. -#if OS_OBJECT_HAVE_OBJC_SUPPORT -- (RACSignal *> *)rac_valuesAndChangesForKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options observer:(__weak NSObject *)observer; -#else -- (RACSignal *> *)rac_valuesAndChangesForKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options observer:(NSObject *)observer; -#endif - -@end - -NS_ASSUME_NONNULL_END - -#define RACAble(...) \ - metamacro_if_eq(1, metamacro_argcount(__VA_ARGS__)) \ - (_RACAbleObject(self, __VA_ARGS__)) \ - (_RACAbleObject(__VA_ARGS__)) - -#define _RACAbleObject(object, property) [object rac_signalForKeyPath:@keypath(object, property) observer:self] - -#define RACAbleWithStart(...) \ - metamacro_if_eq(1, metamacro_argcount(__VA_ARGS__)) \ - (_RACAbleWithStartObject(self, __VA_ARGS__)) \ - (_RACAbleWithStartObject(__VA_ARGS__)) - -#define _RACAbleWithStartObject(object, property) [object rac_signalWithStartingValueForKeyPath:@keypath(object, property) observer:self] diff --git a/LookInside/ReactiveObjC/NSObject+RACPropertySubscribing.m b/LookInside/ReactiveObjC/NSObject+RACPropertySubscribing.m deleted file mode 100644 index a449e0da..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACPropertySubscribing.m +++ /dev/null @@ -1,84 +0,0 @@ -// -// NSObject+RACPropertySubscribing.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/2/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "NSObject+RACPropertySubscribing.h" -#import "RACEXTScope.h" -#import "NSObject+RACDeallocating.h" -#import "NSObject+RACDescription.h" -#import "NSObject+RACKVOWrapper.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACKVOTrampoline.h" -#import "RACSubscriber.h" -#import "RACSignal+Operations.h" -#import "RACTuple.h" -#import - -@implementation NSObject (RACPropertySubscribing) - -- (RACSignal *)rac_valuesForKeyPath:(NSString *)keyPath observer:(__weak NSObject *)observer { - return [[[self - rac_valuesAndChangesForKeyPath:keyPath options:NSKeyValueObservingOptionInitial observer:observer] - map:^(RACTuple *value) { - // -map: because it doesn't require the block trampoline that -reduceEach: uses - return value[0]; - }] - setNameWithFormat:@"RACObserve(%@, %@)", RACDescription(self), keyPath]; -} - -- (RACSignal *)rac_valuesAndChangesForKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options observer:(__weak NSObject *)weakObserver { - NSObject *strongObserver = weakObserver; - keyPath = [keyPath copy]; - - NSRecursiveLock *objectLock = [[NSRecursiveLock alloc] init]; - objectLock.name = @"org.reactivecocoa.ReactiveObjC.NSObjectRACPropertySubscribing"; - - __weak NSObject *weakSelf = self; - - RACSignal *deallocSignal = [[RACSignal - zip:@[ - self.rac_willDeallocSignal, - strongObserver.rac_willDeallocSignal ?: [RACSignal never] - ]] - doCompleted:^{ - // Forces deallocation to wait if the object variables are currently - // being read on another thread. - [objectLock lock]; - @onExit { - [objectLock unlock]; - }; - }]; - - return [[[RACSignal - createSignal:^ RACDisposable * (id subscriber) { - // Hold onto the lock the whole time we're setting up the KVO - // observation, because any resurrection that might be caused by our - // retaining below must be balanced out by the time -dealloc returns - // (if another thread is waiting on the lock above). - [objectLock lock]; - @onExit { - [objectLock unlock]; - }; - - __strong NSObject *observer __attribute__((objc_precise_lifetime)) = weakObserver; - __strong NSObject *self __attribute__((objc_precise_lifetime)) = weakSelf; - - if (self == nil) { - [subscriber sendCompleted]; - return nil; - } - - return [self rac_observeKeyPath:keyPath options:options observer:observer block:^(id value, NSDictionary *change, BOOL causedByDealloc, BOOL affectedOnlyLastComponent) { - [subscriber sendNext:RACTuplePack(value, change)]; - }]; - }] - takeUntil:deallocSignal] - setNameWithFormat:@"%@ -rac_valueAndChangesForKeyPath: %@ options: %lu observer: %@", RACDescription(self), keyPath, (unsigned long)options, RACDescription(strongObserver)]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSObject+RACSelectorSignal.h b/LookInside/ReactiveObjC/NSObject+RACSelectorSignal.h deleted file mode 100644 index bb3672ec..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACSelectorSignal.h +++ /dev/null @@ -1,84 +0,0 @@ -// -// NSObject+RACSelectorSignal.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/18/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -@class RACTuple; -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -/// The domain for any errors originating from -rac_signalForSelector:. -extern NSString * const RACSelectorSignalErrorDomain; - -/// -rac_signalForSelector: was going to add a new method implementation for -/// `selector`, but another thread added an implementation before it was able to. -/// -/// This will _not_ occur for cases where a method implementation exists before -/// -rac_signalForSelector: is invoked. -extern const NSInteger RACSelectorSignalErrorMethodSwizzlingRace; - -@interface NSObject (RACSelectorSignal) - -/// Creates a signal associated with the receiver, which will send a tuple of the -/// method's arguments each time the given selector is invoked. -/// -/// If the selector is already implemented on the receiver, the existing -/// implementation will be invoked _before_ the signal fires. -/// -/// If the selector is not yet implemented on the receiver, the injected -/// implementation will have a `void` return type and accept only object -/// arguments. Invoking the added implementation with non-object values, or -/// expecting a return value, will result in undefined behavior. -/// -/// This is useful for changing an event or delegate callback into a signal. For -/// example, on an NSView: -/// -/// [[view rac_signalForSelector:@selector(mouseDown:)] subscribeNext:^(RACTuple *args) { -/// NSEvent *event = args.first; -/// NSLog(@"mouse button pressed: %@", event); -/// }]; -/// -/// selector - The selector for whose invocations are to be observed. If it -/// doesn't exist, it will be implemented to accept object arguments -/// and return void. This cannot have C arrays or unions as arguments -/// or C arrays, unions, structs, complex or vector types as return -/// type. -/// -/// Returns a signal which will send a tuple of arguments upon each invocation of -/// the selector, then completes when the receiver is deallocated. `next` events -/// will be sent synchronously from the thread that invoked the method. If -/// a runtime call fails, the signal will send an error in the -/// RACSelectorSignalErrorDomain. -- (RACSignal *)rac_signalForSelector:(SEL)selector; - -/// Behaves like -rac_signalForSelector:, but if the selector is not yet -/// implemented on the receiver, its method signature is looked up within -/// `protocol`, and may accept non-object arguments. -/// -/// If the selector is not yet implemented and has a return value, the injected -/// method will return all zero bits (equal to `nil`, `NULL`, 0, 0.0f, etc.). -/// -/// selector - The selector for whose invocations are to be observed. If it -/// doesn't exist, it will be implemented using information from -/// `protocol`, and may accept non-object arguments and return -/// a value. This cannot have C arrays or unions as arguments or -/// return type. -/// protocol - The protocol in which `selector` is declared. This will be used -/// for type information if the selector is not already implemented on -/// the receiver. This must not be `NULL`, and `selector` must exist -/// in this protocol. -/// -/// Returns a signal which will send a tuple of arguments on each invocation of -/// the selector, or an error in RACSelectorSignalErrorDomain if a runtime -/// call fails. -- (RACSignal *)rac_signalForSelector:(SEL)selector fromProtocol:(Protocol *)protocol; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSObject+RACSelectorSignal.m b/LookInside/ReactiveObjC/NSObject+RACSelectorSignal.m deleted file mode 100644 index b5cdfc3d..00000000 --- a/LookInside/ReactiveObjC/NSObject+RACSelectorSignal.m +++ /dev/null @@ -1,329 +0,0 @@ -// -// NSObject+RACSelectorSignal.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/18/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSObject+RACSelectorSignal.h" -#import "RACEXTRuntimeExtensions.h" -#import "NSInvocation+RACTypeParsing.h" -#import "NSObject+RACDeallocating.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACSubject.h" -#import "RACTuple.h" -#import "NSObject+RACDescription.h" -#import -#import - -NSString * const RACSelectorSignalErrorDomain = @"RACSelectorSignalErrorDomain"; -const NSInteger RACSelectorSignalErrorMethodSwizzlingRace = 1; - -static NSString * const RACSignalForSelectorAliasPrefix = @"rac_alias_"; -static NSString * const RACSubclassSuffix = @"_RACSelectorSignal"; -static void *RACSubclassAssociationKey = &RACSubclassAssociationKey; - -static NSMutableSet *swizzledClasses(void) { - static NSMutableSet *set; - static dispatch_once_t pred; - - dispatch_once(&pred, ^{ - set = [[NSMutableSet alloc] init]; - }); - - return set; -} - -@implementation NSObject (RACSelectorSignal) - -static BOOL RACForwardInvocation(id self, NSInvocation *invocation) { - SEL aliasSelector = RACAliasForSelector(invocation.selector); - RACSubject *subject = objc_getAssociatedObject(self, aliasSelector); - - Class class = object_getClass(invocation.target); - BOOL respondsToAlias = [class instancesRespondToSelector:aliasSelector]; - if (respondsToAlias) { - invocation.selector = aliasSelector; - [invocation invoke]; - } - - if (subject == nil) return respondsToAlias; - - [subject sendNext:invocation.rac_argumentsTuple]; - return YES; -} - -static void RACSwizzleForwardInvocation(Class class) { - SEL forwardInvocationSEL = @selector(forwardInvocation:); - Method forwardInvocationMethod = class_getInstanceMethod(class, forwardInvocationSEL); - - // Preserve any existing implementation of -forwardInvocation:. - void (*originalForwardInvocation)(id, SEL, NSInvocation *) = NULL; - if (forwardInvocationMethod != NULL) { - originalForwardInvocation = (__typeof__(originalForwardInvocation))method_getImplementation(forwardInvocationMethod); - } - - // Set up a new version of -forwardInvocation:. - // - // If the selector has been passed to -rac_signalForSelector:, invoke - // the aliased method, and forward the arguments to any attached signals. - // - // If the selector has not been passed to -rac_signalForSelector:, - // invoke any existing implementation of -forwardInvocation:. If there - // was no existing implementation, throw an unrecognized selector - // exception. - id newForwardInvocation = ^(id self, NSInvocation *invocation) { - BOOL matched = RACForwardInvocation(self, invocation); - if (matched) return; - - if (originalForwardInvocation == NULL) { - [self doesNotRecognizeSelector:invocation.selector]; - } else { - originalForwardInvocation(self, forwardInvocationSEL, invocation); - } - }; - - class_replaceMethod(class, forwardInvocationSEL, imp_implementationWithBlock(newForwardInvocation), "v@:@"); -} - -static void RACSwizzleRespondsToSelector(Class class) { - SEL respondsToSelectorSEL = @selector(respondsToSelector:); - - // Preserve existing implementation of -respondsToSelector:. - Method respondsToSelectorMethod = class_getInstanceMethod(class, respondsToSelectorSEL); - BOOL (*originalRespondsToSelector)(id, SEL, SEL) = (__typeof__(originalRespondsToSelector))method_getImplementation(respondsToSelectorMethod); - - // Set up a new version of -respondsToSelector: that returns YES for methods - // added by -rac_signalForSelector:. - // - // If the selector has a method defined on the receiver's actual class, and - // if that method's implementation is _objc_msgForward, then returns whether - // the instance has a signal for the selector. - // Otherwise, call the original -respondsToSelector:. - id newRespondsToSelector = ^ BOOL (id self, SEL selector) { - Method method = rac_getImmediateInstanceMethod(class, selector); - - if (method != NULL && method_getImplementation(method) == _objc_msgForward) { - SEL aliasSelector = RACAliasForSelector(selector); - if (objc_getAssociatedObject(self, aliasSelector) != nil) return YES; - } - - return originalRespondsToSelector(self, respondsToSelectorSEL, selector); - }; - - class_replaceMethod(class, respondsToSelectorSEL, imp_implementationWithBlock(newRespondsToSelector), method_getTypeEncoding(respondsToSelectorMethod)); -} - -static void RACSwizzleGetClass(Class class, Class statedClass) { - SEL selector = @selector(class); - Method method = class_getInstanceMethod(class, selector); - IMP newIMP = imp_implementationWithBlock(^(id self) { - return statedClass; - }); - class_replaceMethod(class, selector, newIMP, method_getTypeEncoding(method)); -} - -static void RACSwizzleMethodSignatureForSelector(Class class) { - IMP newIMP = imp_implementationWithBlock(^(id self, SEL selector) { - // Don't send the -class message to the receiver because we've changed - // that to return the original class. - Class actualClass = object_getClass(self); - Method method = class_getInstanceMethod(actualClass, selector); - if (method == NULL) { - // Messages that the original class dynamically implements fall - // here. - // - // Call the original class' -methodSignatureForSelector:. - struct objc_super target = { - .super_class = class_getSuperclass(class), - .receiver = self, - }; - NSMethodSignature * (*messageSend)(struct objc_super *, SEL, SEL) = (__typeof__(messageSend))objc_msgSendSuper; - return messageSend(&target, @selector(methodSignatureForSelector:), selector); - } - - char const *encoding = method_getTypeEncoding(method); - return [NSMethodSignature signatureWithObjCTypes:encoding]; - }); - - SEL selector = @selector(methodSignatureForSelector:); - Method methodSignatureForSelectorMethod = class_getInstanceMethod(class, selector); - class_replaceMethod(class, selector, newIMP, method_getTypeEncoding(methodSignatureForSelectorMethod)); -} - -// It's hard to tell which struct return types use _objc_msgForward, and -// which use _objc_msgForward_stret instead, so just exclude all struct, array, -// union, complex and vector return types. -static void RACCheckTypeEncoding(const char *typeEncoding) { -#if !NS_BLOCK_ASSERTIONS - // Some types, including vector types, are not encoded. In these cases the - // signature starts with the size of the argument frame. - NSCAssert(*typeEncoding < '1' || *typeEncoding > '9', @"unknown method return type not supported in type encoding: %s", typeEncoding); - NSCAssert(strstr(typeEncoding, "(") != typeEncoding, @"union method return type not supported"); - NSCAssert(strstr(typeEncoding, "{") != typeEncoding, @"struct method return type not supported"); - NSCAssert(strstr(typeEncoding, "[") != typeEncoding, @"array method return type not supported"); - NSCAssert(strstr(typeEncoding, @encode(_Complex float)) != typeEncoding, @"complex float method return type not supported"); - NSCAssert(strstr(typeEncoding, @encode(_Complex double)) != typeEncoding, @"complex double method return type not supported"); - NSCAssert(strstr(typeEncoding, @encode(_Complex long double)) != typeEncoding, @"complex long double method return type not supported"); - -#endif // !NS_BLOCK_ASSERTIONS -} - -static RACSignal *NSObjectRACSignalForSelector(NSObject *self, SEL selector, Protocol *protocol) { - SEL aliasSelector = RACAliasForSelector(selector); - - @synchronized (self) { - RACSubject *subject = objc_getAssociatedObject(self, aliasSelector); - if (subject != nil) return subject; - - Class class = RACSwizzleClass(self); - NSCAssert(class != nil, @"Could not swizzle class of %@", self); - - subject = [[RACSubject subject] setNameWithFormat:@"%@ -rac_signalForSelector: %s", RACDescription(self), sel_getName(selector)]; - objc_setAssociatedObject(self, aliasSelector, subject, OBJC_ASSOCIATION_RETAIN); - - [self.rac_deallocDisposable addDisposable:[RACDisposable disposableWithBlock:^{ - [subject sendCompleted]; - }]]; - - Method targetMethod = class_getInstanceMethod(class, selector); - if (targetMethod == NULL) { - const char *typeEncoding; - if (protocol == NULL) { - typeEncoding = RACSignatureForUndefinedSelector(selector); - } else { - // Look for the selector as an optional instance method. - struct objc_method_description methodDescription = protocol_getMethodDescription(protocol, selector, NO, YES); - - if (methodDescription.name == NULL) { - // Then fall back to looking for a required instance - // method. - methodDescription = protocol_getMethodDescription(protocol, selector, YES, YES); - NSCAssert(methodDescription.name != NULL, @"Selector %@ does not exist in <%s>", NSStringFromSelector(selector), protocol_getName(protocol)); - } - - typeEncoding = methodDescription.types; - } - - RACCheckTypeEncoding(typeEncoding); - - // Define the selector to call -forwardInvocation:. - if (!class_addMethod(class, selector, _objc_msgForward, typeEncoding)) { - NSDictionary *userInfo = @{ - NSLocalizedDescriptionKey: [NSString stringWithFormat:NSLocalizedString(@"A race condition occurred implementing %@ on class %@", nil), NSStringFromSelector(selector), class], - NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString(@"Invoke -rac_signalForSelector: again to override the implementation.", nil) - }; - - return [RACSignal error:[NSError errorWithDomain:RACSelectorSignalErrorDomain code:RACSelectorSignalErrorMethodSwizzlingRace userInfo:userInfo]]; - } - } else if (method_getImplementation(targetMethod) != _objc_msgForward) { - // Make a method alias for the existing method implementation. - const char *typeEncoding = method_getTypeEncoding(targetMethod); - - RACCheckTypeEncoding(typeEncoding); - - BOOL addedAlias __attribute__((unused)) = class_addMethod(class, aliasSelector, method_getImplementation(targetMethod), typeEncoding); - NSCAssert(addedAlias, @"Original implementation for %@ is already copied to %@ on %@", NSStringFromSelector(selector), NSStringFromSelector(aliasSelector), class); - - // Redefine the selector to call -forwardInvocation:. - class_replaceMethod(class, selector, _objc_msgForward, method_getTypeEncoding(targetMethod)); - } - - return subject; - } -} - -static SEL RACAliasForSelector(SEL originalSelector) { - NSString *selectorName = NSStringFromSelector(originalSelector); - return NSSelectorFromString([RACSignalForSelectorAliasPrefix stringByAppendingString:selectorName]); -} - -static const char *RACSignatureForUndefinedSelector(SEL selector) { - const char *name = sel_getName(selector); - NSMutableString *signature = [NSMutableString stringWithString:@"v@:"]; - - while ((name = strchr(name, ':')) != NULL) { - [signature appendString:@"@"]; - name++; - } - - return signature.UTF8String; -} - -static Class RACSwizzleClass(NSObject *self) { - Class statedClass = self.class; - Class baseClass = object_getClass(self); - - // The "known dynamic subclass" is the subclass generated by RAC. - // It's stored as an associated object on every instance that's already - // been swizzled, so that even if something else swizzles the class of - // this instance, we can still access the RAC generated subclass. - Class knownDynamicSubclass = objc_getAssociatedObject(self, RACSubclassAssociationKey); - if (knownDynamicSubclass != Nil) return knownDynamicSubclass; - - NSString *className = NSStringFromClass(baseClass); - - if (statedClass != baseClass) { - // If the class is already lying about what it is, it's probably a KVO - // dynamic subclass or something else that we shouldn't subclass - // ourselves. - // - // Just swizzle -forwardInvocation: in-place. Since the object's class - // was almost certainly dynamically changed, we shouldn't see another of - // these classes in the hierarchy. - // - // Additionally, swizzle -respondsToSelector: because the default - // implementation may be ignorant of methods added to this class. - @synchronized (swizzledClasses()) { - if (![swizzledClasses() containsObject:className]) { - RACSwizzleForwardInvocation(baseClass); - RACSwizzleRespondsToSelector(baseClass); - RACSwizzleGetClass(baseClass, statedClass); - RACSwizzleGetClass(object_getClass(baseClass), statedClass); - RACSwizzleMethodSignatureForSelector(baseClass); - [swizzledClasses() addObject:className]; - } - } - - return baseClass; - } - - const char *subclassName = [className stringByAppendingString:RACSubclassSuffix].UTF8String; - Class subclass = objc_getClass(subclassName); - - if (subclass == nil) { - subclass = objc_allocateClassPair(baseClass, subclassName, 0); - if (subclass == nil) return nil; - - RACSwizzleForwardInvocation(subclass); - RACSwizzleRespondsToSelector(subclass); - - RACSwizzleGetClass(subclass, statedClass); - RACSwizzleGetClass(object_getClass(subclass), statedClass); - - RACSwizzleMethodSignatureForSelector(subclass); - - objc_registerClassPair(subclass); - } - - object_setClass(self, subclass); - objc_setAssociatedObject(self, RACSubclassAssociationKey, subclass, OBJC_ASSOCIATION_ASSIGN); - return subclass; -} - -- (RACSignal *)rac_signalForSelector:(SEL)selector { - NSCParameterAssert(selector != NULL); - - return NSObjectRACSignalForSelector(self, selector, NULL); -} - -- (RACSignal *)rac_signalForSelector:(SEL)selector fromProtocol:(Protocol *)protocol { - NSCParameterAssert(selector != NULL); - NSCParameterAssert(protocol != NULL); - - return NSObjectRACSignalForSelector(self, selector, protocol); -} - -@end diff --git a/LookInside/ReactiveObjC/NSOrderedSet+RACSequenceAdditions.h b/LookInside/ReactiveObjC/NSOrderedSet+RACSequenceAdditions.h deleted file mode 100644 index 8032f277..00000000 --- a/LookInside/ReactiveObjC/NSOrderedSet+RACSequenceAdditions.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// NSOrderedSet+RACSequenceAdditions.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import - -@class RACSequence<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSOrderedSet<__covariant ObjectType> (RACSequenceAdditions) - -/// Creates and returns a sequence corresponding to the receiver. -/// -/// Mutating the receiver will not affect the sequence after it's been created. -@property (nonatomic, copy, readonly) RACSequence *rac_sequence; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSOrderedSet+RACSequenceAdditions.m b/LookInside/ReactiveObjC/NSOrderedSet+RACSequenceAdditions.m deleted file mode 100644 index 31792d45..00000000 --- a/LookInside/ReactiveObjC/NSOrderedSet+RACSequenceAdditions.m +++ /dev/null @@ -1,19 +0,0 @@ -// -// NSOrderedSet+RACSequenceAdditions.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "NSOrderedSet+RACSequenceAdditions.h" -#import "NSArray+RACSequenceAdditions.h" - -@implementation NSOrderedSet (RACSequenceAdditions) - -- (RACSequence *)rac_sequence { - // TODO: First class support for ordered set sequences. - return self.array.rac_sequence; -} - -@end diff --git a/LookInside/ReactiveObjC/NSSet+RACSequenceAdditions.h b/LookInside/ReactiveObjC/NSSet+RACSequenceAdditions.h deleted file mode 100644 index 658f08b4..00000000 --- a/LookInside/ReactiveObjC/NSSet+RACSequenceAdditions.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// NSSet+RACSequenceAdditions.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import - -@class RACSequence<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSSet<__covariant ObjectType> (RACSequenceAdditions) - -/// Creates and returns a sequence corresponding to the receiver. -/// -/// Mutating the receiver will not affect the sequence after it's been created. -@property (nonatomic, copy, readonly) RACSequence *rac_sequence; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSSet+RACSequenceAdditions.m b/LookInside/ReactiveObjC/NSSet+RACSequenceAdditions.m deleted file mode 100644 index 8c7355fa..00000000 --- a/LookInside/ReactiveObjC/NSSet+RACSequenceAdditions.m +++ /dev/null @@ -1,19 +0,0 @@ -// -// NSSet+RACSequenceAdditions.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "NSSet+RACSequenceAdditions.h" -#import "NSArray+RACSequenceAdditions.h" - -@implementation NSSet (RACSequenceAdditions) - -- (RACSequence *)rac_sequence { - // TODO: First class support for set sequences. - return self.allObjects.rac_sequence; -} - -@end diff --git a/LookInside/ReactiveObjC/NSString+RACKeyPathUtilities.h b/LookInside/ReactiveObjC/NSString+RACKeyPathUtilities.h deleted file mode 100644 index d9377042..00000000 --- a/LookInside/ReactiveObjC/NSString+RACKeyPathUtilities.h +++ /dev/null @@ -1,34 +0,0 @@ -// -// NSString+RACKeyPathUtilities.h -// ReactiveObjC -// -// Created by Uri Baghin on 05/05/2013. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -// A private category of methods to extract parts of a key path. -@interface NSString (RACKeyPathUtilities) - -// Returns an array of the components of the receiver. -// -// Calling this method on a string that isn't a key path is considered undefined -// behavior. -- (NSArray *)rac_keyPathComponents; - -// Returns a key path with all the components of the receiver except for the -// last one. -// -// Calling this method on a string that isn't a key path is considered undefined -// behavior. -- (NSString *)rac_keyPathByDeletingLastKeyPathComponent; - -// Returns a key path with all the components of the receiver expect for the -// first one. -// -// Calling this method on a string that isn't a key path is considered undefined -// behavior. -- (NSString *)rac_keyPathByDeletingFirstKeyPathComponent; - -@end diff --git a/LookInside/ReactiveObjC/NSString+RACKeyPathUtilities.m b/LookInside/ReactiveObjC/NSString+RACKeyPathUtilities.m deleted file mode 100644 index d62c0521..00000000 --- a/LookInside/ReactiveObjC/NSString+RACKeyPathUtilities.m +++ /dev/null @@ -1,36 +0,0 @@ -// -// NSString+RACKeyPathUtilities.m -// ReactiveObjC -// -// Created by Uri Baghin on 05/05/2013. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSString+RACKeyPathUtilities.h" - -@implementation NSString (RACKeyPathUtilities) - -- (NSArray *)rac_keyPathComponents { - if (self.length == 0) { - return nil; - } - return [self componentsSeparatedByString:@"."]; -} - -- (NSString *)rac_keyPathByDeletingLastKeyPathComponent { - NSUInteger lastDotIndex = [self rangeOfString:@"." options:NSBackwardsSearch].location; - if (lastDotIndex == NSNotFound) { - return nil; - } - return [self substringToIndex:lastDotIndex]; -} - -- (NSString *)rac_keyPathByDeletingFirstKeyPathComponent { - NSUInteger firstDotIndex = [self rangeOfString:@"."].location; - if (firstDotIndex == NSNotFound) { - return nil; - } - return [self substringFromIndex:firstDotIndex + 1]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSString+RACSequenceAdditions.h b/LookInside/ReactiveObjC/NSString+RACSequenceAdditions.h deleted file mode 100644 index f708469a..00000000 --- a/LookInside/ReactiveObjC/NSString+RACSequenceAdditions.h +++ /dev/null @@ -1,25 +0,0 @@ -// -// NSString+RACSequenceAdditions.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import - -@class RACSequence<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSString (RACSequenceAdditions) - -/// Creates and returns a sequence containing strings corresponding to each -/// composed character sequence in the receiver. -/// -/// Mutating the receiver will not affect the sequence after it's been created. -@property (nonatomic, copy, readonly) RACSequence *rac_sequence; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSString+RACSequenceAdditions.m b/LookInside/ReactiveObjC/NSString+RACSequenceAdditions.m deleted file mode 100644 index cbcc31b0..00000000 --- a/LookInside/ReactiveObjC/NSString+RACSequenceAdditions.m +++ /dev/null @@ -1,18 +0,0 @@ -// -// NSString+RACSequenceAdditions.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "NSString+RACSequenceAdditions.h" -#import "RACStringSequence.h" - -@implementation NSString (RACSequenceAdditions) - -- (RACSequence *)rac_sequence { - return [RACStringSequence sequenceWithString:self offset:0]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSString+RACSupport.h b/LookInside/ReactiveObjC/NSString+RACSupport.h deleted file mode 100644 index e08960db..00000000 --- a/LookInside/ReactiveObjC/NSString+RACSupport.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// NSString+RACSupport.h -// ReactiveObjC -// -// Created by Josh Abernathy on 5/11/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACScheduler; -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSString (RACSupport) - -// Reads in the contents of the file using +[NSString stringWithContentsOfURL:usedEncoding:error:]. -// Note that encoding won't be valid until the signal completes successfully. -// -// scheduler - cannot be nil. -+ (RACSignal *)rac_readContentsOfURL:(NSURL *)URL usedEncoding:(NSStringEncoding *)encoding scheduler:(RACScheduler *)scheduler; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSString+RACSupport.m b/LookInside/ReactiveObjC/NSString+RACSupport.m deleted file mode 100644 index 033bb566..00000000 --- a/LookInside/ReactiveObjC/NSString+RACSupport.m +++ /dev/null @@ -1,37 +0,0 @@ -// -// NSString+RACSupport.m -// ReactiveObjC -// -// Created by Josh Abernathy on 5/11/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "NSString+RACSupport.h" -#import "RACReplaySubject.h" -#import "RACScheduler.h" - -@implementation NSString (RACSupport) - -+ (RACSignal *)rac_readContentsOfURL:(NSURL *)URL usedEncoding:(NSStringEncoding *)encoding scheduler:(RACScheduler *)scheduler { - NSCParameterAssert(URL != nil); - NSCParameterAssert(encoding != nil); - NSCParameterAssert(scheduler != nil); - - RACReplaySubject *subject = [RACReplaySubject subject]; - [subject setNameWithFormat:@"+rac_readContentsOfURL: %@ usedEncoding:scheduler: %@", URL, scheduler]; - - [scheduler schedule:^{ - NSError *error = nil; - NSString *string = [NSString stringWithContentsOfURL:URL usedEncoding:encoding error:&error]; - if (string == nil) { - [subject sendError:error]; - } else { - [subject sendNext:string]; - [subject sendCompleted]; - } - }]; - - return subject; -} - -@end diff --git a/LookInside/ReactiveObjC/NSText+RACSignalSupport.h b/LookInside/ReactiveObjC/NSText+RACSignalSupport.h deleted file mode 100644 index 62dc8a59..00000000 --- a/LookInside/ReactiveObjC/NSText+RACSignalSupport.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// NSText+RACSignalSupport.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-03-08. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSText (RACSignalSupport) - -/// Returns a signal which sends the current `string` of the receiver, then the -/// new value any time it changes. -- (RACSignal *)rac_textSignal; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSText+RACSignalSupport.m b/LookInside/ReactiveObjC/NSText+RACSignalSupport.m deleted file mode 100644 index 2bfeacb2..00000000 --- a/LookInside/ReactiveObjC/NSText+RACSignalSupport.m +++ /dev/null @@ -1,38 +0,0 @@ -// -// NSText+RACSignalSupport.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-03-08. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSText+RACSignalSupport.h" -#import "RACEXTScope.h" -#import "NSObject+RACDescription.h" -#import "RACDisposable.h" -#import "RACSignal.h" -#import "RACSubscriber.h" - -@implementation NSText (RACSignalSupport) - -- (RACSignal *)rac_textSignal { - @unsafeify(self); - return [[[[RACSignal - createSignal:^(id subscriber) { - @strongify(self); - id observer = [NSNotificationCenter.defaultCenter addObserverForName:NSTextDidChangeNotification object:self queue:nil usingBlock:^(NSNotification *note) { - [subscriber sendNext:note.object]; - }]; - - return [RACDisposable disposableWithBlock:^{ - [NSNotificationCenter.defaultCenter removeObserver:observer]; - }]; - }] - map:^(NSText *text) { - return [text.string copy]; - }] - startWith:[self.string copy]] - setNameWithFormat:@"%@ -rac_textSignal", RACDescription(self)]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSURLConnection+RACSupport.h b/LookInside/ReactiveObjC/NSURLConnection+RACSupport.h deleted file mode 100644 index c1c5731d..00000000 --- a/LookInside/ReactiveObjC/NSURLConnection+RACSupport.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// NSURLConnection+RACSupport.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-01. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -@class RACTwoTuple<__covariant First, __covariant Second>; -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSURLConnection (RACSupport) - -/// Lazily loads data for the given request in the background. -/// -/// request - The URL request to load. This must not be nil. -/// -/// Returns a signal which will begin loading the request upon each subscription, -/// then send a tuple of the received response and downloaded data, and complete -/// on a background thread. If any errors occur, the returned signal will error -/// out. -+ (RACSignal *> *)rac_sendAsynchronousRequest:(NSURLRequest *)request; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSURLConnection+RACSupport.m b/LookInside/ReactiveObjC/NSURLConnection+RACSupport.m deleted file mode 100644 index 7acd322a..00000000 --- a/LookInside/ReactiveObjC/NSURLConnection+RACSupport.m +++ /dev/null @@ -1,54 +0,0 @@ -// -// NSURLConnection+RACSupport.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-01. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSURLConnection+RACSupport.h" -#import "RACDisposable.h" -#import "RACSignal.h" -#import "RACSubscriber.h" -#import "RACTuple.h" - -@implementation NSURLConnection (RACSupport) - -+ (RACSignal *)rac_sendAsynchronousRequest:(NSURLRequest *)request { - NSCParameterAssert(request != nil); - - return [[RACSignal - createSignal:^(id subscriber) { - NSOperationQueue *queue = [[NSOperationQueue alloc] init]; - queue.name = @"org.reactivecocoa.ReactiveObjC.NSURLConnectionRACSupport"; -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - [NSURLConnection sendAsynchronousRequest:request queue:queue completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) { - // The docs say that `nil` data means an error occurred, but - // `nil` responses can also occur in practice (circumstances - // unknown). Consider either to be an error. - // - // Note that _empty_ data is not necessarily erroneous, as there - // may be headers but no HTTP body. - if (response == nil || data == nil) { - [subscriber sendError:error]; - } else { - [subscriber sendNext:RACTuplePack(response, data)]; - [subscriber sendCompleted]; - } - }]; -#pragma clang diagnostic pop - - return [RACDisposable disposableWithBlock:^{ - // It's not clear if this will actually cancel the connection, - // but we can at least prevent _some_ unnecessary work -- - // without writing all the code for a proper delegate, which - // doesn't really belong in RAC. - queue.suspended = YES; - [queue cancelAllOperations]; - }]; - }] - setNameWithFormat:@"+rac_sendAsynchronousRequest: %@", request]; -} - -@end diff --git a/LookInside/ReactiveObjC/NSUserDefaults+RACSupport.h b/LookInside/ReactiveObjC/NSUserDefaults+RACSupport.h deleted file mode 100644 index bfc16056..00000000 --- a/LookInside/ReactiveObjC/NSUserDefaults+RACSupport.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// NSUserDefaults+RACSupport.h -// ReactiveObjC -// -// Created by Matt Diephouse on 12/19/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -@class RACChannelTerminal; - -NS_ASSUME_NONNULL_BEGIN - -@interface NSUserDefaults (RACSupport) - -/// Creates and returns a terminal for binding the user defaults key. -/// -/// **Note:** The value in the user defaults is *asynchronously* updated with -/// values sent to the channel. -/// -/// key - The user defaults key to create the channel terminal for. -/// -/// Returns a channel terminal that sends the value of the user defaults key -/// upon subscription, sends an updated value whenever the default changes, and -/// updates the default asynchronously with values it receives. -- (RACChannelTerminal *)rac_channelTerminalForKey:(NSString *)key; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/NSUserDefaults+RACSupport.m b/LookInside/ReactiveObjC/NSUserDefaults+RACSupport.m deleted file mode 100644 index ea51bf92..00000000 --- a/LookInside/ReactiveObjC/NSUserDefaults+RACSupport.m +++ /dev/null @@ -1,58 +0,0 @@ -// -// NSUserDefaults+RACSupport.m -// ReactiveObjC -// -// Created by Matt Diephouse on 12/19/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "NSUserDefaults+RACSupport.h" -#import "RACEXTScope.h" -#import "NSNotificationCenter+RACSupport.h" -#import "NSObject+RACDeallocating.h" -#import "RACChannel.h" -#import "RACScheduler.h" -#import "RACSignal+Operations.h" - -@implementation NSUserDefaults (RACSupport) - -- (RACChannelTerminal *)rac_channelTerminalForKey:(NSString *)key { - NSParameterAssert(key != nil); - - RACChannel *channel = [RACChannel new]; - - RACScheduler *scheduler = [RACScheduler scheduler]; - __block BOOL ignoreNextValue = NO; - - @weakify(self); - [[[[[[[NSNotificationCenter.defaultCenter - rac_addObserverForName:NSUserDefaultsDidChangeNotification object:self] - map:^(id _) { - @strongify(self); - return [self objectForKey:key]; - }] - startWith:[self objectForKey:key]] - // Don't send values that were set on the other side of the terminal. - filter:^ BOOL (id _) { - if (RACScheduler.currentScheduler == scheduler && ignoreNextValue) { - ignoreNextValue = NO; - return NO; - } - return YES; - }] - distinctUntilChanged] - takeUntil:self.rac_willDeallocSignal] - subscribe:channel.leadingTerminal]; - - [[channel.leadingTerminal - deliverOn:scheduler] - subscribeNext:^(id value) { - @strongify(self); - ignoreNextValue = YES; - [self setObject:value forKey:key]; - }]; - - return channel.followingTerminal; -} - -@end diff --git a/LookInside/ReactiveObjC/RACAnnotations.h b/LookInside/ReactiveObjC/RACAnnotations.h deleted file mode 100644 index ed58ef92..00000000 --- a/LookInside/ReactiveObjC/RACAnnotations.h +++ /dev/null @@ -1,11 +0,0 @@ -// -// RACAnnotations.h -// ReactiveObjC -// -// Created by Eric Horacek on 3/31/17. -// Copyright © 2017 GitHub. All rights reserved. -// - -#ifndef RAC_WARN_UNUSED_RESULT -#define RAC_WARN_UNUSED_RESULT __attribute__((warn_unused_result)) -#endif diff --git a/LookInside/ReactiveObjC/RACArraySequence.h b/LookInside/ReactiveObjC/RACArraySequence.h deleted file mode 100644 index 9bf049dc..00000000 --- a/LookInside/ReactiveObjC/RACArraySequence.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// RACArraySequence.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACSequence.h" - -// Private class that adapts an array to the RACSequence interface. -@interface RACArraySequence : RACSequence - -// Returns a sequence for enumerating over the given array, starting from the -// given offset. The array will be copied to prevent mutation. -+ (RACSequence *)sequenceWithArray:(NSArray *)array offset:(NSUInteger)offset; - -@end diff --git a/LookInside/ReactiveObjC/RACArraySequence.m b/LookInside/ReactiveObjC/RACArraySequence.m deleted file mode 100644 index 797d48fb..00000000 --- a/LookInside/ReactiveObjC/RACArraySequence.m +++ /dev/null @@ -1,125 +0,0 @@ -// -// RACArraySequence.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACArraySequence.h" - -@interface RACArraySequence () - -// Redeclared from the superclass and marked deprecated to prevent using `array` -// where `backingArray` is intended. -@property (nonatomic, copy, readonly) NSArray *array __attribute__((deprecated)); - -// The array being sequenced. -@property (nonatomic, copy, readonly) NSArray *backingArray; - -// The index in the array from which the sequence starts. -@property (nonatomic, assign, readonly) NSUInteger offset; - -@end - -@implementation RACArraySequence - -#pragma mark Lifecycle - -+ (RACSequence *)sequenceWithArray:(NSArray *)array offset:(NSUInteger)offset { - NSCParameterAssert(offset <= array.count); - - if (offset == array.count) return self.empty; - - RACArraySequence *seq = [[self alloc] init]; - seq->_backingArray = [array copy]; - seq->_offset = offset; - return seq; -} - -#pragma mark RACSequence - -- (id)head { - return self.backingArray[self.offset]; -} - -- (RACSequence *)tail { - RACSequence *sequence = [self.class sequenceWithArray:self.backingArray offset:self.offset + 1]; - sequence.name = self.name; - return sequence; -} - -#pragma mark NSFastEnumeration - -- (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state objects:(__unsafe_unretained id[])stackbuf count:(NSUInteger)len { - NSCParameterAssert(len > 0); - - if (state->state >= self.backingArray.count) { - // Enumeration has completed. - return 0; - } - - if (state->state == 0) { - state->state = self.offset; - - // Since a sequence doesn't mutate, this just needs to be set to - // something non-NULL. - state->mutationsPtr = state->extra; - } - - state->itemsPtr = stackbuf; - - NSUInteger startIndex = state->state; - NSUInteger index = 0; - - for (id value in self.backingArray) { - // Constructing an index set for -enumerateObjectsAtIndexes: can actually be - // slower than just skipping the items we don't care about. - if (index < startIndex) { - ++index; - continue; - } - - stackbuf[index - startIndex] = value; - - ++index; - if (index - startIndex >= len) break; - } - - NSCAssert(index > startIndex, @"Final index (%lu) should be greater than start index (%lu)", (unsigned long)index, (unsigned long)startIndex); - - state->state = index; - return index - startIndex; -} - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-implementations" -- (NSArray *)array { - return [self.backingArray subarrayWithRange:NSMakeRange(self.offset, self.backingArray.count - self.offset)]; -} -#pragma clang diagnostic pop - -#pragma mark NSCoding - -- (instancetype)initWithCoder:(NSCoder *)coder { - self = [super initWithCoder:coder]; - if (self == nil) return nil; - - _backingArray = [coder decodeObjectForKey:@"array"]; - _offset = 0; - - return self; -} - -- (void)encodeWithCoder:(NSCoder *)coder { - // Encoding is handled in RACSequence. - [super encodeWithCoder:coder]; -} - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p>{ name = %@, array = %@ }", self.class, self, self.name, self.backingArray]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACBehaviorSubject.h b/LookInside/ReactiveObjC/RACBehaviorSubject.h deleted file mode 100644 index a462656e..00000000 --- a/LookInside/ReactiveObjC/RACBehaviorSubject.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// RACBehaviorSubject.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/16/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSubject.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A behavior subject sends the last value it received when it is subscribed to. -@interface RACBehaviorSubject : RACSubject - -/// Creates a new behavior subject with a default value. If it hasn't received -/// any values when it gets subscribed to, it sends the default value. -+ (instancetype)behaviorSubjectWithDefaultValue:(nullable ValueType)value; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACBehaviorSubject.m b/LookInside/ReactiveObjC/RACBehaviorSubject.m deleted file mode 100644 index 857289f4..00000000 --- a/LookInside/ReactiveObjC/RACBehaviorSubject.m +++ /dev/null @@ -1,56 +0,0 @@ -// -// RACBehaviorSubject.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/16/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACBehaviorSubject.h" -#import "RACDisposable.h" -#import "RACScheduler+Private.h" - -@interface RACBehaviorSubject () - -// This property should only be used while synchronized on self. -@property (nonatomic, strong) ValueType currentValue; - -@end - -@implementation RACBehaviorSubject - -#pragma mark Lifecycle - -+ (instancetype)behaviorSubjectWithDefaultValue:(id)value { - RACBehaviorSubject *subject = [self subject]; - subject.currentValue = value; - return subject; -} - -#pragma mark RACSignal - -- (RACDisposable *)subscribe:(id)subscriber { - RACDisposable *subscriptionDisposable = [super subscribe:subscriber]; - - RACDisposable *schedulingDisposable = [RACScheduler.subscriptionScheduler schedule:^{ - @synchronized (self) { - [subscriber sendNext:self.currentValue]; - } - }]; - - return [RACDisposable disposableWithBlock:^{ - [subscriptionDisposable dispose]; - [schedulingDisposable dispose]; - }]; -} - -#pragma mark RACSubscriber - -- (void)sendNext:(id)value { - @synchronized (self) { - self.currentValue = value; - [super sendNext:value]; - } -} - -@end diff --git a/LookInside/ReactiveObjC/RACBlockTrampoline.h b/LookInside/ReactiveObjC/RACBlockTrampoline.h deleted file mode 100644 index c0e89bca..00000000 --- a/LookInside/ReactiveObjC/RACBlockTrampoline.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// RACBlockTrampoline.h -// ReactiveObjC -// -// Created by Josh Abernathy on 10/21/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACTuple; - -// A private class that allows a limited type of dynamic block invocation. -@interface RACBlockTrampoline : NSObject - -// Invokes the given block with the given arguments. All of the block's -// argument types must be objects and it must be typed to return an object. -// -// At this time, it only supports blocks that take up to 15 arguments. Any more -// is just cray. -// -// block - The block to invoke. Must accept as many arguments as are given in -// the arguments array. Cannot be nil. -// arguments - The arguments with which to invoke the block. `RACTupleNil`s will -// be passed as nils. -// -// Returns the return value of invoking the block. -+ (id)invokeBlock:(id)block withArguments:(RACTuple *)arguments; - -@end diff --git a/LookInside/ReactiveObjC/RACBlockTrampoline.m b/LookInside/ReactiveObjC/RACBlockTrampoline.m deleted file mode 100644 index a2d589f1..00000000 --- a/LookInside/ReactiveObjC/RACBlockTrampoline.m +++ /dev/null @@ -1,155 +0,0 @@ -// -// RACBlockTrampoline.m -// ReactiveObjC -// -// Created by Josh Abernathy on 10/21/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACBlockTrampoline.h" -#import "RACTuple.h" - -@interface RACBlockTrampoline () -@property (nonatomic, readonly, copy) id block; -@end - -@implementation RACBlockTrampoline - -#pragma mark API - -- (instancetype)initWithBlock:(id)block { - self = [super init]; - - _block = [block copy]; - - return self; -} - -+ (id)invokeBlock:(id)block withArguments:(RACTuple *)arguments { - NSCParameterAssert(block != NULL); - - RACBlockTrampoline *trampoline = [[self alloc] initWithBlock:block]; - return [trampoline invokeWithArguments:arguments]; -} - -- (id)invokeWithArguments:(RACTuple *)arguments { - SEL selector = [self selectorForArgumentCount:arguments.count]; - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[self methodSignatureForSelector:selector]]; - invocation.selector = selector; - invocation.target = self; - - for (NSUInteger i = 0; i < arguments.count; i++) { - id arg = arguments[i]; - NSInteger argIndex = (NSInteger)(i + 2); - [invocation setArgument:&arg atIndex:argIndex]; - } - - [invocation invoke]; - - __unsafe_unretained id returnVal; - [invocation getReturnValue:&returnVal]; - return returnVal; -} - -- (SEL)selectorForArgumentCount:(NSUInteger)count { - NSCParameterAssert(count > 0); - - switch (count) { - case 0: return NULL; - case 1: return @selector(performWith:); - case 2: return @selector(performWith::); - case 3: return @selector(performWith:::); - case 4: return @selector(performWith::::); - case 5: return @selector(performWith:::::); - case 6: return @selector(performWith::::::); - case 7: return @selector(performWith:::::::); - case 8: return @selector(performWith::::::::); - case 9: return @selector(performWith:::::::::); - case 10: return @selector(performWith::::::::::); - case 11: return @selector(performWith:::::::::::); - case 12: return @selector(performWith::::::::::::); - case 13: return @selector(performWith:::::::::::::); - case 14: return @selector(performWith::::::::::::::); - case 15: return @selector(performWith:::::::::::::::); - } - - NSCAssert(NO, @"The argument count is too damn high! Only blocks of up to 15 arguments are currently supported."); - return NULL; -} - -- (id)performWith:(id)obj1 { - id (^block)(id) = self.block; - return block(obj1); -} - -- (id)performWith:(id)obj1 :(id)obj2 { - id (^block)(id, id) = self.block; - return block(obj1, obj2); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 { - id (^block)(id, id, id) = self.block; - return block(obj1, obj2, obj3); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 { - id (^block)(id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 { - id (^block)(id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 { - id (^block)(id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 { - id (^block)(id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 :(id)obj8 { - id (^block)(id, id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 :(id)obj8 :(id)obj9 { - id (^block)(id, id, id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 :(id)obj8 :(id)obj9 :(id)obj10 { - id (^block)(id, id, id, id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 :(id)obj8 :(id)obj9 :(id)obj10 :(id)obj11 { - id (^block)(id, id, id, id, id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10, obj11); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 :(id)obj8 :(id)obj9 :(id)obj10 :(id)obj11 :(id)obj12 { - id (^block)(id, id, id, id, id, id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10, obj11, obj12); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 :(id)obj8 :(id)obj9 :(id)obj10 :(id)obj11 :(id)obj12 :(id)obj13 { - id (^block)(id, id, id, id, id, id, id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10, obj11, obj12, obj13); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 :(id)obj8 :(id)obj9 :(id)obj10 :(id)obj11 :(id)obj12 :(id)obj13 :(id)obj14 { - id (^block)(id, id, id, id, id, id, id, id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10, obj11, obj12, obj13, obj14); -} - -- (id)performWith:(id)obj1 :(id)obj2 :(id)obj3 :(id)obj4 :(id)obj5 :(id)obj6 :(id)obj7 :(id)obj8 :(id)obj9 :(id)obj10 :(id)obj11 :(id)obj12 :(id)obj13 :(id)obj14 :(id)obj15 { - id (^block)(id, id, id, id, id, id, id, id, id, id, id, id, id, id, id) = self.block; - return block(obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10, obj11, obj12, obj13, obj14, obj15); -} - -@end diff --git a/LookInside/ReactiveObjC/RACChannel.h b/LookInside/ReactiveObjC/RACChannel.h deleted file mode 100644 index 847f1743..00000000 --- a/LookInside/ReactiveObjC/RACChannel.h +++ /dev/null @@ -1,77 +0,0 @@ -// -// RACChannel.h -// ReactiveObjC -// -// Created by Uri Baghin on 01/01/2013. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSignal.h" -#import "RACSubscriber.h" - -@class RACChannelTerminal; - -NS_ASSUME_NONNULL_BEGIN - -/// A two-way channel. -/// -/// Conceptually, RACChannel can be thought of as a bidirectional connection, -/// composed of two controllable signals that work in parallel. -/// -/// For example, when connecting between a view and a model: -/// -/// Model View -/// `leadingTerminal` ------> `followingTerminal` -/// `leadingTerminal` <------ `followingTerminal` -/// -/// The initial value of the model and all future changes to it are _sent on_ the -/// `leadingTerminal`, and _received by_ subscribers of the `followingTerminal`. -/// -/// Likewise, whenever the user changes the value of the view, that value is sent -/// on the `followingTerminal`, and received in the model from the -/// `leadingTerminal`. However, the initial value of the view is not received -/// from the `leadingTerminal` (only future changes). -@interface RACChannel : NSObject - -/// The terminal which "leads" the channel, by sending its latest value -/// immediately to new subscribers of the `followingTerminal`. -/// -/// New subscribers to this terminal will not receive a starting value, but will -/// receive all future values that are sent to the `followingTerminal`. -@property (nonatomic, strong, readonly) RACChannelTerminal *leadingTerminal; - -/// The terminal which "follows" the lead of the other terminal, only sending -/// _future_ values to the subscribers of the `leadingTerminal`. -/// -/// The latest value sent to the `leadingTerminal` (if any) will be sent -/// immediately to new subscribers of this terminal, and then all future values -/// as well. -@property (nonatomic, strong, readonly) RACChannelTerminal *followingTerminal; - -@end - -/// Represents one end of a RACChannel. -/// -/// An terminal is similar to a socket or pipe -- it represents one end of -/// a connection (the RACChannel, in this case). Values sent to this terminal -/// will _not_ be received by its subscribers. Instead, the values will be sent -/// to the subscribers of the RACChannel's _other_ terminal. -/// -/// For example, when using the `followingTerminal`, _sent_ values can only be -/// _received_ from the `leadingTerminal`, and vice versa. -/// -/// To make it easy to terminate a RACChannel, `error` and `completed` events -/// sent to either terminal will be received by the subscribers of _both_ -/// terminals. -/// -/// Do not instantiate this class directly. Create a RACChannel instead. -@interface RACChannelTerminal : RACSignal - -- (instancetype)init __attribute__((unavailable("Instantiate a RACChannel instead"))); - -// Redeclaration of the RACSubscriber method. Made in order to specify a generic type. -- (void)sendNext:(nullable ValueType)value; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACChannel.m b/LookInside/ReactiveObjC/RACChannel.m deleted file mode 100644 index e8f617c9..00000000 --- a/LookInside/ReactiveObjC/RACChannel.m +++ /dev/null @@ -1,88 +0,0 @@ -// -// RACChannel.m -// ReactiveObjC -// -// Created by Uri Baghin on 01/01/2013. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACChannel.h" -#import "RACDisposable.h" -#import "RACReplaySubject.h" -#import "RACSignal+Operations.h" - -@interface RACChannelTerminal () - -/// The values for this terminal. -@property (nonatomic, strong, readonly) RACSignal *values; - -/// A subscriber will will send values to the other terminal. -@property (nonatomic, strong, readonly) id otherTerminal; - -- (instancetype)initWithValues:(RACSignal *)values otherTerminal:(id)otherTerminal; - -@end - -@implementation RACChannel - -- (instancetype)init { - self = [super init]; - - // We don't want any starting value from the leadingSubject, but we do want - // error and completion to be replayed. - RACReplaySubject *leadingSubject = [[RACReplaySubject replaySubjectWithCapacity:0] setNameWithFormat:@"leadingSubject"]; - RACReplaySubject *followingSubject = [[RACReplaySubject replaySubjectWithCapacity:1] setNameWithFormat:@"followingSubject"]; - - // Propagate errors and completion to everything. - [[leadingSubject ignoreValues] subscribe:followingSubject]; - [[followingSubject ignoreValues] subscribe:leadingSubject]; - - _leadingTerminal = [[[RACChannelTerminal alloc] initWithValues:leadingSubject otherTerminal:followingSubject] setNameWithFormat:@"leadingTerminal"]; - _followingTerminal = [[[RACChannelTerminal alloc] initWithValues:followingSubject otherTerminal:leadingSubject] setNameWithFormat:@"followingTerminal"]; - - return self; -} - -@end - -@implementation RACChannelTerminal - -#pragma mark Lifecycle - -- (instancetype)initWithValues:(RACSignal *)values otherTerminal:(id)otherTerminal { - NSCParameterAssert(values != nil); - NSCParameterAssert(otherTerminal != nil); - - self = [super init]; - - _values = values; - _otherTerminal = otherTerminal; - - return self; -} - -#pragma mark RACSignal - -- (RACDisposable *)subscribe:(id)subscriber { - return [self.values subscribe:subscriber]; -} - -#pragma mark - -- (void)sendNext:(id)value { - [self.otherTerminal sendNext:value]; -} - -- (void)sendError:(NSError *)error { - [self.otherTerminal sendError:error]; -} - -- (void)sendCompleted { - [self.otherTerminal sendCompleted]; -} - -- (void)didSubscribeWithDisposable:(RACCompoundDisposable *)disposable { - [self.otherTerminal didSubscribeWithDisposable:disposable]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACCommand.h b/LookInside/ReactiveObjC/RACCommand.h deleted file mode 100644 index 4a268605..00000000 --- a/LookInside/ReactiveObjC/RACCommand.h +++ /dev/null @@ -1,116 +0,0 @@ -// -// RACCommand.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/3/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -/// The domain for errors originating within `RACCommand`. -extern NSString * const RACCommandErrorDomain; - -/// -execute: was invoked while the command was disabled. -extern const NSInteger RACCommandErrorNotEnabled; - -/// A `userInfo` key for an error, associated with the `RACCommand` that the -/// error originated from. -/// -/// This is included only when the error code is `RACCommandErrorNotEnabled`. -extern NSString * const RACUnderlyingCommandErrorKey; - -/// A command is a signal triggered in response to some action, typically -/// UI-related. -@interface RACCommand<__contravariant InputType, __covariant ValueType> : NSObject - -/// A signal of the signals returned by successful invocations of -execute: -/// (i.e., while the receiver is `enabled`). -/// -/// Errors will be automatically caught upon the inner signals, and sent upon -/// `errors` instead. If you _want_ to receive inner errors, use -execute: or -/// -[RACSignal materialize]. -/// -/// Only executions that begin _after_ subscription will be sent upon this -/// signal. All inner signals will arrive upon the main thread. -@property (nonatomic, strong, readonly) RACSignal *> *executionSignals; - -/// A signal of whether this command is currently executing. -/// -/// This will send YES whenever -execute: is invoked and the created signal has -/// not yet terminated. Once all executions have terminated, `executing` will -/// send NO. -/// -/// This signal will send its current value upon subscription, and then all -/// future values on the main thread. -@property (nonatomic, strong, readonly) RACSignal *executing; - -/// A signal of whether this command is able to execute. -/// -/// This will send NO if: -/// -/// - The command was created with an `enabledSignal`, and NO is sent upon that -/// signal, or -/// - `allowsConcurrentExecution` is NO and the command has started executing. -/// -/// Once the above conditions are no longer met, the signal will send YES. -/// -/// This signal will send its current value upon subscription, and then all -/// future values on the main thread. -@property (nonatomic, strong, readonly) RACSignal *enabled; - -/// Forwards any errors that occur within signals returned by -execute:. -/// -/// When an error occurs on a signal returned from -execute:, this signal will -/// send the associated NSError value as a `next` event (since an `error` event -/// would terminate the stream). -/// -/// After subscription, this signal will send all future errors on the main -/// thread. -@property (nonatomic, strong, readonly) RACSignal *errors; - -/// Whether the command allows multiple executions to proceed concurrently. -/// -/// The default value for this property is NO. -@property (atomic, assign) BOOL allowsConcurrentExecution; - -/// Invokes -initWithEnabled:signalBlock: with a nil `enabledSignal`. -- (instancetype)initWithSignalBlock:(RACSignal * (^)(InputType _Nullable input))signalBlock; - -/// Initializes a command that is conditionally enabled. -/// -/// This is the designated initializer for this class. -/// -/// enabledSignal - A signal of BOOLs which indicate whether the command should -/// be enabled. `enabled` will be based on the latest value sent -/// from this signal. Before any values are sent, `enabled` will -/// default to YES. This argument may be nil. -/// signalBlock - A block which will map each input value (passed to -execute:) -/// to a signal of work. The returned signal will be multicasted -/// to a replay subject, sent on `executionSignals`, then -/// subscribed to synchronously. Neither the block nor the -/// returned signal may be nil. -- (instancetype)initWithEnabled:(nullable RACSignal *)enabledSignal signalBlock:(RACSignal * (^)(InputType _Nullable input))signalBlock; - -/// If the receiver is enabled, this method will: -/// -/// 1. Invoke the `signalBlock` given at the time of initialization. -/// 2. Multicast the returned signal to a RACReplaySubject. -/// 3. Send the multicasted signal on `executionSignals`. -/// 4. Subscribe (connect) to the original signal on the main thread. -/// -/// input - The input value to pass to the receiver's `signalBlock`. This may be -/// nil. -/// -/// Returns the multicasted signal, after subscription. If the receiver is not -/// enabled, returns a signal that will send an error with code -/// RACCommandErrorNotEnabled. -- (RACSignal *)execute:(nullable InputType)input; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACCommand.m b/LookInside/ReactiveObjC/RACCommand.m deleted file mode 100644 index ecd62512..00000000 --- a/LookInside/ReactiveObjC/RACCommand.m +++ /dev/null @@ -1,197 +0,0 @@ -// -// RACCommand.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/3/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACCommand.h" -#import "RACEXTScope.h" -#import "NSArray+RACSequenceAdditions.h" -#import "NSObject+RACDeallocating.h" -#import "NSObject+RACDescription.h" -#import "NSObject+RACPropertySubscribing.h" -#import "RACMulticastConnection.h" -#import "RACReplaySubject.h" -#import "RACScheduler.h" -#import "RACSequence.h" -#import "RACSignal+Operations.h" -#import - -NSString * const RACCommandErrorDomain = @"RACCommandErrorDomain"; -NSString * const RACUnderlyingCommandErrorKey = @"RACUnderlyingCommandErrorKey"; - -const NSInteger RACCommandErrorNotEnabled = 1; - -@interface RACCommand () { - // Atomic backing variable for `allowsConcurrentExecution`. - atomic_uint_least32_t _allowsConcurrentExecution; -} - -/// A subject that sends added execution signals. -@property (nonatomic, strong, readonly) RACSubject *addedExecutionSignalsSubject; - -/// A subject that sends the new value of `allowsConcurrentExecution` whenever it changes. -@property (nonatomic, strong, readonly) RACSubject *allowsConcurrentExecutionSubject; - -// `enabled`, but without a hop to the main thread. -// -// Values from this signal may arrive on any thread. -@property (nonatomic, strong, readonly) RACSignal *immediateEnabled; - -// The signal block that the receiver was initialized with. -@property (nonatomic, copy, readonly) RACSignal * (^signalBlock)(id input); - -@end - -@implementation RACCommand - -#pragma mark Properties - -- (BOOL)allowsConcurrentExecution { - return atomic_load_explicit(&_allowsConcurrentExecution, memory_order_acquire) != 0; -} - -- (void)setAllowsConcurrentExecution:(BOOL)allowed { - atomic_store_explicit(&_allowsConcurrentExecution, allowed ? 1u : 0u, memory_order_release); - [self.allowsConcurrentExecutionSubject sendNext:@(atomic_load_explicit(&_allowsConcurrentExecution, memory_order_acquire) != 0)]; -} - -#pragma mark Lifecycle - -- (instancetype)init { - NSCAssert(NO, @"Use -initWithSignalBlock: instead"); - return nil; -} - -- (instancetype)initWithSignalBlock:(RACSignal * (^)(id input))signalBlock { - return [self initWithEnabled:nil signalBlock:signalBlock]; -} - -- (void)dealloc { - [_addedExecutionSignalsSubject sendCompleted]; - [_allowsConcurrentExecutionSubject sendCompleted]; -} - -- (instancetype)initWithEnabled:(RACSignal *)enabledSignal signalBlock:(RACSignal * (^)(id input))signalBlock { - NSCParameterAssert(signalBlock != nil); - - self = [super init]; - - _addedExecutionSignalsSubject = [RACSubject new]; - _allowsConcurrentExecutionSubject = [RACSubject new]; - _signalBlock = [signalBlock copy]; - - _executionSignals = [[[self.addedExecutionSignalsSubject - map:^(RACSignal *signal) { - return [signal catchTo:[RACSignal empty]]; - }] - deliverOn:RACScheduler.mainThreadScheduler] - setNameWithFormat:@"%@ -executionSignals", self]; - - // `errors` needs to be multicasted so that it picks up all - // `activeExecutionSignals` that are added. - // - // In other words, if someone subscribes to `errors` _after_ an execution - // has started, it should still receive any error from that execution. - RACMulticastConnection *errorsConnection = [[[self.addedExecutionSignalsSubject - flattenMap:^(RACSignal *signal) { - return [[signal - ignoreValues] - catch:^(NSError *error) { - return [RACSignal return:error]; - }]; - }] - deliverOn:RACScheduler.mainThreadScheduler] - publish]; - - _errors = [errorsConnection.signal setNameWithFormat:@"%@ -errors", self]; - [errorsConnection connect]; - - RACSignal *immediateExecuting = [[[[self.addedExecutionSignalsSubject - flattenMap:^(RACSignal *signal) { - return [[[signal - catchTo:[RACSignal empty]] - then:^{ - return [RACSignal return:@-1]; - }] - startWith:@1]; - }] - scanWithStart:@0 reduce:^(NSNumber *running, NSNumber *next) { - return @(running.integerValue + next.integerValue); - }] - map:^(NSNumber *count) { - return @(count.integerValue > 0); - }] - startWith:@NO]; - - _executing = [[[[[immediateExecuting - deliverOn:RACScheduler.mainThreadScheduler] - // This is useful before the first value arrives on the main thread. - startWith:@NO] - distinctUntilChanged] - replayLast] - setNameWithFormat:@"%@ -executing", self]; - - RACSignal *moreExecutionsAllowed = [RACSignal - if:[self.allowsConcurrentExecutionSubject startWith:@NO] - then:[RACSignal return:@YES] - else:[immediateExecuting not]]; - - if (enabledSignal == nil) { - enabledSignal = [RACSignal return:@YES]; - } else { - enabledSignal = [enabledSignal startWith:@YES]; - } - - _immediateEnabled = [[[[RACSignal - combineLatest:@[ enabledSignal, moreExecutionsAllowed ]] - and] - takeUntil:self.rac_willDeallocSignal] - replayLast]; - - _enabled = [[[[[self.immediateEnabled - take:1] - concat:[[self.immediateEnabled skip:1] deliverOn:RACScheduler.mainThreadScheduler]] - distinctUntilChanged] - replayLast] - setNameWithFormat:@"%@ -enabled", self]; - - return self; -} - -#pragma mark Execution - -- (RACSignal *)execute:(id)input { - // `immediateEnabled` is guaranteed to send a value upon subscription, so - // -first is acceptable here. - BOOL enabled = [[self.immediateEnabled first] boolValue]; - if (!enabled) { - NSError *error = [NSError errorWithDomain:RACCommandErrorDomain code:RACCommandErrorNotEnabled userInfo:@{ - NSLocalizedDescriptionKey: NSLocalizedString(@"The command is disabled and cannot be executed", nil), - RACUnderlyingCommandErrorKey: self - }]; - - return [RACSignal error:error]; - } - - RACSignal *signal = self.signalBlock(input); - NSCAssert(signal != nil, @"nil signal returned from signal block for value: %@", input); - - // We subscribe to the signal on the main thread so that it occurs _after_ - // -addActiveExecutionSignal: completes below. - // - // This means that `executing` and `enabled` will send updated values before - // the signal actually starts performing work. - RACMulticastConnection *connection = [[signal - subscribeOn:RACScheduler.mainThreadScheduler] - multicast:[RACReplaySubject subject]]; - - [self.addedExecutionSignalsSubject sendNext:connection.signal]; - - [connection connect]; - return [connection.signal setNameWithFormat:@"%@ -execute: %@", self, RACDescription(input)]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACCompoundDisposable.h b/LookInside/ReactiveObjC/RACCompoundDisposable.h deleted file mode 100644 index b4e4dea3..00000000 --- a/LookInside/ReactiveObjC/RACCompoundDisposable.h +++ /dev/null @@ -1,52 +0,0 @@ -// -// RACCompoundDisposable.h -// ReactiveObjC -// -// Created by Josh Abernathy on 11/30/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACDisposable.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A disposable of disposables. When it is disposed, it disposes of all its -/// contained disposables. -/// -/// If -addDisposable: is called after the compound disposable has been disposed -/// of, the given disposable is immediately disposed. This allows a compound -/// disposable to act as a stand-in for a disposable that will be delivered -/// asynchronously. -@interface RACCompoundDisposable : RACDisposable - -/// Creates and returns a new compound disposable. -+ (instancetype)compoundDisposable; - -/// Creates and returns a new compound disposable containing the given -/// disposables. -+ (instancetype)compoundDisposableWithDisposables:(nullable NSArray *)disposables; - -/// Adds the given disposable. If the receiving disposable has already been -/// disposed of, the given disposable is disposed immediately. -/// -/// This method is thread-safe. -/// -/// disposable - The disposable to add. This may be nil, in which case nothing -/// happens. -- (void)addDisposable:(nullable RACDisposable *)disposable; - -/// Removes the specified disposable from the compound disposable (regardless of -/// its disposed status), or does nothing if it's not in the compound disposable. -/// -/// This is mainly useful for limiting the memory usage of the compound -/// disposable for long-running operations. -/// -/// This method is thread-safe. -/// -/// disposable - The disposable to remove. This argument may be nil (to make the -/// use of weak references easier). -- (void)removeDisposable:(nullable RACDisposable *)disposable; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACCompoundDisposable.m b/LookInside/ReactiveObjC/RACCompoundDisposable.m deleted file mode 100644 index 5e38b7df..00000000 --- a/LookInside/ReactiveObjC/RACCompoundDisposable.m +++ /dev/null @@ -1,250 +0,0 @@ -// -// RACCompoundDisposable.m -// ReactiveObjC -// -// Created by Josh Abernathy on 11/30/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACCompoundDisposable.h" -#import "RACCompoundDisposableProvider.h" -#import - -// The number of child disposables for which space will be reserved directly in -// `RACCompoundDisposable`. -// -// This number has been empirically determined to provide a good tradeoff -// between performance, memory usage, and `RACCompoundDisposable` instance size -// in a moderately complex GUI application. -// -// Profile any change! -#define RACCompoundDisposableInlineCount 2 - -static CFMutableArrayRef RACCreateDisposablesArray(void) { - // Compare values using only pointer equality. - CFArrayCallBacks callbacks = kCFTypeArrayCallBacks; - callbacks.equal = NULL; - - return CFArrayCreateMutable(NULL, 0, &callbacks); -} - -@interface RACCompoundDisposable () { - // Used for synchronization. - pthread_mutex_t _mutex; - - #if RACCompoundDisposableInlineCount - // A fast array to the first N of the receiver's disposables. - // - // Once this is full, `_disposables` will be created and used for additional - // disposables. - // - // This array should only be manipulated while _mutex is held. - RACDisposable *_inlineDisposables[RACCompoundDisposableInlineCount]; - #endif - - // Contains the receiver's disposables. - // - // This array should only be manipulated while _mutex is held. If - // `_disposed` is YES, this may be NULL. - CFMutableArrayRef _disposables; - - // Whether the receiver has already been disposed. - // - // This ivar should only be accessed while _mutex is held. - BOOL _disposed; -} - -@end - -@implementation RACCompoundDisposable - -#pragma mark Properties - -- (BOOL)isDisposed { - pthread_mutex_lock(&_mutex); - BOOL disposed = _disposed; - pthread_mutex_unlock(&_mutex); - - return disposed; -} - -#pragma mark Lifecycle - -+ (instancetype)compoundDisposable { - return [[self alloc] initWithDisposables:nil]; -} - -+ (instancetype)compoundDisposableWithDisposables:(NSArray *)disposables { - return [[self alloc] initWithDisposables:disposables]; -} - -- (instancetype)init { - self = [super init]; - - const int result __attribute__((unused)) = pthread_mutex_init(&_mutex, NULL); - NSCAssert(0 == result, @"Failed to initialize mutex with error %d.", result); - - return self; -} - -- (instancetype)initWithDisposables:(NSArray *)otherDisposables { - self = [self init]; - - #if RACCompoundDisposableInlineCount - [otherDisposables enumerateObjectsUsingBlock:^(RACDisposable *disposable, NSUInteger index, BOOL *stop) { - self->_inlineDisposables[index] = disposable; - - // Stop after this iteration if we've reached the end of the inlined - // array. - if (index == RACCompoundDisposableInlineCount - 1) *stop = YES; - }]; - #endif - - if (otherDisposables.count > RACCompoundDisposableInlineCount) { - _disposables = RACCreateDisposablesArray(); - - CFRange range = CFRangeMake(RACCompoundDisposableInlineCount, (CFIndex)otherDisposables.count - RACCompoundDisposableInlineCount); - CFArrayAppendArray(_disposables, (__bridge CFArrayRef)otherDisposables, range); - } - - return self; -} - -- (instancetype)initWithBlock:(void (^)(void))block { - RACDisposable *disposable = [RACDisposable disposableWithBlock:block]; - return [self initWithDisposables:@[ disposable ]]; -} - -- (void)dealloc { - #if RACCompoundDisposableInlineCount - for (unsigned i = 0; i < RACCompoundDisposableInlineCount; i++) { - _inlineDisposables[i] = nil; - } - #endif - - if (_disposables != NULL) { - CFRelease(_disposables); - _disposables = NULL; - } - - const int result __attribute__((unused)) = pthread_mutex_destroy(&_mutex); - NSCAssert(0 == result, @"Failed to destroy mutex with error %d.", result); -} - -#pragma mark Addition and Removal - -- (void)addDisposable:(RACDisposable *)disposable { - NSCParameterAssert(disposable != self); - if (disposable == nil || disposable.disposed) return; - - BOOL shouldDispose = NO; - - pthread_mutex_lock(&_mutex); - { - if (_disposed) { - shouldDispose = YES; - } else { - #if RACCompoundDisposableInlineCount - for (unsigned i = 0; i < RACCompoundDisposableInlineCount; i++) { - if (_inlineDisposables[i] == nil) { - _inlineDisposables[i] = disposable; - goto foundSlot; - } - } - #endif - - if (_disposables == NULL) _disposables = RACCreateDisposablesArray(); - CFArrayAppendValue(_disposables, (__bridge void *)disposable); - - if (RACCOMPOUNDDISPOSABLE_ADDED_ENABLED()) { - RACCOMPOUNDDISPOSABLE_ADDED(self.description.UTF8String, disposable.description.UTF8String, CFArrayGetCount(_disposables) + RACCompoundDisposableInlineCount); - } - - #if RACCompoundDisposableInlineCount - foundSlot:; - #endif - } - } - pthread_mutex_unlock(&_mutex); - - // Performed outside of the lock in case the compound disposable is used - // recursively. - if (shouldDispose) [disposable dispose]; -} - -- (void)removeDisposable:(RACDisposable *)disposable { - if (disposable == nil) return; - - pthread_mutex_lock(&_mutex); - { - if (!_disposed) { - #if RACCompoundDisposableInlineCount - for (unsigned i = 0; i < RACCompoundDisposableInlineCount; i++) { - if (_inlineDisposables[i] == disposable) _inlineDisposables[i] = nil; - } - #endif - - if (_disposables != NULL) { - CFIndex count = CFArrayGetCount(_disposables); - for (CFIndex i = count - 1; i >= 0; i--) { - const void *item = CFArrayGetValueAtIndex(_disposables, i); - if (item == (__bridge void *)disposable) { - CFArrayRemoveValueAtIndex(_disposables, i); - } - } - - if (RACCOMPOUNDDISPOSABLE_REMOVED_ENABLED()) { - RACCOMPOUNDDISPOSABLE_REMOVED(self.description.UTF8String, disposable.description.UTF8String, CFArrayGetCount(_disposables) + RACCompoundDisposableInlineCount); - } - } - } - } - pthread_mutex_unlock(&_mutex); -} - -#pragma mark RACDisposable - -static void disposeEach(const void *value, void *context) { - RACDisposable *disposable = (__bridge id)value; - [disposable dispose]; -} - -- (void)dispose { - #if RACCompoundDisposableInlineCount - RACDisposable *inlineCopy[RACCompoundDisposableInlineCount]; - #endif - - CFArrayRef remainingDisposables = NULL; - - pthread_mutex_lock(&_mutex); - { - _disposed = YES; - - #if RACCompoundDisposableInlineCount - for (unsigned i = 0; i < RACCompoundDisposableInlineCount; i++) { - inlineCopy[i] = _inlineDisposables[i]; - _inlineDisposables[i] = nil; - } - #endif - - remainingDisposables = _disposables; - _disposables = NULL; - } - pthread_mutex_unlock(&_mutex); - - #if RACCompoundDisposableInlineCount - // Dispose outside of the lock in case the compound disposable is used - // recursively. - for (unsigned i = 0; i < RACCompoundDisposableInlineCount; i++) { - [inlineCopy[i] dispose]; - } - #endif - - if (remainingDisposables == NULL) return; - - CFIndex count = CFArrayGetCount(remainingDisposables); - CFArrayApplyFunction(remainingDisposables, CFRangeMake(0, count), &disposeEach, NULL); - CFRelease(remainingDisposables); -} - -@end diff --git a/LookInside/ReactiveObjC/RACCompoundDisposableProvider.d b/LookInside/ReactiveObjC/RACCompoundDisposableProvider.d deleted file mode 100644 index 847db19b..00000000 --- a/LookInside/ReactiveObjC/RACCompoundDisposableProvider.d +++ /dev/null @@ -1,4 +0,0 @@ -provider RACCompoundDisposable { - probe added(char *compoundDisposable, char *disposable, long newTotal); - probe removed(char *compoundDisposable, char *disposable, long newTotal); -}; diff --git a/LookInside/ReactiveObjC/RACCompoundDisposableProvider.h b/LookInside/ReactiveObjC/RACCompoundDisposableProvider.h deleted file mode 100644 index e681d213..00000000 --- a/LookInside/ReactiveObjC/RACCompoundDisposableProvider.h +++ /dev/null @@ -1,10 +0,0 @@ -#ifndef RACCOMPOUNDDISPOSABLE_PROVIDER_H -#define RACCOMPOUNDDISPOSABLE_PROVIDER_H - -#define RACCOMPOUNDDISPOSABLE_ADDED_ENABLED() 0 -#define RACCOMPOUNDDISPOSABLE_ADDED(compoundDisposable, disposable, newTotal) do { } while (0) - -#define RACCOMPOUNDDISPOSABLE_REMOVED_ENABLED() 0 -#define RACCOMPOUNDDISPOSABLE_REMOVED(compoundDisposable, disposable, newTotal) do { } while (0) - -#endif diff --git a/LookInside/ReactiveObjC/RACDelegateProxy.h b/LookInside/ReactiveObjC/RACDelegateProxy.h deleted file mode 100644 index 2ddfd242..00000000 --- a/LookInside/ReactiveObjC/RACDelegateProxy.h +++ /dev/null @@ -1,32 +0,0 @@ -// -// RACDelegateProxy.h -// ReactiveObjC -// -// Created by Cody Krieger on 5/19/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -// A private delegate object suitable for using -// -rac_signalForSelector:fromProtocol: upon. -@interface RACDelegateProxy : NSObject - -// The delegate to which messages should be forwarded if not handled by -// any -signalForSelector: applications. -@property (nonatomic, unsafe_unretained) id rac_proxiedDelegate; - -// Creates a delegate proxy capable of responding to selectors from `protocol`. -- (instancetype)initWithProtocol:(Protocol *)protocol; - -// Calls -rac_signalForSelector:fromProtocol: using the `protocol` specified -// during initialization. -- (RACSignal *)signalForSelector:(SEL)selector; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACDelegateProxy.m b/LookInside/ReactiveObjC/RACDelegateProxy.m deleted file mode 100644 index c1f07c3e..00000000 --- a/LookInside/ReactiveObjC/RACDelegateProxy.m +++ /dev/null @@ -1,75 +0,0 @@ -// -// RACDelegateProxy.m -// ReactiveObjC -// -// Created by Cody Krieger on 5/19/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACDelegateProxy.h" -#import "NSObject+RACSelectorSignal.h" -#import - -@interface RACDelegateProxy () { - // Declared as an ivar to avoid method naming conflicts. - Protocol *_protocol; -} - -@end - -@implementation RACDelegateProxy - -#pragma mark Lifecycle - -- (instancetype)initWithProtocol:(Protocol *)protocol { - NSCParameterAssert(protocol != NULL); - - self = [super init]; - - class_addProtocol(self.class, protocol); - - _protocol = protocol; - - return self; -} - -#pragma mark API - -- (RACSignal *)signalForSelector:(SEL)selector { - return [self rac_signalForSelector:selector fromProtocol:_protocol]; -} - -#pragma mark NSObject - -- (BOOL)isProxy { - return YES; -} - -- (void)forwardInvocation:(NSInvocation *)invocation { - [invocation invokeWithTarget:self.rac_proxiedDelegate]; -} - -- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector { - // Look for the selector as an optional instance method. - struct objc_method_description methodDescription = protocol_getMethodDescription(_protocol, selector, NO, YES); - - if (methodDescription.name == NULL) { - // Then fall back to looking for a required instance - // method. - methodDescription = protocol_getMethodDescription(_protocol, selector, YES, YES); - if (methodDescription.name == NULL) return [super methodSignatureForSelector:selector]; - } - - return [NSMethodSignature signatureWithObjCTypes:methodDescription.types]; -} - -- (BOOL)respondsToSelector:(SEL)selector { - // Add the delegate to the autorelease pool, so it doesn't get deallocated - // between this method call and -forwardInvocation:. - __autoreleasing id delegate = self.rac_proxiedDelegate; - if ([delegate respondsToSelector:selector]) return YES; - - return [super respondsToSelector:selector]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACDisposable.h b/LookInside/ReactiveObjC/RACDisposable.h deleted file mode 100644 index 32320766..00000000 --- a/LookInside/ReactiveObjC/RACDisposable.h +++ /dev/null @@ -1,39 +0,0 @@ -// -// RACDisposable.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/16/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACScopedDisposable; - -NS_ASSUME_NONNULL_BEGIN - -/// A disposable encapsulates the work necessary to tear down and cleanup a -/// subscription. -@interface RACDisposable : NSObject - -/// Whether the receiver has been disposed. -/// -/// Use of this property is discouraged, since it may be set to `YES` -/// concurrently at any time. -/// -/// This property is not KVO-compliant. -@property (atomic, assign, getter = isDisposed, readonly) BOOL disposed; - -+ (instancetype)disposableWithBlock:(void (^)(void))block; - -/// Performs the disposal work. Can be called multiple times, though subsequent -/// calls won't do anything. -- (void)dispose; - -/// Returns a new disposable which will dispose of this disposable when it gets -/// dealloc'd. -- (RACScopedDisposable *)asScopedDisposable; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACDisposable.m b/LookInside/ReactiveObjC/RACDisposable.m deleted file mode 100644 index 397e5858..00000000 --- a/LookInside/ReactiveObjC/RACDisposable.m +++ /dev/null @@ -1,82 +0,0 @@ -// -// RACDisposable.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/16/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACDisposable.h" -#import "RACScopedDisposable.h" -#import - -@interface RACDisposable () { - // A copied block of type void (^)(void) containing the logic for disposal, - // a pointer to `self` if no logic should be performed upon disposal, or - // NULL if the receiver is already disposed. - // - // This should only be used atomically. - _Atomic(void *) _disposeBlock; -} - -@end - -@implementation RACDisposable - -#pragma mark Properties - -- (BOOL)isDisposed { - return atomic_load_explicit(&_disposeBlock, memory_order_acquire) == NULL; -} - -#pragma mark Lifecycle - -- (instancetype)init { - self = [super init]; - - atomic_store_explicit(&_disposeBlock, (__bridge void *)self, memory_order_release); - - return self; -} - -- (instancetype)initWithBlock:(void (^)(void))block { - NSCParameterAssert(block != nil); - - self = [super init]; - - atomic_store_explicit(&_disposeBlock, (void *)CFBridgingRetain([block copy]), memory_order_release); - - return self; -} - -+ (instancetype)disposableWithBlock:(void (^)(void))block { - return [[self alloc] initWithBlock:block]; -} - -- (void)dealloc { - void *disposeBlockPtr = atomic_exchange_explicit(&_disposeBlock, NULL, memory_order_acq_rel); - if (disposeBlockPtr == NULL || disposeBlockPtr == (__bridge void *)self) return; - - CFRelease(disposeBlockPtr); -} - -#pragma mark Disposal - -- (void)dispose { - void (^disposeBlock)(void) = NULL; - - void *blockPtr = atomic_exchange_explicit(&_disposeBlock, NULL, memory_order_acq_rel); - if (blockPtr != NULL && blockPtr != (__bridge void *)self) { - disposeBlock = CFBridgingRelease(blockPtr); - } - - if (disposeBlock != nil) disposeBlock(); -} - -#pragma mark Scoped Disposables - -- (RACScopedDisposable *)asScopedDisposable { - return [RACScopedDisposable scopedDisposableWithDisposable:self]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACDynamicSequence.h b/LookInside/ReactiveObjC/RACDynamicSequence.h deleted file mode 100644 index 545ea18a..00000000 --- a/LookInside/ReactiveObjC/RACDynamicSequence.h +++ /dev/null @@ -1,20 +0,0 @@ -// -// RACDynamicSequence.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACSequence.h" - -// Private class that implements a sequence dynamically using blocks. -@interface RACDynamicSequence : RACSequence - -// Returns a sequence which evaluates `dependencyBlock` only once, the first -// time either `headBlock` or `tailBlock` is evaluated. The result of -// `dependencyBlock` will be passed into `headBlock` and `tailBlock` when -// invoked. -+ (RACSequence *)sequenceWithLazyDependency:(id (^)(void))dependencyBlock headBlock:(id (^)(id dependency))headBlock tailBlock:(RACSequence *(^)(id dependency))tailBlock; - -@end diff --git a/LookInside/ReactiveObjC/RACDynamicSequence.m b/LookInside/ReactiveObjC/RACDynamicSequence.m deleted file mode 100644 index c41b1f53..00000000 --- a/LookInside/ReactiveObjC/RACDynamicSequence.m +++ /dev/null @@ -1,197 +0,0 @@ -// -// RACDynamicSequence.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACDynamicSequence.h" -#import - -// Determines how RACDynamicSequences will be deallocated before the next one is -// shifted onto the autorelease pool. -// -// This avoids stack overflows when deallocating long chains of dynamic -// sequences. -#define DEALLOC_OVERFLOW_GUARD 100 - -@interface RACDynamicSequence () { - // The value for the "head" property, if it's been evaluated already. - // - // Because it's legal for head to be nil, this ivar is valid any time - // headBlock is nil. - // - // This ivar should only be accessed while synchronized on self. - id _head; - - // The value for the "tail" property, if it's been evaluated already. - // - // Because it's legal for tail to be nil, this ivar is valid any time - // tailBlock is nil. - // - // This ivar should only be accessed while synchronized on self. - RACSequence *_tail; - - // The result of an evaluated `dependencyBlock`. - // - // This ivar is valid any time `hasDependency` is YES and `dependencyBlock` - // is nil. - // - // This ivar should only be accessed while synchronized on self. - id _dependency; -} - -// A block used to evaluate head. This should be set to nil after `_head` has been -// initialized. -// -// This is marked `strong` instead of `copy` because of some bizarre block -// copying bug. See https://github.com/ReactiveCocoa/ReactiveCocoa/pull/506. -// -// The signature of this block varies based on the value of `hasDependency`: -// -// - If YES, this block is of type `id (^)(id)`. -// - If NO, this block is of type `id (^)(void)`. -// -// This property should only be accessed while synchronized on self. -@property (nonatomic, strong) id headBlock; - -// A block used to evaluate tail. This should be set to nil after `_tail` has been -// initialized. -// -// This is marked `strong` instead of `copy` because of some bizarre block -// copying bug. See https://github.com/ReactiveCocoa/ReactiveCocoa/pull/506. -// -// The signature of this block varies based on the value of `hasDependency`: -// -// - If YES, this block is of type `RACSequence * (^)(id)`. -// - If NO, this block is of type `RACSequence * (^)(void)`. -// -// This property should only be accessed while synchronized on self. -@property (nonatomic, strong) id tailBlock; - -// Whether the receiver was initialized with a `dependencyBlock`. -// -// This property should only be accessed while synchronized on self. -@property (nonatomic, assign) BOOL hasDependency; - -// A dependency which must be evaluated before `headBlock` and `tailBlock`. This -// should be set to nil after `_dependency` and `dependencyBlockExecuted` have -// been set. -// -// This is marked `strong` instead of `copy` because of some bizarre block -// copying bug. See https://github.com/ReactiveCocoa/ReactiveCocoa/pull/506. -// -// This property should only be accessed while synchronized on self. -@property (nonatomic, strong) id (^dependencyBlock)(void); - -@end - -@implementation RACDynamicSequence - -#pragma mark Lifecycle - -+ (RACSequence *)sequenceWithHeadBlock:(id (^)(void))headBlock tailBlock:(RACSequence *(^)(void))tailBlock { - NSCParameterAssert(headBlock != nil); - - RACDynamicSequence *seq = [[RACDynamicSequence alloc] init]; - seq.headBlock = [headBlock copy]; - seq.tailBlock = [tailBlock copy]; - seq.hasDependency = NO; - return seq; -} - -+ (RACSequence *)sequenceWithLazyDependency:(id (^)(void))dependencyBlock headBlock:(id (^)(id dependency))headBlock tailBlock:(RACSequence *(^)(id dependency))tailBlock { - NSCParameterAssert(dependencyBlock != nil); - NSCParameterAssert(headBlock != nil); - - RACDynamicSequence *seq = [[RACDynamicSequence alloc] init]; - seq.headBlock = [headBlock copy]; - seq.tailBlock = [tailBlock copy]; - seq.dependencyBlock = [dependencyBlock copy]; - seq.hasDependency = YES; - return seq; -} - -- (void)dealloc { - static atomic_int_least32_t directDeallocCount = 0; - - if (atomic_fetch_add_explicit(&directDeallocCount, 1, memory_order_relaxed) + 1 >= DEALLOC_OVERFLOW_GUARD) { - atomic_fetch_sub_explicit(&directDeallocCount, DEALLOC_OVERFLOW_GUARD, memory_order_relaxed); - - // Put this sequence's tail onto the autorelease pool so we stop - // recursing. - __autoreleasing RACSequence *tail __attribute__((unused)) = _tail; - } - - _tail = nil; -} - -#pragma mark RACSequence - -- (id)head { - @synchronized (self) { - id untypedHeadBlock = self.headBlock; - if (untypedHeadBlock == nil) return _head; - - if (self.hasDependency) { - if (self.dependencyBlock != nil) { - _dependency = self.dependencyBlock(); - self.dependencyBlock = nil; - } - - id (^headBlock)(id) = untypedHeadBlock; - _head = headBlock(_dependency); - } else { - id (^headBlock)(void) = untypedHeadBlock; - _head = headBlock(); - } - - self.headBlock = nil; - return _head; - } -} - -- (RACSequence *)tail { - @synchronized (self) { - id untypedTailBlock = self.tailBlock; - if (untypedTailBlock == nil) return _tail; - - if (self.hasDependency) { - if (self.dependencyBlock != nil) { - _dependency = self.dependencyBlock(); - self.dependencyBlock = nil; - } - - RACSequence * (^tailBlock)(id) = untypedTailBlock; - _tail = tailBlock(_dependency); - } else { - RACSequence * (^tailBlock)(void) = untypedTailBlock; - _tail = tailBlock(); - } - - if (_tail.name == nil) _tail.name = self.name; - - self.tailBlock = nil; - return _tail; - } -} - -#pragma mark NSObject - -- (NSString *)description { - id head = @"(unresolved)"; - id tail = @"(unresolved)"; - - @synchronized (self) { - if (self.headBlock == nil) head = _head; - if (self.tailBlock == nil) { - tail = _tail; - if (tail == self) tail = @"(self)"; - } - } - - return [NSString stringWithFormat:@"<%@: %p>{ name = %@, head = %@, tail = %@ }", self.class, self, self.name, head, tail]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACDynamicSignal.h b/LookInside/ReactiveObjC/RACDynamicSignal.h deleted file mode 100644 index cadd7bc0..00000000 --- a/LookInside/ReactiveObjC/RACDynamicSignal.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// RACDynamicSignal.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-10. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSignal.h" - -// A private `RACSignal` subclasses that implements its subscription behavior -// using a block. -@interface RACDynamicSignal : RACSignal - -+ (RACSignal *)createSignal:(RACDisposable * (^)(id subscriber))didSubscribe; - -@end diff --git a/LookInside/ReactiveObjC/RACDynamicSignal.m b/LookInside/ReactiveObjC/RACDynamicSignal.m deleted file mode 100644 index 59722d65..00000000 --- a/LookInside/ReactiveObjC/RACDynamicSignal.m +++ /dev/null @@ -1,54 +0,0 @@ -// -// RACDynamicSignal.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-10. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACDynamicSignal.h" -#import "RACEXTScope.h" -#import "RACCompoundDisposable.h" -#import "RACPassthroughSubscriber.h" -#import "RACScheduler+Private.h" -#import "RACSubscriber.h" -#import - -@interface RACDynamicSignal () - -// The block to invoke for each subscriber. -@property (nonatomic, copy, readonly) RACDisposable * (^didSubscribe)(id subscriber); - -@end - -@implementation RACDynamicSignal - -#pragma mark Lifecycle - -+ (RACSignal *)createSignal:(RACDisposable * (^)(id subscriber))didSubscribe { - RACDynamicSignal *signal = [[self alloc] init]; - signal->_didSubscribe = [didSubscribe copy]; - return [signal setNameWithFormat:@"+createSignal:"]; -} - -#pragma mark Managing Subscribers - -- (RACDisposable *)subscribe:(id)subscriber { - NSCParameterAssert(subscriber != nil); - - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - subscriber = [[RACPassthroughSubscriber alloc] initWithSubscriber:subscriber signal:self disposable:disposable]; - - if (self.didSubscribe != NULL) { - RACDisposable *schedulingDisposable = [RACScheduler.subscriptionScheduler schedule:^{ - RACDisposable *innerDisposable = self.didSubscribe(subscriber); - [disposable addDisposable:innerDisposable]; - }]; - - [disposable addDisposable:schedulingDisposable]; - } - - return disposable; -} - -@end diff --git a/LookInside/ReactiveObjC/RACEXTKeyPathCoding.h b/LookInside/ReactiveObjC/RACEXTKeyPathCoding.h deleted file mode 100644 index 996ce369..00000000 --- a/LookInside/ReactiveObjC/RACEXTKeyPathCoding.h +++ /dev/null @@ -1 +0,0 @@ -#import "extobjc/RACEXTKeyPathCoding.h" diff --git a/LookInside/ReactiveObjC/RACEXTRuntimeExtensions.h b/LookInside/ReactiveObjC/RACEXTRuntimeExtensions.h deleted file mode 100644 index 4730403d..00000000 --- a/LookInside/ReactiveObjC/RACEXTRuntimeExtensions.h +++ /dev/null @@ -1 +0,0 @@ -#import "extobjc/RACEXTRuntimeExtensions.h" diff --git a/LookInside/ReactiveObjC/RACEXTScope.h b/LookInside/ReactiveObjC/RACEXTScope.h deleted file mode 100644 index 493ac907..00000000 --- a/LookInside/ReactiveObjC/RACEXTScope.h +++ /dev/null @@ -1 +0,0 @@ -#import "extobjc/RACEXTScope.h" diff --git a/LookInside/ReactiveObjC/RACEagerSequence.h b/LookInside/ReactiveObjC/RACEagerSequence.h deleted file mode 100644 index 04ddf7e3..00000000 --- a/LookInside/ReactiveObjC/RACEagerSequence.h +++ /dev/null @@ -1,14 +0,0 @@ -// -// RACEagerSequence.h -// ReactiveObjC -// -// Created by Uri Baghin on 02/01/2013. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACArraySequence.h" - -// Private class that implements an eager sequence. -@interface RACEagerSequence : RACArraySequence - -@end diff --git a/LookInside/ReactiveObjC/RACEagerSequence.m b/LookInside/ReactiveObjC/RACEagerSequence.m deleted file mode 100644 index b1f94fc7..00000000 --- a/LookInside/ReactiveObjC/RACEagerSequence.m +++ /dev/null @@ -1,66 +0,0 @@ -// -// RACEagerSequence.m -// ReactiveObjC -// -// Created by Uri Baghin on 02/01/2013. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACEagerSequence.h" -#import "NSObject+RACDescription.h" -#import "RACArraySequence.h" - -@implementation RACEagerSequence - -#pragma mark RACStream - -+ (RACSequence *)return:(id)value { - return [[self sequenceWithArray:@[ value ] offset:0] setNameWithFormat:@"+return: %@", RACDescription(value)]; -} - -- (RACSequence *)bind:(RACSequenceBindBlock (^)(void))block { - NSCParameterAssert(block != nil); - RACStreamBindBlock bindBlock = block(); - NSArray *currentArray = self.array; - NSMutableArray *resultArray = [NSMutableArray arrayWithCapacity:currentArray.count]; - - for (id value in currentArray) { - BOOL stop = NO; - RACSequence *boundValue = (id)bindBlock(value, &stop); - if (boundValue == nil) break; - - for (id x in boundValue) { - [resultArray addObject:x]; - } - - if (stop) break; - } - - return [[self.class sequenceWithArray:resultArray offset:0] setNameWithFormat:@"[%@] -bind:", self.name]; -} - -- (RACSequence *)concat:(RACSequence *)sequence { - NSCParameterAssert(sequence != nil); - NSCParameterAssert([sequence isKindOfClass:RACSequence.class]); - - NSArray *array = [self.array arrayByAddingObjectsFromArray:sequence.array]; - return [[self.class sequenceWithArray:array offset:0] setNameWithFormat:@"[%@] -concat: %@", self.name, sequence]; -} - -#pragma mark Extended methods - -- (RACSequence *)eagerSequence { - return self; -} - -- (RACSequence *)lazySequence { - return [RACArraySequence sequenceWithArray:self.array offset:0]; -} - -- (id)foldRightWithStart:(id)start reduce:(id (^)(id, RACSequence *rest))reduce { - return [super foldRightWithStart:start reduce:^(id first, RACSequence *rest) { - return reduce(first, rest.eagerSequence); - }]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACEmptySequence.h b/LookInside/ReactiveObjC/RACEmptySequence.h deleted file mode 100644 index 1bb77086..00000000 --- a/LookInside/ReactiveObjC/RACEmptySequence.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// RACEmptySequence.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACSequence.h" - -// Private class representing an empty sequence. -@interface RACEmptySequence : RACSequence - -+ (RACEmptySequence *)empty; - -@end diff --git a/LookInside/ReactiveObjC/RACEmptySequence.m b/LookInside/ReactiveObjC/RACEmptySequence.m deleted file mode 100644 index bd3dc2e7..00000000 --- a/LookInside/ReactiveObjC/RACEmptySequence.m +++ /dev/null @@ -1,71 +0,0 @@ -// -// RACEmptySequence.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACEmptySequence.h" - -@implementation RACEmptySequence - -#pragma mark Lifecycle - -+ (instancetype)empty { - static id singleton; - static dispatch_once_t pred; - - dispatch_once(&pred, ^{ - singleton = [[self alloc] init]; - }); - - return singleton; -} - -#pragma mark RACSequence - -- (id)head { - return nil; -} - -- (RACSequence *)tail { - return nil; -} - -- (RACSequence *)bind:(RACStreamBindBlock)bindBlock passingThroughValuesFromSequence:(RACSequence *)passthroughSequence { - return passthroughSequence ?: self; -} - -#pragma mark NSCoding - -- (Class)classForCoder { - // Empty sequences should be encoded as themselves, not array sequences. - return self.class; -} - -- (instancetype)initWithCoder:(NSCoder *)coder { - // Return the singleton. - return self.class.empty; -} - -- (void)encodeWithCoder:(NSCoder *)coder { -} - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p>{ name = %@ }", self.class, self, self.name]; -} - -- (NSUInteger)hash { - // This hash isn't ideal, but it's better than -[RACSequence hash], which - // would just be zero because we have no head. - return (NSUInteger)(__bridge void *)self; -} - -- (BOOL)isEqual:(RACSequence *)seq { - return (self == seq); -} - -@end diff --git a/LookInside/ReactiveObjC/RACEmptySignal.h b/LookInside/ReactiveObjC/RACEmptySignal.h deleted file mode 100644 index dc534518..00000000 --- a/LookInside/ReactiveObjC/RACEmptySignal.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// RACEmptySignal.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-10. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSignal.h" - -// A private `RACSignal` subclasses that synchronously sends completed to any -// subscribers. -@interface RACEmptySignal<__covariant ValueType> : RACSignal - -+ (RACSignal *)empty; - -@end diff --git a/LookInside/ReactiveObjC/RACEmptySignal.m b/LookInside/ReactiveObjC/RACEmptySignal.m deleted file mode 100644 index d47ac273..00000000 --- a/LookInside/ReactiveObjC/RACEmptySignal.m +++ /dev/null @@ -1,62 +0,0 @@ -// -// RACEmptySignal.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-10. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACEmptySignal.h" -#import "RACScheduler+Private.h" -#import "RACSubscriber.h" - -@implementation RACEmptySignal - -#pragma mark Properties - -// Only allow this signal's name to be customized in DEBUG, since it's -// a singleton in release builds (see +empty). -- (void)setName:(NSString *)name { -#ifdef DEBUG - [super setName:name]; -#endif -} - -- (NSString *)name { -#ifdef DEBUG - return super.name; -#else - return @"+empty"; -#endif -} - -#pragma mark Lifecycle - -+ (RACSignal *)empty { -#ifdef DEBUG - // Create multiple instances of this class in DEBUG so users can set custom - // names on each. - return [[[self alloc] init] setNameWithFormat:@"+empty"]; -#else - static id singleton; - static dispatch_once_t pred; - - dispatch_once(&pred, ^{ - singleton = [[self alloc] init]; - }); - - return singleton; -#endif -} - -#pragma mark Subscription - -- (RACDisposable *)subscribe:(id)subscriber { - NSCParameterAssert(subscriber != nil); - - return [RACScheduler.subscriptionScheduler schedule:^{ - [subscriber sendCompleted]; - }]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACErrorSignal.h b/LookInside/ReactiveObjC/RACErrorSignal.h deleted file mode 100644 index 3a72d4d3..00000000 --- a/LookInside/ReactiveObjC/RACErrorSignal.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// RACErrorSignal.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-10. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSignal.h" - -// A private `RACSignal` subclass that synchronously sends an error to any -// subscriber. -@interface RACErrorSignal : RACSignal - -+ (RACSignal *)error:(NSError *)error; - -@end diff --git a/LookInside/ReactiveObjC/RACErrorSignal.m b/LookInside/ReactiveObjC/RACErrorSignal.m deleted file mode 100644 index 1d08a2f9..00000000 --- a/LookInside/ReactiveObjC/RACErrorSignal.m +++ /dev/null @@ -1,47 +0,0 @@ -// -// RACErrorSignal.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-10. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACErrorSignal.h" -#import "RACScheduler+Private.h" -#import "RACSubscriber.h" - -@interface RACErrorSignal () - -// The error to send upon subscription. -@property (nonatomic, strong, readonly) NSError *error; - -@end - -@implementation RACErrorSignal - -#pragma mark Lifecycle - -+ (RACSignal *)error:(NSError *)error { - RACErrorSignal *signal = [[self alloc] init]; - signal->_error = error; - -#ifdef DEBUG - [signal setNameWithFormat:@"+error: %@", error]; -#else - signal.name = @"+error:"; -#endif - - return signal; -} - -#pragma mark Subscription - -- (RACDisposable *)subscribe:(id)subscriber { - NSCParameterAssert(subscriber != nil); - - return [RACScheduler.subscriptionScheduler schedule:^{ - [subscriber sendError:self.error]; - }]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACEvent.h b/LookInside/ReactiveObjC/RACEvent.h deleted file mode 100644 index 68ba13c8..00000000 --- a/LookInside/ReactiveObjC/RACEvent.h +++ /dev/null @@ -1,55 +0,0 @@ -// -// RACEvent.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-01-07. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// Describes the type of a RACEvent. -/// -/// RACEventTypeCompleted - A `completed` event. -/// RACEventTypeError - An `error` event. -/// RACEventTypeNext - A `next` event. -typedef NS_ENUM(NSUInteger, RACEventType) { - RACEventTypeCompleted, - RACEventTypeError, - RACEventTypeNext -}; - -/// Represents an event sent by a RACSignal. -/// -/// This corresponds to the `Notification` class in Rx. -@interface RACEvent<__covariant ValueType> : NSObject - -/// Returns a singleton RACEvent representing the `completed` event. -+ (RACEvent *)completedEvent; - -/// Returns a new event of type RACEventTypeError, containing the given error. -+ (RACEvent *)eventWithError:(nullable NSError *)error; - -/// Returns a new event of type RACEventTypeNext, containing the given value. -+ (RACEvent *)eventWithValue:(nullable ValueType)value; - -/// The type of event represented by the receiver. -@property (nonatomic, assign, readonly) RACEventType eventType; - -/// Returns whether the receiver is of type RACEventTypeCompleted or -/// RACEventTypeError. -@property (nonatomic, getter = isFinished, assign, readonly) BOOL finished; - -/// The error associated with an event of type RACEventTypeError. This will be -/// nil for all other event types. -@property (nonatomic, strong, readonly, nullable) NSError *error; - -/// The value associated with an event of type RACEventTypeNext. This will be -/// nil for all other event types. -@property (nonatomic, strong, readonly, nullable) ValueType value; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACEvent.m b/LookInside/ReactiveObjC/RACEvent.m deleted file mode 100644 index 01f9fff2..00000000 --- a/LookInside/ReactiveObjC/RACEvent.m +++ /dev/null @@ -1,112 +0,0 @@ -// -// RACEvent.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-01-07. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACEvent.h" - -@interface RACEvent () - -// An object associated with this event. This will be used for the error and -// value properties. -@property (nonatomic, strong, readonly) id object; - -// Initializes the receiver with the given type and object. -- (instancetype)initWithEventType:(RACEventType)type object:(id)object; - -@end - -@implementation RACEvent - -#pragma mark Properties - -- (BOOL)isFinished { - return self.eventType == RACEventTypeCompleted || self.eventType == RACEventTypeError; -} - -- (NSError *)error { - return (self.eventType == RACEventTypeError ? self.object : nil); -} - -- (id)value { - return (self.eventType == RACEventTypeNext ? self.object : nil); -} - -#pragma mark Lifecycle - -+ (instancetype)completedEvent { - static dispatch_once_t pred; - static id singleton; - - dispatch_once(&pred, ^{ - singleton = [[self alloc] initWithEventType:RACEventTypeCompleted object:nil]; - }); - - return singleton; -} - -+ (instancetype)eventWithError:(NSError *)error { - return [[self alloc] initWithEventType:RACEventTypeError object:error]; -} - -+ (instancetype)eventWithValue:(id)value { - return [[self alloc] initWithEventType:RACEventTypeNext object:value]; -} - -- (instancetype)initWithEventType:(RACEventType)type object:(id)object { - self = [super init]; - - _eventType = type; - _object = object; - - return self; -} - -#pragma mark NSCopying - -- (id)copyWithZone:(NSZone *)zone { - return self; -} - -#pragma mark NSObject - -- (NSString *)description { - NSString *eventDescription = nil; - - switch (self.eventType) { - case RACEventTypeCompleted: - eventDescription = @"completed"; - break; - - case RACEventTypeError: - eventDescription = [NSString stringWithFormat:@"error = %@", self.object]; - break; - - case RACEventTypeNext: - eventDescription = [NSString stringWithFormat:@"next = %@", self.object]; - break; - - default: - NSCAssert(NO, @"Unrecognized event type: %i", (int)self.eventType); - } - - return [NSString stringWithFormat:@"<%@: %p>{ %@ }", self.class, self, eventDescription]; -} - -- (NSUInteger)hash { - return self.eventType ^ [self.object hash]; -} - -- (BOOL)isEqual:(id)event { - if (event == self) return YES; - if (![event isKindOfClass:RACEvent.class]) return NO; - if (self.eventType != [event eventType]) return NO; - - // Catches the nil case too. - return self.object == [event object] || [self.object isEqual:[event object]]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACGroupedSignal.h b/LookInside/ReactiveObjC/RACGroupedSignal.h deleted file mode 100644 index 06680762..00000000 --- a/LookInside/ReactiveObjC/RACGroupedSignal.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// RACGroupedSignal.h -// ReactiveObjC -// -// Created by Josh Abernathy on 5/2/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSubject.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A grouped signal is used by -[RACSignal groupBy:transform:]. -@interface RACGroupedSignal : RACSubject - -/// The key shared by the group. -@property (nonatomic, readonly, copy) id key; - -+ (instancetype)signalWithKey:(id)key; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACGroupedSignal.m b/LookInside/ReactiveObjC/RACGroupedSignal.m deleted file mode 100644 index a391ef25..00000000 --- a/LookInside/ReactiveObjC/RACGroupedSignal.m +++ /dev/null @@ -1,25 +0,0 @@ -// -// RACGroupedSignal.m -// ReactiveObjC -// -// Created by Josh Abernathy on 5/2/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACGroupedSignal.h" - -@interface RACGroupedSignal () -@property (nonatomic, copy) id key; -@end - -@implementation RACGroupedSignal - -#pragma mark API - -+ (instancetype)signalWithKey:(id)key { - RACGroupedSignal *subject = [self subject]; - subject.key = key; - return subject; -} - -@end diff --git a/LookInside/ReactiveObjC/RACImmediateScheduler.h b/LookInside/ReactiveObjC/RACImmediateScheduler.h deleted file mode 100644 index edafe062..00000000 --- a/LookInside/ReactiveObjC/RACImmediateScheduler.h +++ /dev/null @@ -1,14 +0,0 @@ -// -// RACImmediateScheduler.h -// ReactiveObjC -// -// Created by Josh Abernathy on 11/30/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACScheduler.h" - -// A private scheduler which immediately executes its scheduled blocks. -@interface RACImmediateScheduler : RACScheduler - -@end diff --git a/LookInside/ReactiveObjC/RACImmediateScheduler.m b/LookInside/ReactiveObjC/RACImmediateScheduler.m deleted file mode 100644 index 7237801c..00000000 --- a/LookInside/ReactiveObjC/RACImmediateScheduler.m +++ /dev/null @@ -1,54 +0,0 @@ -// -// RACImmediateScheduler.m -// ReactiveObjC -// -// Created by Josh Abernathy on 11/30/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACImmediateScheduler.h" -#import "RACScheduler+Private.h" - -@implementation RACImmediateScheduler - -#pragma mark Lifecycle - -- (instancetype)init { - return [super initWithName:@"org.reactivecocoa.ReactiveObjC.RACScheduler.immediateScheduler"]; -} - -#pragma mark RACScheduler - -- (RACDisposable *)schedule:(void (^)(void))block { - NSCParameterAssert(block != NULL); - - block(); - return nil; -} - -- (RACDisposable *)after:(NSDate *)date schedule:(void (^)(void))block { - NSCParameterAssert(date != nil); - NSCParameterAssert(block != NULL); - - [NSThread sleepUntilDate:date]; - block(); - - return nil; -} - -- (RACDisposable *)after:(NSDate *)date repeatingEvery:(NSTimeInterval)interval withLeeway:(NSTimeInterval)leeway schedule:(void (^)(void))block { - NSCAssert(NO, @"+[RACScheduler immediateScheduler] does not support %@.", NSStringFromSelector(_cmd)); - return nil; -} - -- (RACDisposable *)scheduleRecursiveBlock:(RACSchedulerRecursiveBlock)recursiveBlock { - for (__block NSUInteger remaining = 1; remaining > 0; remaining--) { - recursiveBlock(^{ - remaining++; - }); - } - - return nil; -} - -@end diff --git a/LookInside/ReactiveObjC/RACIndexSetSequence.h b/LookInside/ReactiveObjC/RACIndexSetSequence.h deleted file mode 100644 index c5a5a9ab..00000000 --- a/LookInside/ReactiveObjC/RACIndexSetSequence.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// RACIndexSetSequence.h -// ReactiveObjC -// -// Created by Sergey Gavrilyuk on 12/18/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSequence.h" - -// Private class that adapts an array to the RACSequence interface. -@interface RACIndexSetSequence : RACSequence - -+ (RACSequence *)sequenceWithIndexSet:(NSIndexSet *)indexSet; - -@end diff --git a/LookInside/ReactiveObjC/RACIndexSetSequence.m b/LookInside/ReactiveObjC/RACIndexSetSequence.m deleted file mode 100644 index 8931c6ce..00000000 --- a/LookInside/ReactiveObjC/RACIndexSetSequence.m +++ /dev/null @@ -1,111 +0,0 @@ -// -// RACIndexSetSequence.m -// ReactiveObjC -// -// Created by Sergey Gavrilyuk on 12/18/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACIndexSetSequence.h" - -@interface RACIndexSetSequence () - -// A buffer holding the `NSUInteger` values to enumerate over. -// -// This is mostly used for memory management. Most access should go through -// `indexes` instead. -@property (nonatomic, strong, readonly) NSData *data; - -// The indexes that this sequence should enumerate. -@property (nonatomic, readonly) const NSUInteger *indexes; - -// The number of indexes to enumerate. -@property (nonatomic, readonly) NSUInteger count; - -@end - -@implementation RACIndexSetSequence - -#pragma mark Lifecycle - -+ (RACSequence *)sequenceWithIndexSet:(NSIndexSet *)indexSet { - NSUInteger count = indexSet.count; - - if (count == 0) return self.empty; - - NSUInteger sizeInBytes = sizeof(NSUInteger) * count; - - NSMutableData *data = [[NSMutableData alloc] initWithCapacity:sizeInBytes]; - [indexSet getIndexes:data.mutableBytes maxCount:count inIndexRange:NULL]; - - RACIndexSetSequence *seq = [[self alloc] init]; - seq->_data = data; - seq->_indexes = data.bytes; - seq->_count = count; - return seq; -} - -+ (instancetype)sequenceWithIndexSetSequence:(RACIndexSetSequence *)indexSetSequence offset:(NSUInteger)offset { - NSCParameterAssert(offset < indexSetSequence.count); - - RACIndexSetSequence *seq = [[self alloc] init]; - seq->_data = indexSetSequence.data; - seq->_indexes = indexSetSequence.indexes + offset; - seq->_count = indexSetSequence.count - offset; - return seq; -} - -#pragma mark RACSequence - -- (id)head { - return @(self.indexes[0]); -} - -- (RACSequence *)tail { - if (self.count <= 1) return [RACSequence empty]; - - return [self.class sequenceWithIndexSetSequence:self offset:1]; -} - -#pragma mark NSFastEnumeration - -- (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state objects:(__unsafe_unretained id[])stackbuf count:(NSUInteger)len { - NSCParameterAssert(len > 0); - - if (state->state >= self.count) { - // Enumeration has completed. - return 0; - } - - if (state->state == 0) { - // Enumeration begun, mark the mutation flag. - state->mutationsPtr = state->extra; - } - - state->itemsPtr = stackbuf; - - unsigned long index = 0; - while (index < MIN(self.count - state->state, len)) { - stackbuf[index] = @(self.indexes[index + state->state]); - ++index; - } - - state->state += index; - return index; -} - -#pragma mark NSObject - -- (NSString *)description { - NSMutableString *indexesStr = [NSMutableString string]; - - for (unsigned int i = 0; i < self.count; ++i) { - if (i > 0) [indexesStr appendString:@", "]; - - [indexesStr appendFormat:@"%lu", (unsigned long)self.indexes[i]]; - } - - return [NSString stringWithFormat:@"<%@: %p>{ name = %@, indexes = %@ }", self.class, self, self.name, indexesStr]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACKVOChannel.h b/LookInside/ReactiveObjC/RACKVOChannel.h deleted file mode 100644 index 7816e368..00000000 --- a/LookInside/ReactiveObjC/RACKVOChannel.h +++ /dev/null @@ -1,107 +0,0 @@ -// -// RACKVOChannel.h -// ReactiveObjC -// -// Created by Uri Baghin on 27/12/2012. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACChannel.h" -#import "RACEXTKeyPathCoding.h" -#import "RACmetamacros.h" - -/// Creates a RACKVOChannel to the given key path. When the targeted object -/// deallocates, the channel will complete. -/// -/// If RACChannelTo() is used as an expression, it returns a RACChannelTerminal that -/// can be used to watch the specified property for changes, and set new values -/// for it. The terminal will start with the property's current value upon -/// subscription. -/// -/// If RACChannelTo() is used on the left-hand side of an assignment, there must a -/// RACChannelTerminal on the right-hand side of the assignment. The two will be -/// subscribed to one another: the property's value is immediately set to the -/// value of the channel terminal on the right-hand side, and subsequent changes -/// to either terminal will be reflected on the other. -/// -/// There are two different versions of this macro: -/// -/// - RACChannelTo(TARGET, KEYPATH, NILVALUE) will create a channel to the `KEYPATH` -/// of `TARGET`. If the terminal is ever sent a `nil` value, the property will -/// be set to `NILVALUE` instead. `NILVALUE` may itself be `nil` for object -/// properties, but an NSValue should be used for primitive properties, to -/// avoid an exception if `nil` is sent (which might occur if an intermediate -/// object is set to `nil`). -/// - RACChannelTo(TARGET, KEYPATH) is the same as the above, but `NILVALUE` defaults to -/// `nil`. -/// -/// Examples -/// -/// RACChannelTerminal *integerChannel = RACChannelTo(self, integerProperty, @42); -/// -/// // Sets self.integerProperty to 5. -/// [integerChannel sendNext:@5]; -/// -/// // Logs the current value of self.integerProperty, and all future changes. -/// [integerChannel subscribeNext:^(id value) { -/// NSLog(@"value: %@", value); -/// }]; -/// -/// // Binds properties to each other, taking the initial value from the right -/// side. -/// RACChannelTo(view, objectProperty) = RACChannelTo(model, objectProperty); -/// RACChannelTo(view, integerProperty, @2) = RACChannelTo(model, integerProperty, @10); -#define RACChannelTo(TARGET, ...) \ - metamacro_if_eq(1, metamacro_argcount(__VA_ARGS__)) \ - (RACChannelTo_(TARGET, __VA_ARGS__, nil)) \ - (RACChannelTo_(TARGET, __VA_ARGS__)) - -/// Do not use this directly. Use the RACChannelTo macro above. -#define RACChannelTo_(TARGET, KEYPATH, NILVALUE) \ - [[RACKVOChannel alloc] initWithTarget:(TARGET) keyPath:@keypath(TARGET, KEYPATH) nilValue:(NILVALUE)][@keypath(RACKVOChannel.new, followingTerminal)] - -NS_ASSUME_NONNULL_BEGIN - -/// A RACChannel that observes a KVO-compliant key path for changes. -@interface RACKVOChannel : RACChannel - -/// Initializes a channel that will observe the given object and key path. -/// -/// The current value of the key path, and future KVO notifications for the given -/// key path, will be sent to subscribers of the channel's `followingTerminal`. -/// Values sent to the `followingTerminal` will be set at the given key path using -/// key-value coding. -/// -/// When the target object deallocates, the channel will complete. Signal errors -/// are considered undefined behavior. -/// -/// This is the designated initializer for this class. -/// -/// target - The object to bind to. -/// keyPath - The key path to observe and set the value of. -/// nilValue - The value to set at the key path whenever a `nil` value is -/// received. This may be nil when connecting to object properties, but -/// an NSValue should be used for primitive properties, to avoid an -/// exception if `nil` is received (which might occur if an intermediate -/// object is set to `nil`). -#if OS_OBJECT_HAVE_OBJC_SUPPORT -- (instancetype)initWithTarget:(__weak NSObject *)target keyPath:(NSString *)keyPath nilValue:(nullable ValueType)nilValue; -#else -// Swift builds with OS_OBJECT_HAVE_OBJC_SUPPORT=0 for Playgrounds and LLDB :( -- (instancetype)initWithTarget:(NSObject *)target keyPath:(NSString *)keyPath nilValue:(nullable ValueType)nilValue; -#endif - -- (instancetype)init __attribute__((unavailable("Use -initWithTarget:keyPath:nilValue: instead"))); - -@end - -/// Methods needed for the convenience macro. Do not call explicitly. -@interface RACKVOChannel (RACChannelTo) - -- (RACChannelTerminal *)objectForKeyedSubscript:(NSString *)key; -- (void)setObject:(RACChannelTerminal *)otherTerminal forKeyedSubscript:(NSString *)key; - -@end - -NS_ASSUME_NONNULL_END - diff --git a/LookInside/ReactiveObjC/RACKVOChannel.m b/LookInside/ReactiveObjC/RACKVOChannel.m deleted file mode 100644 index 1b62ef7b..00000000 --- a/LookInside/ReactiveObjC/RACKVOChannel.m +++ /dev/null @@ -1,207 +0,0 @@ -// -// RACKVOChannel.m -// ReactiveObjC -// -// Created by Uri Baghin on 27/12/2012. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACKVOChannel.h" -#import "RACEXTScope.h" -#import "NSObject+RACDeallocating.h" -#import "NSObject+RACKVOWrapper.h" -#import "NSString+RACKeyPathUtilities.h" -#import "RACChannel.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACSignal+Operations.h" - -// Key for the array of RACKVOChannel's additional thread local -// data in the thread dictionary. -static NSString * const RACKVOChannelDataDictionaryKey = @"RACKVOChannelKey"; - -// Wrapper class for additional thread local data. -@interface RACKVOChannelData : NSObject - -// The flag used to ignore updates the channel itself has triggered. -@property (nonatomic, assign) BOOL ignoreNextUpdate; - -// A pointer to the owner of the data. Only use this for pointer comparison, -// never as an object reference. -@property (nonatomic, assign) void *owner; - -+ (instancetype)dataForChannel:(RACKVOChannel *)channel; - -@end - -@interface RACKVOChannel () - -// The object whose key path the channel is wrapping. -@property (atomic, weak) NSObject *target; - -// The key path the channel is wrapping. -@property (nonatomic, copy, readonly) NSString *keyPath; - -// Returns the existing thread local data container or nil if none exists. -@property (nonatomic, strong, readonly) RACKVOChannelData *currentThreadData; - -// Creates the thread local data container for the channel. -- (void)createCurrentThreadData; - -// Destroy the thread local data container for the channel. -- (void)destroyCurrentThreadData; - -@end - -@implementation RACKVOChannel - -#pragma mark Properties - -- (RACKVOChannelData *)currentThreadData { - NSMutableArray *dataArray = NSThread.currentThread.threadDictionary[RACKVOChannelDataDictionaryKey]; - - for (RACKVOChannelData *data in dataArray) { - if (data.owner == (__bridge void *)self) return data; - } - - return nil; -} - -#pragma mark Lifecycle - -- (instancetype)initWithTarget:(__weak NSObject *)target keyPath:(NSString *)keyPath nilValue:(id)nilValue { - NSCParameterAssert(keyPath.rac_keyPathComponents.count > 0); - - NSObject *strongTarget = target; - - self = [super init]; - - _target = target; - _keyPath = [keyPath copy]; - - [self.leadingTerminal setNameWithFormat:@"[-initWithTarget: %@ keyPath: %@ nilValue: %@] -leadingTerminal", target, keyPath, nilValue]; - [self.followingTerminal setNameWithFormat:@"[-initWithTarget: %@ keyPath: %@ nilValue: %@] -followingTerminal", target, keyPath, nilValue]; - - if (strongTarget == nil) { - [self.leadingTerminal sendCompleted]; - return self; - } - - // Observe the key path on target for changes and forward the changes to the - // terminal. - // - // Intentionally capturing `self` strongly in the blocks below, so the - // channel object stays alive while observing. - RACDisposable *observationDisposable = [strongTarget rac_observeKeyPath:keyPath options:NSKeyValueObservingOptionInitial observer:nil block:^(id value, NSDictionary *change, BOOL causedByDealloc, BOOL affectedOnlyLastComponent) { - // If the change wasn't triggered by deallocation, only affects the last - // path component, and ignoreNextUpdate is set, then it was triggered by - // this channel and should not be forwarded. - if (!causedByDealloc && affectedOnlyLastComponent && self.currentThreadData.ignoreNextUpdate) { - [self destroyCurrentThreadData]; - return; - } - - [self.leadingTerminal sendNext:value]; - }]; - - NSString *keyPathByDeletingLastKeyPathComponent = keyPath.rac_keyPathByDeletingLastKeyPathComponent; - NSArray *keyPathComponents = keyPath.rac_keyPathComponents; - NSUInteger keyPathComponentsCount = keyPathComponents.count; - NSString *lastKeyPathComponent = keyPathComponents.lastObject; - - // Update the value of the property with the values received. - [[self.leadingTerminal - finally:^{ - [observationDisposable dispose]; - }] - subscribeNext:^(id x) { - // Check the value of the second to last key path component. Since the - // channel can only update the value of a property on an object, and not - // update intermediate objects, it can only update the value of the whole - // key path if this object is not nil. - NSObject *object = (keyPathComponentsCount > 1 ? [self.target valueForKeyPath:keyPathByDeletingLastKeyPathComponent] : self.target); - if (object == nil) return; - - // Set the ignoreNextUpdate flag before setting the value so this channel - // ignores the value in the subsequent -didChangeValueForKey: callback. - [self createCurrentThreadData]; - self.currentThreadData.ignoreNextUpdate = YES; - - [object setValue:x ?: nilValue forKey:lastKeyPathComponent]; - } error:^(NSError *error) { - NSCAssert(NO, @"Received error in %@: %@", self, error); - - // Log the error if we're running with assertions disabled. - NSLog(@"Received error in %@: %@", self, error); - }]; - - // Capture `self` weakly for the target's deallocation disposable, so we can - // freely deallocate if we complete before then. - @weakify(self); - - [strongTarget.rac_deallocDisposable addDisposable:[RACDisposable disposableWithBlock:^{ - @strongify(self); - [self.leadingTerminal sendCompleted]; - self.target = nil; - }]]; - - return self; -} - -- (void)createCurrentThreadData { - NSMutableArray *dataArray = NSThread.currentThread.threadDictionary[RACKVOChannelDataDictionaryKey]; - if (dataArray == nil) { - dataArray = [NSMutableArray array]; - NSThread.currentThread.threadDictionary[RACKVOChannelDataDictionaryKey] = dataArray; - [dataArray addObject:[RACKVOChannelData dataForChannel:self]]; - return; - } - - for (RACKVOChannelData *data in dataArray) { - if (data.owner == (__bridge void *)self) return; - } - - [dataArray addObject:[RACKVOChannelData dataForChannel:self]]; -} - -- (void)destroyCurrentThreadData { - NSMutableArray *dataArray = NSThread.currentThread.threadDictionary[RACKVOChannelDataDictionaryKey]; - NSUInteger index = [dataArray indexOfObjectPassingTest:^ BOOL (RACKVOChannelData *data, NSUInteger idx, BOOL *stop) { - return data.owner == (__bridge void *)self; - }]; - - if (index != NSNotFound) [dataArray removeObjectAtIndex:index]; -} - -@end - -@implementation RACKVOChannel (RACChannelTo) - -- (RACChannelTerminal *)objectForKeyedSubscript:(NSString *)key { - NSCParameterAssert(key != nil); - - RACChannelTerminal *terminal = [self valueForKey:key]; - NSCAssert([terminal isKindOfClass:RACChannelTerminal.class], @"Key \"%@\" does not identify a channel terminal", key); - - return terminal; -} - -- (void)setObject:(RACChannelTerminal *)otherTerminal forKeyedSubscript:(NSString *)key { - NSCParameterAssert(otherTerminal != nil); - - RACChannelTerminal *selfTerminal = [self objectForKeyedSubscript:key]; - [otherTerminal subscribe:selfTerminal]; - [[selfTerminal skip:1] subscribe:otherTerminal]; -} - -@end - -@implementation RACKVOChannelData - -+ (instancetype)dataForChannel:(RACKVOChannel *)channel { - RACKVOChannelData *data = [[self alloc] init]; - data->_owner = (__bridge void *)channel; - return data; -} - -@end diff --git a/LookInside/ReactiveObjC/RACKVOProxy.h b/LookInside/ReactiveObjC/RACKVOProxy.h deleted file mode 100644 index 7e497936..00000000 --- a/LookInside/ReactiveObjC/RACKVOProxy.h +++ /dev/null @@ -1,34 +0,0 @@ -// -// RACKVOProxy.h -// ReactiveObjC -// -// Created by Richard Speyer on 4/10/14. -// Copyright (c) 2014 GitHub, Inc. All rights reserved. -// - -#import - -/// A singleton that can act as a proxy between a KVO observation and a RAC -/// subscriber, in order to protect against KVO lifetime issues. -@interface RACKVOProxy : NSObject - -/// Returns the singleton KVO proxy object. -+ (instancetype)sharedProxy; - -/// Registers an observer with the proxy, such that when the proxy receives a -/// KVO change with the given context, it forwards it to the observer. -/// -/// observer - True observer of the KVO change. Must not be nil. -/// context - Arbitrary context object used to differentiate multiple -/// observations of the same keypath. Must be unique, cannot be nil. -- (void)addObserver:(__weak NSObject *)observer forContext:(void *)context; - -/// Removes an observer from the proxy. Parameters must match those passed to -/// addObserver:forContext:. -/// -/// observer - True observer of the KVO change. Must not be nil. -/// context - Arbitrary context object used to differentiate multiple -/// observations of the same keypath. Must be unique, cannot be nil. -- (void)removeObserver:(NSObject *)observer forContext:(void *)context; - -@end diff --git a/LookInside/ReactiveObjC/RACKVOProxy.m b/LookInside/ReactiveObjC/RACKVOProxy.m deleted file mode 100644 index 706b3a10..00000000 --- a/LookInside/ReactiveObjC/RACKVOProxy.m +++ /dev/null @@ -1,69 +0,0 @@ -// -// RACKVOProxy.m -// ReactiveObjC -// -// Created by Richard Speyer on 4/10/14. -// Copyright (c) 2014 GitHub, Inc. All rights reserved. -// - -#import "RACKVOProxy.h" - -@interface RACKVOProxy() - -@property (strong, nonatomic, readonly) NSMapTable *trampolines; -@property (strong, nonatomic, readonly) dispatch_queue_t queue; - -@end - -@implementation RACKVOProxy - -+ (instancetype)sharedProxy { - static RACKVOProxy *proxy; - static dispatch_once_t onceToken; - - dispatch_once(&onceToken, ^{ - proxy = [[self alloc] init]; - }); - - return proxy; -} - -- (instancetype)init { - self = [super init]; - - _queue = dispatch_queue_create("org.reactivecocoa.ReactiveObjC.RACKVOProxy", DISPATCH_QUEUE_SERIAL); - _trampolines = [NSMapTable strongToWeakObjectsMapTable]; - - return self; -} - -- (void)addObserver:(__weak NSObject *)observer forContext:(void *)context { - NSValue *valueContext = [NSValue valueWithPointer:context]; - - dispatch_sync(self.queue, ^{ - [self.trampolines setObject:observer forKey:valueContext]; - }); -} - -- (void)removeObserver:(NSObject *)observer forContext:(void *)context { - NSValue *valueContext = [NSValue valueWithPointer:context]; - - dispatch_sync(self.queue, ^{ - [self.trampolines removeObjectForKey:valueContext]; - }); -} - -- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { - NSValue *valueContext = [NSValue valueWithPointer:context]; - __block NSObject *trueObserver; - - dispatch_sync(self.queue, ^{ - trueObserver = [self.trampolines objectForKey:valueContext]; - }); - - if (trueObserver != nil) { - [trueObserver observeValueForKeyPath:keyPath ofObject:object change:change context:context]; - } -} - -@end diff --git a/LookInside/ReactiveObjC/RACKVOTrampoline.h b/LookInside/ReactiveObjC/RACKVOTrampoline.h deleted file mode 100644 index be44fe95..00000000 --- a/LookInside/ReactiveObjC/RACKVOTrampoline.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// RACKVOTrampoline.h -// ReactiveObjC -// -// Created by Josh Abernathy on 1/15/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import -#import "NSObject+RACKVOWrapper.h" -#import "RACDisposable.h" - -// A private trampoline object that represents a KVO observation. -// -// Disposing of the trampoline will stop observation. -@interface RACKVOTrampoline : RACDisposable - -// Initializes the receiver with the given parameters. -// -// target - The object whose key path should be observed. Cannot be nil. -// observer - The object that gets notified when the value at the key path -// changes. Can be nil. -// keyPath - The key path on `target` to observe. Cannot be nil. -// options - Any key value observing options to use in the observation. -// block - The block to call when the value at the observed key path changes. -// Cannot be nil. -// -// Returns the initialized object. -- (instancetype)initWithTarget:(__weak NSObject *)target observer:(__weak NSObject *)observer keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(RACKVOBlock)block; - -@end diff --git a/LookInside/ReactiveObjC/RACKVOTrampoline.m b/LookInside/ReactiveObjC/RACKVOTrampoline.m deleted file mode 100644 index 22428d84..00000000 --- a/LookInside/ReactiveObjC/RACKVOTrampoline.m +++ /dev/null @@ -1,109 +0,0 @@ -// -// RACKVOTrampoline.m -// ReactiveObjC -// -// Created by Josh Abernathy on 1/15/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACKVOTrampoline.h" -#import "NSObject+RACDeallocating.h" -#import "RACCompoundDisposable.h" -#import "RACKVOProxy.h" - -@interface RACKVOTrampoline () - -// The keypath which the trampoline is observing. -@property (nonatomic, readonly, copy) NSString *keyPath; - -// These properties should only be manipulated while synchronized on the -// receiver. -@property (nonatomic, readonly, copy) RACKVOBlock block; -@property (nonatomic, readonly, unsafe_unretained) NSObject *unsafeTarget; -@property (nonatomic, readonly, weak) NSObject *weakTarget; -@property (nonatomic, readonly, weak) NSObject *observer; - -@end - -@implementation RACKVOTrampoline - -#pragma mark Lifecycle - -- (instancetype)initWithTarget:(__weak NSObject *)target observer:(__weak NSObject *)observer keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(RACKVOBlock)block { - NSCParameterAssert(keyPath != nil); - NSCParameterAssert(block != nil); - - NSObject *strongTarget = target; - if (strongTarget == nil) return nil; - - self = [super init]; - - _keyPath = [keyPath copy]; - - _block = [block copy]; - _weakTarget = target; - _unsafeTarget = strongTarget; - _observer = observer; - - [RACKVOProxy.sharedProxy addObserver:self forContext:(__bridge void *)self]; - [strongTarget addObserver:RACKVOProxy.sharedProxy forKeyPath:self.keyPath options:options context:(__bridge void *)self]; - - [strongTarget.rac_deallocDisposable addDisposable:self]; - [self.observer.rac_deallocDisposable addDisposable:self]; - - return self; -} - -- (void)dealloc { - [self dispose]; -} - -#pragma mark Observation - -- (void)dispose { - NSObject *target; - NSObject *observer; - - @synchronized (self) { - _block = nil; - - // The target should still exist at this point, because we still need to - // tear down its KVO observation. Therefore, we can use the unsafe - // reference (and need to, because the weak one will have been zeroed by - // now). - target = self.unsafeTarget; - observer = self.observer; - - _unsafeTarget = nil; - _observer = nil; - } - - [target.rac_deallocDisposable removeDisposable:self]; - [observer.rac_deallocDisposable removeDisposable:self]; - - [target removeObserver:RACKVOProxy.sharedProxy forKeyPath:self.keyPath context:(__bridge void *)self]; - [RACKVOProxy.sharedProxy removeObserver:self forContext:(__bridge void *)self]; -} - -- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { - if (context != (__bridge void *)self) { - [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; - return; - } - - RACKVOBlock block; - id observer; - id target; - - @synchronized (self) { - block = self.block; - observer = self.observer; - target = self.weakTarget; - } - - if (block == nil || target == nil) return; - - block(target, observer, change); -} - -@end diff --git a/LookInside/ReactiveObjC/RACMulticastConnection+Private.h b/LookInside/ReactiveObjC/RACMulticastConnection+Private.h deleted file mode 100644 index 9b150f1b..00000000 --- a/LookInside/ReactiveObjC/RACMulticastConnection+Private.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// RACMulticastConnection+Private.h -// ReactiveObjC -// -// Created by Josh Abernathy on 4/11/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACMulticastConnection.h" - -@class RACSubject; - -@interface RACMulticastConnection<__covariant ValueType> () - -- (instancetype)initWithSourceSignal:(RACSignal *)source subject:(RACSubject *)subject; - -@end diff --git a/LookInside/ReactiveObjC/RACMulticastConnection.h b/LookInside/ReactiveObjC/RACMulticastConnection.h deleted file mode 100644 index 7b7dbf82..00000000 --- a/LookInside/ReactiveObjC/RACMulticastConnection.h +++ /dev/null @@ -1,53 +0,0 @@ -// -// RACMulticastConnection.h -// ReactiveObjC -// -// Created by Josh Abernathy on 4/11/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import -#import "RACAnnotations.h" - -@class RACDisposable; -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -/// A multicast connection encapsulates the idea of sharing one subscription to a -/// signal to many subscribers. This is most often needed if the subscription to -/// the underlying signal involves side-effects or shouldn't be called more than -/// once. -/// -/// The multicasted signal is only subscribed to when -/// -[RACMulticastConnection connect] is called. Until that happens, no values -/// will be sent on `signal`. See -[RACMulticastConnection autoconnect] for how -/// -[RACMulticastConnection connect] can be called automatically. -/// -/// Note that you shouldn't create RACMulticastConnection manually. Instead use -/// -[RACSignal publish] or -[RACSignal multicast:]. -@interface RACMulticastConnection<__covariant ValueType> : NSObject - -/// The multicasted signal. -@property (nonatomic, strong, readonly) RACSignal *signal; - -/// Connect to the underlying signal by subscribing to it. Calling this multiple -/// times does nothing but return the existing connection's disposable. -/// -/// Returns the disposable for the subscription to the multicasted signal. -- (RACDisposable *)connect; - -/// Connects to the underlying signal when the returned signal is first -/// subscribed to, and disposes of the subscription to the multicasted signal -/// when the returned signal has no subscribers. -/// -/// If new subscribers show up after being disposed, they'll subscribe and then -/// be immediately disposed of. The returned signal will never re-connect to the -/// multicasted signal. -/// -/// Returns the autoconnecting signal. -- (RACSignal *)autoconnect RAC_WARN_UNUSED_RESULT; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACMulticastConnection.m b/LookInside/ReactiveObjC/RACMulticastConnection.m deleted file mode 100644 index e598364f..00000000 --- a/LookInside/ReactiveObjC/RACMulticastConnection.m +++ /dev/null @@ -1,86 +0,0 @@ -// -// RACMulticastConnection.m -// ReactiveObjC -// -// Created by Josh Abernathy on 4/11/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACMulticastConnection.h" -#import "RACMulticastConnection+Private.h" -#import "RACDisposable.h" -#import "RACSerialDisposable.h" -#import "RACSubject.h" -#import - -@interface RACMulticastConnection () { - RACSubject *_signal; - - // When connecting, a caller should attempt to atomically swap the value of this - // from `0` to `1`. - // - // If the swap is successful the caller is resposible for subscribing `_signal` - // to `sourceSignal` and storing the returned disposable in `serialDisposable`. - // - // If the swap is unsuccessful it means that `_sourceSignal` has already been - // connected and the caller has no action to take. - _Atomic(int32_t) _hasConnected; -} - -@property (nonatomic, readonly, strong) RACSignal *sourceSignal; -@property (strong) RACSerialDisposable *serialDisposable; -@end - -@implementation RACMulticastConnection - -#pragma mark Lifecycle - -- (instancetype)initWithSourceSignal:(RACSignal *)source subject:(RACSubject *)subject { - NSCParameterAssert(source != nil); - NSCParameterAssert(subject != nil); - - self = [super init]; - - _sourceSignal = source; - _serialDisposable = [[RACSerialDisposable alloc] init]; - _signal = subject; - - return self; -} - -#pragma mark Connecting - -- (RACDisposable *)connect { - int32_t expected = 0; - BOOL shouldConnect = atomic_compare_exchange_strong_explicit(&_hasConnected, &expected, 1, memory_order_acq_rel, memory_order_acquire); - - if (shouldConnect) { - self.serialDisposable.disposable = [self.sourceSignal subscribe:_signal]; - } - - return self.serialDisposable; -} - -- (RACSignal *)autoconnect { - __block atomic_int_least32_t subscriberCount; - atomic_init(&subscriberCount, 0); - - return [[RACSignal - createSignal:^(id subscriber) { - atomic_fetch_add_explicit(&subscriberCount, 1, memory_order_acq_rel); - - RACDisposable *subscriptionDisposable = [self.signal subscribe:subscriber]; - RACDisposable *connectionDisposable = [self connect]; - - return [RACDisposable disposableWithBlock:^{ - [subscriptionDisposable dispose]; - - if (atomic_fetch_sub_explicit(&subscriberCount, 1, memory_order_acq_rel) == 1) { - [connectionDisposable dispose]; - } - }]; - }] - setNameWithFormat:@"[%@] -autoconnect", self.signal.name]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACPassthroughSubscriber.h b/LookInside/ReactiveObjC/RACPassthroughSubscriber.h deleted file mode 100644 index bfad4a41..00000000 --- a/LookInside/ReactiveObjC/RACPassthroughSubscriber.h +++ /dev/null @@ -1,29 +0,0 @@ -// -// RACPassthroughSubscriber.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-06-13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import -#import "RACSubscriber.h" - -@class RACCompoundDisposable; -@class RACSignal<__covariant ValueType>; - -// A private subscriber that passes through all events to another subscriber -// while not disposed. -@interface RACPassthroughSubscriber : NSObject - -// Initializes the receiver to pass through events until disposed. -// -// subscriber - The subscriber to forward events to. This must not be nil. -// signal - The signal that will be sending events to the receiver. -// disposable - When this disposable is disposed, no more events will be -// forwarded. This must not be nil. -// -// Returns an initialized passthrough subscriber. -- (instancetype)initWithSubscriber:(id)subscriber signal:(RACSignal *)signal disposable:(RACCompoundDisposable *)disposable; - -@end diff --git a/LookInside/ReactiveObjC/RACPassthroughSubscriber.m b/LookInside/ReactiveObjC/RACPassthroughSubscriber.m deleted file mode 100644 index 6a76ae81..00000000 --- a/LookInside/ReactiveObjC/RACPassthroughSubscriber.m +++ /dev/null @@ -1,106 +0,0 @@ -// -// RACPassthroughSubscriber.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-06-13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACPassthroughSubscriber.h" -#import "RACCompoundDisposable.h" -#import "RACSignal.h" -#import "RACSignalProvider.h" - -#if !defined(DTRACE_PROBES_DISABLED) || !DTRACE_PROBES_DISABLED - -static const char *cleanedDTraceString(NSString *original) { - return [original stringByReplacingOccurrencesOfString:@"\\s+" withString:@" " options:NSRegularExpressionSearch range:NSMakeRange(0, original.length)].UTF8String; -} - -__attribute__((unused)) static const char *cleanedSignalDescription(RACSignal *signal) { - NSString *desc = signal.description; - - NSRange range = [desc rangeOfString:@" name:"]; - if (range.location != NSNotFound) { - desc = [desc stringByReplacingCharactersInRange:range withString:@""]; - } - - return cleanedDTraceString(desc); -} - -#endif - -@interface RACPassthroughSubscriber () - -// The subscriber to which events should be forwarded. -@property (nonatomic, strong, readonly) id innerSubscriber; - -// The signal sending events to this subscriber. -// -// This property isn't `weak` because it's only used for DTrace probes, so -// a zeroing weak reference would incur an unnecessary performance penalty in -// normal usage. -@property (nonatomic, unsafe_unretained, readonly) RACSignal *signal; - -// A disposable representing the subscription. When disposed, no further events -// should be sent to the `innerSubscriber`. -@property (nonatomic, strong, readonly) RACCompoundDisposable *disposable; - -@end - -@implementation RACPassthroughSubscriber - -#pragma mark Lifecycle - -- (instancetype)initWithSubscriber:(id)subscriber signal:(RACSignal *)signal disposable:(RACCompoundDisposable *)disposable { - NSCParameterAssert(subscriber != nil); - - self = [super init]; - - _innerSubscriber = subscriber; - _signal = signal; - _disposable = disposable; - - [self.innerSubscriber didSubscribeWithDisposable:self.disposable]; - return self; -} - -#pragma mark RACSubscriber - -- (void)sendNext:(id)value { - if (self.disposable.disposed) return; - - if (RACSIGNAL_NEXT_ENABLED()) { - RACSIGNAL_NEXT(cleanedSignalDescription(self.signal), cleanedDTraceString(self.innerSubscriber.description), cleanedDTraceString([value description])); - } - - [self.innerSubscriber sendNext:value]; -} - -- (void)sendError:(NSError *)error { - if (self.disposable.disposed) return; - - if (RACSIGNAL_ERROR_ENABLED()) { - RACSIGNAL_ERROR(cleanedSignalDescription(self.signal), cleanedDTraceString(self.innerSubscriber.description), cleanedDTraceString(error.description)); - } - - [self.innerSubscriber sendError:error]; -} - -- (void)sendCompleted { - if (self.disposable.disposed) return; - - if (RACSIGNAL_COMPLETED_ENABLED()) { - RACSIGNAL_COMPLETED(cleanedSignalDescription(self.signal), cleanedDTraceString(self.innerSubscriber.description)); - } - - [self.innerSubscriber sendCompleted]; -} - -- (void)didSubscribeWithDisposable:(RACCompoundDisposable *)disposable { - if (disposable != self.disposable) { - [self.disposable addDisposable:disposable]; - } -} - -@end diff --git a/LookInside/ReactiveObjC/RACQueueScheduler+Subclass.h b/LookInside/ReactiveObjC/RACQueueScheduler+Subclass.h deleted file mode 100644 index 74c45868..00000000 --- a/LookInside/ReactiveObjC/RACQueueScheduler+Subclass.h +++ /dev/null @@ -1,44 +0,0 @@ -// -// RACQueueScheduler+Subclass.h -// ReactiveObjC -// -// Created by Josh Abernathy on 6/6/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACQueueScheduler.h" -#import "RACScheduler+Subclass.h" - -NS_ASSUME_NONNULL_BEGIN - -/// An interface for use by GCD queue-based subclasses. -/// -/// See RACScheduler+Subclass.h for subclassing notes. -@interface RACQueueScheduler () - -/// The queue on which blocks are enqueued. -#if OS_OBJECT_USE_OBJC -@property (nonatomic, strong, readonly) dispatch_queue_t queue; -#else -// Swift builds with OS_OBJECT_HAVE_OBJC_SUPPORT=0 for Playgrounds and LLDB :( -@property (nonatomic, assign, readonly) dispatch_queue_t queue; -#endif - -/// Initializes the receiver with the name of the scheduler and the queue which -/// the scheduler should use. -/// -/// name - The name of the scheduler. If nil, a default name will be used. -/// queue - The queue upon which the receiver should enqueue scheduled blocks. -/// This argument must not be NULL. -/// -/// Returns the initialized object. -- (instancetype)initWithName:(nullable NSString *)name queue:(dispatch_queue_t)queue; - -/// Converts a date into a GCD time using dispatch_walltime(). -/// -/// date - The date to convert. This must not be nil. -+ (dispatch_time_t)wallTimeWithDate:(NSDate *)date; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACQueueScheduler.h b/LookInside/ReactiveObjC/RACQueueScheduler.h deleted file mode 100644 index 3b317b0a..00000000 --- a/LookInside/ReactiveObjC/RACQueueScheduler.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// RACQueueScheduler.h -// ReactiveObjC -// -// Created by Josh Abernathy on 11/30/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACScheduler.h" - -NS_ASSUME_NONNULL_BEGIN - -/// An abstract scheduler which asynchronously enqueues all its work to a Grand -/// Central Dispatch queue. -/// -/// Because RACQueueScheduler is abstract, it should not be instantiated -/// directly. Create a subclass using the `RACQueueScheduler+Subclass.h` -/// interface and use that instead. -@interface RACQueueScheduler : RACScheduler -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACQueueScheduler.m b/LookInside/ReactiveObjC/RACQueueScheduler.m deleted file mode 100644 index d9dd189b..00000000 --- a/LookInside/ReactiveObjC/RACQueueScheduler.m +++ /dev/null @@ -1,106 +0,0 @@ -// -// RACQueueScheduler.m -// ReactiveObjC -// -// Created by Josh Abernathy on 11/30/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACQueueScheduler.h" -#import "RACDisposable.h" -#import "RACQueueScheduler+Subclass.h" -#import "RACScheduler+Private.h" - -@implementation RACQueueScheduler - -#pragma mark Lifecycle - -- (instancetype)initWithName:(NSString *)name queue:(dispatch_queue_t)queue { - NSCParameterAssert(queue != NULL); - - self = [super initWithName:name]; - - _queue = queue; -#if !OS_OBJECT_USE_OBJC - dispatch_retain(_queue); -#endif - - return self; -} - -#if !OS_OBJECT_USE_OBJC - -- (void)dealloc { - if (_queue != NULL) { - dispatch_release(_queue); - _queue = NULL; - } -} - -#endif - -#pragma mark Date Conversions - -+ (dispatch_time_t)wallTimeWithDate:(NSDate *)date { - NSCParameterAssert(date != nil); - - double seconds = 0; - double frac = modf(date.timeIntervalSince1970, &seconds); - - struct timespec walltime = { - .tv_sec = (time_t)fmin(fmax(seconds, LONG_MIN), LONG_MAX), - .tv_nsec = (long)fmin(fmax(frac * NSEC_PER_SEC, LONG_MIN), LONG_MAX) - }; - - return dispatch_walltime(&walltime, 0); -} - -#pragma mark RACScheduler - -- (RACDisposable *)schedule:(void (^)(void))block { - NSCParameterAssert(block != NULL); - - RACDisposable *disposable = [[RACDisposable alloc] init]; - - dispatch_async(self.queue, ^{ - if (disposable.disposed) return; - [self performAsCurrentScheduler:block]; - }); - - return disposable; -} - -- (RACDisposable *)after:(NSDate *)date schedule:(void (^)(void))block { - NSCParameterAssert(date != nil); - NSCParameterAssert(block != NULL); - - RACDisposable *disposable = [[RACDisposable alloc] init]; - - dispatch_after([self.class wallTimeWithDate:date], self.queue, ^{ - if (disposable.disposed) return; - [self performAsCurrentScheduler:block]; - }); - - return disposable; -} - -- (RACDisposable *)after:(NSDate *)date repeatingEvery:(NSTimeInterval)interval withLeeway:(NSTimeInterval)leeway schedule:(void (^)(void))block { - NSCParameterAssert(date != nil); - NSCParameterAssert(interval > 0.0 && interval < INT64_MAX / NSEC_PER_SEC); - NSCParameterAssert(leeway >= 0.0 && leeway < INT64_MAX / NSEC_PER_SEC); - NSCParameterAssert(block != NULL); - - uint64_t intervalInNanoSecs = (uint64_t)(interval * NSEC_PER_SEC); - uint64_t leewayInNanoSecs = (uint64_t)(leeway * NSEC_PER_SEC); - - dispatch_source_t timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, self.queue); - dispatch_source_set_timer(timer, [self.class wallTimeWithDate:date], intervalInNanoSecs, leewayInNanoSecs); - dispatch_source_set_event_handler(timer, block); - dispatch_resume(timer); - - return [RACDisposable disposableWithBlock:^{ - dispatch_source_cancel(timer); - }]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACReplaySubject.h b/LookInside/ReactiveObjC/RACReplaySubject.h deleted file mode 100644 index b82c8a06..00000000 --- a/LookInside/ReactiveObjC/RACReplaySubject.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// RACReplaySubject.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/14/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSubject.h" - -NS_ASSUME_NONNULL_BEGIN - -extern const NSUInteger RACReplaySubjectUnlimitedCapacity; - -/// A replay subject saves the values it is sent (up to its defined capacity) -/// and resends those to new subscribers. It will also replay an error or -/// completion. -@interface RACReplaySubject : RACSubject - -/// Creates a new replay subject with the given capacity. A capacity of -/// RACReplaySubjectUnlimitedCapacity means values are never trimmed. -+ (instancetype)replaySubjectWithCapacity:(NSUInteger)capacity; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACReplaySubject.m b/LookInside/ReactiveObjC/RACReplaySubject.m deleted file mode 100644 index 30475fa9..00000000 --- a/LookInside/ReactiveObjC/RACReplaySubject.m +++ /dev/null @@ -1,111 +0,0 @@ -// -// RACReplaySubject.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/14/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACReplaySubject.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACScheduler+Private.h" -#import "RACSubscriber.h" -#import "RACTuple.h" - -const NSUInteger RACReplaySubjectUnlimitedCapacity = NSUIntegerMax; - -@interface RACReplaySubject () - -@property (nonatomic, assign, readonly) NSUInteger capacity; - -// These properties should only be modified while synchronized on self. -@property (nonatomic, strong, readonly) NSMutableArray *valuesReceived; -@property (nonatomic, assign) BOOL hasCompleted; -@property (nonatomic, assign) BOOL hasError; -@property (nonatomic, strong) NSError *error; - -@end - - -@implementation RACReplaySubject - -#pragma mark Lifecycle - -+ (instancetype)replaySubjectWithCapacity:(NSUInteger)capacity { - return [(RACReplaySubject *)[self alloc] initWithCapacity:capacity]; -} - -- (instancetype)init { - return [self initWithCapacity:RACReplaySubjectUnlimitedCapacity]; -} - -- (instancetype)initWithCapacity:(NSUInteger)capacity { - self = [super init]; - - _capacity = capacity; - _valuesReceived = (capacity == RACReplaySubjectUnlimitedCapacity ? [NSMutableArray array] : [NSMutableArray arrayWithCapacity:capacity]); - - return self; -} - -#pragma mark RACSignal - -- (RACDisposable *)subscribe:(id)subscriber { - RACCompoundDisposable *compoundDisposable = [RACCompoundDisposable compoundDisposable]; - - RACDisposable *schedulingDisposable = [RACScheduler.subscriptionScheduler schedule:^{ - @synchronized (self) { - for (id value in self.valuesReceived) { - if (compoundDisposable.disposed) return; - - [subscriber sendNext:(value == RACTupleNil.tupleNil ? nil : value)]; - } - - if (compoundDisposable.disposed) return; - - if (self.hasCompleted) { - [subscriber sendCompleted]; - } else if (self.hasError) { - [subscriber sendError:self.error]; - } else { - RACDisposable *subscriptionDisposable = [super subscribe:subscriber]; - [compoundDisposable addDisposable:subscriptionDisposable]; - } - } - }]; - - [compoundDisposable addDisposable:schedulingDisposable]; - - return compoundDisposable; -} - -#pragma mark RACSubscriber - -- (void)sendNext:(id)value { - @synchronized (self) { - [self.valuesReceived addObject:value ?: RACTupleNil.tupleNil]; - [super sendNext:value]; - - if (self.capacity != RACReplaySubjectUnlimitedCapacity && self.valuesReceived.count > self.capacity) { - [self.valuesReceived removeObjectsInRange:NSMakeRange(0, self.valuesReceived.count - self.capacity)]; - } - } -} - -- (void)sendCompleted { - @synchronized (self) { - self.hasCompleted = YES; - [super sendCompleted]; - } -} - -- (void)sendError:(NSError *)e { - @synchronized (self) { - self.hasError = YES; - self.error = e; - [super sendError:e]; - } -} - -@end diff --git a/LookInside/ReactiveObjC/RACReturnSignal.h b/LookInside/ReactiveObjC/RACReturnSignal.h deleted file mode 100644 index 00360d23..00000000 --- a/LookInside/ReactiveObjC/RACReturnSignal.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// RACReturnSignal.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-10. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSignal.h" - -// A private `RACSignal` subclasses that synchronously sends a value to any -// subscribers, then completes. -@interface RACReturnSignal<__covariant ValueType> : RACSignal - -+ (RACSignal *)return:(ValueType)value; - -@end diff --git a/LookInside/ReactiveObjC/RACReturnSignal.m b/LookInside/ReactiveObjC/RACReturnSignal.m deleted file mode 100644 index dca319d0..00000000 --- a/LookInside/ReactiveObjC/RACReturnSignal.m +++ /dev/null @@ -1,90 +0,0 @@ -// -// RACReturnSignal.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-10-10. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACReturnSignal.h" -#import "RACScheduler+Private.h" -#import "RACSubscriber.h" -#import "RACUnit.h" - -@interface RACReturnSignal () - -// The value to send upon subscription. -@property (nonatomic, strong, readonly) id value; - -@end - -@implementation RACReturnSignal - -#pragma mark Properties - -// Only allow this signal's name to be customized in DEBUG, since it's -// potentially a singleton in release builds (see +return:). -- (void)setName:(NSString *)name { -#ifdef DEBUG - [super setName:name]; -#endif -} - -- (NSString *)name { -#ifdef DEBUG - return super.name; -#else - return @"+return:"; -#endif -} - -#pragma mark Lifecycle - -+ (RACSignal *)return:(id)value { -#ifndef DEBUG - // In release builds, use singletons for two very common cases. - if (value == RACUnit.defaultUnit) { - static RACReturnSignal *unitSingleton; - static dispatch_once_t unitPred; - - dispatch_once(&unitPred, ^{ - unitSingleton = [[self alloc] init]; - unitSingleton->_value = RACUnit.defaultUnit; - }); - - return unitSingleton; - } else if (value == nil) { - static RACReturnSignal *nilSingleton; - static dispatch_once_t nilPred; - - dispatch_once(&nilPred, ^{ - nilSingleton = [[self alloc] init]; - nilSingleton->_value = nil; - }); - - return nilSingleton; - } -#endif - - RACReturnSignal *signal = [[self alloc] init]; - signal->_value = value; - -#ifdef DEBUG - [signal setNameWithFormat:@"+return: %@", value]; -#endif - - return signal; -} - -#pragma mark Subscription - -- (RACDisposable *)subscribe:(id)subscriber { - NSCParameterAssert(subscriber != nil); - - return [RACScheduler.subscriptionScheduler schedule:^{ - [subscriber sendNext:self.value]; - [subscriber sendCompleted]; - }]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACScheduler+Private.h b/LookInside/ReactiveObjC/RACScheduler+Private.h deleted file mode 100644 index 55d60967..00000000 --- a/LookInside/ReactiveObjC/RACScheduler+Private.h +++ /dev/null @@ -1,38 +0,0 @@ -// -// RACScheduler+Private.h -// ReactiveObjC -// -// Created by Josh Abernathy on 11/29/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACScheduler.h" - -NS_ASSUME_NONNULL_BEGIN - -// The thread-specific current scheduler key. -extern NSString * const RACSchedulerCurrentSchedulerKey; - -// A private interface for internal RAC use only. -@interface RACScheduler () - -// A dedicated scheduler that fills two requirements: -// -// 1. By the time subscription happens, we need a valid +currentScheduler. -// 2. Subscription should happen as soon as possible. -// -// To fulfill those two, if we already have a valid +currentScheduler, it -// immediately executes scheduled blocks. If we don't, it will execute scheduled -// blocks with a private background scheduler. -+ (instancetype)subscriptionScheduler; - -// Initializes the receiver with the given name. -// -// name - The name of the scheduler. If nil, a default name will be used. -// -// Returns the initialized object. -- (instancetype)initWithName:(nullable NSString *)name; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACScheduler+Subclass.h b/LookInside/ReactiveObjC/RACScheduler+Subclass.h deleted file mode 100644 index 34d10377..00000000 --- a/LookInside/ReactiveObjC/RACScheduler+Subclass.h +++ /dev/null @@ -1,33 +0,0 @@ -// -// RACScheduler.m -// ReactiveObjC -// -// Created by Miķelis Vindavs on 5/27/14. -// Copyright (c) 2014 GitHub, Inc. All rights reserved. -// - -#import -#import "RACScheduler.h" - -NS_ASSUME_NONNULL_BEGIN - -/// An interface for use by subclasses. -/// -/// Subclasses should use `-performAsCurrentScheduler:` to do the actual block -/// invocation so that +[RACScheduler currentScheduler] behaves as expected. -/// -/// **Note that RACSchedulers are expected to be serial**. Subclasses must honor -/// that contract. See `RACTargetQueueScheduler` for a queue-based scheduler -/// which will enforce the serialization guarantee. -@interface RACScheduler () - -/// Performs the given block with the receiver as the current scheduler for -/// its thread. This should only be called by subclasses to perform their -/// scheduled blocks. -/// -/// block - The block to execute. Cannot be NULL. -- (void)performAsCurrentScheduler:(void (^)(void))block; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACScheduler.h b/LookInside/ReactiveObjC/RACScheduler.h deleted file mode 100644 index c780e337..00000000 --- a/LookInside/ReactiveObjC/RACScheduler.h +++ /dev/null @@ -1,146 +0,0 @@ -// -// RACScheduler.h -// ReactiveObjC -// -// Created by Josh Abernathy on 4/16/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// The priority for the scheduler. -/// -/// RACSchedulerPriorityHigh - High priority. -/// RACSchedulerPriorityDefault - Default priority. -/// RACSchedulerPriorityLow - Low priority. -/// RACSchedulerPriorityBackground - Background priority. -typedef enum : long { - RACSchedulerPriorityHigh = DISPATCH_QUEUE_PRIORITY_HIGH, - RACSchedulerPriorityDefault = DISPATCH_QUEUE_PRIORITY_DEFAULT, - RACSchedulerPriorityLow = DISPATCH_QUEUE_PRIORITY_LOW, - RACSchedulerPriorityBackground = DISPATCH_QUEUE_PRIORITY_BACKGROUND, -} RACSchedulerPriority; - -/// Scheduled with -scheduleRecursiveBlock:, this type of block is passed a block -/// with which it can call itself recursively. -typedef void (^RACSchedulerRecursiveBlock)(void (^reschedule)(void)); - -@class RACDisposable; - -/// Schedulers are used to control when and where work is performed. -@interface RACScheduler : NSObject - -/// A singleton scheduler that immediately executes the blocks it is given. -/// -/// **Note:** Unlike most other schedulers, this does not set the current -/// scheduler. There may still be a valid +currentScheduler if this is used -/// within a block scheduled on a different scheduler. -+ (RACScheduler *)immediateScheduler; - -/// A singleton scheduler that executes blocks in the main thread. -+ (RACScheduler *)mainThreadScheduler; - -/// Creates and returns a new background scheduler with the given priority and -/// name. The name is for debug and instrumentation purposes only. -/// -/// Scheduler creation is cheap. It's unnecessary to save the result of this -/// method call unless you want to serialize some actions on the same background -/// scheduler. -+ (RACScheduler *)schedulerWithPriority:(RACSchedulerPriority)priority name:(nullable NSString *)name; - -/// Invokes +schedulerWithPriority:name: with a default name. -+ (RACScheduler *)schedulerWithPriority:(RACSchedulerPriority)priority; - -/// Invokes +schedulerWithPriority: with RACSchedulerPriorityDefault. -+ (RACScheduler *)scheduler; - -/// The current scheduler. This will only be valid when used from within a -/// -[RACScheduler schedule:] block or when on the main thread. -+ (nullable RACScheduler *)currentScheduler; - -/// Schedule the given block for execution on the scheduler. -/// -/// Scheduled blocks will be executed in the order in which they were scheduled. -/// -/// block - The block to schedule for execution. Cannot be nil. -/// -/// Returns a disposable which can be used to cancel the scheduled block before -/// it begins executing, or nil if cancellation is not supported. -- (nullable RACDisposable *)schedule:(void (^)(void))block; - -/// Schedule the given block for execution on the scheduler at or after -/// a specific time. -/// -/// Note that blocks scheduled for a certain time will not preempt any other -/// scheduled work that is executing at the time. -/// -/// When invoked on the +immediateScheduler, the calling thread **will block** -/// until the specified time. -/// -/// date - The earliest time at which `block` should begin executing. The block -/// may not execute immediately at this time, whether due to system load -/// or another block on the scheduler currently being run. Cannot be nil. -/// block - The block to schedule for execution. Cannot be nil. -/// -/// Returns a disposable which can be used to cancel the scheduled block before -/// it begins executing, or nil if cancellation is not supported. -- (nullable RACDisposable *)after:(NSDate *)date schedule:(void (^)(void))block; - -/// Schedule the given block for execution on the scheduler after the delay. -/// -/// Converts the delay into an NSDate, then invokes `-after:schedule:`. -- (nullable RACDisposable *)afterDelay:(NSTimeInterval)delay schedule:(void (^)(void))block; - -/// Reschedule the given block at a particular interval, starting at a specific -/// time, and with a given leeway for deferral. -/// -/// Note that blocks scheduled for a certain time will not preempt any other -/// scheduled work that is executing at the time. -/// -/// Regardless of the value of `leeway`, the given block may not execute exactly -/// at `when` or exactly on successive intervals, whether due to system load or -/// because another block is currently being run on the scheduler. -/// -/// It is considered undefined behavior to invoke this method on the -/// +immediateScheduler. -/// -/// date - The earliest time at which `block` should begin executing. The -/// block may not execute immediately at this time, whether due to -/// system load or another block on the scheduler currently being -/// run. Cannot be nil. -/// interval - The interval at which the block should be rescheduled, starting -/// from `date`. This will use the system wall clock, to avoid -/// skew when the computer goes to sleep. -/// leeway - A hint to the system indicating the number of seconds that each -/// scheduling can be deferred. Note that this is just a hint, and -/// there may be some additional latency no matter what. -/// block - The block to repeatedly schedule for execution. Cannot be nil. -/// -/// Returns a disposable which can be used to cancel the automatic scheduling and -/// rescheduling, or nil if cancellation is not supported. -- (nullable RACDisposable *)after:(NSDate *)date repeatingEvery:(NSTimeInterval)interval withLeeway:(NSTimeInterval)leeway schedule:(void (^)(void))block; - -/// Schedule the given recursive block for execution on the scheduler. The -/// scheduler will automatically flatten any recursive scheduling into iteration -/// instead, so this can be used without issue for blocks that may keep invoking -/// themselves forever. -/// -/// Scheduled blocks will be executed in the order in which they were scheduled. -/// -/// recursiveBlock - The block to schedule for execution. When invoked, the -/// recursive block will be passed a `void (^)(void)` block -/// which will reschedule the recursive block at the end of the -/// receiver's queue. This passed-in block will automatically -/// skip scheduling if the scheduling of the `recursiveBlock` -/// was disposed in the meantime. -/// -/// Returns a disposable which can be used to cancel the scheduled block before -/// it begins executing, or to stop it from rescheduling if it's already begun -/// execution. -- (nullable RACDisposable *)scheduleRecursiveBlock:(RACSchedulerRecursiveBlock)recursiveBlock; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACScheduler.m b/LookInside/ReactiveObjC/RACScheduler.m deleted file mode 100644 index e260e139..00000000 --- a/LookInside/ReactiveObjC/RACScheduler.m +++ /dev/null @@ -1,211 +0,0 @@ -// -// RACScheduler.m -// ReactiveObjC -// -// Created by Josh Abernathy on 4/16/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACScheduler.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACImmediateScheduler.h" -#import "RACScheduler+Private.h" -#import "RACSubscriptionScheduler.h" -#import "RACTargetQueueScheduler.h" - -// The key for the thread-specific current scheduler. -NSString * const RACSchedulerCurrentSchedulerKey = @"RACSchedulerCurrentSchedulerKey"; - -@interface RACScheduler () -@property (nonatomic, readonly, copy) NSString *name; -@end - -@implementation RACScheduler - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p> %@", self.class, self, self.name]; -} - -#pragma mark Initializers - -- (instancetype)initWithName:(NSString *)name { - self = [super init]; - - if (name == nil) { - _name = [NSString stringWithFormat:@"org.reactivecocoa.ReactiveObjC.%@.anonymousScheduler", self.class]; - } else { - _name = [name copy]; - } - - return self; -} - -#pragma mark Schedulers - -+ (RACScheduler *)immediateScheduler { - static dispatch_once_t onceToken; - static RACScheduler *immediateScheduler; - dispatch_once(&onceToken, ^{ - immediateScheduler = [[RACImmediateScheduler alloc] init]; - }); - - return immediateScheduler; -} - -+ (RACScheduler *)mainThreadScheduler { - static dispatch_once_t onceToken; - static RACScheduler *mainThreadScheduler; - dispatch_once(&onceToken, ^{ - mainThreadScheduler = [[RACTargetQueueScheduler alloc] initWithName:@"org.reactivecocoa.ReactiveObjC.RACScheduler.mainThreadScheduler" targetQueue:dispatch_get_main_queue()]; - }); - - return mainThreadScheduler; -} - -+ (RACScheduler *)schedulerWithPriority:(RACSchedulerPriority)priority name:(NSString *)name { - return [[RACTargetQueueScheduler alloc] initWithName:name targetQueue:dispatch_get_global_queue(priority, 0)]; -} - -+ (RACScheduler *)schedulerWithPriority:(RACSchedulerPriority)priority { - return [self schedulerWithPriority:priority name:@"org.reactivecocoa.ReactiveObjC.RACScheduler.backgroundScheduler"]; -} - -+ (RACScheduler *)scheduler { - return [self schedulerWithPriority:RACSchedulerPriorityDefault]; -} - -+ (RACScheduler *)subscriptionScheduler { - static dispatch_once_t onceToken; - static RACScheduler *subscriptionScheduler; - dispatch_once(&onceToken, ^{ - subscriptionScheduler = [[RACSubscriptionScheduler alloc] init]; - }); - - return subscriptionScheduler; -} - -+ (BOOL)isOnMainThread { - return [NSOperationQueue.currentQueue isEqual:NSOperationQueue.mainQueue] || [NSThread isMainThread]; -} - -+ (RACScheduler *)currentScheduler { - RACScheduler *scheduler = NSThread.currentThread.threadDictionary[RACSchedulerCurrentSchedulerKey]; - if (scheduler != nil) return scheduler; - if ([self.class isOnMainThread]) return RACScheduler.mainThreadScheduler; - - return nil; -} - -#pragma mark Scheduling - -- (RACDisposable *)schedule:(void (^)(void))block { - NSCAssert(NO, @"%@ must be implemented by subclasses.", NSStringFromSelector(_cmd)); - return nil; -} - -- (RACDisposable *)after:(NSDate *)date schedule:(void (^)(void))block { - NSCAssert(NO, @"%@ must be implemented by subclasses.", NSStringFromSelector(_cmd)); - return nil; -} - -- (RACDisposable *)afterDelay:(NSTimeInterval)delay schedule:(void (^)(void))block { - return [self after:[NSDate dateWithTimeIntervalSinceNow:delay] schedule:block]; -} - -- (RACDisposable *)after:(NSDate *)date repeatingEvery:(NSTimeInterval)interval withLeeway:(NSTimeInterval)leeway schedule:(void (^)(void))block { - NSCAssert(NO, @"%@ must be implemented by subclasses.", NSStringFromSelector(_cmd)); - return nil; -} - -- (RACDisposable *)scheduleRecursiveBlock:(RACSchedulerRecursiveBlock)recursiveBlock { - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - - [self scheduleRecursiveBlock:[recursiveBlock copy] addingToDisposable:disposable]; - return disposable; -} - -- (void)scheduleRecursiveBlock:(RACSchedulerRecursiveBlock)recursiveBlock addingToDisposable:(RACCompoundDisposable *)disposable { - @autoreleasepool { - RACCompoundDisposable *selfDisposable = [RACCompoundDisposable compoundDisposable]; - [disposable addDisposable:selfDisposable]; - - __weak RACDisposable *weakSelfDisposable = selfDisposable; - - RACDisposable *schedulingDisposable = [self schedule:^{ - @autoreleasepool { - // At this point, we've been invoked, so our disposable is now useless. - [disposable removeDisposable:weakSelfDisposable]; - } - - if (disposable.disposed) return; - - void (^reallyReschedule)(void) = ^{ - if (disposable.disposed) return; - [self scheduleRecursiveBlock:recursiveBlock addingToDisposable:disposable]; - }; - - // Protects the variables below. - // - // This doesn't actually need to be __block qualified, but Clang - // complains otherwise. :C - __block NSLock *lock = [[NSLock alloc] init]; - lock.name = [NSString stringWithFormat:@"%@ %s", self, sel_getName(_cmd)]; - - __block NSUInteger rescheduleCount = 0; - - // Set to YES once synchronous execution has finished. Further - // rescheduling should occur immediately (rather than being - // flattened). - __block BOOL rescheduleImmediately = NO; - - @autoreleasepool { - recursiveBlock(^{ - [lock lock]; - BOOL immediate = rescheduleImmediately; - if (!immediate) ++rescheduleCount; - [lock unlock]; - - if (immediate) reallyReschedule(); - }); - } - - [lock lock]; - NSUInteger synchronousCount = rescheduleCount; - rescheduleImmediately = YES; - [lock unlock]; - - for (NSUInteger i = 0; i < synchronousCount; i++) { - reallyReschedule(); - } - }]; - - [selfDisposable addDisposable:schedulingDisposable]; - } -} - -- (void)performAsCurrentScheduler:(void (^)(void))block { - NSCParameterAssert(block != NULL); - - // If we're using a concurrent queue, we could end up in here concurrently, - // in which case we *don't* want to clear the current scheduler immediately - // after our block is done executing, but only *after* all our concurrent - // invocations are done. - - RACScheduler *previousScheduler = RACScheduler.currentScheduler; - NSThread.currentThread.threadDictionary[RACSchedulerCurrentSchedulerKey] = self; - - @autoreleasepool { - block(); - } - - if (previousScheduler != nil) { - NSThread.currentThread.threadDictionary[RACSchedulerCurrentSchedulerKey] = previousScheduler; - } else { - [NSThread.currentThread.threadDictionary removeObjectForKey:RACSchedulerCurrentSchedulerKey]; - } -} - -@end diff --git a/LookInside/ReactiveObjC/RACScopedDisposable.h b/LookInside/ReactiveObjC/RACScopedDisposable.h deleted file mode 100644 index ff1067ce..00000000 --- a/LookInside/ReactiveObjC/RACScopedDisposable.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// RACScopedDisposable.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/28/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACDisposable.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A disposable that calls its own -dispose when it is dealloc'd. -@interface RACScopedDisposable : RACDisposable - -/// Creates a new scoped disposable that will also dispose of the given -/// disposable when it is dealloc'd. -+ (instancetype)scopedDisposableWithDisposable:(RACDisposable *)disposable; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACScopedDisposable.m b/LookInside/ReactiveObjC/RACScopedDisposable.m deleted file mode 100644 index 2da50cb9..00000000 --- a/LookInside/ReactiveObjC/RACScopedDisposable.m +++ /dev/null @@ -1,32 +0,0 @@ -// -// RACScopedDisposable.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/28/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACScopedDisposable.h" - -@implementation RACScopedDisposable - -#pragma mark Lifecycle - -+ (instancetype)scopedDisposableWithDisposable:(RACDisposable *)disposable { - return [self disposableWithBlock:^{ - [disposable dispose]; - }]; -} - -- (void)dealloc { - [self dispose]; -} - -#pragma mark RACDisposable - -- (RACScopedDisposable *)asScopedDisposable { - // totally already are - return self; -} - -@end diff --git a/LookInside/ReactiveObjC/RACSequence.h b/LookInside/ReactiveObjC/RACSequence.h deleted file mode 100644 index c7bc6b9a..00000000 --- a/LookInside/ReactiveObjC/RACSequence.h +++ /dev/null @@ -1,419 +0,0 @@ -// -// RACSequence.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import -#import "RACStream.h" - -@class RACTuple; -@class RACScheduler; -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -/// Represents an immutable sequence of values. Unless otherwise specified, the -/// sequences' values are evaluated lazily on demand. Like Cocoa collections, -/// sequences cannot contain nil. -/// -/// Most inherited RACStream methods that accept a block will execute the block -/// _at most_ once for each value that is evaluated in the returned sequence. -/// Side effects are subject to the behavior described in -/// +sequenceWithHeadBlock:tailBlock:. -/// -/// Implemented as a class cluster. A minimal implementation for a subclass -/// consists simply of -head and -tail. -@interface RACSequence<__covariant ValueType> : RACStream - -/// The first object in the sequence, or nil if the sequence is empty. -/// -/// Subclasses must provide an implementation of this method. -@property (nonatomic, strong, readonly, nullable) ValueType head; - -/// All but the first object in the sequence, or nil if there are no other -/// objects. -/// -/// Subclasses must provide an implementation of this method. -@property (nonatomic, strong, readonly, nullable) RACSequence *tail; - -/// Evaluates the full sequence to produce an equivalently-sized array. -@property (nonatomic, copy, readonly) NSArray *array; - -/// Returns an enumerator of all objects in the sequence. -@property (nonatomic, copy, readonly) NSEnumerator *objectEnumerator; - -/// Converts a sequence into an eager sequence. -/// -/// An eager sequence fully evaluates all of its values immediately. Sequences -/// derived from an eager sequence will also be eager. -/// -/// Returns a new eager sequence, or the receiver if the sequence is already -/// eager. -@property (nonatomic, copy, readonly) RACSequence *eagerSequence; - -/// Converts a sequence into a lazy sequence. -/// -/// A lazy sequence evaluates its values on demand, as they are accessed. -/// Sequences derived from a lazy sequence will also be lazy. -/// -/// Returns a new lazy sequence, or the receiver if the sequence is already lazy. -@property (nonatomic, copy, readonly) RACSequence *lazySequence; - -/// Invokes -signalWithScheduler: with a new RACScheduler. -- (RACSignal *)signal; - -/// Evaluates the full sequence on the given scheduler. -/// -/// Each item is evaluated in its own scheduled block, such that control of the -/// scheduler is yielded between each value. -/// -/// Returns a signal which sends the receiver's values on the given scheduler as -/// they're evaluated. -- (RACSignal *)signalWithScheduler:(RACScheduler *)scheduler; - -/// Applies a left fold to the sequence. -/// -/// This is the same as iterating the sequence along with a provided start value. -/// This uses a constant amount of memory. A left fold is left-associative so in -/// the sequence [1,2,3] the block would applied in the following order: -/// reduce(reduce(reduce(start, 1), 2), 3) -/// -/// start - The starting value for the fold. Used as `accumulator` for the -/// first fold. -/// reduce - The block used to combine the accumulated value and the next value. -/// Cannot be nil. -/// -/// Returns a reduced value. -- (id)foldLeftWithStart:(nullable id)start reduce:(id _Nullable (^)(id _Nullable accumulator, ValueType _Nullable value))reduce; - -/// Applies a right fold to the sequence. -/// -/// A right fold is equivalent to recursion on the list. The block is evaluated -/// from the right to the left in list. It is right associative so it's applied -/// to the rightmost elements first. For example, in the sequence [1,2,3] the -/// block is applied in the order: -/// reduce(1, reduce(2, reduce(3, start))) -/// -/// start - The starting value for the fold. -/// reduce - The block used to combine the accumulated value and the next head. -/// The block is given the accumulated value and the value of the rest -/// of the computation (result of the recursion). This is computed when -/// you retrieve its value using `rest.head`. This allows you to -/// prevent unnecessary computation by not accessing `rest.head` if you -/// don't need to. -/// -/// Returns a reduced value. -- (id)foldRightWithStart:(nullable id)start reduce:(id _Nullable (^)(id _Nullable first, RACSequence *rest))reduce; - -/// Check if any value in sequence passes the block. -/// -/// block - The block predicate used to check each item. Cannot be nil. -/// -/// Returns a boolean indiciating if any value in the sequence passed. -- (BOOL)any:(BOOL (^)(ValueType _Nullable value))block; - -/// Check if all values in the sequence pass the block. -/// -/// block - The block predicate used to check each item. Cannot be nil. -/// -/// Returns a boolean indicating if all values in the sequence passed. -- (BOOL)all:(BOOL (^)(ValueType _Nullable value))block; - -/// Returns the first object that passes the block. -/// -/// block - The block predicate used to check each item. Cannot be nil. -/// -/// Returns an object that passes the block or nil if no objects passed. -- (nullable ValueType)objectPassingTest:(BOOL (^)(ValueType _Nullable value))block; - -/// Creates a sequence that dynamically generates its values. -/// -/// headBlock - Invoked the first time -head is accessed. -/// tailBlock - Invoked the first time -tail is accessed. -/// -/// The results from each block are memoized, so each block will be invoked at -/// most once, no matter how many times the head and tail properties of the -/// sequence are accessed. -/// -/// Any side effects in `headBlock` or `tailBlock` should be thread-safe, since -/// the sequence may be evaluated at any time from any thread. Not only that, but -/// -tail may be accessed before -head, or both may be accessed simultaneously. -/// As noted above, side effects will only be triggered the _first_ time -head or -/// -tail is invoked. -/// -/// Returns a sequence that lazily invokes the given blocks to provide head and -/// tail. `headBlock` must not be nil. -+ (RACSequence *)sequenceWithHeadBlock:(ValueType _Nullable (^)(void))headBlock tailBlock:(nullable RACSequence *(^)(void))tailBlock; - -@end - -@interface RACSequence<__covariant ValueType> (RACStream) - -/// Returns a sequence that immediately sends the given value and then completes. -+ (RACSequence *)return:(nullable ValueType)value; - -/// Returns a sequence that immediately completes. -+ (RACSequence *)empty; - -/// A block which accepts a value from a RACSequence and returns a new sequence. -/// -/// Setting `stop` to `YES` will cause the bind to terminate after the returned -/// value. Returning `nil` will result in immediate termination. -typedef RACSequence * _Nullable (^RACSequenceBindBlock)(ValueType _Nullable value, BOOL *stop); - -/// Lazily binds a block to the values in the receiver. -/// -/// This should only be used if you need to terminate the bind early, or close -/// over some state. -flattenMap: is more appropriate for all other cases. -/// -/// block - A block returning a RACSequenceBindBlock. This block will be invoked -/// each time the bound sequence is re-evaluated. This block must not be -/// nil or return nil. -/// -/// Returns a new sequence which represents the combined result of all lazy -/// applications of `block`. -- (RACSequence *)bind:(RACSequenceBindBlock (^)(void))block; - -/// Subscribes to `sequence` when the source sequence completes. -- (RACSequence *)concat:(RACSequence *)sequence; - -/// Zips the values in the receiver with those of the given sequence to create -/// RACTuples. -/// -/// The first `next` of each sequence will be combined, then the second `next`, -/// and so forth, until either sequence completes or errors. -/// -/// sequence - The sequence to zip with. This must not be `nil`. -/// -/// Returns a new sequence of RACTuples, representing the combined values of the -/// two sequences. Any error from one of the original sequence will be forwarded -/// on the returned sequence. -- (RACSequence *)zipWith:(RACSequence *)sequence; - -@end - -/// Redeclarations of operations built on the RACStream primitives with more -/// precise type information. -@interface RACSequence<__covariant ValueType> (RACStreamOperations) - -/// Maps `block` across the values in the receiver and flattens the result. -/// -/// Note that operators applied _after_ -flattenMap: behave differently from -/// operators _within_ -flattenMap:. See the Examples section below. -/// -/// This corresponds to the `SelectMany` method in Rx. -/// -/// block - A block which accepts the values in the receiver and returns a new -/// instance of the receiver's class. Returning `nil` from this block is -/// equivalent to returning an empty sequence. -/// -/// Returns a new sequence which represents the combined sequences resulting -/// from mapping `block`. -- (RACSequence *)flattenMap:(__kindof RACSequence * _Nullable (^)(ValueType _Nullable value))block; - -/// Flattens a sequence of sequences. -/// -/// This corresponds to the `Merge` method in Rx. -/// -/// Returns a sequence consisting of the combined sequences obtained from the -/// receiver. -- (RACSequence *)flatten; - -/// Maps `block` across the values in the receiver. -/// -/// This corresponds to the `Select` method in Rx. -/// -/// Returns a new sequence with the mapped values. -- (RACSequence *)map:(id _Nullable (^)(ValueType _Nullable value))block; - -/// Replaces each value in the receiver with the given object. -/// -/// Returns a new sequence which includes the given object once for each value in -/// the receiver. -- (RACSequence *)mapReplace:(nullable id)object; - -/// Filters out values in the receiver that don't pass the given test. -/// -/// This corresponds to the `Where` method in Rx. -/// -/// Returns a new sequence with only those values that passed. -- (RACSequence *)filter:(BOOL (^)(id _Nullable value))block; - -/// Filters out values in the receiver that equal (via -isEqual:) the provided -/// value. -/// -/// value - The value can be `nil`, in which case it ignores `nil` values. -/// -/// Returns a new sequence containing only the values which did not compare -/// equal to `value`. -- (RACSequence *)ignore:(nullable ValueType)value; - -/// Unpacks each RACTuple in the receiver and maps the values to a new value. -/// -/// reduceBlock - The block which reduces each RACTuple's values into one value. -/// It must take as many arguments as the number of tuple elements -/// to process. Each argument will be an object argument. The -/// return value must be an object. This argument cannot be nil. -/// -/// Returns a new sequence of reduced tuple values. -- (RACSequence *)reduceEach:(RACReduceBlock)reduceBlock; - -/// Returns a sequence consisting of `value`, followed by the values in the -/// receiver. -- (RACSequence *)startWith:(nullable ValueType)value; - -/// Skips the first `skipCount` values in the receiver. -/// -/// Returns the receiver after skipping the first `skipCount` values. If -/// `skipCount` is greater than the number of values in the sequence, an empty -/// sequence is returned. -- (RACSequence *)skip:(NSUInteger)skipCount; - -/// Returns a sequence of the first `count` values in the receiver. If `count` is -/// greater than or equal to the number of values in the sequence, a sequence -/// equivalent to the receiver is returned. -- (RACSequence *)take:(NSUInteger)count; - -/// Zips the values in the given sequences to create RACTuples. -/// -/// The first value of each sequence will be combined, then the second value, -/// and so forth, until at least one of the sequences is exhausted. -/// -/// sequences - The sequence to combine. If this collection is empty, the -/// returned sequence will be empty. -/// -/// Returns a new sequence containing RACTuples of the zipped values from the -/// sequences. -+ (RACSequence *)zip:(id)sequence; - -/// Zips sequences using +zip:, then reduces the resulting tuples into a single -/// value using -reduceEach: -/// -/// sequences - The sequences to combine. If this collection is empty, the -/// returned sequence will be empty. -/// reduceBlock - The block which reduces the values from all the sequences -/// into one value. It must take as many arguments as the -/// number of sequences given. Each argument will be an object -/// argument. The return value must be an object. This argument -/// must not be nil. -/// -/// Example: -/// -/// [RACSequence zip:@[ stringSequence, intSequence ] -/// reduce:^(NSString *string, NSNumber *number) { -/// return [NSString stringWithFormat:@"%@: %@", string, number]; -/// }]; -/// -/// Returns a new sequence containing the results from each invocation of -/// `reduceBlock`. -+ (RACSequence *)zip:(id)sequences reduce:(RACReduceBlock)reduceBlock; - -/// Returns a sequence obtained by concatenating `sequences` in order. -+ (RACSequence *)concat:(id)sequences; - -/// Combines values in the receiver from left to right using the given block. -/// -/// The algorithm proceeds as follows: -/// -/// 1. `startingValue` is passed into the block as the `running` value, and the -/// first element of the receiver is passed into the block as the `next` value. -/// 2. The result of the invocation is added to the returned sequence. -/// 3. The result of the invocation (`running`) and the next element of the -/// receiver (`next`) is passed into `block`. -/// 4. Steps 2 and 3 are repeated until all values have been processed. -/// -/// startingValue - The value to be combined with the first element of the -/// receiver. This value may be `nil`. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. If the receiver is empty, this block will never be -/// invoked. Cannot be nil. -/// -/// Examples -/// -/// RACSequence *numbers = @[ @1, @2, @3, @4 ].rac_sequence; -/// -/// // Contains 1, 3, 6, 10 -/// RACSequence *sums = [numbers scanWithStart:@0 reduce:^(NSNumber *sum, NSNumber *next) { -/// return @(sum.integerValue + next.integerValue); -/// }]; -/// -/// Returns a new sequence that consists of each application of `reduceBlock`. If -/// the receiver is empty, an empty sequence is returned. -- (RACSequence *)scanWithStart:(nullable id)startingValue reduce:(id _Nullable (^)(id _Nullable running, ValueType _Nullable next))reduceBlock; - -/// Combines values in the receiver from left to right using the given block -/// which also takes zero-based index of the values. -/// -/// startingValue - The value to be combined with the first element of the -/// receiver. This value may be `nil`. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. This block takes zero-based index value as the last -/// parameter. If the receiver is empty, this block will never -/// be invoked. Cannot be nil. -/// -/// Returns a new sequence that consists of each application of `reduceBlock`. -/// If the receiver is empty, an empty sequence is returned. -- (RACSequence *)scanWithStart:(nullable id)startingValue reduceWithIndex:(id _Nullable (^)(id _Nullable running, ValueType _Nullable next, NSUInteger index))reduceBlock; - -/// Combines each previous and current value into one object. -/// -/// This method is similar to -scanWithStart:reduce:, but only ever operates on -/// the previous and current values (instead of the whole sequence), and does -/// not pass the return value of `reduceBlock` into the next invocation of it. -/// -/// start - The value passed into `reduceBlock` as `previous` for the -/// first value. -/// reduceBlock - The block that combines the previous value and the current -/// value to create the reduced value. Cannot be nil. -/// -/// Examples -/// -/// RACSequence *numbers = [@[ @1, @2, @3, @4 ].rac_sequence; -/// -/// // Contains 1, 3, 5, 7 -/// RACSequence *sums = [numbers combinePreviousWithStart:@0 reduce:^(NSNumber *previous, NSNumber *next) { -/// return @(previous.integerValue + next.integerValue); -/// }]; -/// -/// Returns a new sequence consisting of the return values from each application of -/// `reduceBlock`. -- (RACSequence *)combinePreviousWithStart:(nullable ValueType)start reduce:(id _Nullable (^)(ValueType _Nullable previous, ValueType _Nullable current))reduceBlock; - -/// Takes values until the given block returns `YES`. -/// -/// Returns a RACSequence of the initial values in the receiver that fail -/// `predicate`. If `predicate` never returns `YES`, a sequence equivalent to -/// the receiver is returned. -- (RACSequence *)takeUntilBlock:(BOOL (^)(ValueType _Nullable x))predicate; - -/// Takes values until the given block returns `NO`. -/// -/// Returns a sequence of the initial values in the receiver that pass -/// `predicate`. If `predicate` never returns `NO`, a sequence equivalent to the -/// receiver is returned. -- (RACSequence *)takeWhileBlock:(BOOL (^)(ValueType _Nullable x))predicate; - -/// Skips values until the given block returns `YES`. -/// -/// Returns a sequence containing the values of the receiver that follow any -/// initial values failing `predicate`. If `predicate` never returns `YES`, -/// an empty sequence is returned. -- (RACSequence *)skipUntilBlock:(BOOL (^)(ValueType _Nullable x))predicate; - -/// Skips values until the given block returns `NO`. -/// -/// Returns a sequence containing the values of the receiver that follow any -/// initial values passing `predicate`. If `predicate` never returns `NO`, an -/// empty sequence is returned. -- (RACSequence *)skipWhileBlock:(BOOL (^)(ValueType _Nullable x))predicate; - -/// Returns a sequence of values for which -isEqual: returns NO when compared to -/// the previous value. -- (RACSequence *)distinctUntilChanged; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACSequence.m b/LookInside/ReactiveObjC/RACSequence.m deleted file mode 100644 index baf83029..00000000 --- a/LookInside/ReactiveObjC/RACSequence.m +++ /dev/null @@ -1,371 +0,0 @@ -// -// RACSequence.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACSequence.h" -#import "RACArraySequence.h" -#import "RACDynamicSequence.h" -#import "RACEagerSequence.h" -#import "RACEmptySequence.h" -#import "RACScheduler.h" -#import "RACSignal.h" -#import "RACSubscriber.h" -#import "RACTuple.h" -#import "RACUnarySequence.h" - -// An enumerator over sequences. -@interface RACSequenceEnumerator : NSEnumerator - -// The sequence the enumerator is enumerating. -// -// This will change as the enumerator is exhausted. This property should only be -// accessed while synchronized on self. -@property (nonatomic, strong) RACSequence *sequence; - -@end - -@interface RACSequence () - -// Performs one iteration of lazy binding, passing through values from `current` -// until the sequence is exhausted, then recursively binding the remaining -// values in the receiver. -// -// Returns a new sequence which contains `current`, followed by the combined -// result of all applications of `block` to the remaining values in the receiver. -- (RACSequence *)bind:(RACSequenceBindBlock)block passingThroughValuesFromSequence:(RACSequence *)current; - -@end - -@implementation RACSequenceEnumerator - -- (id)nextObject { - id object = nil; - - @synchronized (self) { - object = self.sequence.head; - self.sequence = self.sequence.tail; - } - - return object; -} - -@end - -@implementation RACSequence - -#pragma mark Lifecycle - -+ (RACSequence *)sequenceWithHeadBlock:(id (^)(void))headBlock tailBlock:(RACSequence *(^)(void))tailBlock { - return [[RACDynamicSequence sequenceWithHeadBlock:headBlock tailBlock:tailBlock] setNameWithFormat:@"+sequenceWithHeadBlock:tailBlock:"]; -} - -#pragma mark Class cluster primitives - -- (id)head { - NSCAssert(NO, @"%s must be overridden by subclasses", __func__); - return nil; -} - -- (RACSequence *)tail { - NSCAssert(NO, @"%s must be overridden by subclasses", __func__); - return nil; -} - -#pragma mark RACStream - -+ (RACSequence *)empty { - return RACEmptySequence.empty; -} - -+ (RACSequence *)return:(id)value { - return [RACUnarySequence return:value]; -} - -- (RACSequence *)bind:(RACSequenceBindBlock (^)(void))block { - RACSequenceBindBlock bindBlock = block(); - return [[self bind:bindBlock passingThroughValuesFromSequence:nil] setNameWithFormat:@"[%@] -bind:", self.name]; -} - -- (RACSequence *)bind:(RACSequenceBindBlock)bindBlock passingThroughValuesFromSequence:(RACSequence *)passthroughSequence { - // Store values calculated in the dependency here instead, avoiding any kind - // of temporary collection and boxing. - // - // This relies on the implementation of RACDynamicSequence synchronizing - // access to its head, tail, and dependency, and we're only doing it because - // we really need the performance. - __block RACSequence *valuesSeq = self; - __block RACSequence *current = passthroughSequence; - __block BOOL stop = NO; - - RACSequence *sequence = [RACDynamicSequence sequenceWithLazyDependency:^ id { - while (current.head == nil) { - if (stop) return nil; - - // We've exhausted the current sequence, create a sequence from the - // next value. - id value = valuesSeq.head; - - if (value == nil) { - // We've exhausted all the sequences. - stop = YES; - return nil; - } - - current = (id)bindBlock(value, &stop); - if (current == nil) { - stop = YES; - return nil; - } - - valuesSeq = valuesSeq.tail; - } - - NSCAssert([current isKindOfClass:RACSequence.class], @"-bind: block returned an object that is not a sequence: %@", current); - return nil; - } headBlock:^(id _) { - return current.head; - } tailBlock:^ id (id _) { - if (stop) return nil; - - return [valuesSeq bind:bindBlock passingThroughValuesFromSequence:current.tail]; - }]; - - sequence.name = self.name; - return sequence; -} - -- (RACSequence *)concat:(RACSequence *)sequence { - NSCParameterAssert(sequence != nil); - - return [[[RACArraySequence sequenceWithArray:@[ self, sequence ] offset:0] - flatten] - setNameWithFormat:@"[%@] -concat: %@", self.name, sequence]; -} - -- (RACSequence *)zipWith:(RACSequence *)sequence { - NSCParameterAssert(sequence != nil); - - return [[RACSequence - sequenceWithHeadBlock:^ id { - if (self.head == nil || sequence.head == nil) return nil; - return RACTuplePack(self.head, sequence.head); - } tailBlock:^ id { - if (self.tail == nil || [[RACSequence empty] isEqual:self.tail]) return nil; - if (sequence.tail == nil || [[RACSequence empty] isEqual:sequence.tail]) return nil; - - return [self.tail zipWith:sequence.tail]; - }] - setNameWithFormat:@"[%@] -zipWith: %@", self.name, sequence]; -} - -#pragma mark Extended methods - -- (NSArray *)array { - NSMutableArray *array = [NSMutableArray array]; - for (id obj in self) { - [array addObject:obj]; - } - - return [array copy]; -} - -- (NSEnumerator *)objectEnumerator { - RACSequenceEnumerator *enumerator = [[RACSequenceEnumerator alloc] init]; - enumerator.sequence = self; - return enumerator; -} - -- (RACSignal *)signal { - return [[self signalWithScheduler:[RACScheduler scheduler]] setNameWithFormat:@"[%@] -signal", self.name]; -} - -- (RACSignal *)signalWithScheduler:(RACScheduler *)scheduler { - return [[RACSignal createSignal:^(id subscriber) { - __block RACSequence *sequence = self; - - return [scheduler scheduleRecursiveBlock:^(void (^reschedule)(void)) { - if (sequence.head == nil) { - [subscriber sendCompleted]; - return; - } - - [subscriber sendNext:sequence.head]; - - sequence = sequence.tail; - reschedule(); - }]; - }] setNameWithFormat:@"[%@] -signalWithScheduler: %@", self.name, scheduler]; -} - -- (id)foldLeftWithStart:(id)start reduce:(id (^)(id, id))reduce { - NSCParameterAssert(reduce != NULL); - - if (self.head == nil) return start; - - for (id value in self) { - start = reduce(start, value); - } - - return start; -} - -- (id)foldRightWithStart:(id)start reduce:(id (^)(id, RACSequence *))reduce { - NSCParameterAssert(reduce != NULL); - - if (self.head == nil) return start; - - RACSequence *rest = [RACSequence sequenceWithHeadBlock:^{ - if (self.tail) { - return [self.tail foldRightWithStart:start reduce:reduce]; - } else { - return start; - } - } tailBlock:nil]; - - return reduce(self.head, rest); -} - -- (BOOL)any:(BOOL (^)(id))block { - NSCParameterAssert(block != NULL); - - return [self objectPassingTest:block] != nil; -} - -- (BOOL)all:(BOOL (^)(id))block { - NSCParameterAssert(block != NULL); - - NSNumber *result = [self foldLeftWithStart:@YES reduce:^(NSNumber *accumulator, id value) { - return @(accumulator.boolValue && block(value)); - }]; - - return result.boolValue; -} - -- (id)objectPassingTest:(BOOL (^)(id))block { - NSCParameterAssert(block != NULL); - - return [self filter:block].head; -} - -- (RACSequence *)eagerSequence { - return [RACEagerSequence sequenceWithArray:self.array offset:0]; -} - -- (RACSequence *)lazySequence { - return self; -} - -#pragma mark NSCopying - -- (id)copyWithZone:(NSZone *)zone { - return self; -} - -#pragma mark NSCoding - -- (Class)classForCoder { - // Most sequences should be archived as RACArraySequences. - return RACArraySequence.class; -} - -- (id)initWithCoder:(NSCoder *)coder { - if (![self isKindOfClass:RACArraySequence.class]) return [[RACArraySequence alloc] initWithCoder:coder]; - - // Decoding is handled in RACArraySequence. - return [super init]; -} - -- (void)encodeWithCoder:(NSCoder *)coder { - [coder encodeObject:self.array forKey:@"array"]; -} - -#pragma mark NSFastEnumeration - -- (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state objects:(__unsafe_unretained id *)stackbuf count:(NSUInteger)len { - if (state->state == ULONG_MAX) { - // Enumeration has completed. - return 0; - } - - // We need to traverse the sequence itself on repeated calls to this - // method, so use the 'state' field to track the current head. - RACSequence *(^getSequence)(void) = ^{ - return (__bridge RACSequence *)(void *)state->state; - }; - - void (^setSequence)(RACSequence *) = ^(RACSequence *sequence) { - // Release the old sequence and retain the new one. - CFBridgingRelease((void *)state->state); - - state->state = (unsigned long)CFBridgingRetain(sequence); - }; - - void (^complete)(void) = ^{ - // Release any stored sequence. - setSequence(nil); - state->state = ULONG_MAX; - }; - - if (state->state == 0) { - // Since a sequence doesn't mutate, this just needs to be set to - // something non-NULL. - state->mutationsPtr = state->extra; - - setSequence(self); - } - - state->itemsPtr = stackbuf; - - NSUInteger enumeratedCount = 0; - while (enumeratedCount < len) { - RACSequence *seq = getSequence(); - - // Because the objects in a sequence may be generated lazily, we want to - // prevent them from being released until the enumerator's used them. - __autoreleasing id obj = seq.head; - if (obj == nil) { - complete(); - break; - } - - stackbuf[enumeratedCount++] = obj; - - if (seq.tail == nil) { - complete(); - break; - } - - setSequence(seq.tail); - } - - return enumeratedCount; -} - -#pragma mark NSObject - -- (NSUInteger)hash { - return [self.head hash]; -} - -- (BOOL)isEqual:(RACSequence *)seq { - if (self == seq) return YES; - if (![seq isKindOfClass:RACSequence.class]) return NO; - - for (id selfObj in self) { - id seqObj = seq.head; - - // Handles the nil case too. - if (![seqObj isEqual:selfObj]) return NO; - - seq = seq.tail; - } - - // self is now depleted -- the argument should be too. - return (seq.head == nil); -} - -@end diff --git a/LookInside/ReactiveObjC/RACSerialDisposable.h b/LookInside/ReactiveObjC/RACSerialDisposable.h deleted file mode 100644 index c45fb756..00000000 --- a/LookInside/ReactiveObjC/RACSerialDisposable.h +++ /dev/null @@ -1,47 +0,0 @@ -// -// RACSerialDisposable.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-07-22. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACDisposable.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A disposable that contains exactly one other disposable and allows it to be -/// swapped out atomically. -@interface RACSerialDisposable : RACDisposable - -/// The inner disposable managed by the serial disposable. -/// -/// This property is thread-safe for reading and writing. However, if you want to -/// read the current value _and_ write a new one atomically, use -/// -swapInDisposable: instead. -/// -/// Disposing of the receiver will also dispose of the current disposable set for -/// this property, then set the property to nil. If any new disposable is set -/// after the receiver is disposed, it will be disposed immediately and this -/// property will remain set to nil. -@property (atomic, strong, nullable) RACDisposable *disposable; - -/// Creates a serial disposable which will wrap the given disposable. -/// -/// disposable - The value to set for `disposable`. This may be nil. -/// -/// Returns a RACSerialDisposable. -+ (instancetype)serialDisposableWithDisposable:(nullable RACDisposable *)disposable; - -/// Atomically swaps the receiver's `disposable` for `newDisposable`. -/// -/// newDisposable - The new value for `disposable`. If the receiver has already -/// been disposed, this disposable will be too, and `disposable` -/// will remain set to nil. This argument may be nil. -/// -/// Returns the previous value for the `disposable` property. -- (nullable RACDisposable *)swapInDisposable:(nullable RACDisposable *)newDisposable; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACSerialDisposable.m b/LookInside/ReactiveObjC/RACSerialDisposable.m deleted file mode 100644 index 38a9f1a8..00000000 --- a/LookInside/ReactiveObjC/RACSerialDisposable.m +++ /dev/null @@ -1,121 +0,0 @@ -// -// RACSerialDisposable.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-07-22. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSerialDisposable.h" -#import - -@interface RACSerialDisposable () { - // The receiver's `disposable`. This variable must only be referenced while - // _mutex is held. - RACDisposable * _disposable; - - // YES if the receiver has been disposed. This variable must only be accessed - // while _mutex is held. - BOOL _disposed; - - // A mutex to protect access to _disposable and _disposed. - pthread_mutex_t _mutex; -} - -@end - -@implementation RACSerialDisposable - -#pragma mark Properties - -- (BOOL)isDisposed { - pthread_mutex_lock(&_mutex); - const BOOL disposed = _disposed; - pthread_mutex_unlock(&_mutex); - - return disposed; -} - -- (RACDisposable *)disposable { - pthread_mutex_lock(&_mutex); - RACDisposable * const result = _disposable; - pthread_mutex_unlock(&_mutex); - - return result; -} - -- (void)setDisposable:(RACDisposable *)disposable { - [self swapInDisposable:disposable]; -} - -#pragma mark Lifecycle - -+ (instancetype)serialDisposableWithDisposable:(RACDisposable *)disposable { - RACSerialDisposable *serialDisposable = [[self alloc] init]; - serialDisposable.disposable = disposable; - return serialDisposable; -} - -- (instancetype)init { - self = [super init]; - if (self == nil) return nil; - - const int result __attribute__((unused)) = pthread_mutex_init(&_mutex, NULL); - NSCAssert(0 == result, @"Failed to initialize mutex with error %d", result); - - return self; -} - -- (instancetype)initWithBlock:(void (^)(void))block { - self = [self init]; - if (self == nil) return nil; - - self.disposable = [RACDisposable disposableWithBlock:block]; - - return self; -} - -- (void)dealloc { - const int result __attribute__((unused)) = pthread_mutex_destroy(&_mutex); - NSCAssert(0 == result, @"Failed to destroy mutex with error %d", result); -} - -#pragma mark Inner Disposable - -- (RACDisposable *)swapInDisposable:(RACDisposable *)newDisposable { - RACDisposable *existingDisposable; - BOOL alreadyDisposed; - - pthread_mutex_lock(&_mutex); - alreadyDisposed = _disposed; - if (!alreadyDisposed) { - existingDisposable = _disposable; - _disposable = newDisposable; - } - pthread_mutex_unlock(&_mutex); - - if (alreadyDisposed) { - [newDisposable dispose]; - return nil; - } - - return existingDisposable; -} - -#pragma mark Disposal - -- (void)dispose { - RACDisposable *existingDisposable; - - pthread_mutex_lock(&_mutex); - if (!_disposed) { - existingDisposable = _disposable; - _disposed = YES; - _disposable = nil; - } - pthread_mutex_unlock(&_mutex); - - [existingDisposable dispose]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACSignal+Operations.h b/LookInside/ReactiveObjC/RACSignal+Operations.h deleted file mode 100644 index 3c9ef046..00000000 --- a/LookInside/ReactiveObjC/RACSignal+Operations.h +++ /dev/null @@ -1,714 +0,0 @@ -// -// RACSignal+Operations.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-09-06. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import -#import "RACSignal.h" - -@class RACCommand; -@class RACDisposable; -@class RACMulticastConnection<__covariant ValueType>; -@class RACScheduler; -@class RACSequence<__covariant ValueType>; -@class RACSubject; -@class RACTuple; -@class RACTwoTuple<__covariant First, __covariant Second>; -@class RACEvent<__covariant ValueType>; -@class RACGroupedSignal; -@protocol RACSubscriber; - -NS_ASSUME_NONNULL_BEGIN - -/// The domain for errors originating in RACSignal operations. -extern NSString * const RACSignalErrorDomain; - -/// The error code used with -timeout:. -extern const NSInteger RACSignalErrorTimedOut; - -/// The error code used when a value passed into +switch:cases:default: does not -/// match any of the cases, and no default was given. -extern const NSInteger RACSignalErrorNoMatchingCase; - -@interface RACSignal<__covariant ValueType> (Operations) - -/// Do the given block on `next`. This should be used to inject side effects into -/// the signal. -- (RACSignal *)doNext:(void (^)(ValueType _Nullable x))block RAC_WARN_UNUSED_RESULT; - -/// Do the given block on `error`. This should be used to inject side effects -/// into the signal. -- (RACSignal *)doError:(void (^)(NSError * _Nonnull error))block RAC_WARN_UNUSED_RESULT; - -/// Do the given block on `completed`. This should be used to inject side effects -/// into the signal. -- (RACSignal *)doCompleted:(void (^)(void))block RAC_WARN_UNUSED_RESULT; - -/// Sends `next`s only if we don't receive another `next` in `interval` seconds. -/// -/// If a `next` is received, and then another `next` is received before -/// `interval` seconds have passed, the first value is discarded. -/// -/// After `interval` seconds have passed since the most recent `next` was sent, -/// the most recent `next` is forwarded on the scheduler that the value was -/// originally received on. If +[RACScheduler currentScheduler] was nil at the -/// time, a private background scheduler is used. -/// -/// Returns a signal which sends throttled and delayed `next` events. Completion -/// and errors are always forwarded immediately. -- (RACSignal *)throttle:(NSTimeInterval)interval RAC_WARN_UNUSED_RESULT; - -/// Throttles `next`s for which `predicate` returns YES. -/// -/// When `predicate` returns YES for a `next`: -/// -/// 1. If another `next` is received before `interval` seconds have passed, the -/// prior value is discarded. This happens regardless of whether the new -/// value will be throttled. -/// 2. After `interval` seconds have passed since the value was originally -/// received, it will be forwarded on the scheduler that it was received -/// upon. If +[RACScheduler currentScheduler] was nil at the time, a private -/// background scheduler is used. -/// -/// When `predicate` returns NO for a `next`, it is forwarded immediately, -/// without any throttling. -/// -/// interval - The number of seconds for which to buffer the latest value that -/// passes `predicate`. -/// predicate - Passed each `next` from the receiver, this block returns -/// whether the given value should be throttled. This argument must -/// not be nil. -/// -/// Returns a signal which sends `next` events, throttled when `predicate` -/// returns YES. Completion and errors are always forwarded immediately. -- (RACSignal *)throttle:(NSTimeInterval)interval valuesPassingTest:(BOOL (^)(id _Nullable next))predicate RAC_WARN_UNUSED_RESULT; - -/// Forwards `next` and `completed` events after delaying for `interval` seconds -/// on the current scheduler (on which the events were delivered). -/// -/// If +[RACScheduler currentScheduler] is nil when `next` or `completed` is -/// received, a private background scheduler is used. -/// -/// Returns a signal which sends delayed `next` and `completed` events. Errors -/// are always forwarded immediately. -- (RACSignal *)delay:(NSTimeInterval)interval RAC_WARN_UNUSED_RESULT; - -/// Resubscribes when the signal completes. -- (RACSignal *)repeat RAC_WARN_UNUSED_RESULT; - -/// Executes the given block each time a subscription is created. -/// -/// block - A block which defines the subscription side effects. Cannot be `nil`. -/// -/// Example: -/// -/// // Write new file, with backup. -/// [[[[fileManager -/// rac_createFileAtPath:path contents:data] -/// initially:^{ -/// // 2. Second, backup current file -/// [fileManager moveItemAtPath:path toPath:backupPath error:nil]; -/// }] -/// initially:^{ -/// // 1. First, acquire write lock. -/// [writeLock lock]; -/// }] -/// finally:^{ -/// [writeLock unlock]; -/// }]; -/// -/// Returns a signal that passes through all events of the receiver, plus -/// introduces side effects which occur prior to any subscription side effects -/// of the receiver. -- (RACSignal *)initially:(void (^)(void))block RAC_WARN_UNUSED_RESULT; - -/// Executes the given block when the signal completes or errors. -- (RACSignal *)finally:(void (^)(void))block RAC_WARN_UNUSED_RESULT; - -/// Divides the receiver's `next`s into buffers which deliver every `interval` -/// seconds. -/// -/// interval - The interval in which values are grouped into one buffer. -/// scheduler - The scheduler upon which the returned signal will deliver its -/// values. This must not be nil or +[RACScheduler -/// immediateScheduler]. -/// -/// Returns a signal which sends RACTuples of the buffered values at each -/// interval on `scheduler`. When the receiver completes, any currently-buffered -/// values will be sent immediately. -- (RACSignal *)bufferWithTime:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler; - -/// Collects all receiver's `next`s into a NSArray. Nil values will be converted -/// to NSNull. -/// -/// This corresponds to the `ToArray` method in Rx. -/// -/// Returns a signal which sends a single NSArray when the receiver completes -/// successfully. -- (RACSignal *> *)collect RAC_WARN_UNUSED_RESULT; - -/// Takes the last `count` `next`s after the receiving signal completes. -- (RACSignal *)takeLast:(NSUInteger)count RAC_WARN_UNUSED_RESULT; - -/// Combines the latest values from the receiver and the given signal into -/// 2-tuples, once both have sent at least one `next`. -/// -/// Any additional `next`s will result in a new RACTuple with the latest values -/// from both signals. -/// -/// signal - The signal to combine with. This argument must not be nil. -/// -/// Returns a signal which sends RACTuples of the combined values, forwards any -/// `error` events, and completes when both input signals complete. -- (RACSignal *> *)combineLatestWith:(RACSignal *)signal RAC_WARN_UNUSED_RESULT; - -/// Combines the latest values from the given signals into RACTuples, once all -/// the signals have sent at least one `next`. -/// -/// Any additional `next`s will result in a new RACTuple with the latest values -/// from all signals. -/// -/// signals - The signals to combine. If this collection is empty, the returned -/// signal will immediately complete upon subscription. -/// -/// Returns a signal which sends RACTuples of the combined values, forwards any -/// `error` events, and completes when all input signals complete. -+ (RACSignal *)combineLatest:(id)signals RAC_WARN_UNUSED_RESULT; - -/// Combines signals using +combineLatest:, then reduces the resulting tuples -/// into a single value using -reduceEach:. -/// -/// signals - The signals to combine. If this collection is empty, the -/// returned signal will immediately complete upon subscription. -/// reduceBlock - The block which reduces the latest values from all the -/// signals into one value. It must take as many arguments as the -/// number of signals given. Each argument will be an object -/// argument. The return value must be an object. This argument -/// must not be nil. -/// -/// Example: -/// -/// [RACSignal combineLatest:@[ stringSignal, intSignal ] reduce:^(NSString *string, NSNumber *number) { -/// return [NSString stringWithFormat:@"%@: %@", string, number]; -/// }]; -/// -/// Returns a signal which sends the results from each invocation of -/// `reduceBlock`. -+ (RACSignal *)combineLatest:(id)signals reduce:(RACGenericReduceBlock)reduceBlock RAC_WARN_UNUSED_RESULT; - - -/// Merges the receiver and the given signal with `+merge:` and returns the -/// resulting signal. -- (RACSignal *)merge:(RACSignal *)signal RAC_WARN_UNUSED_RESULT; - -/// Sends the latest `next` from any of the signals. -/// -/// Returns a signal that passes through values from each of the given signals, -/// and sends `completed` when all of them complete. If any signal sends an error, -/// the returned signal sends `error` immediately. -+ (RACSignal *)merge:(id)signals RAC_WARN_UNUSED_RESULT; - -/// Merges the signals sent by the receiver into a flattened signal, but only -/// subscribes to `maxConcurrent` number of signals at a time. New signals are -/// queued and subscribed to as other signals complete. -/// -/// If an error occurs on any of the signals, it is sent on the returned signal. -/// It completes only after the receiver and all sent signals have completed. -/// -/// This corresponds to `Merge(IObservable>, Int32)` -/// in Rx. -/// -/// maxConcurrent - the maximum number of signals to subscribe to at a -/// time. If 0, it subscribes to an unlimited number of -/// signals. -- (RACSignal *)flatten:(NSUInteger)maxConcurrent RAC_WARN_UNUSED_RESULT; - -/// Ignores all `next`s from the receiver, waits for the receiver to complete, -/// then subscribes to a new signal. -/// -/// block - A block which will create or obtain a new signal to subscribe to, -/// executed only after the receiver completes. This block must not be -/// nil, and it must not return a nil signal. -/// -/// Returns a signal which will pass through the events of the signal created in -/// `block`. If the receiver errors out, the returned signal will error as well. -- (RACSignal *)then:(RACSignal * (^)(void))block RAC_WARN_UNUSED_RESULT; - -/// Concats the inner signals of a signal of signals. -- (RACSignal *)concat RAC_WARN_UNUSED_RESULT; - -/// Aggregates the `next` values of the receiver into a single combined value. -/// -/// The algorithm proceeds as follows: -/// -/// 1. `start` is passed into the block as the `running` value, and the first -/// element of the receiver is passed into the block as the `next` value. -/// 2. The result of the invocation (`running`) and the next element of the -/// receiver (`next`) is passed into `reduceBlock`. -/// 3. Steps 2 and 3 are repeated until all values have been processed. -/// 4. The last result of `reduceBlock` is sent on the returned signal. -/// -/// This method is similar to -scanWithStart:reduce:, except that only the -/// final result is sent on the returned signal. -/// -/// start - The value to be combined with the first element of the -/// receiver. This value may be `nil`. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. If the receiver is empty, this block will never be -/// invoked. Cannot be nil. -/// -/// Returns a signal that will send the aggregated value when the receiver -/// completes, then itself complete. If the receiver never sends any values, -/// `start` will be sent instead. -- (RACSignal *)aggregateWithStart:(id)start reduce:(id (^)(id running, id next))reduceBlock RAC_WARN_UNUSED_RESULT; - -/// Aggregates the `next` values of the receiver into a single combined value. -/// This is indexed version of -aggregateWithStart:reduce:. -/// -/// start - The value to be combined with the first element of the -/// receiver. This value may be `nil`. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. This block takes zero-based index value as the last -/// parameter. If the receiver is empty, this block will never be -/// invoked. Cannot be nil. -/// -/// Returns a signal that will send the aggregated value when the receiver -/// completes, then itself complete. If the receiver never sends any values, -/// `start` will be sent instead. -- (RACSignal *)aggregateWithStart:(id)start reduceWithIndex:(id (^)(id running, id next, NSUInteger index))reduceBlock RAC_WARN_UNUSED_RESULT; - -/// Aggregates the `next` values of the receiver into a single combined value. -/// -/// This invokes `startFactory` block on each subscription, then calls -/// -aggregateWithStart:reduce: with the return value of the block as start value. -/// -/// startFactory - The block that returns start value which will be combined -/// with the first element of the receiver. Cannot be nil. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. If the receiver is empty, this block will never be -/// invoked. Cannot be nil. -/// -/// Returns a signal that will send the aggregated value when the receiver -/// completes, then itself complete. If the receiver never sends any values, -/// the return value of `startFactory` will be sent instead. -- (RACSignal *)aggregateWithStartFactory:(id (^)(void))startFactory reduce:(id (^)(id running, id next))reduceBlock RAC_WARN_UNUSED_RESULT; - -/// Invokes -setKeyPath:onObject:nilValue: with `nil` for the nil value. -/// -/// WARNING: Under certain conditions, this method is known to be thread-unsafe. -/// See the description in -setKeyPath:onObject:nilValue:. -- (RACDisposable *)setKeyPath:(NSString *)keyPath onObject:(NSObject *)object; - -/// Binds the receiver to an object, automatically setting the given key path on -/// every `next`. When the signal completes, the binding is automatically -/// disposed of. -/// -/// WARNING: Under certain conditions, this method is known to be thread-unsafe. -/// A crash can result if `object` is deallocated concurrently on -/// another thread within a window of time between a value being sent -/// on this signal and immediately prior to the invocation of -/// -setValue:forKeyPath:, which sets the property. To prevent this, -/// ensure `object` is deallocated on the same thread the receiver -/// sends on, or ensure that the returned disposable is disposed of -/// before `object` deallocates. -/// See https://github.com/ReactiveCocoa/ReactiveCocoa/pull/1184 -/// -/// Sending an error on the signal is considered undefined behavior, and will -/// generate an assertion failure in Debug builds. -/// -/// A given key on an object should only have one active signal bound to it at any -/// given time. Binding more than one signal to the same property is considered -/// undefined behavior. -/// -/// keyPath - The key path to update with `next`s from the receiver. -/// object - The object that `keyPath` is relative to. -/// nilValue - The value to set at the key path whenever `nil` is sent by the -/// receiver. This may be nil when binding to object properties, but -/// an NSValue should be used for primitive properties, to avoid an -/// exception if `nil` is sent (which might occur if an intermediate -/// object is set to `nil`). -/// -/// Returns a disposable which can be used to terminate the binding. -- (RACDisposable *)setKeyPath:(NSString *)keyPath onObject:(NSObject *)object nilValue:(nullable id)nilValue; - -/// Sends NSDate.date every `interval` seconds. -/// -/// interval - The time interval in seconds at which the current time is sent. -/// scheduler - The scheduler upon which the current NSDate should be sent. This -/// must not be nil or +[RACScheduler immediateScheduler]. -/// -/// Returns a signal that sends the current date/time every `interval` on -/// `scheduler`. -+ (RACSignal *)interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler RAC_WARN_UNUSED_RESULT; - -/// Sends NSDate.date at intervals of at least `interval` seconds, up to -/// approximately `interval` + `leeway` seconds. -/// -/// The created signal will defer sending each `next` for at least `interval` -/// seconds, and for an additional amount of time up to `leeway` seconds in the -/// interest of performance or power consumption. Note that some additional -/// latency is to be expected, even when specifying a `leeway` of 0. -/// -/// interval - The base interval between `next`s. -/// scheduler - The scheduler upon which the current NSDate should be sent. This -/// must not be nil or +[RACScheduler immediateScheduler]. -/// leeway - The maximum amount of additional time the `next` can be deferred. -/// -/// Returns a signal that sends the current date/time at intervals of at least -/// `interval seconds` up to approximately `interval` + `leeway` seconds on -/// `scheduler`. -+ (RACSignal *)interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler withLeeway:(NSTimeInterval)leeway RAC_WARN_UNUSED_RESULT; - -/// Takes `next`s until the `signalTrigger` sends `next` or `completed`. -/// -/// Returns a signal which passes through all events from the receiver until -/// `signalTrigger` sends `next` or `completed`, at which point the returned signal -/// will send `completed`. -- (RACSignal *)takeUntil:(RACSignal *)signalTrigger RAC_WARN_UNUSED_RESULT; - -/// Takes `next`s until the `replacement` sends an event. -/// -/// replacement - The signal which replaces the receiver as soon as it sends an -/// event. -/// -/// Returns a signal which passes through `next`s and `error` from the receiver -/// until `replacement` sends an event, at which point the returned signal will -/// send that event and switch to passing through events from `replacement` -/// instead, regardless of whether the receiver has sent events already. -- (RACSignal *)takeUntilReplacement:(RACSignal *)replacement RAC_WARN_UNUSED_RESULT; - -/// Subscribes to the returned signal when an error occurs. -- (RACSignal *)catch:(RACSignal * (^)(NSError * _Nonnull error))catchBlock RAC_WARN_UNUSED_RESULT; - -/// Subscribes to the given signal when an error occurs. -- (RACSignal *)catchTo:(RACSignal *)signal RAC_WARN_UNUSED_RESULT; - -/// Returns a signal that will either immediately send the return value of -/// `tryBlock` and complete, or error using the `NSError` passed out from the -/// block. -/// -/// tryBlock - An action that performs some computation that could fail. If the -/// block returns nil, the block must return an error via the -/// `errorPtr` parameter. -/// -/// Example: -/// -/// [RACSignal try:^(NSError **error) { -/// return [NSJSONSerialization JSONObjectWithData:someJSONData options:0 error:error]; -/// }]; -+ (RACSignal *)try:(nullable ValueType (^)(NSError **errorPtr))tryBlock RAC_WARN_UNUSED_RESULT; - -/// Runs `tryBlock` against each of the receiver's values, passing values -/// until `tryBlock` returns NO, or the receiver completes. -/// -/// tryBlock - An action to run against each of the receiver's values. -/// The block should return YES to indicate that the action was -/// successful. This block must not be nil. -/// -/// Example: -/// -/// // The returned signal will send an error if data values cannot be -/// // written to `someFileURL`. -/// [signal try:^(NSData *data, NSError **errorPtr) { -/// return [data writeToURL:someFileURL options:NSDataWritingAtomic error:errorPtr]; -/// }]; -/// -/// Returns a signal which passes through all the values of the receiver. If -/// `tryBlock` fails for any value, the returned signal will error using the -/// `NSError` passed out from the block. -- (RACSignal *)try:(BOOL (^)(id _Nullable value, NSError **errorPtr))tryBlock RAC_WARN_UNUSED_RESULT; - -/// Runs `mapBlock` against each of the receiver's values, mapping values until -/// `mapBlock` returns nil, or the receiver completes. -/// -/// mapBlock - An action to map each of the receiver's values. The block should -/// return a non-nil value to indicate that the action was successful. -/// This block must not be nil. -/// -/// Example: -/// -/// // The returned signal will send an error if data cannot be read from -/// // `fileURL`. -/// [signal tryMap:^(NSURL *fileURL, NSError **errorPtr) { -/// return [NSData dataWithContentsOfURL:fileURL options:0 error:errorPtr]; -/// }]; -/// -/// Returns a signal which transforms all the values of the receiver. If -/// `mapBlock` returns nil for any value, the returned signal will error using -/// the `NSError` passed out from the block. -- (RACSignal *)tryMap:(id (^)(id _Nullable value, NSError **errorPtr))mapBlock RAC_WARN_UNUSED_RESULT; - -/// Returns the first `next`. Note that this is a blocking call. -- (nullable ValueType)first; - -/// Returns the first `next` or `defaultValue` if the signal completes or errors -/// without sending a `next`. Note that this is a blocking call. -- (nullable ValueType)firstOrDefault:(nullable ValueType)defaultValue; - -/// Returns the first `next` or `defaultValue` if the signal completes or errors -/// without sending a `next`. If an error occurs success will be NO and error -/// will be populated. Note that this is a blocking call. -/// -/// Both success and error may be NULL. -- (nullable ValueType)firstOrDefault:(nullable ValueType)defaultValue success:(nullable BOOL *)success error:(NSError * _Nullable * _Nullable)error; - -/// Blocks the caller and waits for the signal to complete. -/// -/// error - If not NULL, set to any error that occurs. -/// -/// Returns whether the signal completed successfully. If NO, `error` will be set -/// to the error that occurred. -- (BOOL)waitUntilCompleted:(NSError * _Nullable * _Nullable)error; - -/// Defers creation of a signal until the signal's actually subscribed to. -/// -/// This can be used to effectively turn a hot signal into a cold signal. -+ (RACSignal *)defer:(RACSignal * (^)(void))block RAC_WARN_UNUSED_RESULT; - -/// Every time the receiver sends a new RACSignal, subscribes and sends `next`s and -/// `error`s only for that signal. -/// -/// The receiver must be a signal of signals. -/// -/// Returns a signal which passes through `next`s and `error`s from the latest -/// signal sent by the receiver, and sends `completed` when both the receiver and -/// the last sent signal complete. -- (RACSignal *)switchToLatest RAC_WARN_UNUSED_RESULT; - -/// Switches between the signals in `cases` as well as `defaultSignal` based on -/// the latest value sent by `signal`. -/// -/// signal - A signal of objects used as keys in the `cases` dictionary. -/// This argument must not be nil. -/// cases - A dictionary that has signals as values. This argument must -/// not be nil. A RACTupleNil key in this dictionary will match -/// nil `next` events that are received on `signal`. -/// defaultSignal - The signal to pass through after `signal` sends a value for -/// which `cases` does not contain a signal. If nil, any -/// unmatched values will result in -/// a RACSignalErrorNoMatchingCase error. -/// -/// Returns a signal which passes through `next`s and `error`s from one of the -/// the signals in `cases` or `defaultSignal`, and sends `completed` when both -/// `signal` and the last used signal complete. If no `defaultSignal` is given, -/// an unmatched `next` will result in an error on the returned signal. -+ (RACSignal *)switch:(RACSignal *)signal cases:(NSDictionary *)cases default:(nullable RACSignal *)defaultSignal RAC_WARN_UNUSED_RESULT; - -/// Switches between `trueSignal` and `falseSignal` based on the latest value -/// sent by `boolSignal`. -/// -/// boolSignal - A signal of BOOLs determining whether `trueSignal` or -/// `falseSignal` should be active. This argument must not be nil. -/// trueSignal - The signal to pass through after `boolSignal` has sent YES. -/// This argument must not be nil. -/// falseSignal - The signal to pass through after `boolSignal` has sent NO. This -/// argument must not be nil. -/// -/// Returns a signal which passes through `next`s and `error`s from `trueSignal` -/// and/or `falseSignal`, and sends `completed` when both `boolSignal` and the -/// last switched signal complete. -+ (RACSignal *)if:(RACSignal *)boolSignal then:(RACSignal *)trueSignal else:(RACSignal *)falseSignal RAC_WARN_UNUSED_RESULT; - -/// Adds every `next` to an array. Nils are represented by NSNulls. Note that -/// this is a blocking call. -/// -/// **This is not the same as the `ToArray` method in Rx.** See -collect for -/// that behavior instead. -/// -/// Returns the array of `next` values, or nil if an error occurs. -- (nullable NSArray *)toArray; - -/// Adds every `next` to a sequence. Nils are represented by NSNulls. -/// -/// This corresponds to the `ToEnumerable` method in Rx. -/// -/// Returns a sequence which provides values from the signal as they're sent. -/// Trying to retrieve a value from the sequence which has not yet been sent will -/// block. -@property (nonatomic, strong, readonly) RACSequence *sequence; - -/// Creates and returns a multicast connection. This allows you to share a single -/// subscription to the underlying signal. -- (RACMulticastConnection *)publish RAC_WARN_UNUSED_RESULT; - -/// Creates and returns a multicast connection that pushes values into the given -/// subject. This allows you to share a single subscription to the underlying -/// signal. -- (RACMulticastConnection *)multicast:(RACSubject *)subject RAC_WARN_UNUSED_RESULT; - -/// Multicasts the signal to a RACReplaySubject of unlimited capacity, and -/// immediately connects to the resulting RACMulticastConnection. -/// -/// Returns the connected, multicasted signal. -- (RACSignal *)replay; - -/// Multicasts the signal to a RACReplaySubject of capacity 1, and immediately -/// connects to the resulting RACMulticastConnection. -/// -/// Returns the connected, multicasted signal. -- (RACSignal *)replayLast; - -/// Multicasts the signal to a RACReplaySubject of unlimited capacity, and -/// lazily connects to the resulting RACMulticastConnection. -/// -/// This means the returned signal will subscribe to the multicasted signal only -/// when the former receives its first subscription. -/// -/// Returns the lazily connected, multicasted signal. -- (RACSignal *)replayLazily; - -/// Sends an error after `interval` seconds if the source doesn't complete -/// before then. -/// -/// The error will be in the RACSignalErrorDomain and have a code of -/// RACSignalErrorTimedOut. -/// -/// interval - The number of seconds after which the signal should error out. -/// scheduler - The scheduler upon which any timeout error should be sent. This -/// must not be nil or +[RACScheduler immediateScheduler]. -/// -/// Returns a signal that passes through the receiver's events, until the stream -/// finishes or times out, at which point an error will be sent on `scheduler`. -- (RACSignal *)timeout:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler RAC_WARN_UNUSED_RESULT; - -/// Creates and returns a signal that delivers its events on the given scheduler. -/// Any side effects of the receiver will still be performed on the original -/// thread. -/// -/// This is ideal when the signal already performs its work on the desired -/// thread, but you want to handle its events elsewhere. -/// -/// This corresponds to the `ObserveOn` method in Rx. -- (RACSignal *)deliverOn:(RACScheduler *)scheduler RAC_WARN_UNUSED_RESULT; - -/// Creates and returns a signal that executes its side effects and delivers its -/// events on the given scheduler. -/// -/// Use of this operator should be avoided whenever possible, because the -/// receiver's side effects may not be safe to run on another thread. If you just -/// want to receive the signal's events on `scheduler`, use -deliverOn: instead. -- (RACSignal *)subscribeOn:(RACScheduler *)scheduler RAC_WARN_UNUSED_RESULT; - -/// Creates and returns a signal that delivers its events on the main thread. -/// If events are already being sent on the main thread, they may be passed on -/// without delay. An event will instead be queued for later delivery on the main -/// thread if sent on another thread, or if a previous event is already being -/// processed, or has been queued. -/// -/// Any side effects of the receiver will still be performed on the original -/// thread. -/// -/// This can be used when a signal will cause UI updates, to avoid potential -/// flicker caused by delayed delivery of events, such as the first event from -/// a RACObserve at view instantiation. -- (RACSignal *)deliverOnMainThread RAC_WARN_UNUSED_RESULT; - -/// Groups each received object into a group, as determined by calling `keyBlock` -/// with that object. The object sent is transformed by calling `transformBlock` -/// with the object. If `transformBlock` is nil, it sends the original object. -/// -/// The returned signal is a signal of RACGroupedSignal. -- (RACSignal *)groupBy:(id _Nullable (^)(id _Nullable object))keyBlock transform:(nullable id _Nullable (^)(id _Nullable object))transformBlock RAC_WARN_UNUSED_RESULT; - -/// Calls -[RACSignal groupBy:keyBlock transform:nil]. -- (RACSignal *)groupBy:(id _Nullable (^)(id _Nullable object))keyBlock RAC_WARN_UNUSED_RESULT; - -/// Sends an [NSNumber numberWithBool:YES] if the receiving signal sends any -/// objects. -- (RACSignal *)any RAC_WARN_UNUSED_RESULT; - -/// Sends an [NSNumber numberWithBool:YES] if the receiving signal sends any -/// objects that pass `predicateBlock`. -/// -/// predicateBlock - cannot be nil. -- (RACSignal *)any:(BOOL (^)(id _Nullable object))predicateBlock RAC_WARN_UNUSED_RESULT; - -/// Sends an [NSNumber numberWithBool:YES] if all the objects the receiving -/// signal sends pass `predicateBlock`. -/// -/// predicateBlock - cannot be nil. -- (RACSignal *)all:(BOOL (^)(id _Nullable object))predicateBlock RAC_WARN_UNUSED_RESULT; - -/// Resubscribes to the receiving signal if an error occurs, up until it has -/// retried the given number of times. -/// -/// retryCount - if 0, it keeps retrying until it completes. -- (RACSignal *)retry:(NSInteger)retryCount RAC_WARN_UNUSED_RESULT; - -/// Resubscribes to the receiving signal if an error occurs. -- (RACSignal *)retry RAC_WARN_UNUSED_RESULT; - -/// Sends the latest value from the receiver only when `sampler` sends a value. -/// The returned signal could repeat values if `sampler` fires more often than -/// the receiver. Values from `sampler` are ignored before the receiver sends -/// its first value. -/// -/// sampler - The signal that controls when the latest value from the receiver -/// is sent. Cannot be nil. -- (RACSignal *)sample:(RACSignal *)sampler RAC_WARN_UNUSED_RESULT; - -/// Ignores all `next`s from the receiver. -/// -/// Returns a signal which only passes through `error` or `completed` events from -/// the receiver. -- (RACSignal *)ignoreValues RAC_WARN_UNUSED_RESULT; - -/// Converts each of the receiver's events into a RACEvent object. -/// -/// Returns a signal which sends the receiver's events as RACEvents, and -/// completes after the receiver sends `completed` or `error`. -- (RACSignal *> *)materialize RAC_WARN_UNUSED_RESULT; - -/// Converts each RACEvent in the receiver back into "real" RACSignal events. -/// -/// Returns a signal which sends `next` for each value RACEvent, `error` for each -/// error RACEvent, and `completed` for each completed RACEvent. -- (RACSignal *)dematerialize RAC_WARN_UNUSED_RESULT; - -/// Inverts each NSNumber-wrapped BOOL sent by the receiver. It will assert if -/// the receiver sends anything other than NSNumbers. -/// -/// Returns a signal of inverted NSNumber-wrapped BOOLs. -- (RACSignal *)not RAC_WARN_UNUSED_RESULT; - -/// Performs a boolean AND on all of the RACTuple of NSNumbers in sent by the receiver. -/// -/// Asserts if the receiver sends anything other than a RACTuple of one or more NSNumbers. -/// -/// Returns a signal that applies AND to each NSNumber in the tuple. -- (RACSignal *)and RAC_WARN_UNUSED_RESULT; - -/// Performs a boolean OR on all of the RACTuple of NSNumbers in sent by the receiver. -/// -/// Asserts if the receiver sends anything other than a RACTuple of one or more NSNumbers. -/// -/// Returns a signal that applies OR to each NSNumber in the tuple. -- (RACSignal *)or RAC_WARN_UNUSED_RESULT; - -/// Sends the result of calling the block with arguments as packed in each RACTuple -/// sent by the receiver. -/// -/// The receiver must send tuple values, where the first element of the tuple is -/// a block, taking a number of parameters equal to the count of the remaining -/// elements of the tuple, and returning an object. Each block must take at least -/// one argument, so each tuple must contain at least 2 elements. -/// -/// Example: -/// -/// RACSignal *adder = [RACSignal return:^(NSNumber *a, NSNumber *b) { -/// return @(a.intValue + b.intValue); -/// }]; -/// RACSignal *sums = [[RACSignal -/// combineLatest:@[ adder, as, bs ]] -/// reduceApply]; -/// -/// Returns a signal of the result of applying the first element of each tuple -/// to the remaining elements. -- (RACSignal *)reduceApply RAC_WARN_UNUSED_RESULT; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACSignal+Operations.m b/LookInside/ReactiveObjC/RACSignal+Operations.m deleted file mode 100644 index 94465037..00000000 --- a/LookInside/ReactiveObjC/RACSignal+Operations.m +++ /dev/null @@ -1,1331 +0,0 @@ -// -// RACSignal+Operations.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-09-06. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSignal+Operations.h" -#import "NSObject+RACDeallocating.h" -#import "NSObject+RACDescription.h" -#import "RACBlockTrampoline.h" -#import "RACCommand.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACEvent.h" -#import "RACGroupedSignal.h" -#import "RACMulticastConnection+Private.h" -#import "RACReplaySubject.h" -#import "RACScheduler.h" -#import "RACSerialDisposable.h" -#import "RACSignalSequence.h" -#import "RACStream+Private.h" -#import "RACSubject.h" -#import "RACSubscriber+Private.h" -#import "RACSubscriber.h" -#import "RACTuple.h" -#import "RACUnit.h" -#import -#import - -NSString * const RACSignalErrorDomain = @"RACSignalErrorDomain"; - -const NSInteger RACSignalErrorTimedOut = 1; -const NSInteger RACSignalErrorNoMatchingCase = 2; - -// Subscribes to the given signal with the given blocks. -// -// If the signal errors or completes, the corresponding block is invoked. If the -// disposable passed to the block is _not_ disposed, then the signal is -// subscribed to again. -static RACDisposable *subscribeForever (RACSignal *signal, void (^next)(id), void (^error)(NSError *, RACDisposable *), void (^completed)(RACDisposable *)) { - next = [next copy]; - error = [error copy]; - completed = [completed copy]; - - RACCompoundDisposable *compoundDisposable = [RACCompoundDisposable compoundDisposable]; - - RACSchedulerRecursiveBlock recursiveBlock = ^(void (^recurse)(void)) { - RACCompoundDisposable *selfDisposable = [RACCompoundDisposable compoundDisposable]; - [compoundDisposable addDisposable:selfDisposable]; - - __weak RACDisposable *weakSelfDisposable = selfDisposable; - - RACDisposable *subscriptionDisposable = [signal subscribeNext:next error:^(NSError *e) { - @autoreleasepool { - error(e, compoundDisposable); - [compoundDisposable removeDisposable:weakSelfDisposable]; - } - - recurse(); - } completed:^{ - @autoreleasepool { - completed(compoundDisposable); - [compoundDisposable removeDisposable:weakSelfDisposable]; - } - - recurse(); - }]; - - [selfDisposable addDisposable:subscriptionDisposable]; - }; - - // Subscribe once immediately, and then use recursive scheduling for any - // further resubscriptions. - recursiveBlock(^{ - RACScheduler *recursiveScheduler = RACScheduler.currentScheduler ?: [RACScheduler scheduler]; - - RACDisposable *schedulingDisposable = [recursiveScheduler scheduleRecursiveBlock:recursiveBlock]; - [compoundDisposable addDisposable:schedulingDisposable]; - }); - - return compoundDisposable; -} - -@implementation RACSignal (Operations) - -- (RACSignal *)doNext:(void (^)(id x))block { - NSCParameterAssert(block != NULL); - - return [[RACSignal createSignal:^(id subscriber) { - return [self subscribeNext:^(id x) { - block(x); - [subscriber sendNext:x]; - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - [subscriber sendCompleted]; - }]; - }] setNameWithFormat:@"[%@] -doNext:", self.name]; -} - -- (RACSignal *)doError:(void (^)(NSError *error))block { - NSCParameterAssert(block != NULL); - - return [[RACSignal createSignal:^(id subscriber) { - return [self subscribeNext:^(id x) { - [subscriber sendNext:x]; - } error:^(NSError *error) { - block(error); - [subscriber sendError:error]; - } completed:^{ - [subscriber sendCompleted]; - }]; - }] setNameWithFormat:@"[%@] -doError:", self.name]; -} - -- (RACSignal *)doCompleted:(void (^)(void))block { - NSCParameterAssert(block != NULL); - - return [[RACSignal createSignal:^(id subscriber) { - return [self subscribeNext:^(id x) { - [subscriber sendNext:x]; - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - block(); - [subscriber sendCompleted]; - }]; - }] setNameWithFormat:@"[%@] -doCompleted:", self.name]; -} - -- (RACSignal *)throttle:(NSTimeInterval)interval { - return [[self throttle:interval valuesPassingTest:^(id _) { - return YES; - }] setNameWithFormat:@"[%@] -throttle: %f", self.name, (double)interval]; -} - -- (RACSignal *)throttle:(NSTimeInterval)interval valuesPassingTest:(BOOL (^)(id next))predicate { - NSCParameterAssert(interval >= 0); - NSCParameterAssert(predicate != nil); - - return [[RACSignal createSignal:^(id subscriber) { - RACCompoundDisposable *compoundDisposable = [RACCompoundDisposable compoundDisposable]; - - // We may never use this scheduler, but we need to set it up ahead of - // time so that our scheduled blocks are run serially if we do. - RACScheduler *scheduler = [RACScheduler scheduler]; - - // Information about any currently-buffered `next` event. - __block id nextValue = nil; - __block BOOL hasNextValue = NO; - RACSerialDisposable *nextDisposable = [[RACSerialDisposable alloc] init]; - - void (^flushNext)(BOOL send) = ^(BOOL send) { - @synchronized (compoundDisposable) { - [nextDisposable.disposable dispose]; - - if (!hasNextValue) return; - if (send) [subscriber sendNext:nextValue]; - - nextValue = nil; - hasNextValue = NO; - } - }; - - RACDisposable *subscriptionDisposable = [self subscribeNext:^(id x) { - RACScheduler *delayScheduler = RACScheduler.currentScheduler ?: scheduler; - BOOL shouldThrottle = predicate(x); - - @synchronized (compoundDisposable) { - flushNext(NO); - if (!shouldThrottle) { - [subscriber sendNext:x]; - return; - } - - nextValue = x; - hasNextValue = YES; - nextDisposable.disposable = [delayScheduler afterDelay:interval schedule:^{ - flushNext(YES); - }]; - } - } error:^(NSError *error) { - [compoundDisposable dispose]; - [subscriber sendError:error]; - } completed:^{ - flushNext(YES); - [subscriber sendCompleted]; - }]; - - [compoundDisposable addDisposable:subscriptionDisposable]; - return compoundDisposable; - }] setNameWithFormat:@"[%@] -throttle: %f valuesPassingTest:", self.name, (double)interval]; -} - -- (RACSignal *)delay:(NSTimeInterval)interval { - return [[RACSignal createSignal:^(id subscriber) { - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - - // We may never use this scheduler, but we need to set it up ahead of - // time so that our scheduled blocks are run serially if we do. - RACScheduler *scheduler = [RACScheduler scheduler]; - - void (^schedule)(dispatch_block_t) = ^(dispatch_block_t block) { - RACScheduler *delayScheduler = RACScheduler.currentScheduler ?: scheduler; - RACDisposable *schedulerDisposable = [delayScheduler afterDelay:interval schedule:block]; - [disposable addDisposable:schedulerDisposable]; - }; - - RACDisposable *subscriptionDisposable = [self subscribeNext:^(id x) { - schedule(^{ - [subscriber sendNext:x]; - }); - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - schedule(^{ - [subscriber sendCompleted]; - }); - }]; - - [disposable addDisposable:subscriptionDisposable]; - return disposable; - }] setNameWithFormat:@"[%@] -delay: %f", self.name, (double)interval]; -} - -- (RACSignal *)repeat { - return [[RACSignal createSignal:^(id subscriber) { - return subscribeForever(self, - ^(id x) { - [subscriber sendNext:x]; - }, - ^(NSError *error, RACDisposable *disposable) { - [disposable dispose]; - [subscriber sendError:error]; - }, - ^(RACDisposable *disposable) { - // Resubscribe. - }); - }] setNameWithFormat:@"[%@] -repeat", self.name]; -} - -- (RACSignal *)catch:(RACSignal * (^)(NSError *error))catchBlock { - NSCParameterAssert(catchBlock != NULL); - - return [[RACSignal createSignal:^(id subscriber) { - RACSerialDisposable *catchDisposable = [[RACSerialDisposable alloc] init]; - - RACDisposable *subscriptionDisposable = [self subscribeNext:^(id x) { - [subscriber sendNext:x]; - } error:^(NSError *error) { - RACSignal *signal = catchBlock(error); - NSCAssert(signal != nil, @"Expected non-nil signal from catch block on %@", self); - catchDisposable.disposable = [signal subscribe:subscriber]; - } completed:^{ - [subscriber sendCompleted]; - }]; - - return [RACDisposable disposableWithBlock:^{ - [catchDisposable dispose]; - [subscriptionDisposable dispose]; - }]; - }] setNameWithFormat:@"[%@] -catch:", self.name]; -} - -- (RACSignal *)catchTo:(RACSignal *)signal { - return [[self catch:^(NSError *error) { - return signal; - }] setNameWithFormat:@"[%@] -catchTo: %@", self.name, signal]; -} - -+ (RACSignal *)try:(id (^)(NSError **errorPtr))tryBlock { - NSCParameterAssert(tryBlock != NULL); - - return [[RACSignal createSignal:^(id subscriber) { - NSError *error; - id value = tryBlock(&error); - RACSignal *signal = (value == nil ? [RACSignal error:error] : [RACSignal return:value]); - return [signal subscribe:subscriber]; - }] setNameWithFormat:@"+try:"]; -} - -- (RACSignal *)try:(BOOL (^)(id value, NSError **errorPtr))tryBlock { - NSCParameterAssert(tryBlock != NULL); - - return [[self flattenMap:^(id value) { - NSError *error = nil; - BOOL passed = tryBlock(value, &error); - return (passed ? [RACSignal return:value] : [RACSignal error:error]); - }] setNameWithFormat:@"[%@] -try:", self.name]; -} - -- (RACSignal *)tryMap:(id (^)(id value, NSError **errorPtr))mapBlock { - NSCParameterAssert(mapBlock != NULL); - - return [[self flattenMap:^(id value) { - NSError *error = nil; - id mappedValue = mapBlock(value, &error); - return (mappedValue == nil ? [RACSignal error:error] : [RACSignal return:mappedValue]); - }] setNameWithFormat:@"[%@] -tryMap:", self.name]; -} - -- (RACSignal *)initially:(void (^)(void))block { - NSCParameterAssert(block != NULL); - - return [[RACSignal defer:^{ - block(); - return self; - }] setNameWithFormat:@"[%@] -initially:", self.name]; -} - -- (RACSignal *)finally:(void (^)(void))block { - NSCParameterAssert(block != NULL); - - return [[[self - doError:^(NSError *error) { - block(); - }] - doCompleted:^{ - block(); - }] - setNameWithFormat:@"[%@] -finally:", self.name]; -} - -- (RACSignal *)bufferWithTime:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler { - NSCParameterAssert(scheduler != nil); - NSCParameterAssert(scheduler != RACScheduler.immediateScheduler); - - return [[RACSignal createSignal:^(id subscriber) { - RACSerialDisposable *timerDisposable = [[RACSerialDisposable alloc] init]; - NSMutableArray *values = [NSMutableArray array]; - - void (^flushValues)(void) = ^{ - @synchronized (values) { - [timerDisposable.disposable dispose]; - - if (values.count == 0) return; - - RACTuple *tuple = [RACTuple tupleWithObjectsFromArray:values]; - [values removeAllObjects]; - [subscriber sendNext:tuple]; - } - }; - - RACDisposable *selfDisposable = [self subscribeNext:^(id x) { - @synchronized (values) { - if (values.count == 0) { - timerDisposable.disposable = [scheduler afterDelay:interval schedule:flushValues]; - } - - [values addObject:x ?: RACTupleNil.tupleNil]; - } - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - flushValues(); - [subscriber sendCompleted]; - }]; - - return [RACDisposable disposableWithBlock:^{ - [selfDisposable dispose]; - [timerDisposable dispose]; - }]; - }] setNameWithFormat:@"[%@] -bufferWithTime: %f onScheduler: %@", self.name, (double)interval, scheduler]; -} - -- (RACSignal *)collect { - return [[self aggregateWithStartFactory:^{ - return [[NSMutableArray alloc] init]; - } reduce:^(NSMutableArray *collectedValues, id x) { - [collectedValues addObject:(x ?: NSNull.null)]; - return collectedValues; - }] setNameWithFormat:@"[%@] -collect", self.name]; -} - -- (RACSignal *)takeLast:(NSUInteger)count { - return [[RACSignal createSignal:^(id subscriber) { - NSMutableArray *valuesTaken = [NSMutableArray arrayWithCapacity:count]; - return [self subscribeNext:^(id x) { - [valuesTaken addObject:x ? : RACTupleNil.tupleNil]; - - while (valuesTaken.count > count) { - [valuesTaken removeObjectAtIndex:0]; - } - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - for (id value in valuesTaken) { - [subscriber sendNext:value == RACTupleNil.tupleNil ? nil : value]; - } - - [subscriber sendCompleted]; - }]; - }] setNameWithFormat:@"[%@] -takeLast: %lu", self.name, (unsigned long)count]; -} - -- (RACSignal *)combineLatestWith:(RACSignal *)signal { - NSCParameterAssert(signal != nil); - - return [[RACSignal createSignal:^(id subscriber) { - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - - __block id lastSelfValue = nil; - __block BOOL selfCompleted = NO; - - __block id lastOtherValue = nil; - __block BOOL otherCompleted = NO; - - void (^sendNext)(void) = ^{ - @synchronized (disposable) { - if (lastSelfValue == nil || lastOtherValue == nil) return; - [subscriber sendNext:RACTuplePack(lastSelfValue, lastOtherValue)]; - } - }; - - RACDisposable *selfDisposable = [self subscribeNext:^(id x) { - @synchronized (disposable) { - lastSelfValue = x ?: RACTupleNil.tupleNil; - sendNext(); - } - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - @synchronized (disposable) { - selfCompleted = YES; - if (otherCompleted) [subscriber sendCompleted]; - } - }]; - - [disposable addDisposable:selfDisposable]; - - RACDisposable *otherDisposable = [signal subscribeNext:^(id x) { - @synchronized (disposable) { - lastOtherValue = x ?: RACTupleNil.tupleNil; - sendNext(); - } - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - @synchronized (disposable) { - otherCompleted = YES; - if (selfCompleted) [subscriber sendCompleted]; - } - }]; - - [disposable addDisposable:otherDisposable]; - - return disposable; - }] setNameWithFormat:@"[%@] -combineLatestWith: %@", self.name, signal]; -} - -+ (RACSignal *)combineLatest:(id)signals { - return [[self join:signals block:^(RACSignal *left, RACSignal *right) { - return [left combineLatestWith:right]; - }] setNameWithFormat:@"+combineLatest: %@", signals]; -} - -+ (RACSignal *)combineLatest:(id)signals reduce:(RACGenericReduceBlock)reduceBlock { - NSCParameterAssert(reduceBlock != nil); - - RACSignal *result = [self combineLatest:signals]; - - // Although we assert this condition above, older versions of this method - // supported this argument being nil. Avoid crashing Release builds of - // apps that depended on that. - if (reduceBlock != nil) result = [result reduceEach:reduceBlock]; - - return [result setNameWithFormat:@"+combineLatest: %@ reduce:", signals]; -} - -- (RACSignal *)merge:(RACSignal *)signal { - return [[RACSignal - merge:@[ self, signal ]] - setNameWithFormat:@"[%@] -merge: %@", self.name, signal]; -} - -+ (RACSignal *)merge:(id)signals { - NSMutableArray *copiedSignals = [[NSMutableArray alloc] init]; - for (RACSignal *signal in signals) { - [copiedSignals addObject:signal]; - } - - return [[[RACSignal - createSignal:^ RACDisposable * (id subscriber) { - for (RACSignal *signal in copiedSignals) { - [subscriber sendNext:signal]; - } - - [subscriber sendCompleted]; - return nil; - }] - flatten] - setNameWithFormat:@"+merge: %@", copiedSignals]; -} - -- (RACSignal *)flatten:(NSUInteger)maxConcurrent { - return [[RACSignal createSignal:^(id subscriber) { - RACCompoundDisposable *compoundDisposable = [[RACCompoundDisposable alloc] init]; - - // Contains disposables for the currently active subscriptions. - // - // This should only be used while synchronized on `subscriber`. - NSMutableArray *activeDisposables = [[NSMutableArray alloc] initWithCapacity:maxConcurrent]; - - // Whether the signal-of-signals has completed yet. - // - // This should only be used while synchronized on `subscriber`. - __block BOOL selfCompleted = NO; - - // Subscribes to the given signal. - __block void (^subscribeToSignal)(RACSignal *); - - // Weak reference to the above, to avoid a leak. - __weak __block void (^recur)(RACSignal *); - - // Sends completed to the subscriber if all signals are finished. - // - // This should only be used while synchronized on `subscriber`. - void (^completeIfAllowed)(void) = ^{ - if (selfCompleted && activeDisposables.count == 0) { - [subscriber sendCompleted]; - } - }; - - // The signals waiting to be started. - // - // This array should only be used while synchronized on `subscriber`. - NSMutableArray *queuedSignals = [NSMutableArray array]; - - recur = subscribeToSignal = ^(RACSignal *signal) { - RACSerialDisposable *serialDisposable = [[RACSerialDisposable alloc] init]; - - @synchronized (subscriber) { - [compoundDisposable addDisposable:serialDisposable]; - [activeDisposables addObject:serialDisposable]; - } - - serialDisposable.disposable = [signal subscribeNext:^(id x) { - [subscriber sendNext:x]; - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - __strong void (^subscribeToSignal)(RACSignal *) = recur; - RACSignal *nextSignal; - - @synchronized (subscriber) { - [compoundDisposable removeDisposable:serialDisposable]; - [activeDisposables removeObjectIdenticalTo:serialDisposable]; - - if (queuedSignals.count == 0) { - completeIfAllowed(); - return; - } - - nextSignal = queuedSignals[0]; - [queuedSignals removeObjectAtIndex:0]; - } - - subscribeToSignal(nextSignal); - }]; - }; - - [compoundDisposable addDisposable:[self subscribeNext:^(RACSignal *signal) { - if (signal == nil) return; - - NSCAssert([signal isKindOfClass:RACSignal.class], @"Expected a RACSignal, got %@", signal); - - @synchronized (subscriber) { - if (maxConcurrent > 0 && activeDisposables.count >= maxConcurrent) { - [queuedSignals addObject:signal]; - - // If we need to wait, skip subscribing to this - // signal. - return; - } - } - - subscribeToSignal(signal); - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - @synchronized (subscriber) { - selfCompleted = YES; - completeIfAllowed(); - } - }]]; - - [compoundDisposable addDisposable:[RACDisposable disposableWithBlock:^{ - // A strong reference is held to `subscribeToSignal` until we're - // done, preventing it from deallocating early. - subscribeToSignal = nil; - }]]; - - return compoundDisposable; - }] setNameWithFormat:@"[%@] -flatten: %lu", self.name, (unsigned long)maxConcurrent]; -} - -- (RACSignal *)then:(RACSignal * (^)(void))block { - NSCParameterAssert(block != nil); - - return [[[self - ignoreValues] - concat:[RACSignal defer:block]] - setNameWithFormat:@"[%@] -then:", self.name]; -} - -- (RACSignal *)concat { - return [[self flatten:1] setNameWithFormat:@"[%@] -concat", self.name]; -} - -- (RACSignal *)aggregateWithStartFactory:(id (^)(void))startFactory reduce:(id (^)(id running, id next))reduceBlock { - NSCParameterAssert(startFactory != NULL); - NSCParameterAssert(reduceBlock != NULL); - - return [[RACSignal defer:^{ - return [self aggregateWithStart:startFactory() reduce:reduceBlock]; - }] setNameWithFormat:@"[%@] -aggregateWithStartFactory:reduce:", self.name]; -} - -- (RACSignal *)aggregateWithStart:(id)start reduce:(id (^)(id running, id next))reduceBlock { - return [[self - aggregateWithStart:start - reduceWithIndex:^(id running, id next, NSUInteger index) { - return reduceBlock(running, next); - }] - setNameWithFormat:@"[%@] -aggregateWithStart: %@ reduce:", self.name, RACDescription(start)]; -} - -- (RACSignal *)aggregateWithStart:(id)start reduceWithIndex:(id (^)(id, id, NSUInteger))reduceBlock { - return [[[[self - scanWithStart:start reduceWithIndex:reduceBlock] - startWith:start] - takeLast:1] - setNameWithFormat:@"[%@] -aggregateWithStart: %@ reduceWithIndex:", self.name, RACDescription(start)]; -} - -- (RACDisposable *)setKeyPath:(NSString *)keyPath onObject:(NSObject *)object { - return [self setKeyPath:keyPath onObject:object nilValue:nil]; -} - -- (RACDisposable *)setKeyPath:(NSString *)keyPath onObject:(NSObject *)object nilValue:(id)nilValue { - NSCParameterAssert(keyPath != nil); - NSCParameterAssert(object != nil); - - keyPath = [keyPath copy]; - - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - - // Purposely not retaining 'object', since we want to tear down the binding - // when it deallocates normally. - __block _Atomic(void *) objectPtr = (__bridge void *)object; - - RACDisposable *subscriptionDisposable = [self subscribeNext:^(id x) { - // Possibly spec, possibly compiler bug, but this __bridge cast does not - // result in a retain here, effectively an invisible __unsafe_unretained - // qualifier. Using objc_precise_lifetime gives the __strong reference - // desired. The explicit use of __strong is strictly defensive. - void *objectPointer = atomic_load_explicit(&objectPtr, memory_order_acquire); - __strong NSObject *object __attribute__((objc_precise_lifetime)) = (__bridge __strong id)objectPointer; - [object setValue:x ?: nilValue forKeyPath:keyPath]; - } error:^(NSError *error) { - void *objectPointer = atomic_load_explicit(&objectPtr, memory_order_acquire); - __strong NSObject *object __attribute__((objc_precise_lifetime)) = (__bridge __strong id)objectPointer; - - NSCAssert(NO, @"Received error from %@ in binding for key path \"%@\" on %@: %@", self, keyPath, object, error); - - // Log the error if we're running with assertions disabled. - NSLog(@"Received error from %@ in binding for key path \"%@\" on %@: %@", self, keyPath, object, error); - - [disposable dispose]; - } completed:^{ - [disposable dispose]; - }]; - - [disposable addDisposable:subscriptionDisposable]; - - #if DEBUG - static void *bindingsKey = &bindingsKey; - NSMutableDictionary *bindings; - - @synchronized (object) { - bindings = objc_getAssociatedObject(object, bindingsKey); - if (bindings == nil) { - bindings = [NSMutableDictionary dictionary]; - objc_setAssociatedObject(object, bindingsKey, bindings, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - } - - @synchronized (bindings) { - NSCAssert(bindings[keyPath] == nil, @"Signal %@ is already bound to key path \"%@\" on object %@, adding signal %@ is undefined behavior", [bindings[keyPath] nonretainedObjectValue], keyPath, object, self); - - bindings[keyPath] = [NSValue valueWithNonretainedObject:self]; - } - #endif - - RACDisposable *clearPointerDisposable = [RACDisposable disposableWithBlock:^{ - #if DEBUG - @synchronized (bindings) { - [bindings removeObjectForKey:keyPath]; - } - #endif - - atomic_exchange_explicit(&objectPtr, NULL, memory_order_acq_rel); - }]; - - [disposable addDisposable:clearPointerDisposable]; - - [object.rac_deallocDisposable addDisposable:disposable]; - - RACCompoundDisposable *objectDisposable = object.rac_deallocDisposable; - return [RACDisposable disposableWithBlock:^{ - [objectDisposable removeDisposable:disposable]; - [disposable dispose]; - }]; -} - -+ (RACSignal *)interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler { - return [[RACSignal interval:interval onScheduler:scheduler withLeeway:0.0] setNameWithFormat:@"+interval: %f onScheduler: %@", (double)interval, scheduler]; -} - -+ (RACSignal *)interval:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler withLeeway:(NSTimeInterval)leeway { - NSCParameterAssert(scheduler != nil); - NSCParameterAssert(scheduler != RACScheduler.immediateScheduler); - - return [[RACSignal createSignal:^(id subscriber) { - return [scheduler after:[NSDate dateWithTimeIntervalSinceNow:interval] repeatingEvery:interval withLeeway:leeway schedule:^{ - [subscriber sendNext:[NSDate date]]; - }]; - }] setNameWithFormat:@"+interval: %f onScheduler: %@ withLeeway: %f", (double)interval, scheduler, (double)leeway]; -} - -- (RACSignal *)takeUntil:(RACSignal *)signalTrigger { - return [[RACSignal createSignal:^(id subscriber) { - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - void (^triggerCompletion)(void) = ^{ - [disposable dispose]; - [subscriber sendCompleted]; - }; - - RACDisposable *triggerDisposable = [signalTrigger subscribeNext:^(id _) { - triggerCompletion(); - } completed:^{ - triggerCompletion(); - }]; - - [disposable addDisposable:triggerDisposable]; - - if (!disposable.disposed) { - RACDisposable *selfDisposable = [self subscribeNext:^(id x) { - [subscriber sendNext:x]; - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - [disposable dispose]; - [subscriber sendCompleted]; - }]; - - [disposable addDisposable:selfDisposable]; - } - - return disposable; - }] setNameWithFormat:@"[%@] -takeUntil: %@", self.name, signalTrigger]; -} - -- (RACSignal *)takeUntilReplacement:(RACSignal *)replacement { - return [RACSignal createSignal:^(id subscriber) { - RACSerialDisposable *selfDisposable = [[RACSerialDisposable alloc] init]; - - RACDisposable *replacementDisposable = [replacement subscribeNext:^(id x) { - [selfDisposable dispose]; - [subscriber sendNext:x]; - } error:^(NSError *error) { - [selfDisposable dispose]; - [subscriber sendError:error]; - } completed:^{ - [selfDisposable dispose]; - [subscriber sendCompleted]; - }]; - - if (!selfDisposable.disposed) { - selfDisposable.disposable = [[self - concat:[RACSignal never]] - subscribe:subscriber]; - } - - return [RACDisposable disposableWithBlock:^{ - [selfDisposable dispose]; - [replacementDisposable dispose]; - }]; - }]; -} - -- (RACSignal *)switchToLatest { - return [[RACSignal createSignal:^(id subscriber) { - RACMulticastConnection *connection = [self publish]; - - RACDisposable *subscriptionDisposable = [[connection.signal - flattenMap:^(RACSignal *x) { - NSCAssert(x == nil || [x isKindOfClass:RACSignal.class], @"-switchToLatest requires that the source signal (%@) send signals. Instead we got: %@", self, x); - - // -concat:[RACSignal never] prevents completion of the receiver from - // prematurely terminating the inner signal. - return [x takeUntil:[connection.signal concat:[RACSignal never]]]; - }] - subscribe:subscriber]; - - RACDisposable *connectionDisposable = [connection connect]; - return [RACDisposable disposableWithBlock:^{ - [subscriptionDisposable dispose]; - [connectionDisposable dispose]; - }]; - }] setNameWithFormat:@"[%@] -switchToLatest", self.name]; -} - -+ (RACSignal *)switch:(RACSignal *)signal cases:(NSDictionary *)cases default:(RACSignal *)defaultSignal { - NSCParameterAssert(signal != nil); - NSCParameterAssert(cases != nil); - - for (id key in cases) { - id value __attribute__((unused)) = cases[key]; - NSCAssert([value isKindOfClass:RACSignal.class], @"Expected all cases to be RACSignals, %@ isn't", value); - } - - NSDictionary *copy = [cases copy]; - - return [[[signal - map:^(id key) { - if (key == nil) key = RACTupleNil.tupleNil; - - RACSignal *signal = copy[key] ?: defaultSignal; - if (signal == nil) { - NSString *description = [NSString stringWithFormat:NSLocalizedString(@"No matching signal found for value %@", @""), key]; - return [RACSignal error:[NSError errorWithDomain:RACSignalErrorDomain code:RACSignalErrorNoMatchingCase userInfo:@{ NSLocalizedDescriptionKey: description }]]; - } - - return signal; - }] - switchToLatest] - setNameWithFormat:@"+switch: %@ cases: %@ default: %@", signal, cases, defaultSignal]; -} - -+ (RACSignal *)if:(RACSignal *)boolSignal then:(RACSignal *)trueSignal else:(RACSignal *)falseSignal { - NSCParameterAssert(boolSignal != nil); - NSCParameterAssert(trueSignal != nil); - NSCParameterAssert(falseSignal != nil); - - return [[[boolSignal - map:^(NSNumber *value) { - NSCAssert([value isKindOfClass:NSNumber.class], @"Expected %@ to send BOOLs, not %@", boolSignal, value); - - return (value.boolValue ? trueSignal : falseSignal); - }] - switchToLatest] - setNameWithFormat:@"+if: %@ then: %@ else: %@", boolSignal, trueSignal, falseSignal]; -} - -- (id)first { - return [self firstOrDefault:nil]; -} - -- (id)firstOrDefault:(id)defaultValue { - return [self firstOrDefault:defaultValue success:NULL error:NULL]; -} - -- (id)firstOrDefault:(id)defaultValue success:(BOOL *)success error:(NSError **)error { - NSCondition *condition = [[NSCondition alloc] init]; - condition.name = [NSString stringWithFormat:@"[%@] -firstOrDefault: %@ success:error:", self.name, defaultValue]; - - __block id value = defaultValue; - __block BOOL done = NO; - - // Ensures that we don't pass values across thread boundaries by reference. - __block NSError *localError; - __block BOOL localSuccess; - - [[self take:1] subscribeNext:^(id x) { - [condition lock]; - - value = x; - localSuccess = YES; - - done = YES; - [condition broadcast]; - [condition unlock]; - } error:^(NSError *e) { - [condition lock]; - - if (!done) { - localSuccess = NO; - localError = e; - - done = YES; - [condition broadcast]; - } - - [condition unlock]; - } completed:^{ - [condition lock]; - - localSuccess = YES; - - done = YES; - [condition broadcast]; - [condition unlock]; - }]; - - [condition lock]; - while (!done) { - [condition wait]; - } - - if (success != NULL) *success = localSuccess; - if (error != NULL) *error = localError; - - [condition unlock]; - return value; -} - -- (BOOL)waitUntilCompleted:(NSError **)error { - BOOL success = NO; - - [[[self - ignoreValues] - setNameWithFormat:@"[%@] -waitUntilCompleted:", self.name] - firstOrDefault:nil success:&success error:error]; - - return success; -} - -+ (RACSignal *)defer:(RACSignal * (^)(void))block { - NSCParameterAssert(block != NULL); - - return [[RACSignal createSignal:^(id subscriber) { - return [block() subscribe:subscriber]; - }] setNameWithFormat:@"+defer:"]; -} - -- (NSArray *)toArray { - return [[[self collect] first] copy]; -} - -- (RACSequence *)sequence { - return [[RACSignalSequence sequenceWithSignal:self] setNameWithFormat:@"[%@] -sequence", self.name]; -} - -- (RACMulticastConnection *)publish { - RACSubject *subject = [[RACSubject subject] setNameWithFormat:@"[%@] -publish", self.name]; - RACMulticastConnection *connection = [self multicast:subject]; - return connection; -} - -- (RACMulticastConnection *)multicast:(RACSubject *)subject { - [subject setNameWithFormat:@"[%@] -multicast: %@", self.name, subject.name]; - RACMulticastConnection *connection = [[RACMulticastConnection alloc] initWithSourceSignal:self subject:subject]; - return connection; -} - -- (RACSignal *)replay { - RACReplaySubject *subject = [[RACReplaySubject subject] setNameWithFormat:@"[%@] -replay", self.name]; - - RACMulticastConnection *connection = [self multicast:subject]; - [connection connect]; - - return connection.signal; -} - -- (RACSignal *)replayLast { - RACReplaySubject *subject = [[RACReplaySubject replaySubjectWithCapacity:1] setNameWithFormat:@"[%@] -replayLast", self.name]; - - RACMulticastConnection *connection = [self multicast:subject]; - [connection connect]; - - return connection.signal; -} - -- (RACSignal *)replayLazily { - RACMulticastConnection *connection = [self multicast:[RACReplaySubject subject]]; - return [[RACSignal - defer:^{ - [connection connect]; - return connection.signal; - }] - setNameWithFormat:@"[%@] -replayLazily", self.name]; -} - -- (RACSignal *)timeout:(NSTimeInterval)interval onScheduler:(RACScheduler *)scheduler { - NSCParameterAssert(scheduler != nil); - NSCParameterAssert(scheduler != RACScheduler.immediateScheduler); - - return [[RACSignal createSignal:^(id subscriber) { - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - - RACDisposable *timeoutDisposable = [scheduler afterDelay:interval schedule:^{ - [disposable dispose]; - [subscriber sendError:[NSError errorWithDomain:RACSignalErrorDomain code:RACSignalErrorTimedOut userInfo:nil]]; - }]; - - [disposable addDisposable:timeoutDisposable]; - - RACDisposable *subscriptionDisposable = [self subscribeNext:^(id x) { - [subscriber sendNext:x]; - } error:^(NSError *error) { - [disposable dispose]; - [subscriber sendError:error]; - } completed:^{ - [disposable dispose]; - [subscriber sendCompleted]; - }]; - - [disposable addDisposable:subscriptionDisposable]; - return disposable; - }] setNameWithFormat:@"[%@] -timeout: %f onScheduler: %@", self.name, (double)interval, scheduler]; -} - -- (RACSignal *)deliverOn:(RACScheduler *)scheduler { - return [[RACSignal createSignal:^(id subscriber) { - return [self subscribeNext:^(id x) { - [scheduler schedule:^{ - [subscriber sendNext:x]; - }]; - } error:^(NSError *error) { - [scheduler schedule:^{ - [subscriber sendError:error]; - }]; - } completed:^{ - [scheduler schedule:^{ - [subscriber sendCompleted]; - }]; - }]; - }] setNameWithFormat:@"[%@] -deliverOn: %@", self.name, scheduler]; -} - -- (RACSignal *)subscribeOn:(RACScheduler *)scheduler { - return [[RACSignal createSignal:^(id subscriber) { - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - - RACDisposable *schedulingDisposable = [scheduler schedule:^{ - RACDisposable *subscriptionDisposable = [self subscribe:subscriber]; - - [disposable addDisposable:subscriptionDisposable]; - }]; - - [disposable addDisposable:schedulingDisposable]; - return disposable; - }] setNameWithFormat:@"[%@] -subscribeOn: %@", self.name, scheduler]; -} - -- (RACSignal *)deliverOnMainThread { - return [[RACSignal createSignal:^(id subscriber) { - __block atomic_int_least32_t queueLength; - atomic_init(&queueLength, 0); - - void (^performOnMainThread)(dispatch_block_t) = ^(dispatch_block_t block) { - int32_t queued = atomic_fetch_add_explicit(&queueLength, 1, memory_order_acq_rel) + 1; - if (NSThread.isMainThread && queued == 1) { - block(); - atomic_fetch_sub_explicit(&queueLength, 1, memory_order_acq_rel); - } else { - dispatch_async(dispatch_get_main_queue(), ^{ - block(); - atomic_fetch_sub_explicit(&queueLength, 1, memory_order_acq_rel); - }); - } - }; - - return [self subscribeNext:^(id x) { - performOnMainThread(^{ - [subscriber sendNext:x]; - }); - } error:^(NSError *error) { - performOnMainThread(^{ - [subscriber sendError:error]; - }); - } completed:^{ - performOnMainThread(^{ - [subscriber sendCompleted]; - }); - }]; - }] setNameWithFormat:@"[%@] -deliverOnMainThread", self.name]; -} - -- (RACSignal *)groupBy:(id (^)(id object))keyBlock transform:(id (^)(id object))transformBlock { - NSCParameterAssert(keyBlock != NULL); - - return [[RACSignal createSignal:^(id subscriber) { - NSMutableDictionary *groups = [NSMutableDictionary dictionary]; - NSMutableArray *orderedGroups = [NSMutableArray array]; - - return [self subscribeNext:^(id x) { - id key = keyBlock(x); - RACGroupedSignal *groupSubject = nil; - @synchronized(groups) { - groupSubject = groups[key]; - if (groupSubject == nil) { - groupSubject = [RACGroupedSignal signalWithKey:key]; - groups[key] = groupSubject; - [orderedGroups addObject:groupSubject]; - [subscriber sendNext:groupSubject]; - } - } - - [groupSubject sendNext:transformBlock != NULL ? transformBlock(x) : x]; - } error:^(NSError *error) { - [subscriber sendError:error]; - - [orderedGroups makeObjectsPerformSelector:@selector(sendError:) withObject:error]; - } completed:^{ - [subscriber sendCompleted]; - - [orderedGroups makeObjectsPerformSelector:@selector(sendCompleted)]; - }]; - }] setNameWithFormat:@"[%@] -groupBy:transform:", self.name]; -} - -- (RACSignal *)groupBy:(id (^)(id object))keyBlock { - return [[self groupBy:keyBlock transform:nil] setNameWithFormat:@"[%@] -groupBy:", self.name]; -} - -- (RACSignal *)any { - return [[self any:^(id x) { - return YES; - }] setNameWithFormat:@"[%@] -any", self.name]; -} - -- (RACSignal *)any:(BOOL (^)(id object))predicateBlock { - NSCParameterAssert(predicateBlock != NULL); - - return [[[self materialize] bind:^{ - return ^(RACEvent *event, BOOL *stop) { - if (event.finished) { - *stop = YES; - return [RACSignal return:@NO]; - } - - if (predicateBlock(event.value)) { - *stop = YES; - return [RACSignal return:@YES]; - } - - return [RACSignal empty]; - }; - }] setNameWithFormat:@"[%@] -any:", self.name]; -} - -- (RACSignal *)all:(BOOL (^)(id object))predicateBlock { - NSCParameterAssert(predicateBlock != NULL); - - return [[[self materialize] bind:^{ - return ^(RACEvent *event, BOOL *stop) { - if (event.eventType == RACEventTypeCompleted) { - *stop = YES; - return [RACSignal return:@YES]; - } - - if (event.eventType == RACEventTypeError || !predicateBlock(event.value)) { - *stop = YES; - return [RACSignal return:@NO]; - } - - return [RACSignal empty]; - }; - }] setNameWithFormat:@"[%@] -all:", self.name]; -} - -- (RACSignal *)retry:(NSInteger)retryCount { - return [[RACSignal createSignal:^(id subscriber) { - __block NSInteger currentRetryCount = 0; - return subscribeForever(self, - ^(id x) { - [subscriber sendNext:x]; - }, - ^(NSError *error, RACDisposable *disposable) { - if (retryCount == 0 || currentRetryCount < retryCount) { - // Resubscribe. - currentRetryCount++; - return; - } - - [disposable dispose]; - [subscriber sendError:error]; - }, - ^(RACDisposable *disposable) { - [disposable dispose]; - [subscriber sendCompleted]; - }); - }] setNameWithFormat:@"[%@] -retry: %lu", self.name, (unsigned long)retryCount]; -} - -- (RACSignal *)retry { - return [[self retry:0] setNameWithFormat:@"[%@] -retry", self.name]; -} - -- (RACSignal *)sample:(RACSignal *)sampler { - NSCParameterAssert(sampler != nil); - - return [[RACSignal createSignal:^(id subscriber) { - NSLock *lock = [[NSLock alloc] init]; - __block id lastValue; - __block BOOL hasValue = NO; - - RACSerialDisposable *samplerDisposable = [[RACSerialDisposable alloc] init]; - RACDisposable *sourceDisposable = [self subscribeNext:^(id x) { - [lock lock]; - hasValue = YES; - lastValue = x; - [lock unlock]; - } error:^(NSError *error) { - [samplerDisposable dispose]; - [subscriber sendError:error]; - } completed:^{ - [samplerDisposable dispose]; - [subscriber sendCompleted]; - }]; - - samplerDisposable.disposable = [sampler subscribeNext:^(id _) { - BOOL shouldSend = NO; - id value; - [lock lock]; - shouldSend = hasValue; - value = lastValue; - [lock unlock]; - - if (shouldSend) { - [subscriber sendNext:value]; - } - } error:^(NSError *error) { - [sourceDisposable dispose]; - [subscriber sendError:error]; - } completed:^{ - [sourceDisposable dispose]; - [subscriber sendCompleted]; - }]; - - return [RACDisposable disposableWithBlock:^{ - [samplerDisposable dispose]; - [sourceDisposable dispose]; - }]; - }] setNameWithFormat:@"[%@] -sample: %@", self.name, sampler]; -} - -- (RACSignal *)ignoreValues { - return [[self filter:^(id _) { - return NO; - }] setNameWithFormat:@"[%@] -ignoreValues", self.name]; -} - -- (RACSignal *)materialize { - return [[RACSignal createSignal:^(id subscriber) { - return [self subscribeNext:^(id x) { - [subscriber sendNext:[RACEvent eventWithValue:x]]; - } error:^(NSError *error) { - [subscriber sendNext:[RACEvent eventWithError:error]]; - [subscriber sendCompleted]; - } completed:^{ - [subscriber sendNext:RACEvent.completedEvent]; - [subscriber sendCompleted]; - }]; - }] setNameWithFormat:@"[%@] -materialize", self.name]; -} - -- (RACSignal *)dematerialize { - return [[self bind:^{ - return ^(RACEvent *event, BOOL *stop) { - switch (event.eventType) { - case RACEventTypeCompleted: - *stop = YES; - return [RACSignal empty]; - - case RACEventTypeError: - *stop = YES; - return [RACSignal error:event.error]; - - case RACEventTypeNext: - return [RACSignal return:event.value]; - } - }; - }] setNameWithFormat:@"[%@] -dematerialize", self.name]; -} - -- (RACSignal *)not { - return [[self map:^(NSNumber *value) { - NSCAssert([value isKindOfClass:NSNumber.class], @"-not must only be used on a signal of NSNumbers. Instead, got: %@", value); - - return @(!value.boolValue); - }] setNameWithFormat:@"[%@] -not", self.name]; -} - -- (RACSignal *)and { - return [[self map:^(RACTuple *tuple) { - NSCAssert([tuple isKindOfClass:RACTuple.class], @"-and must only be used on a signal of RACTuples of NSNumbers. Instead, received: %@", tuple); - NSCAssert(tuple.count > 0, @"-and must only be used on a signal of RACTuples of NSNumbers, with at least 1 value in the tuple"); - - return @([tuple.rac_sequence all:^(NSNumber *number) { - NSCAssert([number isKindOfClass:NSNumber.class], @"-and must only be used on a signal of RACTuples of NSNumbers. Instead, tuple contains a non-NSNumber value: %@", tuple); - - return number.boolValue; - }]); - }] setNameWithFormat:@"[%@] -and", self.name]; -} - -- (RACSignal *)or { - return [[self map:^(RACTuple *tuple) { - NSCAssert([tuple isKindOfClass:RACTuple.class], @"-or must only be used on a signal of RACTuples of NSNumbers. Instead, received: %@", tuple); - NSCAssert(tuple.count > 0, @"-or must only be used on a signal of RACTuples of NSNumbers, with at least 1 value in the tuple"); - - return @([tuple.rac_sequence any:^(NSNumber *number) { - NSCAssert([number isKindOfClass:NSNumber.class], @"-or must only be used on a signal of RACTuples of NSNumbers. Instead, tuple contains a non-NSNumber value: %@", tuple); - - return number.boolValue; - }]); - }] setNameWithFormat:@"[%@] -or", self.name]; -} - -- (RACSignal *)reduceApply { - return [[self map:^(RACTuple *tuple) { - NSCAssert([tuple isKindOfClass:RACTuple.class], @"-reduceApply must only be used on a signal of RACTuples. Instead, received: %@", tuple); - NSCAssert(tuple.count > 1, @"-reduceApply must only be used on a signal of RACTuples, with at least a block in tuple[0] and its first argument in tuple[1]"); - - // We can't use -array, because we need to preserve RACTupleNil - NSMutableArray *tupleArray = [NSMutableArray arrayWithCapacity:tuple.count]; - for (id val in tuple) { - [tupleArray addObject:val]; - } - RACTuple *arguments = [RACTuple tupleWithObjectsFromArray:[tupleArray subarrayWithRange:NSMakeRange(1, tupleArray.count - 1)]]; - - return [RACBlockTrampoline invokeBlock:tuple[0] withArguments:arguments]; - }] setNameWithFormat:@"[%@] -reduceApply", self.name]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACSignal.h b/LookInside/ReactiveObjC/RACSignal.h deleted file mode 100644 index 1d4670e7..00000000 --- a/LookInside/ReactiveObjC/RACSignal.h +++ /dev/null @@ -1,508 +0,0 @@ -// -// RACSignal.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/1/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import -#import "RACAnnotations.h" -#import "RACStream.h" - -@class RACDisposable; -@class RACScheduler; -@class RACSubject; -@class RACTuple; -@class RACTwoTuple<__covariant First, __covariant Second>; -@protocol RACSubscriber; - -NS_ASSUME_NONNULL_BEGIN - -@interface RACSignal<__covariant ValueType> : RACStream - -/// Creates a new signal. This is the preferred way to create a new signal -/// operation or behavior. -/// -/// Events can be sent to new subscribers immediately in the `didSubscribe` -/// block, but the subscriber will not be able to dispose of the signal until -/// a RACDisposable is returned from `didSubscribe`. In the case of infinite -/// signals, this won't _ever_ happen if events are sent immediately. -/// -/// To ensure that the signal is disposable, events can be scheduled on the -/// +[RACScheduler currentScheduler] (so that they're deferred, not sent -/// immediately), or they can be sent in the background. The RACDisposable -/// returned by the `didSubscribe` block should cancel any such scheduling or -/// asynchronous work. -/// -/// didSubscribe - Called when the signal is subscribed to. The new subscriber is -/// passed in. You can then manually control the by -/// sending it -sendNext:, -sendError:, and -sendCompleted, -/// as defined by the operation you're implementing. This block -/// should return a RACDisposable which cancels any ongoing work -/// triggered by the subscription, and cleans up any resources or -/// disposables created as part of it. When the disposable is -/// disposed of, the signal must not send any more events to the -/// `subscriber`. If no cleanup is necessary, return nil. -/// -/// **Note:** The `didSubscribe` block is called every time a new subscriber -/// subscribes. Any side effects within the block will thus execute once for each -/// subscription, not necessarily on one thread, and possibly even -/// simultaneously! -+ (RACSignal *)createSignal:(RACDisposable * _Nullable (^)(id subscriber))didSubscribe RAC_WARN_UNUSED_RESULT; - -/// Returns a signal that immediately sends the given error. -+ (RACSignal *)error:(nullable NSError *)error RAC_WARN_UNUSED_RESULT; - -/// Returns a signal that never completes. -+ (RACSignal *)never RAC_WARN_UNUSED_RESULT; - -/// Immediately schedules the given block on the given scheduler. The block is -/// given a subscriber to which it can send events. -/// -/// scheduler - The scheduler on which `block` will be scheduled and results -/// delivered. Cannot be nil. -/// block - The block to invoke. Cannot be NULL. -/// -/// Returns a signal which will send all events sent on the subscriber given to -/// `block`. All events will be sent on `scheduler` and it will replay any missed -/// events to new subscribers. -+ (RACSignal *)startEagerlyWithScheduler:(RACScheduler *)scheduler block:(void (^)(id subscriber))block; - -/// Invokes the given block only on the first subscription. The block is given a -/// subscriber to which it can send events. -/// -/// Note that disposing of the subscription to the returned signal will *not* -/// dispose of the underlying subscription. If you need that behavior, see -/// -[RACMulticastConnection autoconnect]. The underlying subscription will never -/// be disposed of. Because of this, `block` should never return an infinite -/// signal since there would be no way of ending it. -/// -/// scheduler - The scheduler on which the block should be scheduled. Note that -/// if given +[RACScheduler immediateScheduler], the block will be -/// invoked synchronously on the first subscription. Cannot be nil. -/// block - The block to invoke on the first subscription. Cannot be NULL. -/// -/// Returns a signal which will pass through the events sent to the subscriber -/// given to `block` and replay any missed events to new subscribers. -+ (RACSignal *)startLazilyWithScheduler:(RACScheduler *)scheduler block:(void (^)(id subscriber))block RAC_WARN_UNUSED_RESULT; - -@end - -@interface RACSignal<__covariant ValueType> (RACStream) - -/// Returns a signal that immediately sends the given value and then completes. -+ (RACSignal *)return:(nullable ValueType)value RAC_WARN_UNUSED_RESULT; - -/// Returns a signal that immediately completes. -+ (RACSignal *)empty RAC_WARN_UNUSED_RESULT; - -/// A block which accepts a value from a RACSignal and returns a new signal. -/// -/// Setting `stop` to `YES` will cause the bind to terminate after the returned -/// value. Returning `nil` will result in immediate termination. -typedef RACSignal * _Nullable (^RACSignalBindBlock)(ValueType _Nullable value, BOOL *stop); - -/// Lazily binds a block to the values in the receiver. -/// -/// This should only be used if you need to terminate the bind early, or close -/// over some state. -flattenMap: is more appropriate for all other cases. -/// -/// block - A block returning a RACSignalBindBlock. This block will be invoked -/// each time the bound signal is re-evaluated. This block must not be -/// nil or return nil. -/// -/// Returns a new signal which represents the combined result of all lazy -/// applications of `block`. -- (RACSignal *)bind:(RACSignalBindBlock (^)(void))block RAC_WARN_UNUSED_RESULT; - -/// Subscribes to `signal` when the source signal completes. -- (RACSignal *)concat:(RACSignal *)signal RAC_WARN_UNUSED_RESULT; - -/// Zips the values in the receiver with those of the given signal to create -/// RACTuples. -/// -/// The first `next` of each signal will be combined, then the second `next`, -/// and so forth, until either signal completes or errors. -/// -/// signal - The signal to zip with. This must not be `nil`. -/// -/// Returns a new signal of RACTuples, representing the combined values of the -/// two signals. Any error from one of the original signals will be forwarded on -/// the returned signal. -- (RACSignal *> *)zipWith:(RACSignal *)signal RAC_WARN_UNUSED_RESULT; - -@end - -/// Redeclarations of operations built on the RACStream primitives with more -/// precise ValueType information. -/// -/// In cases where the ValueType of the result of the operation is not able to -/// be inferred, the ValueType is erased in the result. -/// -/// In cases where instancetype is a valid return type, the operation is not -/// redeclared here. -@interface RACSignal<__covariant ValueType> (RACStreamOperations) - -/// Maps `block` across the values in the receiver and flattens the result. -/// -/// Note that operators applied _after_ -flattenMap: behave differently from -/// operators _within_ -flattenMap:. See the Examples section below. -/// -/// This corresponds to the `SelectMany` method in Rx. -/// -/// block - A block which accepts the values in the receiver and returns a new -/// instance of the receiver's class. Returning `nil` from this block is -/// equivalent to returning an empty signal. -/// -/// Examples -/// -/// [signal flattenMap:^(id x) { -/// // Logs each time a returned signal completes. -/// return [[RACSignal return:x] logCompleted]; -/// }]; -/// -/// [[signal -/// flattenMap:^(id x) { -/// return [RACSignal return:x]; -/// }] -/// // Logs only once, when all of the signals complete. -/// logCompleted]; -/// -/// Returns a new signal which represents the combined signals resulting from -/// mapping `block`. -- (RACSignal *)flattenMap:(__kindof RACSignal * _Nullable (^)(ValueType _Nullable value))block RAC_WARN_UNUSED_RESULT; - -/// Flattens a signal of signals. -/// -/// This corresponds to the `Merge` method in Rx. -/// -/// Returns a signal consisting of the combined signals obtained from the -/// receiver. -- (RACSignal *)flatten RAC_WARN_UNUSED_RESULT; - -/// Maps `block` across the values in the receiver. -/// -/// This corresponds to the `Select` method in Rx. -/// -/// Returns a new signal with the mapped values. -- (RACSignal *)map:(id _Nullable (^)(ValueType _Nullable value))block RAC_WARN_UNUSED_RESULT; - -/// Replaces each value in the receiver with the given object. -/// -/// Returns a new signal which includes the given object once for each value in -/// the receiver. -- (RACSignal *)mapReplace:(nullable id)object RAC_WARN_UNUSED_RESULT; - -/// Filters out values in the receiver that don't pass the given test. -/// -/// This corresponds to the `Where` method in Rx. -/// -/// Returns a new signal with only those values that passed. -- (RACSignal *)filter:(BOOL (^)(ValueType _Nullable value))block RAC_WARN_UNUSED_RESULT; - -/// Filters out values in the receiver that equal (via -isEqual:) the provided -/// value. -/// -/// value - The value can be `nil`, in which case it ignores `nil` values. -/// -/// Returns a new signal containing only the values which did not compare equal -/// to `value`. -- (RACSignal *)ignore:(nullable ValueType)value RAC_WARN_UNUSED_RESULT; - -/// Unpacks each RACTuple in the receiver and maps the values to a new value. -/// -/// reduceBlock - The block which reduces each RACTuple's values into one value. -/// It must take as many arguments as the number of tuple elements -/// to process. Each argument will be an object argument. The -/// return value must be an object. This argument cannot be nil. -/// -/// Returns a new signal of reduced tuple values. -- (RACSignal *)reduceEach:(RACReduceBlock)reduceBlock RAC_WARN_UNUSED_RESULT; - -/// Returns a signal consisting of `value`, followed by the values in the -/// receiver. -- (RACSignal *)startWith:(nullable ValueType)value RAC_WARN_UNUSED_RESULT; - -/// Skips the first `skipCount` values in the receiver. -/// -/// Returns the receiver after skipping the first `skipCount` values. If -/// `skipCount` is greater than the number of values in the signal, an empty -/// signal is returned. -- (RACSignal *)skip:(NSUInteger)skipCount RAC_WARN_UNUSED_RESULT; - -/// Returns a signal of the first `count` values in the receiver. If `count` is -/// greater than or equal to the number of values in the signal, a signal -/// equivalent to the receiver is returned. -- (RACSignal *)take:(NSUInteger)count RAC_WARN_UNUSED_RESULT; - -/// Zips the values in the given signals to create RACTuples. -/// -/// The first value of each signals will be combined, then the second value, and -/// so forth, until at least one of the signals is exhausted. -/// -/// signals - The signals to combine. If this collection is empty, the returned -/// signal will be empty. -/// -/// Returns a new signal containing RACTuples of the zipped values from the -/// signals. -+ (RACSignal *)zip:(id)signals RAC_WARN_UNUSED_RESULT; - -/// Zips signals using +zip:, then reduces the resulting tuples into a single -/// value using -reduceEach: -/// -/// signals - The signals to combine. If this collection is empty, the -/// returned signal will be empty. -/// reduceBlock - The block which reduces the values from all the signals -/// into one value. It must take as many arguments as the -/// number of signals given. Each argument will be an object -/// argument. The return value must be an object. This argument -/// must not be nil. -/// -/// Example: -/// -/// [RACSignal zip:@[ stringSignal, intSignal ] -/// reduce:^(NSString *string, NSNumber *number) { -/// return [NSString stringWithFormat:@"%@: %@", string, number]; -/// }]; -/// -/// Returns a new signal containing the results from each invocation of -/// `reduceBlock`. -+ (RACSignal *)zip:(id)signals reduce:(RACGenericReduceBlock)reduceBlock RAC_WARN_UNUSED_RESULT; - -/// Returns a signal obtained by concatenating `signals` in order. -+ (RACSignal *)concat:(id)signals RAC_WARN_UNUSED_RESULT; - -/// Combines values in the receiver from left to right using the given block. -/// -/// The algorithm proceeds as follows: -/// -/// 1. `startingValue` is passed into the block as the `running` value, and the -/// first element of the receiver is passed into the block as the `next` value. -/// 2. The result of the invocation is added to the returned signal. -/// 3. The result of the invocation (`running`) and the next element of the -/// receiver (`next`) is passed into `block`. -/// 4. Steps 2 and 3 are repeated until all values have been processed. -/// -/// startingValue - The value to be combined with the first element of the -/// receiver. This value may be `nil`. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. If the receiver is empty, this block will never be -/// invoked. Cannot be nil. -/// -/// Examples -/// -/// RACSequence *numbers = @[ @1, @2, @3, @4 ].rac_sequence; -/// -/// // Contains 1, 3, 6, 10 -/// RACSequence *sums = [numbers scanWithStart:@0 reduce:^(NSNumber *sum, NSNumber *next) { -/// return @(sum.integerValue + next.integerValue); -/// }]; -/// -/// Returns a new signal that consists of each application of `reduceBlock`. If -/// the receiver is empty, an empty signal is returned. -- (RACSignal *)scanWithStart:(nullable id)startingValue reduce:(id _Nullable (^)(id _Nullable running, ValueType _Nullable next))reduceBlock RAC_WARN_UNUSED_RESULT; - -/// Combines values in the receiver from left to right using the given block -/// which also takes zero-based index of the values. -/// -/// startingValue - The value to be combined with the first element of the -/// receiver. This value may be `nil`. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. This block takes zero-based index value as the last -/// parameter. If the receiver is empty, this block will never -/// be invoked. Cannot be nil. -/// -/// Returns a new signal that consists of each application of `reduceBlock`. If -/// the receiver is empty, an empty signal is returned. -- (RACSignal *)scanWithStart:(nullable id)startingValue reduceWithIndex:(id _Nullable (^)(id _Nullable running, ValueType _Nullable next, NSUInteger index))reduceBlock RAC_WARN_UNUSED_RESULT; - -/// Combines each previous and current value into one object. -/// -/// This method is similar to -scanWithStart:reduce:, but only ever operates on -/// the previous and current values (instead of the whole signal), and does not -/// pass the return value of `reduceBlock` into the next invocation of it. -/// -/// start - The value passed into `reduceBlock` as `previous` for the -/// first value. -/// reduceBlock - The block that combines the previous value and the current -/// value to create the reduced value. Cannot be nil. -/// -/// Examples -/// -/// RACSignal *numbers = [@[ @1, @2, @3, @4 ].rac_sequence -/// signalWithScheduler:RACScheduler.immediateScheduler]; -/// -/// // Contains 1, 3, 5, 7 -/// RACSignal *sums = [numbers combinePreviousWithStart:@0 reduce:^(NSNumber *previous, NSNumber *next) { -/// return @(previous.integerValue + next.integerValue); -/// }]; -/// -/// Returns a new signal consisting of the return values from each application of -/// `reduceBlock`. -- (RACSignal *)combinePreviousWithStart:(nullable ValueType)start reduce:(id _Nullable (^)(ValueType _Nullable previous, ValueType _Nullable current))reduceBlock RAC_WARN_UNUSED_RESULT; - -/// Takes values until the given block returns `YES`. -/// -/// Returns a signal of the initial values in the receiver that fail `predicate`. -/// If `predicate` never returns `YES`, a signal equivalent to the receiver is -/// returned. -- (RACSignal *)takeUntilBlock:(BOOL (^)(ValueType _Nullable x))predicate RAC_WARN_UNUSED_RESULT; - -/// Takes values until the given block returns `NO`. -/// -/// Returns a signal of the initial values in the receiver that pass `predicate`. -/// If `predicate` never returns `NO`, a signal equivalent to the receiver is -/// returned. -- (RACSignal *)takeWhileBlock:(BOOL (^)(ValueType _Nullable x))predicate RAC_WARN_UNUSED_RESULT; - -/// Skips values until the given block returns `YES`. -/// -/// Returns a signal containing the values of the receiver that follow any -/// initial values failing `predicate`. If `predicate` never returns `YES`, -/// an empty signal is returned. -- (RACSignal *)skipUntilBlock:(BOOL (^)(ValueType _Nullable x))predicate RAC_WARN_UNUSED_RESULT; - -/// Skips values until the given block returns `NO`. -/// -/// Returns a signal containing the values of the receiver that follow any -/// initial values passing `predicate`. If `predicate` never returns `NO`, an -/// empty signal is returned. -- (RACSignal *)skipWhileBlock:(BOOL (^)(ValueType _Nullable x))predicate RAC_WARN_UNUSED_RESULT; - -/// Returns a signal of values for which -isEqual: returns NO when compared to the -/// previous value. -- (RACSignal *)distinctUntilChanged RAC_WARN_UNUSED_RESULT; - -@end - -@interface RACSignal<__covariant ValueType> (Subscription) - -/// Subscribes `subscriber` to changes on the receiver. The receiver defines which -/// events it actually sends and in what situations the events are sent. -/// -/// Subscription will always happen on a valid RACScheduler. If the -/// +[RACScheduler currentScheduler] cannot be determined at the time of -/// subscription (e.g., because the calling code is running on a GCD queue or -/// NSOperationQueue), subscription will occur on a private background scheduler. -/// On the main thread, subscriptions will always occur immediately, with a -/// +[RACScheduler currentScheduler] of +[RACScheduler mainThreadScheduler]. -/// -/// This method must be overridden by any subclasses. -/// -/// Returns nil or a disposable. You can call -[RACDisposable dispose] if you -/// need to end your subscription before it would "naturally" end, either by -/// completing or erroring. Once the disposable has been disposed, the subscriber -/// won't receive any more events from the subscription. -- (RACDisposable *)subscribe:(id)subscriber; - -/// Convenience method to subscribe to the `next` event. -/// -/// This corresponds to `IObserver.OnNext` in Rx. -- (RACDisposable *)subscribeNext:(void (^)(ValueType _Nullable x))nextBlock; - -/// Convenience method to subscribe to the `next` and `completed` events. -- (RACDisposable *)subscribeNext:(void (^)(ValueType _Nullable x))nextBlock completed:(void (^)(void))completedBlock; - -/// Convenience method to subscribe to the `next`, `completed`, and `error` events. -- (RACDisposable *)subscribeNext:(void (^)(ValueType _Nullable x))nextBlock error:(void (^)(NSError * _Nullable error))errorBlock completed:(void (^)(void))completedBlock; - -/// Convenience method to subscribe to `error` events. -/// -/// This corresponds to the `IObserver.OnError` in Rx. -- (RACDisposable *)subscribeError:(void (^)(NSError * _Nullable error))errorBlock; - -/// Convenience method to subscribe to `completed` events. -/// -/// This corresponds to the `IObserver.OnCompleted` in Rx. -- (RACDisposable *)subscribeCompleted:(void (^)(void))completedBlock; - -/// Convenience method to subscribe to `next` and `error` events. -- (RACDisposable *)subscribeNext:(void (^)(ValueType _Nullable x))nextBlock error:(void (^)(NSError * _Nullable error))errorBlock; - -/// Convenience method to subscribe to `error` and `completed` events. -- (RACDisposable *)subscribeError:(void (^)(NSError * _Nullable error))errorBlock completed:(void (^)(void))completedBlock; - -@end - -/// Additional methods to assist with debugging. -@interface RACSignal<__covariant ValueType> (Debugging) - -/// Logs all events that the receiver sends. -- (RACSignal *)logAll RAC_WARN_UNUSED_RESULT; - -/// Logs each `next` that the receiver sends. -- (RACSignal *)logNext RAC_WARN_UNUSED_RESULT; - -/// Logs any error that the receiver sends. -- (RACSignal *)logError RAC_WARN_UNUSED_RESULT; - -/// Logs any `completed` event that the receiver sends. -- (RACSignal *)logCompleted RAC_WARN_UNUSED_RESULT; - -@end - -/// Additional methods to assist with unit testing. -/// -/// **These methods should never ship in production code.** -@interface RACSignal<__covariant ValueType> (Testing) - -/// Spins the main run loop for a short while, waiting for the receiver to send a `next` -/// or the provided timeout to elapse. -/// -/// **Because this method executes the run loop recursively, it should only be used -/// on the main thread, and only from a unit test.** -/// -/// defaultValue - Returned if the receiver completes or errors before sending -/// a `next`, or if the method times out. This argument may be -/// nil. -/// success - If not NULL, set to whether the receiver completed -/// successfully. -/// error - If not NULL, set to any error that occurred. -/// -/// Returns the first value received, or `defaultValue` if no value is received -/// before the signal finishes or the method times out. -- (nullable ValueType)asynchronousFirstOrDefault:(nullable ValueType)defaultValue success:(nullable BOOL *)success error:(NSError * _Nullable * _Nullable)error timeout:(NSTimeInterval)timeout; - -/// Spins the main run loop for a short while, waiting for the receiver to send a `next`. -/// -/// **Because this method executes the run loop recursively, it should only be used -/// on the main thread, and only from a unit test.** -/// -/// defaultValue - Returned if the receiver completes or errors before sending -/// a `next`, or if the method times out. This argument may be -/// nil. -/// success - If not NULL, set to whether the receiver completed -/// successfully. -/// error - If not NULL, set to any error that occurred. -/// -/// Returns the first value received, or `defaultValue` if no value is received -/// before the signal finishes or the method times out. -- (nullable ValueType)asynchronousFirstOrDefault:(nullable ValueType)defaultValue success:(nullable BOOL *)success error:(NSError * _Nullable * _Nullable)error; - -/// Spins the main run loop for a short while, waiting for the receiver to complete. -/// or the provided timeout to elapse. -/// -/// **Because this method executes the run loop recursively, it should only be used -/// on the main thread, and only from a unit test.** -/// -/// error - If not NULL, set to any error that occurs. -/// -/// Returns whether the signal completed successfully before timing out. If NO, -/// `error` will be set to any error that occurred. -- (BOOL)asynchronouslyWaitUntilCompleted:(NSError * _Nullable * _Nullable)error timeout:(NSTimeInterval)timeout; - -/// Spins the main run loop for a short while, waiting for the receiver to complete -/// -/// **Because this method executes the run loop recursively, it should only be used -/// on the main thread, and only from a unit test.** -/// -/// error - If not NULL, set to any error that occurs. -/// -/// Returns whether the signal completed successfully before timing out. If NO, -/// `error` will be set to any error that occurred. -- (BOOL)asynchronouslyWaitUntilCompleted:(NSError * _Nullable * _Nullable)error; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACSignal.m b/LookInside/ReactiveObjC/RACSignal.m deleted file mode 100644 index 63bfa0d7..00000000 --- a/LookInside/ReactiveObjC/RACSignal.m +++ /dev/null @@ -1,410 +0,0 @@ -// -// RACSignal.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/15/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSignal.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACDynamicSignal.h" -#import "RACEmptySignal.h" -#import "RACErrorSignal.h" -#import "RACMulticastConnection.h" -#import "RACReplaySubject.h" -#import "RACReturnSignal.h" -#import "RACScheduler.h" -#import "RACSerialDisposable.h" -#import "RACSignal+Operations.h" -#import "RACSubject.h" -#import "RACSubscriber+Private.h" -#import "RACTuple.h" -#import - -@implementation RACSignal - -#pragma mark Lifecycle - -+ (RACSignal *)createSignal:(RACDisposable * (^)(id subscriber))didSubscribe { - return [RACDynamicSignal createSignal:didSubscribe]; -} - -+ (RACSignal *)error:(NSError *)error { - return [RACErrorSignal error:error]; -} - -+ (RACSignal *)never { - return [[self createSignal:^ RACDisposable * (id subscriber) { - return nil; - }] setNameWithFormat:@"+never"]; -} - -+ (RACSignal *)startEagerlyWithScheduler:(RACScheduler *)scheduler block:(void (^)(id subscriber))block { - NSCParameterAssert(scheduler != nil); - NSCParameterAssert(block != NULL); - - RACSignal *signal = [self startLazilyWithScheduler:scheduler block:block]; - // Subscribe to force the lazy signal to call its block. - [[signal publish] connect]; - return [signal setNameWithFormat:@"+startEagerlyWithScheduler: %@ block:", scheduler]; -} - -+ (RACSignal *)startLazilyWithScheduler:(RACScheduler *)scheduler block:(void (^)(id subscriber))block { - NSCParameterAssert(scheduler != nil); - NSCParameterAssert(block != NULL); - - RACMulticastConnection *connection = [[RACSignal - createSignal:^ id (id subscriber) { - block(subscriber); - return nil; - }] - multicast:[RACReplaySubject subject]]; - - return [[[RACSignal - createSignal:^ id (id subscriber) { - [connection.signal subscribe:subscriber]; - [connection connect]; - return nil; - }] - subscribeOn:scheduler] - setNameWithFormat:@"+startLazilyWithScheduler: %@ block:", scheduler]; -} - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p> name: %@", self.class, self, self.name]; -} - -@end - -@implementation RACSignal (RACStream) - -+ (RACSignal *)empty { - return [RACEmptySignal empty]; -} - -+ (RACSignal *)return:(id)value { - return [RACReturnSignal return:value]; -} - -- (RACSignal *)bind:(RACSignalBindBlock (^)(void))block { - NSCParameterAssert(block != NULL); - - /* - * -bind: should: - * - * 1. Subscribe to the original signal of values. - * 2. Any time the original signal sends a value, transform it using the binding block. - * 3. If the binding block returns a signal, subscribe to it, and pass all of its values through to the subscriber as they're received. - * 4. If the binding block asks the bind to terminate, complete the _original_ signal. - * 5. When _all_ signals complete, send completed to the subscriber. - * - * If any signal sends an error at any point, send that to the subscriber. - */ - - return [[RACSignal createSignal:^(id subscriber) { - RACSignalBindBlock bindingBlock = block(); - - __block atomic_int_least32_t signalCount; // indicates self - atomic_init(&signalCount, 1); - - RACCompoundDisposable *compoundDisposable = [RACCompoundDisposable compoundDisposable]; - - void (^completeSignal)(RACDisposable *) = ^(RACDisposable *finishedDisposable) { - if (atomic_fetch_sub_explicit(&signalCount, 1, memory_order_acq_rel) == 1) { - [subscriber sendCompleted]; - [compoundDisposable dispose]; - } else { - [compoundDisposable removeDisposable:finishedDisposable]; - } - }; - - void (^addSignal)(RACSignal *) = ^(RACSignal *signal) { - atomic_fetch_add_explicit(&signalCount, 1, memory_order_acq_rel); - - RACSerialDisposable *selfDisposable = [[RACSerialDisposable alloc] init]; - [compoundDisposable addDisposable:selfDisposable]; - - RACDisposable *disposable = [signal subscribeNext:^(id x) { - [subscriber sendNext:x]; - } error:^(NSError *error) { - [compoundDisposable dispose]; - [subscriber sendError:error]; - } completed:^{ - @autoreleasepool { - completeSignal(selfDisposable); - } - }]; - - selfDisposable.disposable = disposable; - }; - - @autoreleasepool { - RACSerialDisposable *selfDisposable = [[RACSerialDisposable alloc] init]; - [compoundDisposable addDisposable:selfDisposable]; - - RACDisposable *bindingDisposable = [self subscribeNext:^(id x) { - // Manually check disposal to handle synchronous errors. - if (compoundDisposable.disposed) return; - - BOOL stop = NO; - id signal = bindingBlock(x, &stop); - - @autoreleasepool { - if (signal != nil) addSignal(signal); - if (signal == nil || stop) { - [selfDisposable dispose]; - completeSignal(selfDisposable); - } - } - } error:^(NSError *error) { - [compoundDisposable dispose]; - [subscriber sendError:error]; - } completed:^{ - @autoreleasepool { - completeSignal(selfDisposable); - } - }]; - - selfDisposable.disposable = bindingDisposable; - } - - return compoundDisposable; - }] setNameWithFormat:@"[%@] -bind:", self.name]; -} - -- (RACSignal *)concat:(RACSignal *)signal { - return [[RACSignal createSignal:^(id subscriber) { - RACCompoundDisposable *compoundDisposable = [[RACCompoundDisposable alloc] init]; - - RACDisposable *sourceDisposable = [self subscribeNext:^(id x) { - [subscriber sendNext:x]; - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - RACDisposable *concattedDisposable = [signal subscribe:subscriber]; - [compoundDisposable addDisposable:concattedDisposable]; - }]; - - [compoundDisposable addDisposable:sourceDisposable]; - return compoundDisposable; - }] setNameWithFormat:@"[%@] -concat: %@", self.name, signal]; -} - -- (RACSignal *)zipWith:(RACSignal *)signal { - NSCParameterAssert(signal != nil); - - return [[RACSignal createSignal:^(id subscriber) { - __block BOOL selfCompleted = NO; - NSMutableArray *selfValues = [NSMutableArray array]; - - __block BOOL otherCompleted = NO; - NSMutableArray *otherValues = [NSMutableArray array]; - - void (^sendCompletedIfNecessary)(void) = ^{ - @synchronized (selfValues) { - BOOL selfEmpty = (selfCompleted && selfValues.count == 0); - BOOL otherEmpty = (otherCompleted && otherValues.count == 0); - if (selfEmpty || otherEmpty) [subscriber sendCompleted]; - } - }; - - void (^sendNext)(void) = ^{ - @synchronized (selfValues) { - if (selfValues.count == 0) return; - if (otherValues.count == 0) return; - - RACTuple *tuple = RACTuplePack(selfValues[0], otherValues[0]); - [selfValues removeObjectAtIndex:0]; - [otherValues removeObjectAtIndex:0]; - - [subscriber sendNext:tuple]; - sendCompletedIfNecessary(); - } - }; - - RACDisposable *selfDisposable = [self subscribeNext:^(id x) { - @synchronized (selfValues) { - [selfValues addObject:x ?: RACTupleNil.tupleNil]; - sendNext(); - } - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - @synchronized (selfValues) { - selfCompleted = YES; - sendCompletedIfNecessary(); - } - }]; - - RACDisposable *otherDisposable = [signal subscribeNext:^(id x) { - @synchronized (selfValues) { - [otherValues addObject:x ?: RACTupleNil.tupleNil]; - sendNext(); - } - } error:^(NSError *error) { - [subscriber sendError:error]; - } completed:^{ - @synchronized (selfValues) { - otherCompleted = YES; - sendCompletedIfNecessary(); - } - }]; - - return [RACDisposable disposableWithBlock:^{ - [selfDisposable dispose]; - [otherDisposable dispose]; - }]; - }] setNameWithFormat:@"[%@] -zipWith: %@", self.name, signal]; -} - -@end - -@implementation RACSignal (Subscription) - -- (RACDisposable *)subscribe:(id)subscriber { - NSCAssert(NO, @"This method must be overridden by subclasses"); - return nil; -} - -- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock { - NSCParameterAssert(nextBlock != NULL); - - RACSubscriber *o = [RACSubscriber subscriberWithNext:nextBlock error:NULL completed:NULL]; - return [self subscribe:o]; -} - -- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock completed:(void (^)(void))completedBlock { - NSCParameterAssert(nextBlock != NULL); - NSCParameterAssert(completedBlock != NULL); - - RACSubscriber *o = [RACSubscriber subscriberWithNext:nextBlock error:NULL completed:completedBlock]; - return [self subscribe:o]; -} - -- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock error:(void (^)(NSError *error))errorBlock completed:(void (^)(void))completedBlock { - NSCParameterAssert(nextBlock != NULL); - NSCParameterAssert(errorBlock != NULL); - NSCParameterAssert(completedBlock != NULL); - - RACSubscriber *o = [RACSubscriber subscriberWithNext:nextBlock error:errorBlock completed:completedBlock]; - return [self subscribe:o]; -} - -- (RACDisposable *)subscribeError:(void (^)(NSError *error))errorBlock { - NSCParameterAssert(errorBlock != NULL); - - RACSubscriber *o = [RACSubscriber subscriberWithNext:NULL error:errorBlock completed:NULL]; - return [self subscribe:o]; -} - -- (RACDisposable *)subscribeCompleted:(void (^)(void))completedBlock { - NSCParameterAssert(completedBlock != NULL); - - RACSubscriber *o = [RACSubscriber subscriberWithNext:NULL error:NULL completed:completedBlock]; - return [self subscribe:o]; -} - -- (RACDisposable *)subscribeNext:(void (^)(id x))nextBlock error:(void (^)(NSError *error))errorBlock { - NSCParameterAssert(nextBlock != NULL); - NSCParameterAssert(errorBlock != NULL); - - RACSubscriber *o = [RACSubscriber subscriberWithNext:nextBlock error:errorBlock completed:NULL]; - return [self subscribe:o]; -} - -- (RACDisposable *)subscribeError:(void (^)(NSError *))errorBlock completed:(void (^)(void))completedBlock { - NSCParameterAssert(completedBlock != NULL); - NSCParameterAssert(errorBlock != NULL); - - RACSubscriber *o = [RACSubscriber subscriberWithNext:NULL error:errorBlock completed:completedBlock]; - return [self subscribe:o]; -} - -@end - -@implementation RACSignal (Debugging) - -- (RACSignal *)logAll { - return [[[self logNext] logError] logCompleted]; -} - -- (RACSignal *)logNext { - return [[self doNext:^(id x) { - NSLog(@"%@ next: %@", self, x); - }] setNameWithFormat:@"%@", self.name]; -} - -- (RACSignal *)logError { - return [[self doError:^(NSError *error) { - NSLog(@"%@ error: %@", self, error); - }] setNameWithFormat:@"%@", self.name]; -} - -- (RACSignal *)logCompleted { - return [[self doCompleted:^{ - NSLog(@"%@ completed", self); - }] setNameWithFormat:@"%@", self.name]; -} - -@end - -@implementation RACSignal (Testing) - -static const NSTimeInterval RACSignalAsynchronousWaitTimeout = 10; - -- (id)asynchronousFirstOrDefault:(id)defaultValue success:(BOOL *)success error:(NSError **)error { - return [self asynchronousFirstOrDefault:defaultValue success:success error:error timeout:RACSignalAsynchronousWaitTimeout]; -} - -- (id)asynchronousFirstOrDefault:(id)defaultValue success:(BOOL *)success error:(NSError **)error timeout:(NSTimeInterval)timeout { - NSCAssert([NSThread isMainThread], @"%s should only be used from the main thread", __func__); - - __block id result = defaultValue; - __block BOOL done = NO; - - // Ensures that we don't pass values across thread boundaries by reference. - __block NSError *localError; - __block BOOL localSuccess = YES; - - [[[[self - take:1] - timeout:timeout onScheduler:[RACScheduler scheduler]] - deliverOn:RACScheduler.mainThreadScheduler] - subscribeNext:^(id x) { - result = x; - done = YES; - } error:^(NSError *e) { - if (!done) { - localSuccess = NO; - localError = e; - done = YES; - } - } completed:^{ - done = YES; - }]; - - do { - [NSRunLoop.mainRunLoop runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; - } while (!done); - - if (success != NULL) *success = localSuccess; - if (error != NULL) *error = localError; - - return result; -} - -- (BOOL)asynchronouslyWaitUntilCompleted:(NSError **)error timeout:(NSTimeInterval)timeout { - BOOL success = NO; - [[self ignoreValues] asynchronousFirstOrDefault:nil success:&success error:error timeout:timeout]; - return success; -} - -- (BOOL)asynchronouslyWaitUntilCompleted:(NSError **)error { - return [self asynchronouslyWaitUntilCompleted:error timeout:RACSignalAsynchronousWaitTimeout]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACSignalProvider.d b/LookInside/ReactiveObjC/RACSignalProvider.d deleted file mode 100644 index 8add9a10..00000000 --- a/LookInside/ReactiveObjC/RACSignalProvider.d +++ /dev/null @@ -1,5 +0,0 @@ -provider RACSignal { - probe next(char *signal, char *subscriber, char *valueDescription); - probe completed(char *signal, char *subscriber); - probe error(char *signal, char *subscriber, char *errorDescription); -}; diff --git a/LookInside/ReactiveObjC/RACSignalProvider.h b/LookInside/ReactiveObjC/RACSignalProvider.h deleted file mode 100644 index ff13abcf..00000000 --- a/LookInside/ReactiveObjC/RACSignalProvider.h +++ /dev/null @@ -1,13 +0,0 @@ -#ifndef RACSIGNAL_PROVIDER_H -#define RACSIGNAL_PROVIDER_H - -#define RACSIGNAL_NEXT_ENABLED() 0 -#define RACSIGNAL_NEXT(signal, subscriber, valueDescription) do { } while (0) - -#define RACSIGNAL_COMPLETED_ENABLED() 0 -#define RACSIGNAL_COMPLETED(signal, subscriber) do { } while (0) - -#define RACSIGNAL_ERROR_ENABLED() 0 -#define RACSIGNAL_ERROR(signal, subscriber, errorDescription) do { } while (0) - -#endif diff --git a/LookInside/ReactiveObjC/RACSignalSequence.h b/LookInside/ReactiveObjC/RACSignalSequence.h deleted file mode 100644 index dadf5fee..00000000 --- a/LookInside/ReactiveObjC/RACSignalSequence.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// RACSignalSequence.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-11-09. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSequence.h" - -@class RACSignal<__covariant ValueType>; - -// Private class that adapts a RACSignal to the RACSequence interface. -@interface RACSignalSequence : RACSequence - -// Returns a sequence for enumerating over the given signal. -+ (RACSequence *)sequenceWithSignal:(RACSignal *)signal; - -@end diff --git a/LookInside/ReactiveObjC/RACSignalSequence.m b/LookInside/ReactiveObjC/RACSignalSequence.m deleted file mode 100644 index 14cea6b1..00000000 --- a/LookInside/ReactiveObjC/RACSignalSequence.m +++ /dev/null @@ -1,79 +0,0 @@ -// -// RACSignalSequence.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-11-09. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSignalSequence.h" -#import "RACDisposable.h" -#import "RACReplaySubject.h" -#import "RACSignal+Operations.h" - -@interface RACSignalSequence () - -// Replays the signal given on initialization. -@property (nonatomic, strong, readonly) RACReplaySubject *subject; - -@end - -@implementation RACSignalSequence - -#pragma mark Lifecycle - -+ (RACSequence *)sequenceWithSignal:(RACSignal *)signal { - RACSignalSequence *seq = [[self alloc] init]; - - RACReplaySubject *subject = [RACReplaySubject subject]; - [signal subscribeNext:^(id value) { - [subject sendNext:value]; - } error:^(NSError *error) { - [subject sendError:error]; - } completed:^{ - [subject sendCompleted]; - }]; - - seq->_subject = subject; - return seq; -} - -#pragma mark RACSequence - -- (id)head { - id value = [self.subject firstOrDefault:self]; - - if (value == self) { - return nil; - } else { - return value ?: NSNull.null; - } -} - -- (RACSequence *)tail { - RACSequence *sequence = [self.class sequenceWithSignal:[self.subject skip:1]]; - sequence.name = self.name; - return sequence; -} - -- (NSArray *)array { - return self.subject.toArray; -} - -#pragma mark NSObject - -- (NSString *)description { - // Synchronously accumulate the values that have been sent so far. - NSMutableArray *values = [NSMutableArray array]; - RACDisposable *disposable = [self.subject subscribeNext:^(id value) { - @synchronized (values) { - [values addObject:value ?: NSNull.null]; - } - }]; - - [disposable dispose]; - - return [NSString stringWithFormat:@"<%@: %p>{ name = %@, values = %@ … }", self.class, self, self.name, values]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACStream+Private.h b/LookInside/ReactiveObjC/RACStream+Private.h deleted file mode 100644 index 9c7f48cd..00000000 --- a/LookInside/ReactiveObjC/RACStream+Private.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// RACStream+Private.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-07-22. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACStream.h" - -@interface RACStream () - -// Combines a list of streams using the logic of the given block. -// -// streams - The streams to combine. -// block - An operator that combines two streams and returns a new one. The -// returned stream should contain 2-tuples of the streams' combined -// values. -// -// Returns a combined stream. -+ (__kindof RACStream *)join:(id)streams block:(RACStream * (^)(id, id))block; - -@end diff --git a/LookInside/ReactiveObjC/RACStream.h b/LookInside/ReactiveObjC/RACStream.h deleted file mode 100644 index b397d18b..00000000 --- a/LookInside/ReactiveObjC/RACStream.h +++ /dev/null @@ -1,337 +0,0 @@ -// -// RACStream.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-31. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACStream; - -NS_ASSUME_NONNULL_BEGIN - -/// An abstract class representing any stream of values. -/// -/// This class represents a monad, upon which many stream-based operations can -/// be built. -/// -/// When subclassing RACStream, only the methods in the main @interface body need -/// to be overridden. -@interface RACStream<__covariant ValueType> : NSObject - -_Pragma("clang diagnostic push") \ -_Pragma("clang diagnostic ignored \"-Wstrict-prototypes\"") \ -typedef id _Nonnull (^RACReduceBlock)(); -typedef ValueType _Nonnull (^RACGenericReduceBlock)(); -_Pragma("clang diagnostic pop") - -/// Returns an empty stream. -+ (__kindof RACStream *)empty; - -/// Lifts `value` into the stream monad. -/// -/// Returns a stream containing only the given value. -+ (__kindof RACStream *)return:(nullable ValueType)value; - -/// A block which accepts a value from a RACStream and returns a new instance -/// of the same stream class. -/// -/// Setting `stop` to `YES` will cause the bind to terminate after the returned -/// value. Returning `nil` will result in immediate termination. -typedef RACStream * _Nullable (^RACStreamBindBlock)(ValueType _Nullable value, BOOL *stop); - -/// Lazily binds a block to the values in the receiver. -/// -/// This should only be used if you need to terminate the bind early, or close -/// over some state. -flattenMap: is more appropriate for all other cases. -/// -/// block - A block returning a RACStreamBindBlock. This block will be invoked -/// each time the bound stream is re-evaluated. This block must not be -/// nil or return nil. -/// -/// Returns a new stream which represents the combined result of all lazy -/// applications of `block`. -- (__kindof RACStream *)bind:(RACStreamBindBlock (^)(void))block; - -/// Appends the values of `stream` to the values in the receiver. -/// -/// stream - A stream to concatenate. This must be an instance of the same -/// concrete class as the receiver, and should not be `nil`. -/// -/// Returns a new stream representing the receiver followed by `stream`. -- (__kindof RACStream *)concat:(RACStream *)stream; - -/// Zips the values in the receiver with those of the given stream to create -/// RACTuples. -/// -/// The first value of each stream will be combined, then the second value, and -/// so forth, until at least one of the streams is exhausted. -/// -/// stream - The stream to zip with. This must be an instance of the same -/// concrete class as the receiver, and should not be `nil`. -/// -/// Returns a new stream of RACTuples, representing the zipped values of the -/// two streams. -- (__kindof RACStream *)zipWith:(RACStream *)stream; - -@end - -/// This extension contains functionality to support naming streams for -/// debugging. -/// -/// Subclasses do not need to override the methods here. -@interface RACStream () - -/// The name of the stream. This is for debugging/human purposes only. -@property (copy) NSString *name; - -/// Sets the name of the receiver to the given format string. -/// -/// This is for debugging purposes only, and won't do anything unless the -/// RAC_DEBUG_SIGNAL_NAMES environment variable is set. -/// -/// Returns the receiver, for easy method chaining. -- (instancetype)setNameWithFormat:(NSString *)format, ... NS_FORMAT_FUNCTION(1, 2); - -@end - -/// Operations built on the RACStream primitives. -/// -/// These methods do not need to be overridden, although subclasses may -/// occasionally gain better performance from doing so. -@interface RACStream<__covariant ValueType> (Operations) - -/// Maps `block` across the values in the receiver and flattens the result. -/// -/// Note that operators applied _after_ -flattenMap: behave differently from -/// operators _within_ -flattenMap:. See the Examples section below. -/// -/// This corresponds to the `SelectMany` method in Rx. -/// -/// block - A block which accepts the values in the receiver and returns a new -/// instance of the receiver's class. Returning `nil` from this block is -/// equivalent to returning an empty signal. -/// -/// Examples -/// -/// [signal flattenMap:^(id x) { -/// // Logs each time a returned signal completes. -/// return [[RACSignal return:x] logCompleted]; -/// }]; -/// -/// [[signal -/// flattenMap:^(id x) { -/// return [RACSignal return:x]; -/// }] -/// // Logs only once, when all of the signals complete. -/// logCompleted]; -/// -/// Returns a new stream which represents the combined streams resulting from -/// mapping `block`. -- (__kindof RACStream *)flattenMap:(__kindof RACStream * _Nullable (^)(ValueType _Nullable value))block; - -/// Flattens a stream of streams. -/// -/// This corresponds to the `Merge` method in Rx. -/// -/// Returns a stream consisting of the combined streams obtained from the -/// receiver. -- (__kindof RACStream *)flatten; - -/// Maps `block` across the values in the receiver. -/// -/// This corresponds to the `Select` method in Rx. -/// -/// Returns a new stream with the mapped values. -- (__kindof RACStream *)map:(id _Nullable (^)(ValueType _Nullable value))block; - -/// Replaces each value in the receiver with the given object. -/// -/// Returns a new stream which includes the given object once for each value in -/// the receiver. -- (__kindof RACStream *)mapReplace:(nullable id)object; - -/// Filters out values in the receiver that don't pass the given test. -/// -/// This corresponds to the `Where` method in Rx. -/// -/// Returns a new stream with only those values that passed. -- (__kindof RACStream *)filter:(BOOL (^)(ValueType _Nullable value))block; - -/// Filters out values in the receiver that equal (via -isEqual:) the provided value. -/// -/// value - The value can be `nil`, in which case it ignores `nil` values. -/// -/// Returns a new stream containing only the values which did not compare equal -/// to `value`. -- (__kindof RACStream *)ignore:(nullable ValueType)value; - -/// Unpacks each RACTuple in the receiver and maps the values to a new value. -/// -/// reduceBlock - The block which reduces each RACTuple's values into one value. -/// It must take as many arguments as the number of tuple elements -/// to process. Each argument will be an object argument. The -/// return value must be an object. This argument cannot be nil. -/// -/// Returns a new stream of reduced tuple values. -- (__kindof RACStream *)reduceEach:(RACReduceBlock)reduceBlock; - -/// Returns a stream consisting of `value`, followed by the values in the -/// receiver. -- (__kindof RACStream *)startWith:(nullable ValueType)value; - -/// Skips the first `skipCount` values in the receiver. -/// -/// Returns the receiver after skipping the first `skipCount` values. If -/// `skipCount` is greater than the number of values in the stream, an empty -/// stream is returned. -- (__kindof RACStream *)skip:(NSUInteger)skipCount; - -/// Returns a stream of the first `count` values in the receiver. If `count` is -/// greater than or equal to the number of values in the stream, a stream -/// equivalent to the receiver is returned. -- (__kindof RACStream *)take:(NSUInteger)count; - -/// Zips the values in the given streams to create RACTuples. -/// -/// The first value of each stream will be combined, then the second value, and -/// so forth, until at least one of the streams is exhausted. -/// -/// streams - The streams to combine. These must all be instances of the same -/// concrete class implementing the protocol. If this collection is -/// empty, the returned stream will be empty. -/// -/// Returns a new stream containing RACTuples of the zipped values from the -/// streams. -+ (__kindof RACStream *)zip:(id)streams; - -/// Zips streams using +zip:, then reduces the resulting tuples into a single -/// value using -reduceEach: -/// -/// streams - The streams to combine. These must all be instances of the -/// same concrete class implementing the protocol. If this -/// collection is empty, the returned stream will be empty. -/// reduceBlock - The block which reduces the values from all the streams -/// into one value. It must take as many arguments as the -/// number of streams given. Each argument will be an object -/// argument. The return value must be an object. This argument -/// must not be nil. -/// -/// Example: -/// -/// [RACStream zip:@[ stringSignal, intSignal ] reduce:^(NSString *string, NSNumber *number) { -/// return [NSString stringWithFormat:@"%@: %@", string, number]; -/// }]; -/// -/// Returns a new stream containing the results from each invocation of -/// `reduceBlock`. -+ (__kindof RACStream *)zip:(id)streams reduce:(RACGenericReduceBlock)reduceBlock; - -/// Returns a stream obtained by concatenating `streams` in order. -+ (__kindof RACStream *)concat:(id)streams; - -/// Combines values in the receiver from left to right using the given block. -/// -/// The algorithm proceeds as follows: -/// -/// 1. `startingValue` is passed into the block as the `running` value, and the -/// first element of the receiver is passed into the block as the `next` value. -/// 2. The result of the invocation is added to the returned stream. -/// 3. The result of the invocation (`running`) and the next element of the -/// receiver (`next`) is passed into `block`. -/// 4. Steps 2 and 3 are repeated until all values have been processed. -/// -/// startingValue - The value to be combined with the first element of the -/// receiver. This value may be `nil`. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. If the receiver is empty, this block will never be -/// invoked. Cannot be nil. -/// -/// Examples -/// -/// RACSequence *numbers = @[ @1, @2, @3, @4 ].rac_sequence; -/// -/// // Contains 1, 3, 6, 10 -/// RACSequence *sums = [numbers scanWithStart:@0 reduce:^(NSNumber *sum, NSNumber *next) { -/// return @(sum.integerValue + next.integerValue); -/// }]; -/// -/// Returns a new stream that consists of each application of `reduceBlock`. If the -/// receiver is empty, an empty stream is returned. -- (__kindof RACStream *)scanWithStart:(nullable id)startingValue reduce:(id _Nullable (^)(id _Nullable running, ValueType _Nullable next))reduceBlock; - -/// Combines values in the receiver from left to right using the given block -/// which also takes zero-based index of the values. -/// -/// startingValue - The value to be combined with the first element of the -/// receiver. This value may be `nil`. -/// reduceBlock - The block that describes how to combine values of the -/// receiver. This block takes zero-based index value as the last -/// parameter. If the receiver is empty, this block will never -/// be invoked. Cannot be nil. -/// -/// Returns a new stream that consists of each application of `reduceBlock`. If the -/// receiver is empty, an empty stream is returned. -- (__kindof RACStream *)scanWithStart:(nullable id)startingValue reduceWithIndex:(id _Nullable (^)(id _Nullable running, ValueType _Nullable next, NSUInteger index))reduceBlock; - -/// Combines each previous and current value into one object. -/// -/// This method is similar to -scanWithStart:reduce:, but only ever operates on -/// the previous and current values (instead of the whole stream), and does not -/// pass the return value of `reduceBlock` into the next invocation of it. -/// -/// start - The value passed into `reduceBlock` as `previous` for the -/// first value. -/// reduceBlock - The block that combines the previous value and the current -/// value to create the reduced value. Cannot be nil. -/// -/// Examples -/// -/// RACSequence *numbers = @[ @1, @2, @3, @4 ].rac_sequence; -/// -/// // Contains 1, 3, 5, 7 -/// RACSequence *sums = [numbers combinePreviousWithStart:@0 reduce:^(NSNumber *previous, NSNumber *next) { -/// return @(previous.integerValue + next.integerValue); -/// }]; -/// -/// Returns a new stream consisting of the return values from each application of -/// `reduceBlock`. -- (__kindof RACStream *)combinePreviousWithStart:(nullable ValueType)start reduce:(id _Nullable (^)(ValueType _Nullable previous, ValueType _Nullable current))reduceBlock; - -/// Takes values until the given block returns `YES`. -/// -/// Returns a stream of the initial values in the receiver that fail `predicate`. -/// If `predicate` never returns `YES`, a stream equivalent to the receiver is -/// returned. -- (__kindof RACStream *)takeUntilBlock:(BOOL (^)(ValueType _Nullable x))predicate; - -/// Takes values until the given block returns `NO`. -/// -/// Returns a stream of the initial values in the receiver that pass `predicate`. -/// If `predicate` never returns `NO`, a stream equivalent to the receiver is -/// returned. -- (__kindof RACStream *)takeWhileBlock:(BOOL (^)(ValueType _Nullable x))predicate; - -/// Skips values until the given block returns `YES`. -/// -/// Returns a stream containing the values of the receiver that follow any -/// initial values failing `predicate`. If `predicate` never returns `YES`, -/// an empty stream is returned. -- (__kindof RACStream *)skipUntilBlock:(BOOL (^)(ValueType _Nullable x))predicate; - -/// Skips values until the given block returns `NO`. -/// -/// Returns a stream containing the values of the receiver that follow any -/// initial values passing `predicate`. If `predicate` never returns `NO`, an -/// empty stream is returned. -- (__kindof RACStream *)skipWhileBlock:(BOOL (^)(ValueType _Nullable x))predicate; - -/// Returns a stream of values for which -isEqual: returns NO when compared to the -/// previous value. -- (__kindof RACStream *)distinctUntilChanged; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACStream.m b/LookInside/ReactiveObjC/RACStream.m deleted file mode 100644 index 431b6c6b..00000000 --- a/LookInside/ReactiveObjC/RACStream.m +++ /dev/null @@ -1,355 +0,0 @@ -// -// RACStream.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-31. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACStream.h" -#import "NSObject+RACDescription.h" -#import "RACBlockTrampoline.h" -#import "RACTuple.h" - -@implementation RACStream - -#pragma mark Lifecycle - -- (instancetype)init { - self = [super init]; - - self.name = @""; - return self; -} - -#pragma mark Abstract methods - -+ (__kindof RACStream *)empty { - NSString *reason = [NSString stringWithFormat:@"%@ must be overridden by subclasses", NSStringFromSelector(_cmd)]; - @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:nil]; -} - -- (__kindof RACStream *)bind:(RACStreamBindBlock (^)(void))block { - NSString *reason = [NSString stringWithFormat:@"%@ must be overridden by subclasses", NSStringFromSelector(_cmd)]; - @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:nil]; -} - -+ (__kindof RACStream *)return:(id)value { - NSString *reason = [NSString stringWithFormat:@"%@ must be overridden by subclasses", NSStringFromSelector(_cmd)]; - @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:nil]; -} - -- (__kindof RACStream *)concat:(RACStream *)stream { - NSString *reason = [NSString stringWithFormat:@"%@ must be overridden by subclasses", NSStringFromSelector(_cmd)]; - @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:nil]; -} - -- (__kindof RACStream *)zipWith:(RACStream *)stream { - NSString *reason = [NSString stringWithFormat:@"%@ must be overridden by subclasses", NSStringFromSelector(_cmd)]; - @throw [NSException exceptionWithName:NSInternalInconsistencyException reason:reason userInfo:nil]; -} - -#pragma mark Naming - -- (instancetype)setNameWithFormat:(NSString *)format, ... { - if (getenv("RAC_DEBUG_SIGNAL_NAMES") == NULL) return self; - - NSCParameterAssert(format != nil); - - va_list args; - va_start(args, format); - - NSString *str = [[NSString alloc] initWithFormat:format arguments:args]; - va_end(args); - - self.name = str; - return self; -} - -@end - -@implementation RACStream (Operations) - -- (__kindof RACStream *)flattenMap:(__kindof RACStream * (^)(id value))block { - Class class = self.class; - - return [[self bind:^{ - return ^(id value, BOOL *stop) { - id stream = block(value) ?: [class empty]; - NSCAssert([stream isKindOfClass:RACStream.class], @"Value returned from -flattenMap: is not a stream: %@", stream); - - return stream; - }; - }] setNameWithFormat:@"[%@] -flattenMap:", self.name]; -} - -- (__kindof RACStream *)flatten { - return [[self flattenMap:^(id value) { - return value; - }] setNameWithFormat:@"[%@] -flatten", self.name]; -} - -- (__kindof RACStream *)map:(id (^)(id value))block { - NSCParameterAssert(block != nil); - - Class class = self.class; - - return [[self flattenMap:^(id value) { - return [class return:block(value)]; - }] setNameWithFormat:@"[%@] -map:", self.name]; -} - -- (__kindof RACStream *)mapReplace:(id)object { - return [[self map:^(id _) { - return object; - }] setNameWithFormat:@"[%@] -mapReplace: %@", self.name, RACDescription(object)]; -} - -- (__kindof RACStream *)combinePreviousWithStart:(id)start reduce:(id (^)(id previous, id next))reduceBlock { - NSCParameterAssert(reduceBlock != NULL); - return [[[self - scanWithStart:RACTuplePack(start) - reduce:^(RACTuple *previousTuple, id next) { - id value = reduceBlock(previousTuple[0], next); - return RACTuplePack(next, value); - }] - map:^(RACTuple *tuple) { - return tuple[1]; - }] - setNameWithFormat:@"[%@] -combinePreviousWithStart: %@ reduce:", self.name, RACDescription(start)]; -} - -- (__kindof RACStream *)filter:(BOOL (^)(id value))block { - NSCParameterAssert(block != nil); - - Class class = self.class; - - return [[self flattenMap:^ id (id value) { - if (block(value)) { - return [class return:value]; - } else { - return class.empty; - } - }] setNameWithFormat:@"[%@] -filter:", self.name]; -} - -- (__kindof RACStream *)ignore:(id)value { - return [[self filter:^ BOOL (id innerValue) { - return innerValue != value && ![innerValue isEqual:value]; - }] setNameWithFormat:@"[%@] -ignore: %@", self.name, RACDescription(value)]; -} - -- (__kindof RACStream *)reduceEach:(RACReduceBlock)reduceBlock { - NSCParameterAssert(reduceBlock != nil); - - __weak RACStream *stream __attribute__((unused)) = self; - return [[self map:^(RACTuple *t) { - NSCAssert([t isKindOfClass:RACTuple.class], @"Value from stream %@ is not a tuple: %@", stream, t); - return [RACBlockTrampoline invokeBlock:reduceBlock withArguments:t]; - }] setNameWithFormat:@"[%@] -reduceEach:", self.name]; -} - -- (__kindof RACStream *)startWith:(id)value { - return [[[self.class return:value] - concat:self] - setNameWithFormat:@"[%@] -startWith: %@", self.name, RACDescription(value)]; -} - -- (__kindof RACStream *)skip:(NSUInteger)skipCount { - Class class = self.class; - - return [[self bind:^{ - __block NSUInteger skipped = 0; - - return ^(id value, BOOL *stop) { - if (skipped >= skipCount) return [class return:value]; - - skipped++; - return class.empty; - }; - }] setNameWithFormat:@"[%@] -skip: %lu", self.name, (unsigned long)skipCount]; -} - -- (__kindof RACStream *)take:(NSUInteger)count { - Class class = self.class; - - if (count == 0) return class.empty; - - return [[self bind:^{ - __block NSUInteger taken = 0; - - return ^ id (id value, BOOL *stop) { - if (taken < count) { - ++taken; - if (taken == count) *stop = YES; - return [class return:value]; - } else { - return nil; - } - }; - }] setNameWithFormat:@"[%@] -take: %lu", self.name, (unsigned long)count]; -} - -+ (__kindof RACStream *)join:(id)streams block:(RACStream * (^)(id, id))block { - RACStream *current = nil; - - // Creates streams of successively larger tuples by combining the input - // streams one-by-one. - for (RACStream *stream in streams) { - // For the first stream, just wrap its values in a RACTuple. That way, - // if only one stream is given, the result is still a stream of tuples. - if (current == nil) { - current = [stream map:^(id x) { - return RACTuplePack(x); - }]; - - continue; - } - - current = block(current, stream); - } - - if (current == nil) return [self empty]; - - return [current map:^(RACTuple *xs) { - // Right now, each value is contained in its own tuple, sorta like: - // - // (((1), 2), 3) - // - // We need to unwrap all the layers and create a tuple out of the result. - NSMutableArray *values = [[NSMutableArray alloc] init]; - - while (xs != nil) { - [values insertObject:xs.last ?: RACTupleNil.tupleNil atIndex:0]; - xs = (xs.count > 1 ? xs.first : nil); - } - - return [RACTuple tupleWithObjectsFromArray:values]; - }]; -} - -+ (__kindof RACStream *)zip:(id)streams { - return [[self join:streams block:^(RACStream *left, RACStream *right) { - return [left zipWith:right]; - }] setNameWithFormat:@"+zip: %@", streams]; -} - -+ (__kindof RACStream *)zip:(id)streams reduce:(RACGenericReduceBlock)reduceBlock { - NSCParameterAssert(reduceBlock != nil); - - RACStream *result = [self zip:streams]; - - // Although we assert this condition above, older versions of this method - // supported this argument being nil. Avoid crashing Release builds of - // apps that depended on that. - if (reduceBlock != nil) result = [result reduceEach:reduceBlock]; - - return [result setNameWithFormat:@"+zip: %@ reduce:", streams]; -} - -+ (__kindof RACStream *)concat:(id)streams { - RACStream *result = self.empty; - for (RACStream *stream in streams) { - result = [result concat:stream]; - } - - return [result setNameWithFormat:@"+concat: %@", streams]; -} - -- (__kindof RACStream *)scanWithStart:(id)startingValue reduce:(id (^)(id running, id next))reduceBlock { - NSCParameterAssert(reduceBlock != nil); - - return [[self - scanWithStart:startingValue - reduceWithIndex:^(id running, id next, NSUInteger index) { - return reduceBlock(running, next); - }] - setNameWithFormat:@"[%@] -scanWithStart: %@ reduce:", self.name, RACDescription(startingValue)]; -} - -- (__kindof RACStream *)scanWithStart:(id)startingValue reduceWithIndex:(id (^)(id, id, NSUInteger))reduceBlock { - NSCParameterAssert(reduceBlock != nil); - - Class class = self.class; - - return [[self bind:^{ - __block id running = startingValue; - __block NSUInteger index = 0; - - return ^(id value, BOOL *stop) { - running = reduceBlock(running, value, index++); - return [class return:running]; - }; - }] setNameWithFormat:@"[%@] -scanWithStart: %@ reduceWithIndex:", self.name, RACDescription(startingValue)]; -} - -- (__kindof RACStream *)takeUntilBlock:(BOOL (^)(id x))predicate { - NSCParameterAssert(predicate != nil); - - Class class = self.class; - - return [[self bind:^{ - return ^ id (id value, BOOL *stop) { - if (predicate(value)) return nil; - - return [class return:value]; - }; - }] setNameWithFormat:@"[%@] -takeUntilBlock:", self.name]; -} - -- (__kindof RACStream *)takeWhileBlock:(BOOL (^)(id x))predicate { - NSCParameterAssert(predicate != nil); - - return [[self takeUntilBlock:^ BOOL (id x) { - return !predicate(x); - }] setNameWithFormat:@"[%@] -takeWhileBlock:", self.name]; -} - -- (__kindof RACStream *)skipUntilBlock:(BOOL (^)(id x))predicate { - NSCParameterAssert(predicate != nil); - - Class class = self.class; - - return [[self bind:^{ - __block BOOL skipping = YES; - - return ^ id (id value, BOOL *stop) { - if (skipping) { - if (predicate(value)) { - skipping = NO; - } else { - return class.empty; - } - } - - return [class return:value]; - }; - }] setNameWithFormat:@"[%@] -skipUntilBlock:", self.name]; -} - -- (__kindof RACStream *)skipWhileBlock:(BOOL (^)(id x))predicate { - NSCParameterAssert(predicate != nil); - - return [[self skipUntilBlock:^ BOOL (id x) { - return !predicate(x); - }] setNameWithFormat:@"[%@] -skipWhileBlock:", self.name]; -} - -- (__kindof RACStream *)distinctUntilChanged { - Class class = self.class; - - return [[self bind:^{ - __block id lastValue = nil; - __block BOOL initial = YES; - - return ^(id x, BOOL *stop) { - if (!initial && (lastValue == x || [x isEqual:lastValue])) return [class empty]; - - initial = NO; - lastValue = x; - return [class return:x]; - }; - }] setNameWithFormat:@"[%@] -distinctUntilChanged", self.name]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACStringSequence.h b/LookInside/ReactiveObjC/RACStringSequence.h deleted file mode 100644 index 6597fc84..00000000 --- a/LookInside/ReactiveObjC/RACStringSequence.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// RACStringSequence.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACSequence.h" - -// Private class that adapts a string to the RACSequence interface. -@interface RACStringSequence : RACSequence - -// Returns a sequence for enumerating over the given string, starting from the -// given character offset. The string will be copied to prevent mutation. -+ (RACSequence *)sequenceWithString:(NSString *)string offset:(NSUInteger)offset; - -@end diff --git a/LookInside/ReactiveObjC/RACStringSequence.m b/LookInside/ReactiveObjC/RACStringSequence.m deleted file mode 100644 index 809db5d3..00000000 --- a/LookInside/ReactiveObjC/RACStringSequence.m +++ /dev/null @@ -1,65 +0,0 @@ -// -// RACStringSequence.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2012-10-29. -// Copyright (c) 2012 GitHub. All rights reserved. -// - -#import "RACStringSequence.h" - -@interface RACStringSequence () - -// The string being sequenced. -@property (nonatomic, copy, readonly) NSString *string; - -// The index in the string from which the sequence starts. -@property (nonatomic, assign, readonly) NSUInteger offset; - -@end - -@implementation RACStringSequence - -#pragma mark Lifecycle - -+ (RACSequence *)sequenceWithString:(NSString *)string offset:(NSUInteger)offset { - NSCParameterAssert(offset <= string.length); - - if (offset == string.length) return self.empty; - - RACStringSequence *seq = [[self alloc] init]; - seq->_string = [string copy]; - seq->_offset = offset; - return seq; -} - -#pragma mark RACSequence - -- (id)head { - return [self.string substringWithRange:NSMakeRange(self.offset, 1)]; -} - -- (RACSequence *)tail { - RACSequence *sequence = [self.class sequenceWithString:self.string offset:self.offset + 1]; - sequence.name = self.name; - return sequence; -} - -- (NSArray *)array { - NSUInteger substringLength = self.string.length - self.offset; - NSMutableArray *array = [NSMutableArray arrayWithCapacity:substringLength]; - - [self.string enumerateSubstringsInRange:NSMakeRange(self.offset, substringLength) options:NSStringEnumerationByComposedCharacterSequences usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) { - [array addObject:substring]; - }]; - - return [array copy]; -} - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p>{ name = %@, string = %@ }", self.class, self, self.name, [self.string substringFromIndex:self.offset]]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACSubject.h b/LookInside/ReactiveObjC/RACSubject.h deleted file mode 100644 index 79807d96..00000000 --- a/LookInside/ReactiveObjC/RACSubject.h +++ /dev/null @@ -1,29 +0,0 @@ -// -// RACSubject.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/9/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSignal.h" -#import "RACSubscriber.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A subject can be thought of as a signal that you can manually control by -/// sending next, completed, and error. -/// -/// They're most helpful in bridging the non-RAC world to RAC, since they let you -/// manually control the sending of events. -@interface RACSubject : RACSignal - -/// Returns a new subject. -+ (instancetype)subject; - -// Redeclaration of the RACSubscriber method. Made in order to specify a generic type. -- (void)sendNext:(nullable ValueType)value; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACSubject.m b/LookInside/ReactiveObjC/RACSubject.m deleted file mode 100644 index 1b87eced..00000000 --- a/LookInside/ReactiveObjC/RACSubject.m +++ /dev/null @@ -1,126 +0,0 @@ -// -// RACSubject.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/9/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSubject.h" -#import "RACEXTScope.h" -#import "RACCompoundDisposable.h" -#import "RACPassthroughSubscriber.h" - -@interface RACSubject () - -// Contains all current subscribers to the receiver. -// -// This should only be used while synchronized on `self`. -@property (nonatomic, strong, readonly) NSMutableArray *subscribers; - -// Contains all of the receiver's subscriptions to other signals. -@property (nonatomic, strong, readonly) RACCompoundDisposable *disposable; - -// Enumerates over each of the receiver's `subscribers` and invokes `block` for -// each. -- (void)enumerateSubscribersUsingBlock:(void (^)(id subscriber))block; - -@end - -@implementation RACSubject - -#pragma mark Lifecycle - -+ (instancetype)subject { - return [[self alloc] init]; -} - -- (instancetype)init { - self = [super init]; - if (self == nil) return nil; - - _disposable = [RACCompoundDisposable compoundDisposable]; - _subscribers = [[NSMutableArray alloc] initWithCapacity:1]; - - return self; -} - -- (void)dealloc { - [self.disposable dispose]; -} - -#pragma mark Subscription - -- (RACDisposable *)subscribe:(id)subscriber { - NSCParameterAssert(subscriber != nil); - - RACCompoundDisposable *disposable = [RACCompoundDisposable compoundDisposable]; - subscriber = [[RACPassthroughSubscriber alloc] initWithSubscriber:subscriber signal:self disposable:disposable]; - - NSMutableArray *subscribers = self.subscribers; - @synchronized (subscribers) { - [subscribers addObject:subscriber]; - } - - [disposable addDisposable:[RACDisposable disposableWithBlock:^{ - @synchronized (subscribers) { - // Since newer subscribers are generally shorter-lived, search - // starting from the end of the list. - NSUInteger index = [subscribers indexOfObjectWithOptions:NSEnumerationReverse passingTest:^ BOOL (id obj, NSUInteger index, BOOL *stop) { - return obj == subscriber; - }]; - - if (index != NSNotFound) [subscribers removeObjectAtIndex:index]; - } - }]]; - - return disposable; -} - -- (void)enumerateSubscribersUsingBlock:(void (^)(id subscriber))block { - NSArray *subscribers; - @synchronized (self.subscribers) { - subscribers = [self.subscribers copy]; - } - - for (id subscriber in subscribers) { - block(subscriber); - } -} - -#pragma mark RACSubscriber - -- (void)sendNext:(id)value { - [self enumerateSubscribersUsingBlock:^(id subscriber) { - [subscriber sendNext:value]; - }]; -} - -- (void)sendError:(NSError *)error { - [self.disposable dispose]; - - [self enumerateSubscribersUsingBlock:^(id subscriber) { - [subscriber sendError:error]; - }]; -} - -- (void)sendCompleted { - [self.disposable dispose]; - - [self enumerateSubscribersUsingBlock:^(id subscriber) { - [subscriber sendCompleted]; - }]; -} - -- (void)didSubscribeWithDisposable:(RACCompoundDisposable *)d { - if (d.disposed) return; - [self.disposable addDisposable:d]; - - @weakify(self, d); - [d addDisposable:[RACDisposable disposableWithBlock:^{ - @strongify(self, d); - [self.disposable removeDisposable:d]; - }]]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACSubscriber+Private.h b/LookInside/ReactiveObjC/RACSubscriber+Private.h deleted file mode 100644 index f898d1c2..00000000 --- a/LookInside/ReactiveObjC/RACSubscriber+Private.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// RACSubscriber+Private.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-06-13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSubscriber.h" - -// A simple block-based subscriber. -@interface RACSubscriber : NSObject - -// Creates a new subscriber with the given blocks. -+ (instancetype)subscriberWithNext:(void (^)(id x))next error:(void (^)(NSError *error))error completed:(void (^)(void))completed; - -@end diff --git a/LookInside/ReactiveObjC/RACSubscriber.h b/LookInside/ReactiveObjC/RACSubscriber.h deleted file mode 100644 index 417e0c49..00000000 --- a/LookInside/ReactiveObjC/RACSubscriber.h +++ /dev/null @@ -1,55 +0,0 @@ -// -// RACSubscriber.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/1/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -@class RACCompoundDisposable; - -NS_ASSUME_NONNULL_BEGIN - -/// Represents any object which can directly receive values from a RACSignal. -/// -/// You generally shouldn't need to implement this protocol. +[RACSignal -/// createSignal:], RACSignal's subscription methods, or RACSubject should work -/// for most uses. -/// -/// Implementors of this protocol may receive messages and values from multiple -/// threads simultaneously, and so should be thread-safe. Subscribers will also -/// be weakly referenced so implementations must allow that. -@protocol RACSubscriber -@required - -/// Sends the next value to subscribers. -/// -/// value - The value to send. This can be `nil`. -- (void)sendNext:(nullable id)value; - -/// Sends the error to subscribers. -/// -/// error - The error to send. This can be `nil`. -/// -/// This terminates the subscription, and invalidates the subscriber (such that -/// it cannot subscribe to anything else in the future). -- (void)sendError:(nullable NSError *)error; - -/// Sends completed to subscribers. -/// -/// This terminates the subscription, and invalidates the subscriber (such that -/// it cannot subscribe to anything else in the future). -- (void)sendCompleted; - -/// Sends the subscriber a disposable that represents one of its subscriptions. -/// -/// A subscriber may receive multiple disposables if it gets subscribed to -/// multiple signals; however, any error or completed events must terminate _all_ -/// subscriptions. -- (void)didSubscribeWithDisposable:(RACCompoundDisposable *)disposable; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACSubscriber.m b/LookInside/ReactiveObjC/RACSubscriber.m deleted file mode 100644 index 4240434f..00000000 --- a/LookInside/ReactiveObjC/RACSubscriber.m +++ /dev/null @@ -1,111 +0,0 @@ -// -// RACSubscriber.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/1/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSubscriber.h" -#import "RACSubscriber+Private.h" -#import "RACEXTScope.h" -#import "RACCompoundDisposable.h" - -@interface RACSubscriber () - -// These callbacks should only be accessed while synchronized on self. -@property (nonatomic, copy) void (^next)(id value); -@property (nonatomic, copy) void (^error)(NSError *error); -@property (nonatomic, copy) void (^completed)(void); - -@property (nonatomic, strong, readonly) RACCompoundDisposable *disposable; - -@end - -@implementation RACSubscriber - -#pragma mark Lifecycle - -+ (instancetype)subscriberWithNext:(void (^)(id x))next error:(void (^)(NSError *error))error completed:(void (^)(void))completed { - RACSubscriber *subscriber = [[self alloc] init]; - - subscriber->_next = [next copy]; - subscriber->_error = [error copy]; - subscriber->_completed = [completed copy]; - - return subscriber; -} - -- (instancetype)init { - self = [super init]; - - @unsafeify(self); - - RACDisposable *selfDisposable = [RACDisposable disposableWithBlock:^{ - @strongify(self); - - @synchronized (self) { - self.next = nil; - self.error = nil; - self.completed = nil; - } - }]; - - _disposable = [RACCompoundDisposable compoundDisposable]; - [_disposable addDisposable:selfDisposable]; - - return self; -} - -- (void)dealloc { - [self.disposable dispose]; -} - -#pragma mark RACSubscriber - -- (void)sendNext:(id)value { - @synchronized (self) { - void (^nextBlock)(id) = [self.next copy]; - if (nextBlock == nil) return; - - nextBlock(value); - } -} - -- (void)sendError:(NSError *)e { - @synchronized (self) { - void (^errorBlock)(NSError *) = [self.error copy]; - [self.disposable dispose]; - - if (errorBlock == nil) return; - errorBlock(e); - } -} - -- (void)sendCompleted { - @synchronized (self) { - void (^completedBlock)(void) = [self.completed copy]; - [self.disposable dispose]; - - if (completedBlock == nil) return; - completedBlock(); - } -} - -- (void)didSubscribeWithDisposable:(RACCompoundDisposable *)otherDisposable { - if (otherDisposable.disposed) return; - - RACCompoundDisposable *selfDisposable = self.disposable; - [selfDisposable addDisposable:otherDisposable]; - - @unsafeify(otherDisposable); - - // If this subscription terminates, purge its disposable to avoid unbounded - // memory growth. - [otherDisposable addDisposable:[RACDisposable disposableWithBlock:^{ - @strongify(otherDisposable); - [selfDisposable removeDisposable:otherDisposable]; - }]]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACSubscriptingAssignmentTrampoline.h b/LookInside/ReactiveObjC/RACSubscriptingAssignmentTrampoline.h deleted file mode 100644 index a6d5c614..00000000 --- a/LookInside/ReactiveObjC/RACSubscriptingAssignmentTrampoline.h +++ /dev/null @@ -1,58 +0,0 @@ -// -// RACSubscriptingAssignmentTrampoline.h -// ReactiveObjC -// -// Created by Josh Abernathy on 9/24/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import -#import "RACEXTKeyPathCoding.h" - -@class RACSignal<__covariant ValueType>; - -NS_ASSUME_NONNULL_BEGIN - -/// Assigns a signal to an object property, automatically setting the given key -/// path on every `next`. When the signal completes, the binding is automatically -/// disposed of. -/// -/// There are two different versions of this macro: -/// -/// - RAC(TARGET, KEYPATH, NILVALUE) will bind the `KEYPATH` of `TARGET` to the -/// given signal. If the signal ever sends a `nil` value, the property will be -/// set to `NILVALUE` instead. `NILVALUE` may itself be `nil` for object -/// properties, but an NSValue should be used for primitive properties, to -/// avoid an exception if `nil` is sent (which might occur if an intermediate -/// object is set to `nil`). -/// - RAC(TARGET, KEYPATH) is the same as the above, but `NILVALUE` defaults to -/// `nil`. -/// -/// See -[RACSignal setKeyPath:onObject:nilValue:] for more information about the -/// binding's semantics. -/// -/// Examples -/// -/// RAC(self, objectProperty) = objectSignal; -/// RAC(self, stringProperty, @"foobar") = stringSignal; -/// RAC(self, integerProperty, @42) = integerSignal; -/// -/// WARNING: Under certain conditions, use of this macro can be thread-unsafe. -/// See the documentation of -setKeyPath:onObject:nilValue:. -#define RAC(TARGET, ...) \ - metamacro_if_eq(1, metamacro_argcount(__VA_ARGS__)) \ - (RAC_(TARGET, __VA_ARGS__, nil)) \ - (RAC_(TARGET, __VA_ARGS__)) - -/// Do not use this directly. Use the RAC macro above. -#define RAC_(TARGET, KEYPATH, NILVALUE) \ - [[RACSubscriptingAssignmentTrampoline alloc] initWithTarget:(TARGET) nilValue:(NILVALUE)][@keypath(TARGET, KEYPATH)] - -@interface RACSubscriptingAssignmentTrampoline : NSObject - -- (nullable instancetype)initWithTarget:(nullable id)target nilValue:(nullable id)nilValue; -- (void)setObject:(RACSignal *)signal forKeyedSubscript:(NSString *)keyPath; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACSubscriptingAssignmentTrampoline.m b/LookInside/ReactiveObjC/RACSubscriptingAssignmentTrampoline.m deleted file mode 100644 index 085ab0f9..00000000 --- a/LookInside/ReactiveObjC/RACSubscriptingAssignmentTrampoline.m +++ /dev/null @@ -1,42 +0,0 @@ -// -// RACSubscriptingAssignmentTrampoline.m -// ReactiveObjC -// -// Created by Josh Abernathy on 9/24/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSubscriptingAssignmentTrampoline.h" -#import "RACSignal+Operations.h" - -@interface RACSubscriptingAssignmentTrampoline () - -// The object to bind to. -@property (nonatomic, strong, readonly) id target; - -// A value to use when `nil` is sent on the bound signal. -@property (nonatomic, strong, readonly) id nilValue; - -@end - -@implementation RACSubscriptingAssignmentTrampoline - -- (instancetype)initWithTarget:(id)target nilValue:(id)nilValue { - // This is often a programmer error, but this prevents crashes if the target - // object has unexpectedly deallocated. - if (target == nil) return nil; - - self = [super init]; - if (self == nil) return nil; - - _target = target; - _nilValue = nilValue; - - return self; -} - -- (void)setObject:(RACSignal *)signal forKeyedSubscript:(NSString *)keyPath { - [signal setKeyPath:keyPath onObject:self.target nilValue:self.nilValue]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACSubscriptionScheduler.h b/LookInside/ReactiveObjC/RACSubscriptionScheduler.h deleted file mode 100644 index 97f88be5..00000000 --- a/LookInside/ReactiveObjC/RACSubscriptionScheduler.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// RACSubscriptionScheduler.h -// ReactiveObjC -// -// Created by Josh Abernathy on 11/30/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACScheduler.h" - -NS_ASSUME_NONNULL_BEGIN - -// A private scheduler used only for subscriptions. See the private -// +[RACScheduler subscriptionScheduler] method for more information. -@interface RACSubscriptionScheduler : RACScheduler - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACSubscriptionScheduler.m b/LookInside/ReactiveObjC/RACSubscriptionScheduler.m deleted file mode 100644 index d503dee7..00000000 --- a/LookInside/ReactiveObjC/RACSubscriptionScheduler.m +++ /dev/null @@ -1,53 +0,0 @@ -// -// RACSubscriptionScheduler.m -// ReactiveObjC -// -// Created by Josh Abernathy on 11/30/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACSubscriptionScheduler.h" -#import "RACScheduler+Private.h" - -@interface RACSubscriptionScheduler () - -// A private background scheduler on which to subscribe if the +currentScheduler -// is unknown. -@property (nonatomic, strong, readonly) RACScheduler *backgroundScheduler; - -@end - -@implementation RACSubscriptionScheduler - -#pragma mark Lifecycle - -- (instancetype)init { - self = [super initWithName:@"org.reactivecocoa.ReactiveObjC.RACScheduler.subscriptionScheduler"]; - - _backgroundScheduler = [RACScheduler scheduler]; - - return self; -} - -#pragma mark RACScheduler - -- (RACDisposable *)schedule:(void (^)(void))block { - NSCParameterAssert(block != NULL); - - if (RACScheduler.currentScheduler == nil) return [self.backgroundScheduler schedule:block]; - - block(); - return nil; -} - -- (RACDisposable *)after:(NSDate *)date schedule:(void (^)(void))block { - RACScheduler *scheduler = RACScheduler.currentScheduler ?: self.backgroundScheduler; - return [scheduler after:date schedule:block]; -} - -- (RACDisposable *)after:(NSDate *)date repeatingEvery:(NSTimeInterval)interval withLeeway:(NSTimeInterval)leeway schedule:(void (^)(void))block { - RACScheduler *scheduler = RACScheduler.currentScheduler ?: self.backgroundScheduler; - return [scheduler after:date repeatingEvery:interval withLeeway:leeway schedule:block]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACTargetQueueScheduler.h b/LookInside/ReactiveObjC/RACTargetQueueScheduler.h deleted file mode 100644 index 01618115..00000000 --- a/LookInside/ReactiveObjC/RACTargetQueueScheduler.h +++ /dev/null @@ -1,28 +0,0 @@ -// -// RACTargetQueueScheduler.h -// ReactiveObjC -// -// Created by Josh Abernathy on 6/6/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACQueueScheduler.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A scheduler that enqueues blocks on a private serial queue, targeting an -/// arbitrary GCD queue. -@interface RACTargetQueueScheduler : RACQueueScheduler - -/// Initializes the receiver with a serial queue that will target the given -/// `targetQueue`. -/// -/// name - The name of the scheduler. If nil, a default name will be used. -/// targetQueue - The queue to target. Cannot be NULL. -/// -/// Returns the initialized object. -- (instancetype)initWithName:(nullable NSString *)name targetQueue:(dispatch_queue_t)targetQueue; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACTargetQueueScheduler.m b/LookInside/ReactiveObjC/RACTargetQueueScheduler.m deleted file mode 100644 index 6e1dcaf7..00000000 --- a/LookInside/ReactiveObjC/RACTargetQueueScheduler.m +++ /dev/null @@ -1,31 +0,0 @@ -// -// RACTargetQueueScheduler.m -// ReactiveObjC -// -// Created by Josh Abernathy on 6/6/13. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACTargetQueueScheduler.h" -#import "RACQueueScheduler+Subclass.h" - -@implementation RACTargetQueueScheduler - -#pragma mark Lifecycle - -- (instancetype)initWithName:(NSString *)name targetQueue:(dispatch_queue_t)targetQueue { - NSCParameterAssert(targetQueue != NULL); - - if (name == nil) { - name = [NSString stringWithFormat:@"org.reactivecocoa.ReactiveObjC.RACTargetQueueScheduler(%s)", dispatch_queue_get_label(targetQueue)]; - } - - dispatch_queue_t queue = dispatch_queue_create(name.UTF8String, DISPATCH_QUEUE_SERIAL); - if (queue == NULL) return nil; - - dispatch_set_target_queue(queue, targetQueue); - - return [super initWithName:name queue:queue]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACTestScheduler.h b/LookInside/ReactiveObjC/RACTestScheduler.h deleted file mode 100644 index bf460e0c..00000000 --- a/LookInside/ReactiveObjC/RACTestScheduler.h +++ /dev/null @@ -1,46 +0,0 @@ -// -// RACTestScheduler.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-07-06. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACScheduler.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A special kind of scheduler that steps through virtualized time. -/// -/// This scheduler class can be used in unit tests to verify asynchronous -/// behaviors without spending significant time waiting. -/// -/// This class can be used from multiple threads, but only one thread can `step` -/// through the enqueued actions at a time. Other threads will wait while the -/// scheduled blocks are being executed. -@interface RACTestScheduler : RACScheduler - -/// Initializes a new test scheduler. -- (instancetype)init; - -/// Executes the next scheduled block, if any. -/// -/// This method will block until the scheduled action has completed. -- (void)step; - -/// Executes up to the next `ticks` scheduled blocks. -/// -/// This method will block until the scheduled actions have completed. -/// -/// ticks - The number of scheduled blocks to execute. If there aren't this many -/// blocks enqueued, all scheduled blocks are executed. -- (void)step:(NSUInteger)ticks; - -/// Executes all of the scheduled blocks on the receiver. -/// -/// This method will block until the scheduled actions have completed. -- (void)stepAll; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACTestScheduler.m b/LookInside/ReactiveObjC/RACTestScheduler.m deleted file mode 100644 index 2d7a448f..00000000 --- a/LookInside/ReactiveObjC/RACTestScheduler.m +++ /dev/null @@ -1,221 +0,0 @@ -// -// RACTestScheduler.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-07-06. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACTestScheduler.h" -#import "RACEXTScope.h" -#import "RACCompoundDisposable.h" -#import "RACDisposable.h" -#import "RACScheduler+Private.h" - -@interface RACTestSchedulerAction : NSObject - -// The date at which the action should be executed. -// -// This absolute time will not actually be honored. This date is only used for -// comparison, to determine which block should be run _next_. -@property (nonatomic, copy, readonly) NSDate *date; - -// The scheduled block. -@property (nonatomic, copy, readonly) void (^block)(void); - -// A disposable for this action. -// -// When disposed, the action should not start executing if it hasn't already. -@property (nonatomic, strong, readonly) RACDisposable *disposable; - -// Initializes a new scheduler action. -- (instancetype)initWithDate:(NSDate *)date block:(void (^)(void))block; - -@end - -static CFComparisonResult RACCompareScheduledActions(const void *ptr1, const void *ptr2, void *info) { - RACTestSchedulerAction *action1 = (__bridge id)ptr1; - RACTestSchedulerAction *action2 = (__bridge id)ptr2; - return CFDateCompare((__bridge CFDateRef)action1.date, (__bridge CFDateRef)action2.date, NULL); -} - -static const void *RACRetainScheduledAction(CFAllocatorRef allocator, const void *ptr) { - return CFRetain(ptr); -} - -static void RACReleaseScheduledAction(CFAllocatorRef allocator, const void *ptr) { - CFRelease(ptr); -} - -@interface RACTestScheduler () - -// All of the RACTestSchedulerActions that have been enqueued and not yet -// executed. -// -// The minimum value in the heap represents the action to execute next. -// -// This property should only be used while synchronized on self. -@property (nonatomic, assign, readonly) CFBinaryHeapRef scheduledActions; - -// The number of blocks that have been directly enqueued with -schedule: so -// far. -// -// This is used to ensure unique dates when two blocks are enqueued -// simultaneously. -// -// This property should only be used while synchronized on self. -@property (nonatomic, assign) NSUInteger numberOfDirectlyScheduledBlocks; - -@end - -@implementation RACTestScheduler - -#pragma mark Lifecycle - -- (instancetype)init { - self = [super initWithName:@"org.reactivecocoa.ReactiveObjC.RACTestScheduler"]; - - CFBinaryHeapCallBacks callbacks = (CFBinaryHeapCallBacks){ - .version = 0, - .retain = &RACRetainScheduledAction, - .release = &RACReleaseScheduledAction, - .copyDescription = &CFCopyDescription, - .compare = &RACCompareScheduledActions - }; - - _scheduledActions = CFBinaryHeapCreate(NULL, 0, &callbacks, NULL); - return self; -} - -- (void)dealloc { - [self stepAll]; - - if (_scheduledActions != NULL) { - CFBridgingRelease(_scheduledActions); - _scheduledActions = NULL; - } -} - -#pragma mark Execution - -- (void)step { - [self step:1]; -} - -- (void)step:(NSUInteger)ticks { - @synchronized (self) { - for (NSUInteger i = 0; i < ticks; i++) { - const void *actionPtr = NULL; - if (!CFBinaryHeapGetMinimumIfPresent(self.scheduledActions, &actionPtr)) break; - - RACTestSchedulerAction *action = (__bridge id)actionPtr; - CFBinaryHeapRemoveMinimumValue(self.scheduledActions); - - if (action.disposable.disposed) continue; - - RACScheduler *previousScheduler = RACScheduler.currentScheduler; - NSThread.currentThread.threadDictionary[RACSchedulerCurrentSchedulerKey] = self; - - action.block(); - - if (previousScheduler != nil) { - NSThread.currentThread.threadDictionary[RACSchedulerCurrentSchedulerKey] = previousScheduler; - } else { - [NSThread.currentThread.threadDictionary removeObjectForKey:RACSchedulerCurrentSchedulerKey]; - } - } - } -} - -- (void)stepAll { - [self step:NSUIntegerMax]; -} - -#pragma mark RACScheduler - -- (RACDisposable *)schedule:(void (^)(void))block { - NSCParameterAssert(block != nil); - - @synchronized (self) { - NSDate *uniqueDate = [NSDate dateWithTimeIntervalSinceReferenceDate:self.numberOfDirectlyScheduledBlocks]; - self.numberOfDirectlyScheduledBlocks++; - - RACTestSchedulerAction *action = [[RACTestSchedulerAction alloc] initWithDate:uniqueDate block:block]; - CFBinaryHeapAddValue(self.scheduledActions, (__bridge void *)action); - - return action.disposable; - } -} - -- (RACDisposable *)after:(NSDate *)date schedule:(void (^)(void))block { - NSCParameterAssert(date != nil); - NSCParameterAssert(block != nil); - - @synchronized (self) { - RACTestSchedulerAction *action = [[RACTestSchedulerAction alloc] initWithDate:date block:block]; - CFBinaryHeapAddValue(self.scheduledActions, (__bridge void *)action); - - return action.disposable; - } -} - -- (RACDisposable *)after:(NSDate *)date repeatingEvery:(NSTimeInterval)interval withLeeway:(NSTimeInterval)leeway schedule:(void (^)(void))block { - NSCParameterAssert(date != nil); - NSCParameterAssert(block != nil); - NSCParameterAssert(interval >= 0); - NSCParameterAssert(leeway >= 0); - - RACCompoundDisposable *compoundDisposable = [RACCompoundDisposable compoundDisposable]; - - @weakify(self); - @synchronized (self) { - __block RACDisposable *thisDisposable = nil; - - void (^reschedulingBlock)(void) = ^{ - @strongify(self); - - [compoundDisposable removeDisposable:thisDisposable]; - - // Schedule the next interval. - RACDisposable *schedulingDisposable = [self after:[date dateByAddingTimeInterval:interval] repeatingEvery:interval withLeeway:leeway schedule:block]; - [compoundDisposable addDisposable:schedulingDisposable]; - - block(); - }; - - RACTestSchedulerAction *action = [[RACTestSchedulerAction alloc] initWithDate:date block:reschedulingBlock]; - CFBinaryHeapAddValue(self.scheduledActions, (__bridge void *)action); - - thisDisposable = action.disposable; - [compoundDisposable addDisposable:thisDisposable]; - } - - return compoundDisposable; -} - -@end - -@implementation RACTestSchedulerAction - -#pragma mark Lifecycle - -- (instancetype)initWithDate:(NSDate *)date block:(void (^)(void))block { - NSCParameterAssert(date != nil); - NSCParameterAssert(block != nil); - - self = [super init]; - - _date = [date copy]; - _block = [block copy]; - _disposable = [[RACDisposable alloc] init]; - - return self; -} - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p>{ date: %@ }", self.class, self, self.date]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACTuple.h b/LookInside/ReactiveObjC/RACTuple.h deleted file mode 100644 index 60632364..00000000 --- a/LookInside/ReactiveObjC/RACTuple.h +++ /dev/null @@ -1,251 +0,0 @@ -// -// RACTuple.h -// ReactiveObjC -// -// Created by Josh Abernathy on 4/12/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import -#import "RACmetamacros.h" - -@class RACSequence; - -/// Creates a new tuple with the given values. At least one value must be given. -/// Values can be nil. -#define RACTuplePack(...) \ - RACTuplePack_(__VA_ARGS__) - -/// Declares new object variables and unpacks a RACTuple into them. -/// -/// This macro should be used on the left side of an assignment, with the -/// tuple on the right side. Nothing else should appear on the same line, and the -/// macro should not be the only statement in a conditional or loop body. -/// -/// If the tuple has more values than there are variables listed, the excess -/// values are ignored. -/// -/// If the tuple has fewer values than there are variables listed, the excess -/// variables are initialized to nil. -/// -/// Examples -/// -/// RACTupleUnpack(NSString *string, NSNumber *num) = [RACTuple tupleWithObjects:@"foo", @5, nil]; -/// NSLog(@"string: %@", string); -/// NSLog(@"num: %@", num); -/// -/// /* The above is equivalent to: */ -/// RACTuple *t = [RACTuple tupleWithObjects:@"foo", @5, nil]; -/// NSString *string = t[0]; -/// NSNumber *num = t[1]; -/// NSLog(@"string: %@", string); -/// NSLog(@"num: %@", num); -#define RACTupleUnpack(...) \ - RACTupleUnpack_(__VA_ARGS__) - -@class RACTwoTuple<__covariant First, __covariant Second>; -@class RACThreeTuple<__covariant First, __covariant Second, __covariant Third>; -@class RACFourTuple<__covariant First, __covariant Second, __covariant Third, __covariant Fourth>; -@class RACFiveTuple<__covariant First, __covariant Second, __covariant Third, __covariant Fourth, __covariant Fifth>; - -NS_ASSUME_NONNULL_BEGIN - -/// A sentinel object that represents nils in the tuple. -/// -/// It should never be necessary to create a tuple nil yourself. Just use -/// +tupleNil. -@interface RACTupleNil : NSObject -/// A singleton instance. -+ (RACTupleNil *)tupleNil; -@end - - -/// A tuple is an ordered collection of objects. It may contain nils, represented -/// by RACTupleNil. -@interface RACTuple : NSObject - -@property (nonatomic, readonly) NSUInteger count; - -/// These properties all return the object at that index or nil if the number of -/// objects is less than the index. -@property (nonatomic, readonly, nullable) id first; -@property (nonatomic, readonly, nullable) id second; -@property (nonatomic, readonly, nullable) id third; -@property (nonatomic, readonly, nullable) id fourth; -@property (nonatomic, readonly, nullable) id fifth; -@property (nonatomic, readonly, nullable) id last; - -/// Creates a new tuple out of the array. Does not convert nulls to nils. -+ (instancetype)tupleWithObjectsFromArray:(NSArray *)array; - -/// Creates a new tuple out of the array. If `convert` is YES, it also converts -/// every NSNull to RACTupleNil. -+ (instancetype)tupleWithObjectsFromArray:(NSArray *)array convertNullsToNils:(BOOL)convert; - -/// Creates a new tuple with the given objects. Use RACTupleNil to represent -/// nils. -+ (instancetype)tupleWithObjects:(id)object, ... NS_REQUIRES_NIL_TERMINATION; - -/// Returns the object at `index` or nil if the object is a RACTupleNil. Unlike -/// NSArray and friends, it's perfectly fine to ask for the object at an index -/// past the tuple's count - 1. It will simply return nil. -- (nullable id)objectAtIndex:(NSUInteger)index; - -/// Returns an array of all the objects. RACTupleNils are converted to NSNulls. -- (NSArray *)allObjects; - -/// Appends `obj` to the receiver. -/// -/// obj - The object to add to the tuple. This argument may be nil. -/// -/// Returns a new tuple. -- (__kindof RACTuple *)tupleByAddingObject:(nullable id)obj; - -@end - -@interface RACTuple (RACSequenceAdditions) - -/// Returns a sequence of all the objects. RACTupleNils are converted to NSNulls. -@property (nonatomic, copy, readonly) RACSequence *rac_sequence; - -@end - -@interface RACTuple (ObjectSubscripting) -/// Returns the object at that index or nil if the number of objects is less -/// than the index. -- (nullable id)objectAtIndexedSubscript:(NSUInteger)idx; -@end - -/// A tuple with exactly one generic value. -@interface RACOneTuple<__covariant First> : RACTuple - -+ (instancetype)tupleWithObjects:(id)object, ... __attribute((unavailable("Use pack: instead."))); - -- (RACTwoTuple *)tupleByAddingObject:(nullable id)obj; - -/// Creates a new tuple with the given values. -+ (RACOneTuple *)pack:(nullable First)first; - -@property (nonatomic, readonly, nullable) First first; - -@end - -/// A tuple with exactly two generic values. -@interface RACTwoTuple<__covariant First, __covariant Second> : RACTuple - -+ (instancetype)tupleWithObjects:(id)object, ... __attribute((unavailable("Use pack:: instead."))); - -- (RACThreeTuple *)tupleByAddingObject:(nullable id)obj; - -/// Creates a new tuple with the given value. -+ (RACTwoTuple *)pack:(nullable First)first :(nullable Second)second; - -@property (nonatomic, readonly, nullable) First first; -@property (nonatomic, readonly, nullable) Second second; - -@end - -/// A tuple with exactly three generic values. -@interface RACThreeTuple<__covariant First, __covariant Second, __covariant Third> : RACTuple - -+ (instancetype)tupleWithObjects:(id)object, ... __attribute((unavailable("Use pack::: instead."))); - -- (RACFourTuple *)tupleByAddingObject:(nullable id)obj; - -/// Creates a new tuple with the given values. -+ (instancetype)pack:(nullable First)first :(nullable Second)second :(nullable Third)third; - -@property (nonatomic, readonly, nullable) First first; -@property (nonatomic, readonly, nullable) Second second; -@property (nonatomic, readonly, nullable) Third third; - -@end - -/// A tuple with exactly four generic values. -@interface RACFourTuple<__covariant First, __covariant Second, __covariant Third, __covariant Fourth> : RACTuple - -+ (instancetype)tupleWithObjects:(id)object, ... __attribute((unavailable("Use pack:::: instead."))); - -- (RACFiveTuple *)tupleByAddingObject:(nullable id)obj; - -/// Creates a new tuple with the given values. -+ (instancetype)pack:(nullable First)first :(nullable Second)second :(nullable Third)third :(nullable Fourth)fourth; - -@property (nonatomic, readonly, nullable) First first; -@property (nonatomic, readonly, nullable) Second second; -@property (nonatomic, readonly, nullable) Third third; -@property (nonatomic, readonly, nullable) Fourth fourth; - -@end - -/// A tuple with exactly five generic values. -@interface RACFiveTuple<__covariant First, __covariant Second, __covariant Third, __covariant Fourth, __covariant Fifth> : RACTuple - -+ (instancetype)tupleWithObjects:(id)object, ... __attribute((unavailable("Use pack::::: instead."))); - -/// Creates a new tuple with the given values. -+ (instancetype)pack:(nullable First)first :(nullable Second)second :(nullable Third)third :(nullable Fourth)fourth :(nullable Fifth)fifth; - -@property (nonatomic, readonly, nullable) First first; -@property (nonatomic, readonly, nullable) Second second; -@property (nonatomic, readonly, nullable) Third third; -@property (nonatomic, readonly, nullable) Fourth fourth; -@property (nonatomic, readonly, nullable) Fifth fifth; - -@end - -/// This and everything below is for internal use only. -/// -/// See RACTuplePack() and RACTupleUnpack() instead. -#define RACTuplePack_(...) \ - ([RACTuplePack_class_name(__VA_ARGS__) tupleWithObjectsFromArray:@[ metamacro_foreach(RACTuplePack_object_or_ractuplenil,, __VA_ARGS__) ]]) - -#define RACTuplePack_object_or_ractuplenil(INDEX, ARG) \ - (ARG) ?: RACTupleNil.tupleNil, - -/// Returns the class that should be used to create a tuple with the provided -/// variadic arguments to RACTuplePack_(). Supports up to 20 arguments. -#define RACTuplePack_class_name(...) \ - metamacro_at(20, __VA_ARGS__, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACTuple, RACFiveTuple, RACFourTuple, RACThreeTuple, RACTwoTuple, RACOneTuple) - -#define RACTupleUnpack_(...) \ - metamacro_foreach(RACTupleUnpack_decl,, __VA_ARGS__) \ - \ - int RACTupleUnpack_state = 0; \ - \ - RACTupleUnpack_after: \ - ; \ - metamacro_foreach(RACTupleUnpack_assign,, __VA_ARGS__) \ - if (RACTupleUnpack_state != 0) RACTupleUnpack_state = 2; \ - \ - while (RACTupleUnpack_state != 2) \ - if (RACTupleUnpack_state == 1) { \ - goto RACTupleUnpack_after; \ - } else \ - for (; RACTupleUnpack_state != 1; RACTupleUnpack_state = 1) \ - [RACTupleUnpackingTrampoline trampoline][ @[ metamacro_foreach(RACTupleUnpack_value,, __VA_ARGS__) ] ] - -#define RACTupleUnpack_state metamacro_concat(RACTupleUnpack_state, __LINE__) -#define RACTupleUnpack_after metamacro_concat(RACTupleUnpack_after, __LINE__) -#define RACTupleUnpack_loop metamacro_concat(RACTupleUnpack_loop, __LINE__) - -#define RACTupleUnpack_decl_name(INDEX) \ - metamacro_concat(metamacro_concat(RACTupleUnpack, __LINE__), metamacro_concat(_var, INDEX)) - -#define RACTupleUnpack_decl(INDEX, ARG) \ - __strong id RACTupleUnpack_decl_name(INDEX); - -#define RACTupleUnpack_assign(INDEX, ARG) \ - __strong ARG = RACTupleUnpack_decl_name(INDEX); - -#define RACTupleUnpack_value(INDEX, ARG) \ - [NSValue valueWithPointer:&RACTupleUnpack_decl_name(INDEX)], - -@interface RACTupleUnpackingTrampoline : NSObject - -+ (instancetype)trampoline; -- (void)setObject:(nullable RACTuple *)tuple forKeyedSubscript:(NSArray *)variables; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACTuple.m b/LookInside/ReactiveObjC/RACTuple.m deleted file mode 100644 index 7794eed2..00000000 --- a/LookInside/ReactiveObjC/RACTuple.m +++ /dev/null @@ -1,436 +0,0 @@ -// -// RACTuple.m -// ReactiveObjC -// -// Created by Josh Abernathy on 4/12/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACTuple.h" -#import "RACEXTKeyPathCoding.h" -#import "RACTupleSequence.h" - -@implementation RACTupleNil - -+ (RACTupleNil *)tupleNil { - static dispatch_once_t onceToken; - static RACTupleNil *tupleNil = nil; - dispatch_once(&onceToken, ^{ - tupleNil = [[self alloc] init]; - }); - - return tupleNil; -} - -#pragma mark NSCopying - -- (id)copyWithZone:(NSZone *)zone { - return self; -} - -#pragma mark NSCoding - -- (instancetype)initWithCoder:(NSCoder *)coder { - // Always return the singleton. - return self.class.tupleNil; -} - -- (void)encodeWithCoder:(NSCoder *)coder { -} - -@end - - -@interface RACTuple () - -- (instancetype)initWithBackingArray:(NSArray *)backingArray NS_DESIGNATED_INITIALIZER; - -@property (nonatomic, readonly) NSArray *backingArray; - -@end - - -@implementation RACTuple - -- (instancetype)init { - return [self initWithBackingArray:@[]]; -} - -- (instancetype)initWithBackingArray:(NSArray *)backingArray { - self = [super init]; - - _backingArray = [backingArray copy]; - - return self; -} - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p> %@", self.class, self, self.allObjects]; -} - -- (BOOL)isEqual:(RACTuple *)object { - if (object == self) return YES; - if (![object isKindOfClass:self.class]) return NO; - - return [self.backingArray isEqual:object.backingArray]; -} - -- (NSUInteger)hash { - return self.backingArray.hash; -} - -#pragma mark NSFastEnumeration - -- (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state objects:(id __unsafe_unretained [])buffer count:(NSUInteger)len { - return [self.backingArray countByEnumeratingWithState:state objects:buffer count:len]; -} - -#pragma mark NSCopying - -- (instancetype)copyWithZone:(NSZone *)zone { - // we're immutable, bitches! - return self; -} - -#pragma mark NSCoding - -- (instancetype)initWithCoder:(NSCoder *)coder { - self = [self init]; - - _backingArray = [coder decodeObjectForKey:@keypath(self.backingArray)]; - - return self; -} - -- (void)encodeWithCoder:(NSCoder *)coder { - if (self.backingArray != nil) [coder encodeObject:self.backingArray forKey:@keypath(self.backingArray)]; -} - -#pragma mark API - -+ (instancetype)tupleWithObjectsFromArray:(NSArray *)array { - return [self tupleWithObjectsFromArray:array convertNullsToNils:NO]; -} - -+ (instancetype)tupleWithObjectsFromArray:(NSArray *)array convertNullsToNils:(BOOL)convert { - if (!convert) { - return [[self alloc] initWithBackingArray:array]; - } - - NSMutableArray *newArray = [NSMutableArray arrayWithCapacity:array.count]; - for (id object in array) { - [newArray addObject:(object == NSNull.null ? RACTupleNil.tupleNil : object)]; - } - - return [[self alloc] initWithBackingArray:newArray]; -} - -+ (instancetype)tupleWithObjects:(id)object, ... { - va_list args; - va_start(args, object); - - NSUInteger count = 0; - for (id currentObject = object; currentObject != nil; currentObject = va_arg(args, id)) { - ++count; - } - - va_end(args); - - if (count == 0) { - return [[self alloc] init]; - } - - NSMutableArray *objects = [[NSMutableArray alloc] initWithCapacity:count]; - - va_start(args, object); - for (id currentObject = object; currentObject != nil; currentObject = va_arg(args, id)) { - [objects addObject:currentObject]; - } - - va_end(args); - - return [[self alloc] initWithBackingArray:objects]; -} - -- (id)objectAtIndex:(NSUInteger)index { - if (index >= self.count) return nil; - - id object = self.backingArray[index]; - return (object == RACTupleNil.tupleNil ? nil : object); -} - -- (NSArray *)allObjects { - NSMutableArray *newArray = [NSMutableArray arrayWithCapacity:self.backingArray.count]; - for (id object in self.backingArray) { - [newArray addObject:(object == RACTupleNil.tupleNil ? NSNull.null : object)]; - } - - return newArray; -} - -- (instancetype)tupleByAddingObject:(id)obj { - NSArray *newArray = [self.backingArray arrayByAddingObject:obj ?: RACTupleNil.tupleNil]; - return [self.class tupleWithObjectsFromArray:newArray]; -} - -- (NSUInteger)count { - return self.backingArray.count; -} - -- (id)first { - return self[0]; -} - -- (id)second { - return self[1]; -} - -- (id)third { - return self[2]; -} - -- (id)fourth { - return self[3]; -} - -- (id)fifth { - return self[4]; -} - -- (id)last { - return self[self.count - 1]; -} - -@end - - -@implementation RACTuple (RACSequenceAdditions) - -- (RACSequence *)rac_sequence { - return [RACTupleSequence sequenceWithTupleBackingArray:self.backingArray offset:0]; -} - -@end - -@implementation RACTuple (ObjectSubscripting) - -- (id)objectAtIndexedSubscript:(NSUInteger)idx { - return [self objectAtIndex:idx]; -} - -@end - -@implementation RACOneTuple - -- (instancetype)init { - return [self initWithBackingArray:@[ RACTupleNil.tupleNil ]]; -} - -- (instancetype)initWithBackingArray:(NSArray *)backingArray { - NSParameterAssert(backingArray.count == 1); - return [super initWithBackingArray:backingArray]; -} - -- (RACTwoTuple *)tupleByAddingObject:(id)obj { - NSArray *newArray = [self.backingArray arrayByAddingObject:obj ?: RACTupleNil.tupleNil]; - return [RACTwoTuple tupleWithObjectsFromArray:newArray]; -} - -+ (instancetype)pack:(id)first { - return [self tupleWithObjectsFromArray:@[ - first ?: RACTupleNil.tupleNil, - ]]; -} - -- (BOOL)isEqual:(RACTuple *)object { - if (object == self) return YES; - - // We consider a RACTuple with an identical backing array as equal. - if (![object isKindOfClass:RACTuple.class]) return NO; - - return [self.backingArray isEqual:object.backingArray]; -} - -@dynamic first; - -@end - -@implementation RACTwoTuple - -- (instancetype)init { - return [self initWithBackingArray:@[ RACTupleNil.tupleNil, RACTupleNil.tupleNil ]]; -} - -- (instancetype)initWithBackingArray:(NSArray *)backingArray { - NSParameterAssert(backingArray.count == 2); - return [super initWithBackingArray:backingArray]; -} - -- (RACThreeTuple *)tupleByAddingObject:(id)obj { - NSArray *newArray = [self.backingArray arrayByAddingObject:obj ?: RACTupleNil.tupleNil]; - return [RACThreeTuple tupleWithObjectsFromArray:newArray]; -} - -+ (instancetype)pack:(id)first :(id)second { - return [self tupleWithObjectsFromArray:@[ - first ?: RACTupleNil.tupleNil, - second ?: RACTupleNil.tupleNil, - ]]; -} - -- (BOOL)isEqual:(RACTuple *)object { - if (object == self) return YES; - - // We consider a RACTuple with an identical backing array as equal. - if (![object isKindOfClass:RACTuple.class]) return NO; - - return [self.backingArray isEqual:object.backingArray]; -} - -@dynamic first; -@dynamic second; - -@end - -@implementation RACThreeTuple - -- (instancetype)init { - return [super initWithBackingArray:@[ RACTupleNil.tupleNil, RACTupleNil.tupleNil, RACTupleNil.tupleNil ]]; -} - -- (instancetype)initWithBackingArray:(NSArray *)backingArray { - NSParameterAssert(backingArray.count == 3); - return [super initWithBackingArray:backingArray]; -} - -- (RACFourTuple *)tupleByAddingObject:(id)obj { - NSArray *newArray = [self.backingArray arrayByAddingObject:obj ?: RACTupleNil.tupleNil]; - return [RACFourTuple tupleWithObjectsFromArray:newArray]; -} - -+ (instancetype)pack:(id)first :(id)second :(id)third { - return [self tupleWithObjectsFromArray:@[ - first ?: RACTupleNil.tupleNil, - second ?: RACTupleNil.tupleNil, - third ?: RACTupleNil.tupleNil, - ]]; -} - -- (BOOL)isEqual:(RACTuple *)object { - if (object == self) return YES; - - // We consider a RACTuple with an identical backing array as equal. - if (![object isKindOfClass:RACTuple.class]) return NO; - - return [self.backingArray isEqual:object.backingArray]; -} - -@dynamic first; -@dynamic second; -@dynamic third; - -@end - -@implementation RACFourTuple - -- (instancetype)init { - return [self initWithBackingArray:@[ RACTupleNil.tupleNil, RACTupleNil.tupleNil, RACTupleNil.tupleNil, RACTupleNil.tupleNil ]]; -} - -- (instancetype)initWithBackingArray:(NSArray *)backingArray { - NSParameterAssert(backingArray.count == 4); - return [super initWithBackingArray:backingArray]; -} - -- (RACFiveTuple *)tupleByAddingObject:(id)obj { - NSArray *newArray = [self.backingArray arrayByAddingObject:obj ?: RACTupleNil.tupleNil]; - return [RACFiveTuple tupleWithObjectsFromArray:newArray]; -} - -+ (instancetype)pack:(id)first :(id)second :(id)third :(id)fourth { - return [self tupleWithObjectsFromArray:@[ - first ?: RACTupleNil.tupleNil, - second ?: RACTupleNil.tupleNil, - third ?: RACTupleNil.tupleNil, - fourth ?: RACTupleNil.tupleNil, - ]]; -} - -- (BOOL)isEqual:(RACTuple *)object { - if (object == self) return YES; - - // We consider a RACTuple with an identical backing array as equal. - if (![object isKindOfClass:RACTuple.class]) return NO; - - return [self.backingArray isEqual:object.backingArray]; -} - -@dynamic first; -@dynamic second; -@dynamic third; -@dynamic fourth; - -@end - -@implementation RACFiveTuple - -- (instancetype)init { - return [self initWithBackingArray:@[ RACTupleNil.tupleNil, RACTupleNil.tupleNil, RACTupleNil.tupleNil, RACTupleNil.tupleNil, RACTupleNil.tupleNil ]]; -} - -- (instancetype)initWithBackingArray:(NSArray *)backingArray { - NSParameterAssert(backingArray.count == 5); - return [super initWithBackingArray:backingArray]; -} - -+ (instancetype)pack:(id)first :(id)second :(id)third :(id)fourth :(id)fifth { - return [self tupleWithObjectsFromArray:@[ - first ?: RACTupleNil.tupleNil, - second ?: RACTupleNil.tupleNil, - third ?: RACTupleNil.tupleNil, - fourth ?: RACTupleNil.tupleNil, - fifth ?: RACTupleNil.tupleNil, - ]]; -} - -- (BOOL)isEqual:(RACTuple *)object { - if (object == self) return YES; - - // We consider a RACTuple with an identical backing array as equal. - if (![object isKindOfClass:RACTuple.class]) return NO; - - return [self.backingArray isEqual:object.backingArray]; -} - -@dynamic first; -@dynamic second; -@dynamic third; -@dynamic fourth; -@dynamic fifth; - -@end - -@implementation RACTupleUnpackingTrampoline - -#pragma mark Lifecycle - -+ (instancetype)trampoline { - static dispatch_once_t onceToken; - static id trampoline = nil; - dispatch_once(&onceToken, ^{ - trampoline = [[self alloc] init]; - }); - - return trampoline; -} - -- (void)setObject:(RACTuple *)tuple forKeyedSubscript:(NSArray *)variables { - NSCParameterAssert(variables != nil); - - [variables enumerateObjectsUsingBlock:^(NSValue *value, NSUInteger index, BOOL *stop) { - __strong id *ptr = (__strong id *)value.pointerValue; - *ptr = tuple[index]; - }]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACTupleSequence.h b/LookInside/ReactiveObjC/RACTupleSequence.h deleted file mode 100644 index 4aca7941..00000000 --- a/LookInside/ReactiveObjC/RACTupleSequence.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// RACTupleSequence.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-05-01. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSequence.h" - -// Private class that adapts a RACTuple to the RACSequence interface. -@interface RACTupleSequence : RACSequence - -// Returns a sequence for enumerating over the given backing array (from a -// RACTuple), starting from the given offset. -+ (RACSequence *)sequenceWithTupleBackingArray:(NSArray *)backingArray offset:(NSUInteger)offset; - -@end diff --git a/LookInside/ReactiveObjC/RACTupleSequence.m b/LookInside/ReactiveObjC/RACTupleSequence.m deleted file mode 100644 index ee725ea4..00000000 --- a/LookInside/ReactiveObjC/RACTupleSequence.m +++ /dev/null @@ -1,68 +0,0 @@ -// -// RACTupleSequence.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-05-01. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACTupleSequence.h" -#import "RACTuple.h" - -@interface RACTupleSequence () - -// The array being sequenced, as taken from RACTuple.backingArray. -@property (nonatomic, strong, readonly) NSArray *tupleBackingArray; - -// The index in the array from which the sequence starts. -@property (nonatomic, assign, readonly) NSUInteger offset; - -@end - -@implementation RACTupleSequence - -#pragma mark Lifecycle - -+ (RACSequence *)sequenceWithTupleBackingArray:(NSArray *)backingArray offset:(NSUInteger)offset { - NSCParameterAssert(offset <= backingArray.count); - - if (offset == backingArray.count) return self.empty; - - RACTupleSequence *seq = [[self alloc] init]; - seq->_tupleBackingArray = backingArray; - seq->_offset = offset; - return seq; -} - -#pragma mark RACSequence - -- (id)head { - id object = self.tupleBackingArray[self.offset]; - return (object == RACTupleNil.tupleNil ? NSNull.null : object); -} - -- (RACSequence *)tail { - RACSequence *sequence = [self.class sequenceWithTupleBackingArray:self.tupleBackingArray offset:self.offset + 1]; - sequence.name = self.name; - return sequence; -} - -- (NSArray *)array { - NSRange range = NSMakeRange(self.offset, self.tupleBackingArray.count - self.offset); - NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:range.length]; - - [self.tupleBackingArray enumerateObjectsAtIndexes:[NSIndexSet indexSetWithIndexesInRange:range] options:0 usingBlock:^(id object, NSUInteger index, BOOL *stop) { - id mappedObject = (object == RACTupleNil.tupleNil ? NSNull.null : object); - [array addObject:mappedObject]; - }]; - - return array; -} - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p>{ name = %@, tuple = %@ }", self.class, self, self.name, self.tupleBackingArray]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACUnarySequence.h b/LookInside/ReactiveObjC/RACUnarySequence.h deleted file mode 100644 index 9a897ca5..00000000 --- a/LookInside/ReactiveObjC/RACUnarySequence.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// RACUnarySequence.h -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-05-01. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACSequence.h" - -// Private class representing a sequence of exactly one value. -@interface RACUnarySequence : RACSequence - -+ (RACUnarySequence *)return:(id)value; - -@end diff --git a/LookInside/ReactiveObjC/RACUnarySequence.m b/LookInside/ReactiveObjC/RACUnarySequence.m deleted file mode 100644 index 82752c21..00000000 --- a/LookInside/ReactiveObjC/RACUnarySequence.m +++ /dev/null @@ -1,81 +0,0 @@ -// -// RACUnarySequence.m -// ReactiveObjC -// -// Created by Justin Spahr-Summers on 2013-05-01. -// Copyright (c) 2013 GitHub, Inc. All rights reserved. -// - -#import "RACUnarySequence.h" -#import "RACEXTKeyPathCoding.h" -#import "NSObject+RACDescription.h" - -@interface RACUnarySequence () - -// The single value stored in this sequence. -@property (nonatomic, strong, readwrite) id head; - -@end - -@implementation RACUnarySequence - -#pragma mark Properties - -@synthesize head = _head; - -#pragma mark Lifecycle - -+ (RACUnarySequence *)return:(id)value { - RACUnarySequence *sequence = [[self alloc] init]; - sequence.head = value; - return [sequence setNameWithFormat:@"+return: %@", RACDescription(value)]; -} - -#pragma mark RACSequence - -- (RACSequence *)tail { - return nil; -} - -- (RACSequence *)bind:(RACSequenceBindBlock (^)(void))block { - RACStreamBindBlock bindBlock = block(); - BOOL stop = NO; - - RACSequence *result = (id)[bindBlock(self.head, &stop) setNameWithFormat:@"[%@] -bind:", self.name]; - return result ?: self.class.empty; -} - -#pragma mark NSCoding - -- (Class)classForCoder { - // Unary sequences should be encoded as themselves, not array sequences. - return self.class; -} - -- (instancetype)initWithCoder:(NSCoder *)coder { - id value = [coder decodeObjectForKey:@keypath(self.head)]; - return [self.class return:value]; -} - -- (void)encodeWithCoder:(NSCoder *)coder { - if (self.head != nil) [coder encodeObject:self.head forKey:@keypath(self.head)]; -} - -#pragma mark NSObject - -- (NSString *)description { - return [NSString stringWithFormat:@"<%@: %p>{ name = %@, head = %@ }", self.class, self, self.name, self.head]; -} - -- (NSUInteger)hash { - return [self.head hash]; -} - -- (BOOL)isEqual:(RACUnarySequence *)seq { - if (self == seq) return YES; - if (![seq isKindOfClass:RACUnarySequence.class]) return NO; - - return self.head == seq.head || [(NSObject *)self.head isEqual:seq.head]; -} - -@end diff --git a/LookInside/ReactiveObjC/RACUnit.h b/LookInside/ReactiveObjC/RACUnit.h deleted file mode 100644 index cb4824c4..00000000 --- a/LookInside/ReactiveObjC/RACUnit.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// RACUnit.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/27/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/// A unit represents an empty value. -/// -/// It should never be necessary to create a unit yourself. Just use +defaultUnit. -@interface RACUnit : NSObject - -/// A singleton instance. -+ (RACUnit *)defaultUnit; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ReactiveObjC/RACUnit.m b/LookInside/ReactiveObjC/RACUnit.m deleted file mode 100644 index 691e5da4..00000000 --- a/LookInside/ReactiveObjC/RACUnit.m +++ /dev/null @@ -1,25 +0,0 @@ -// -// RACUnit.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/27/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACUnit.h" - -@implementation RACUnit - -#pragma mark API - -+ (RACUnit *)defaultUnit { - static dispatch_once_t onceToken; - static RACUnit *defaultUnit = nil; - dispatch_once(&onceToken, ^{ - defaultUnit = [[self alloc] init]; - }); - - return defaultUnit; -} - -@end diff --git a/LookInside/ReactiveObjC/RACValueTransformer.h b/LookInside/ReactiveObjC/RACValueTransformer.h deleted file mode 100644 index c827e6cd..00000000 --- a/LookInside/ReactiveObjC/RACValueTransformer.h +++ /dev/null @@ -1,16 +0,0 @@ -// -// RACValueTransformer.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/6/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -// A private block based transformer. -@interface RACValueTransformer : NSValueTransformer - -+ (instancetype)transformerWithBlock:(id (^)(id value))block; - -@end diff --git a/LookInside/ReactiveObjC/RACValueTransformer.m b/LookInside/ReactiveObjC/RACValueTransformer.m deleted file mode 100644 index 6f44e0f2..00000000 --- a/LookInside/ReactiveObjC/RACValueTransformer.m +++ /dev/null @@ -1,42 +0,0 @@ -// -// RACValueTransformer.m -// ReactiveObjC -// -// Created by Josh Abernathy on 3/6/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import "RACValueTransformer.h" - -@interface RACValueTransformer () -@property (nonatomic, copy) id (^transformBlock)(id value); -@end - - -@implementation RACValueTransformer - - -#pragma mark NSValueTransformer - -+ (BOOL)allowsReverseTransformation { - return NO; -} - -- (id)transformedValue:(id)value { - return self.transformBlock(value); -} - - -#pragma mark API - -@synthesize transformBlock; - -+ (instancetype)transformerWithBlock:(id (^)(id value))block { - NSCParameterAssert(block != NULL); - - RACValueTransformer *transformer = [[self alloc] init]; - transformer.transformBlock = block; - return transformer; -} - -@end diff --git a/LookInside/ReactiveObjC/RACmetamacros.h b/LookInside/ReactiveObjC/RACmetamacros.h deleted file mode 100644 index 4575bbec..00000000 --- a/LookInside/ReactiveObjC/RACmetamacros.h +++ /dev/null @@ -1 +0,0 @@ -#import "extobjc/RACmetamacros.h" diff --git a/LookInside/ReactiveObjC/ReactiveObjC.h b/LookInside/ReactiveObjC/ReactiveObjC.h deleted file mode 100644 index c20da472..00000000 --- a/LookInside/ReactiveObjC/ReactiveObjC.h +++ /dev/null @@ -1,68 +0,0 @@ -// -// ReactiveObjC.h -// ReactiveObjC -// -// Created by Josh Abernathy on 3/5/12. -// Copyright (c) 2012 GitHub, Inc. All rights reserved. -// - -#import - -//! Project version number for ReactiveObjC. -FOUNDATION_EXPORT double ReactiveObjCVersionNumber; - -//! Project version string for ReactiveObjC. -FOUNDATION_EXPORT const unsigned char ReactiveObjCVersionString[]; - -#import "RACEXTKeyPathCoding.h" -#import "RACEXTScope.h" -#import "NSArray+RACSequenceAdditions.h" -#import "NSData+RACSupport.h" -#import "NSDictionary+RACSequenceAdditions.h" -#import "NSEnumerator+RACSequenceAdditions.h" -#import "NSFileHandle+RACSupport.h" -#import "NSNotificationCenter+RACSupport.h" -#import "NSObject+RACDeallocating.h" -#import "NSObject+RACLifting.h" -#import "NSObject+RACPropertySubscribing.h" -#import "NSObject+RACSelectorSignal.h" -#import "NSOrderedSet+RACSequenceAdditions.h" -#import "NSSet+RACSequenceAdditions.h" -#import "NSString+RACSequenceAdditions.h" -#import "NSString+RACSupport.h" -#import "NSIndexSet+RACSequenceAdditions.h" -#import "NSUserDefaults+RACSupport.h" -#import "RACBehaviorSubject.h" -#import "RACChannel.h" -#import "RACCommand.h" -#import "RACCompoundDisposable.h" -#import "RACDelegateProxy.h" -#import "RACDisposable.h" -#import "RACEvent.h" -#import "RACGroupedSignal.h" -#import "RACKVOChannel.h" -#import "RACMulticastConnection.h" -#import "RACQueueScheduler.h" -#import "RACQueueScheduler+Subclass.h" -#import "RACReplaySubject.h" -#import "RACScheduler.h" -#import "RACScheduler+Subclass.h" -#import "RACScopedDisposable.h" -#import "RACSequence.h" -#import "RACSerialDisposable.h" -#import "RACSignal+Operations.h" -#import "RACSignal.h" -#import "RACStream.h" -#import "RACSubject.h" -#import "RACSubscriber.h" -#import "RACSubscriptingAssignmentTrampoline.h" -#import "RACTargetQueueScheduler.h" -#import "RACTestScheduler.h" -#import "RACTuple.h" -#import "RACUnit.h" - -#import "NSControl+RACCommandSupport.h" -#import "NSControl+RACTextSignalSupport.h" -#import "NSObject+RACAppKitBindings.h" -#import "NSText+RACSignalSupport.h" -#import "NSURLConnection+RACSupport.h" diff --git a/LookInside/ReactiveObjC/extobjc/RACEXTKeyPathCoding.h b/LookInside/ReactiveObjC/extobjc/RACEXTKeyPathCoding.h deleted file mode 100644 index 01063196..00000000 --- a/LookInside/ReactiveObjC/extobjc/RACEXTKeyPathCoding.h +++ /dev/null @@ -1,68 +0,0 @@ -// -// EXTKeyPathCoding.h -// extobjc -// -// Created by Justin Spahr-Summers on 19.06.12. -// Copyright (C) 2012 Justin Spahr-Summers. -// Released under the MIT license. -// - -#import -#import "RACmetamacros.h" - -/** - * \@keypath allows compile-time verification of key paths. Given a real object - * receiver and key path: - * - * @code - -NSString *UTF8StringPath = @keypath(str.lowercaseString.UTF8String); -// => @"lowercaseString.UTF8String" - -NSString *versionPath = @keypath(NSObject, version); -// => @"version" - -NSString *lowercaseStringPath = @keypath(NSString.new, lowercaseString); -// => @"lowercaseString" - - * @endcode - * - * ... the macro returns an \c NSString containing all but the first path - * component or argument (e.g., @"lowercaseString.UTF8String", @"version"). - * - * In addition to simply creating a key path, this macro ensures that the key - * path is valid at compile-time (causing a syntax error if not), and supports - * refactoring, such that changing the name of the property will also update any - * uses of \@keypath. - */ -#define keypath(...) \ - metamacro_if_eq(1, metamacro_argcount(__VA_ARGS__))(keypath1(__VA_ARGS__))(keypath2(__VA_ARGS__)) - -#define keypath1(PATH) \ - (((void)(NO && ((void)PATH, NO)), strchr(# PATH, '.') + 1)) - -#define keypath2(OBJ, PATH) \ - (((void)(NO && ((void)OBJ.PATH, NO)), # PATH)) - -/** - * \@collectionKeypath allows compile-time verification of key paths across collections NSArray/NSSet etc. Given a real object - * receiver, collection object receiver and related keypaths: - * - * @code - - NSString *employessFirstNamePath = @collectionKeypath(department.employees, Employee.new, firstName) - // => @"employees.firstName" - - NSString *employessFirstNamePath = @collectionKeypath(Department.new, employees, Employee.new, firstName) - // => @"employees.firstName" - - * @endcode - * - */ -#define collectionKeypath(...) \ - metamacro_if_eq(3, metamacro_argcount(__VA_ARGS__))(collectionKeypath3(__VA_ARGS__))(collectionKeypath4(__VA_ARGS__)) - -#define collectionKeypath3(PATH, COLLECTION_OBJECT, COLLECTION_PATH) ([[NSString stringWithFormat:@"%s.%s",keypath(PATH), keypath(COLLECTION_OBJECT, COLLECTION_PATH)] UTF8String]) - -#define collectionKeypath4(OBJ, PATH, COLLECTION_OBJECT, COLLECTION_PATH) ([[NSString stringWithFormat:@"%s.%s",keypath(OBJ, PATH), keypath(COLLECTION_OBJECT, COLLECTION_PATH)] UTF8String]) - diff --git a/LookInside/ReactiveObjC/extobjc/RACEXTRuntimeExtensions.h b/LookInside/ReactiveObjC/extobjc/RACEXTRuntimeExtensions.h deleted file mode 100644 index ab4e11d0..00000000 --- a/LookInside/ReactiveObjC/extobjc/RACEXTRuntimeExtensions.h +++ /dev/null @@ -1,122 +0,0 @@ -// -// EXTRuntimeExtensions.h -// extobjc -// -// Created by Justin Spahr-Summers on 2011-03-05. -// Copyright (C) 2012 Justin Spahr-Summers. -// Released under the MIT license. -// - -#import - -/** - * Describes the memory management policy of a property. - */ -typedef enum { - /** - * The value is assigned. - */ - rac_propertyMemoryManagementPolicyAssign = 0, - - /** - * The value is retained. - */ - rac_propertyMemoryManagementPolicyRetain, - - /** - * The value is copied. - */ - rac_propertyMemoryManagementPolicyCopy -} rac_propertyMemoryManagementPolicy; - -/** - * Describes the attributes and type information of a property. - */ -typedef struct { - /** - * Whether this property was declared with the \c readonly attribute. - */ - BOOL readonly; - - /** - * Whether this property was declared with the \c nonatomic attribute. - */ - BOOL nonatomic; - - /** - * Whether the property is a weak reference. - */ - BOOL weak; - - /** - * Whether the property is eligible for garbage collection. - */ - BOOL canBeCollected; - - /** - * Whether this property is defined with \c \@dynamic. - */ - BOOL dynamic; - - /** - * The memory management policy for this property. This will always be - * #rac_propertyMemoryManagementPolicyAssign if #readonly is \c YES. - */ - rac_propertyMemoryManagementPolicy memoryManagementPolicy; - - /** - * The selector for the getter of this property. This will reflect any - * custom \c getter= attribute provided in the property declaration, or the - * inferred getter name otherwise. - */ - SEL getter; - - /** - * The selector for the setter of this property. This will reflect any - * custom \c setter= attribute provided in the property declaration, or the - * inferred setter name otherwise. - * - * @note If #readonly is \c YES, this value will represent what the setter - * \e would be, if the property were writable. - */ - SEL setter; - - /** - * The backing instance variable for this property, or \c NULL if \c - * \c @synthesize was not used, and therefore no instance variable exists. This - * would also be the case if the property is implemented dynamically. - */ - const char *ivar; - - /** - * If this property is defined as being an instance of a specific class, - * this will be the class object representing it. - * - * This will be \c nil if the property was defined as type \c id, if the - * property is not of an object type, or if the class could not be found at - * runtime. - */ - Class objectClass; - - /** - * The type encoding for the value of this property. This is the type as it - * would be returned by the \c \@encode() directive. - */ - char type[]; -} rac_propertyAttributes; - -/** - * Finds the instance method named \a aSelector on \a aClass and returns it, or - * returns \c NULL if no such instance method exists. Unlike \c - * class_getInstanceMethod(), this does not search superclasses. - * - * @note To get class methods in this manner, use a metaclass for \a aClass. - */ -Method rac_getImmediateInstanceMethod (Class aClass, SEL aSelector); - -/** - * Returns a pointer to a structure containing information about \a property. - * You must \c free() the returned pointer. Returns \c NULL if there is an error - * obtaining information from \a property. - */ -rac_propertyAttributes *rac_copyPropertyAttributes (objc_property_t property); diff --git a/LookInside/ReactiveObjC/extobjc/RACEXTRuntimeExtensions.m b/LookInside/ReactiveObjC/extobjc/RACEXTRuntimeExtensions.m deleted file mode 100644 index 0ad8e4e5..00000000 --- a/LookInside/ReactiveObjC/extobjc/RACEXTRuntimeExtensions.m +++ /dev/null @@ -1,234 +0,0 @@ -// -// EXTRuntimeExtensions.m -// extobjc -// -// Created by Justin Spahr-Summers on 2011-03-05. -// Copyright (C) 2012 Justin Spahr-Summers. -// Released under the MIT license. -// - -#import "RACEXTRuntimeExtensions.h" - -#import -#import -#import -#import -#import -#import -#import -#import - -rac_propertyAttributes *rac_copyPropertyAttributes (objc_property_t property) { - const char * const attrString = property_getAttributes(property); - if (!attrString) { - fprintf(stderr, "ERROR: Could not get attribute string from property %s\n", property_getName(property)); - return NULL; - } - - if (attrString[0] != 'T') { - fprintf(stderr, "ERROR: Expected attribute string \"%s\" for property %s to start with 'T'\n", attrString, property_getName(property)); - return NULL; - } - - const char *typeString = attrString + 1; - const char *next = NSGetSizeAndAlignment(typeString, NULL, NULL); - if (!next) { - fprintf(stderr, "ERROR: Could not read past type in attribute string \"%s\" for property %s\n", attrString, property_getName(property)); - return NULL; - } - - size_t typeLength = (size_t)(next - typeString); - if (!typeLength) { - fprintf(stderr, "ERROR: Invalid type in attribute string \"%s\" for property %s\n", attrString, property_getName(property)); - return NULL; - } - - // allocate enough space for the structure and the type string (plus a NUL) - rac_propertyAttributes *attributes = calloc(1, sizeof(rac_propertyAttributes) + typeLength + 1); - if (!attributes) { - fprintf(stderr, "ERROR: Could not allocate rac_propertyAttributes structure for attribute string \"%s\" for property %s\n", attrString, property_getName(property)); - return NULL; - } - - // copy the type string - strncpy(attributes->type, typeString, typeLength); - attributes->type[typeLength] = '\0'; - - // if this is an object type, and immediately followed by a quoted string... - if (typeString[0] == *(@encode(id)) && typeString[1] == '"') { - // we should be able to extract a class name - const char *className = typeString + 2; - next = strchr(className, '"'); - - if (!next) { - fprintf(stderr, "ERROR: Could not read class name in attribute string \"%s\" for property %s\n", attrString, property_getName(property)); - return NULL; - } - - if (className != next) { - size_t classNameLength = (size_t)(next - className); - char trimmedName[classNameLength + 1]; - - strncpy(trimmedName, className, classNameLength); - trimmedName[classNameLength] = '\0'; - - // attempt to look up the class in the runtime - attributes->objectClass = objc_getClass(trimmedName); - } - } - - if (*next != '\0') { - // skip past any junk before the first flag - next = strchr(next, ','); - } - - while (next && *next == ',') { - char flag = next[1]; - next += 2; - - switch (flag) { - case '\0': - break; - - case 'R': - attributes->readonly = YES; - break; - - case 'C': - attributes->memoryManagementPolicy = rac_propertyMemoryManagementPolicyCopy; - break; - - case '&': - attributes->memoryManagementPolicy = rac_propertyMemoryManagementPolicyRetain; - break; - - case 'N': - attributes->nonatomic = YES; - break; - - case 'G': - case 'S': - { - const char *nextFlag = strchr(next, ','); - SEL name = NULL; - - if (!nextFlag) { - // assume that the rest of the string is the selector - const char *selectorString = next; - next = ""; - - name = sel_registerName(selectorString); - } else { - size_t selectorLength = (size_t)(nextFlag - next); - if (!selectorLength) { - fprintf(stderr, "ERROR: Found zero length selector name in attribute string \"%s\" for property %s\n", attrString, property_getName(property)); - goto errorOut; - } - - char selectorString[selectorLength + 1]; - - strncpy(selectorString, next, selectorLength); - selectorString[selectorLength] = '\0'; - - name = sel_registerName(selectorString); - next = nextFlag; - } - - if (flag == 'G') - attributes->getter = name; - else - attributes->setter = name; - } - - break; - - case 'D': - attributes->dynamic = YES; - attributes->ivar = NULL; - break; - - case 'V': - // assume that the rest of the string (if present) is the ivar name - if (*next == '\0') { - // if there's nothing there, let's assume this is dynamic - attributes->ivar = NULL; - } else { - attributes->ivar = next; - next = ""; - } - - break; - - case 'W': - attributes->weak = YES; - break; - - case 'P': - attributes->canBeCollected = YES; - break; - - case 't': - fprintf(stderr, "ERROR: Old-style type encoding is unsupported in attribute string \"%s\" for property %s\n", attrString, property_getName(property)); - - // skip over this type encoding - while (*next != ',' && *next != '\0') - ++next; - - break; - - default: - fprintf(stderr, "ERROR: Unrecognized attribute string flag '%c' in attribute string \"%s\" for property %s\n", flag, attrString, property_getName(property)); - } - } - - if (next && *next != '\0') { - fprintf(stderr, "Warning: Unparsed data \"%s\" in attribute string \"%s\" for property %s\n", next, attrString, property_getName(property)); - } - - if (!attributes->getter) { - // use the property name as the getter by default - attributes->getter = sel_registerName(property_getName(property)); - } - - if (!attributes->setter) { - const char *propertyName = property_getName(property); - size_t propertyNameLength = strlen(propertyName); - - // we want to transform the name to setProperty: style - size_t setterLength = propertyNameLength + 4; - - char setterName[setterLength + 1]; - strncpy(setterName, "set", 3); - strncpy(setterName + 3, propertyName, propertyNameLength); - - // capitalize property name for the setter - setterName[3] = (char)toupper(setterName[3]); - - setterName[setterLength - 1] = ':'; - setterName[setterLength] = '\0'; - - attributes->setter = sel_registerName(setterName); - } - - return attributes; - -errorOut: - free(attributes); - return NULL; -} - -Method rac_getImmediateInstanceMethod (Class aClass, SEL aSelector) { - unsigned methodCount = 0; - Method *methods = class_copyMethodList(aClass, &methodCount); - Method foundMethod = NULL; - - for (unsigned methodIndex = 0;methodIndex < methodCount;++methodIndex) { - if (method_getName(methods[methodIndex]) == aSelector) { - foundMethod = methods[methodIndex]; - break; - } - } - - free(methods); - return foundMethod; -} diff --git a/LookInside/ReactiveObjC/extobjc/RACEXTScope.h b/LookInside/ReactiveObjC/extobjc/RACEXTScope.h deleted file mode 100644 index f89dca45..00000000 --- a/LookInside/ReactiveObjC/extobjc/RACEXTScope.h +++ /dev/null @@ -1,118 +0,0 @@ -// -// EXTScope.h -// extobjc -// -// Created by Justin Spahr-Summers on 2011-05-04. -// Copyright (C) 2012 Justin Spahr-Summers. -// Released under the MIT license. -// - -#import "RACmetamacros.h" - -/** - * \@onExit defines some code to be executed when the current scope exits. The - * code must be enclosed in braces and terminated with a semicolon, and will be - * executed regardless of how the scope is exited, including from exceptions, - * \c goto, \c return, \c break, and \c continue. - * - * Provided code will go into a block to be executed later. Keep this in mind as - * it pertains to memory management, restrictions on assignment, etc. Because - * the code is used within a block, \c return is a legal (though perhaps - * confusing) way to exit the cleanup block early. - * - * Multiple \@onExit statements in the same scope are executed in reverse - * lexical order. This helps when pairing resource acquisition with \@onExit - * statements, as it guarantees teardown in the opposite order of acquisition. - * - * @note This statement cannot be used within scopes defined without braces - * (like a one line \c if). In practice, this is not an issue, since \@onExit is - * a useless construct in such a case anyways. - */ -#define onExit \ - rac_keywordify \ - __strong rac_cleanupBlock_t metamacro_concat(rac_exitBlock_, __LINE__) __attribute__((cleanup(rac_executeCleanupBlock), unused)) = ^ - -/** - * Creates \c __weak shadow variables for each of the variables provided as - * arguments, which can later be made strong again with #strongify. - * - * This is typically used to weakly reference variables in a block, but then - * ensure that the variables stay alive during the actual execution of the block - * (if they were live upon entry). - * - * See #strongify for an example of usage. - */ -#define weakify(...) \ - rac_keywordify \ - metamacro_foreach_cxt(rac_weakify_,, __weak, __VA_ARGS__) - -/** - * Like #weakify, but uses \c __unsafe_unretained instead, for targets or - * classes that do not support weak references. - */ -#define unsafeify(...) \ - rac_keywordify \ - metamacro_foreach_cxt(rac_weakify_,, __unsafe_unretained, __VA_ARGS__) - -/** - * Strongly references each of the variables provided as arguments, which must - * have previously been passed to #weakify. - * - * The strong references created will shadow the original variable names, such - * that the original names can be used without issue (and a significantly - * reduced risk of retain cycles) in the current scope. - * - * @code - - id foo = [[NSObject alloc] init]; - id bar = [[NSObject alloc] init]; - - @weakify(foo, bar); - - // this block will not keep 'foo' or 'bar' alive - BOOL (^matchesFooOrBar)(id) = ^ BOOL (id obj){ - // but now, upon entry, 'foo' and 'bar' will stay alive until the block has - // finished executing - @strongify(foo, bar); - - return [foo isEqual:obj] || [bar isEqual:obj]; - }; - - * @endcode - */ -#define strongify(...) \ - rac_keywordify \ - _Pragma("clang diagnostic push") \ - _Pragma("clang diagnostic ignored \"-Wshadow\"") \ - metamacro_foreach(rac_strongify_,, __VA_ARGS__) \ - _Pragma("clang diagnostic pop") - -/*** implementation details follow ***/ -typedef void (^rac_cleanupBlock_t)(void); - -static inline void rac_executeCleanupBlock (__strong rac_cleanupBlock_t *block) { - (*block)(); -} - -#define rac_weakify_(INDEX, CONTEXT, VAR) \ - CONTEXT __typeof__(VAR) metamacro_concat(VAR, _weak_) = (VAR); - -#define rac_strongify_(INDEX, VAR) \ - __strong __typeof__(VAR) VAR = metamacro_concat(VAR, _weak_); - -// Details about the choice of backing keyword: -// -// The use of @try/@catch/@finally can cause the compiler to suppress -// return-type warnings. -// The use of @autoreleasepool {} is not optimized away by the compiler, -// resulting in superfluous creation of autorelease pools. -// -// Since neither option is perfect, and with no other alternatives, the -// compromise is to use @autorelease in DEBUG builds to maintain compiler -// analysis, and to use @try/@catch otherwise to avoid insertion of unnecessary -// autorelease pools. -#if DEBUG -#define rac_keywordify autoreleasepool {} -#else -#define rac_keywordify try {} @catch (...) {} -#endif diff --git a/LookInside/ReactiveObjC/extobjc/RACmetamacros.h b/LookInside/ReactiveObjC/extobjc/RACmetamacros.h deleted file mode 100644 index 1a0af4fe..00000000 --- a/LookInside/ReactiveObjC/extobjc/RACmetamacros.h +++ /dev/null @@ -1,661 +0,0 @@ -/** - * Macros for metaprogramming - * ExtendedC - * - * Copyright (C) 2012 Justin Spahr-Summers - * Released under the MIT license - */ - -/** - * Executes one or more expressions (which may have a void type, such as a call - * to a function that returns no value) and always returns true. - */ -#define metamacro_exprify(...) \ - ((__VA_ARGS__), true) - -/** - * Returns a string representation of VALUE after full macro expansion. - */ -#define metamacro_stringify(VALUE) \ - metamacro_stringify_(VALUE) - -/** - * Returns A and B concatenated after full macro expansion. - */ -#define metamacro_concat(A, B) \ - metamacro_concat_(A, B) - -/** - * Returns the Nth variadic argument (starting from zero). At least - * N + 1 variadic arguments must be given. N must be between zero and twenty, - * inclusive. - */ -#define metamacro_at(N, ...) \ - metamacro_concat(metamacro_at, N)(__VA_ARGS__) - -/** - * Returns the number of arguments (up to twenty) provided to the macro. At - * least one argument must be provided. - * - * Inspired by P99: http://p99.gforge.inria.fr - */ -#define metamacro_argcount(...) \ - metamacro_at(20, __VA_ARGS__, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1) - -/** - * Identical to #metamacro_foreach_cxt, except that no CONTEXT argument is - * given. Only the index and current argument will thus be passed to MACRO. - */ -#define metamacro_foreach(MACRO, SEP, ...) \ - metamacro_foreach_cxt(metamacro_foreach_iter, SEP, MACRO, __VA_ARGS__) - -/** - * For each consecutive variadic argument (up to twenty), MACRO is passed the - * zero-based index of the current argument, CONTEXT, and then the argument - * itself. The results of adjoining invocations of MACRO are then separated by - * SEP. - * - * Inspired by P99: http://p99.gforge.inria.fr - */ -#define metamacro_foreach_cxt(MACRO, SEP, CONTEXT, ...) \ - metamacro_concat(metamacro_foreach_cxt, metamacro_argcount(__VA_ARGS__))(MACRO, SEP, CONTEXT, __VA_ARGS__) - -/** - * Identical to #metamacro_foreach_cxt. This can be used when the former would - * fail due to recursive macro expansion. - */ -#define metamacro_foreach_cxt_recursive(MACRO, SEP, CONTEXT, ...) \ - metamacro_concat(metamacro_foreach_cxt_recursive, metamacro_argcount(__VA_ARGS__))(MACRO, SEP, CONTEXT, __VA_ARGS__) - -/** - * In consecutive order, appends each variadic argument (up to twenty) onto - * BASE. The resulting concatenations are then separated by SEP. - * - * This is primarily useful to manipulate a list of macro invocations into instead - * invoking a different, possibly related macro. - */ -#define metamacro_foreach_concat(BASE, SEP, ...) \ - metamacro_foreach_cxt(metamacro_foreach_concat_iter, SEP, BASE, __VA_ARGS__) - -/** - * Iterates COUNT times, each time invoking MACRO with the current index - * (starting at zero) and CONTEXT. The results of adjoining invocations of MACRO - * are then separated by SEP. - * - * COUNT must be an integer between zero and twenty, inclusive. - */ -#define metamacro_for_cxt(COUNT, MACRO, SEP, CONTEXT) \ - metamacro_concat(metamacro_for_cxt, COUNT)(MACRO, SEP, CONTEXT) - -/** - * Returns the first argument given. At least one argument must be provided. - * - * This is useful when implementing a variadic macro, where you may have only - * one variadic argument, but no way to retrieve it (for example, because \c ... - * always needs to match at least one argument). - * - * @code - -#define varmacro(...) \ - metamacro_head(__VA_ARGS__) - - * @endcode - */ -#define metamacro_head(...) \ - metamacro_head_(__VA_ARGS__, 0) - -/** - * Returns every argument except the first. At least two arguments must be - * provided. - */ -#define metamacro_tail(...) \ - metamacro_tail_(__VA_ARGS__) - -/** - * Returns the first N (up to twenty) variadic arguments as a new argument list. - * At least N variadic arguments must be provided. - */ -#define metamacro_take(N, ...) \ - metamacro_concat(metamacro_take, N)(__VA_ARGS__) - -/** - * Removes the first N (up to twenty) variadic arguments from the given argument - * list. At least N variadic arguments must be provided. - */ -#define metamacro_drop(N, ...) \ - metamacro_concat(metamacro_drop, N)(__VA_ARGS__) - -/** - * Decrements VAL, which must be a number between zero and twenty, inclusive. - * - * This is primarily useful when dealing with indexes and counts in - * metaprogramming. - */ -#define metamacro_dec(VAL) \ - metamacro_at(VAL, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19) - -/** - * Increments VAL, which must be a number between zero and twenty, inclusive. - * - * This is primarily useful when dealing with indexes and counts in - * metaprogramming. - */ -#define metamacro_inc(VAL) \ - metamacro_at(VAL, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21) - -/** - * If A is equal to B, the next argument list is expanded; otherwise, the - * argument list after that is expanded. A and B must be numbers between zero - * and twenty, inclusive. Additionally, B must be greater than or equal to A. - * - * @code - -// expands to true -metamacro_if_eq(0, 0)(true)(false) - -// expands to false -metamacro_if_eq(0, 1)(true)(false) - - * @endcode - * - * This is primarily useful when dealing with indexes and counts in - * metaprogramming. - */ -#define metamacro_if_eq(A, B) \ - metamacro_concat(metamacro_if_eq, A)(B) - -/** - * Identical to #metamacro_if_eq. This can be used when the former would fail - * due to recursive macro expansion. - */ -#define metamacro_if_eq_recursive(A, B) \ - metamacro_concat(metamacro_if_eq_recursive, A)(B) - -/** - * Returns 1 if N is an even number, or 0 otherwise. N must be between zero and - * twenty, inclusive. - * - * For the purposes of this test, zero is considered even. - */ -#define metamacro_is_even(N) \ - metamacro_at(N, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1) - -/** - * Returns the logical NOT of B, which must be the number zero or one. - */ -#define metamacro_not(B) \ - metamacro_at(B, 1, 0) - -// IMPLEMENTATION DETAILS FOLLOW! -// Do not write code that depends on anything below this line. -#define metamacro_stringify_(VALUE) # VALUE -#define metamacro_concat_(A, B) A ## B -#define metamacro_foreach_iter(INDEX, MACRO, ARG) MACRO(INDEX, ARG) -#define metamacro_head_(FIRST, ...) FIRST -#define metamacro_tail_(FIRST, ...) __VA_ARGS__ -#define metamacro_consume_(...) -#define metamacro_expand_(...) __VA_ARGS__ - -// implemented from scratch so that metamacro_concat() doesn't end up nesting -#define metamacro_foreach_concat_iter(INDEX, BASE, ARG) metamacro_foreach_concat_iter_(BASE, ARG) -#define metamacro_foreach_concat_iter_(BASE, ARG) BASE ## ARG - -// metamacro_at expansions -#define metamacro_at0(...) metamacro_head(__VA_ARGS__) -#define metamacro_at1(_0, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at2(_0, _1, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at3(_0, _1, _2, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at4(_0, _1, _2, _3, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at5(_0, _1, _2, _3, _4, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at6(_0, _1, _2, _3, _4, _5, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at7(_0, _1, _2, _3, _4, _5, _6, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at8(_0, _1, _2, _3, _4, _5, _6, _7, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at9(_0, _1, _2, _3, _4, _5, _6, _7, _8, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at10(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at11(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at12(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at13(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at14(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at15(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at16(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at17(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at18(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at19(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, ...) metamacro_head(__VA_ARGS__) -#define metamacro_at20(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, ...) metamacro_head(__VA_ARGS__) - -// metamacro_foreach_cxt expansions -#define metamacro_foreach_cxt0(MACRO, SEP, CONTEXT) -#define metamacro_foreach_cxt1(MACRO, SEP, CONTEXT, _0) MACRO(0, CONTEXT, _0) - -#define metamacro_foreach_cxt2(MACRO, SEP, CONTEXT, _0, _1) \ - metamacro_foreach_cxt1(MACRO, SEP, CONTEXT, _0) \ - SEP \ - MACRO(1, CONTEXT, _1) - -#define metamacro_foreach_cxt3(MACRO, SEP, CONTEXT, _0, _1, _2) \ - metamacro_foreach_cxt2(MACRO, SEP, CONTEXT, _0, _1) \ - SEP \ - MACRO(2, CONTEXT, _2) - -#define metamacro_foreach_cxt4(MACRO, SEP, CONTEXT, _0, _1, _2, _3) \ - metamacro_foreach_cxt3(MACRO, SEP, CONTEXT, _0, _1, _2) \ - SEP \ - MACRO(3, CONTEXT, _3) - -#define metamacro_foreach_cxt5(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4) \ - metamacro_foreach_cxt4(MACRO, SEP, CONTEXT, _0, _1, _2, _3) \ - SEP \ - MACRO(4, CONTEXT, _4) - -#define metamacro_foreach_cxt6(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5) \ - metamacro_foreach_cxt5(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4) \ - SEP \ - MACRO(5, CONTEXT, _5) - -#define metamacro_foreach_cxt7(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6) \ - metamacro_foreach_cxt6(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5) \ - SEP \ - MACRO(6, CONTEXT, _6) - -#define metamacro_foreach_cxt8(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7) \ - metamacro_foreach_cxt7(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6) \ - SEP \ - MACRO(7, CONTEXT, _7) - -#define metamacro_foreach_cxt9(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8) \ - metamacro_foreach_cxt8(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7) \ - SEP \ - MACRO(8, CONTEXT, _8) - -#define metamacro_foreach_cxt10(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ - metamacro_foreach_cxt9(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8) \ - SEP \ - MACRO(9, CONTEXT, _9) - -#define metamacro_foreach_cxt11(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \ - metamacro_foreach_cxt10(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ - SEP \ - MACRO(10, CONTEXT, _10) - -#define metamacro_foreach_cxt12(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \ - metamacro_foreach_cxt11(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \ - SEP \ - MACRO(11, CONTEXT, _11) - -#define metamacro_foreach_cxt13(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \ - metamacro_foreach_cxt12(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \ - SEP \ - MACRO(12, CONTEXT, _12) - -#define metamacro_foreach_cxt14(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \ - metamacro_foreach_cxt13(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \ - SEP \ - MACRO(13, CONTEXT, _13) - -#define metamacro_foreach_cxt15(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \ - metamacro_foreach_cxt14(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \ - SEP \ - MACRO(14, CONTEXT, _14) - -#define metamacro_foreach_cxt16(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \ - metamacro_foreach_cxt15(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \ - SEP \ - MACRO(15, CONTEXT, _15) - -#define metamacro_foreach_cxt17(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16) \ - metamacro_foreach_cxt16(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \ - SEP \ - MACRO(16, CONTEXT, _16) - -#define metamacro_foreach_cxt18(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17) \ - metamacro_foreach_cxt17(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16) \ - SEP \ - MACRO(17, CONTEXT, _17) - -#define metamacro_foreach_cxt19(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18) \ - metamacro_foreach_cxt18(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17) \ - SEP \ - MACRO(18, CONTEXT, _18) - -#define metamacro_foreach_cxt20(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19) \ - metamacro_foreach_cxt19(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18) \ - SEP \ - MACRO(19, CONTEXT, _19) - -// metamacro_foreach_cxt_recursive expansions -#define metamacro_foreach_cxt_recursive0(MACRO, SEP, CONTEXT) -#define metamacro_foreach_cxt_recursive1(MACRO, SEP, CONTEXT, _0) MACRO(0, CONTEXT, _0) - -#define metamacro_foreach_cxt_recursive2(MACRO, SEP, CONTEXT, _0, _1) \ - metamacro_foreach_cxt_recursive1(MACRO, SEP, CONTEXT, _0) \ - SEP \ - MACRO(1, CONTEXT, _1) - -#define metamacro_foreach_cxt_recursive3(MACRO, SEP, CONTEXT, _0, _1, _2) \ - metamacro_foreach_cxt_recursive2(MACRO, SEP, CONTEXT, _0, _1) \ - SEP \ - MACRO(2, CONTEXT, _2) - -#define metamacro_foreach_cxt_recursive4(MACRO, SEP, CONTEXT, _0, _1, _2, _3) \ - metamacro_foreach_cxt_recursive3(MACRO, SEP, CONTEXT, _0, _1, _2) \ - SEP \ - MACRO(3, CONTEXT, _3) - -#define metamacro_foreach_cxt_recursive5(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4) \ - metamacro_foreach_cxt_recursive4(MACRO, SEP, CONTEXT, _0, _1, _2, _3) \ - SEP \ - MACRO(4, CONTEXT, _4) - -#define metamacro_foreach_cxt_recursive6(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5) \ - metamacro_foreach_cxt_recursive5(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4) \ - SEP \ - MACRO(5, CONTEXT, _5) - -#define metamacro_foreach_cxt_recursive7(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6) \ - metamacro_foreach_cxt_recursive6(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5) \ - SEP \ - MACRO(6, CONTEXT, _6) - -#define metamacro_foreach_cxt_recursive8(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7) \ - metamacro_foreach_cxt_recursive7(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6) \ - SEP \ - MACRO(7, CONTEXT, _7) - -#define metamacro_foreach_cxt_recursive9(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8) \ - metamacro_foreach_cxt_recursive8(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7) \ - SEP \ - MACRO(8, CONTEXT, _8) - -#define metamacro_foreach_cxt_recursive10(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ - metamacro_foreach_cxt_recursive9(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8) \ - SEP \ - MACRO(9, CONTEXT, _9) - -#define metamacro_foreach_cxt_recursive11(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \ - metamacro_foreach_cxt_recursive10(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9) \ - SEP \ - MACRO(10, CONTEXT, _10) - -#define metamacro_foreach_cxt_recursive12(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \ - metamacro_foreach_cxt_recursive11(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10) \ - SEP \ - MACRO(11, CONTEXT, _11) - -#define metamacro_foreach_cxt_recursive13(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \ - metamacro_foreach_cxt_recursive12(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11) \ - SEP \ - MACRO(12, CONTEXT, _12) - -#define metamacro_foreach_cxt_recursive14(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \ - metamacro_foreach_cxt_recursive13(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12) \ - SEP \ - MACRO(13, CONTEXT, _13) - -#define metamacro_foreach_cxt_recursive15(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \ - metamacro_foreach_cxt_recursive14(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13) \ - SEP \ - MACRO(14, CONTEXT, _14) - -#define metamacro_foreach_cxt_recursive16(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \ - metamacro_foreach_cxt_recursive15(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14) \ - SEP \ - MACRO(15, CONTEXT, _15) - -#define metamacro_foreach_cxt_recursive17(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16) \ - metamacro_foreach_cxt_recursive16(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15) \ - SEP \ - MACRO(16, CONTEXT, _16) - -#define metamacro_foreach_cxt_recursive18(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17) \ - metamacro_foreach_cxt_recursive17(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16) \ - SEP \ - MACRO(17, CONTEXT, _17) - -#define metamacro_foreach_cxt_recursive19(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18) \ - metamacro_foreach_cxt_recursive18(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17) \ - SEP \ - MACRO(18, CONTEXT, _18) - -#define metamacro_foreach_cxt_recursive20(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19) \ - metamacro_foreach_cxt_recursive19(MACRO, SEP, CONTEXT, _0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18) \ - SEP \ - MACRO(19, CONTEXT, _19) - -// metamacro_for_cxt expansions -#define metamacro_for_cxt0(MACRO, SEP, CONTEXT) -#define metamacro_for_cxt1(MACRO, SEP, CONTEXT) MACRO(0, CONTEXT) - -#define metamacro_for_cxt2(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt1(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(1, CONTEXT) - -#define metamacro_for_cxt3(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt2(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(2, CONTEXT) - -#define metamacro_for_cxt4(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt3(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(3, CONTEXT) - -#define metamacro_for_cxt5(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt4(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(4, CONTEXT) - -#define metamacro_for_cxt6(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt5(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(5, CONTEXT) - -#define metamacro_for_cxt7(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt6(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(6, CONTEXT) - -#define metamacro_for_cxt8(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt7(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(7, CONTEXT) - -#define metamacro_for_cxt9(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt8(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(8, CONTEXT) - -#define metamacro_for_cxt10(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt9(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(9, CONTEXT) - -#define metamacro_for_cxt11(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt10(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(10, CONTEXT) - -#define metamacro_for_cxt12(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt11(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(11, CONTEXT) - -#define metamacro_for_cxt13(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt12(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(12, CONTEXT) - -#define metamacro_for_cxt14(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt13(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(13, CONTEXT) - -#define metamacro_for_cxt15(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt14(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(14, CONTEXT) - -#define metamacro_for_cxt16(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt15(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(15, CONTEXT) - -#define metamacro_for_cxt17(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt16(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(16, CONTEXT) - -#define metamacro_for_cxt18(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt17(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(17, CONTEXT) - -#define metamacro_for_cxt19(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt18(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(18, CONTEXT) - -#define metamacro_for_cxt20(MACRO, SEP, CONTEXT) \ - metamacro_for_cxt19(MACRO, SEP, CONTEXT) \ - SEP \ - MACRO(19, CONTEXT) - -// metamacro_if_eq expansions -#define metamacro_if_eq0(VALUE) \ - metamacro_concat(metamacro_if_eq0_, VALUE) - -#define metamacro_if_eq0_0(...) __VA_ARGS__ metamacro_consume_ -#define metamacro_if_eq0_1(...) metamacro_expand_ -#define metamacro_if_eq0_2(...) metamacro_expand_ -#define metamacro_if_eq0_3(...) metamacro_expand_ -#define metamacro_if_eq0_4(...) metamacro_expand_ -#define metamacro_if_eq0_5(...) metamacro_expand_ -#define metamacro_if_eq0_6(...) metamacro_expand_ -#define metamacro_if_eq0_7(...) metamacro_expand_ -#define metamacro_if_eq0_8(...) metamacro_expand_ -#define metamacro_if_eq0_9(...) metamacro_expand_ -#define metamacro_if_eq0_10(...) metamacro_expand_ -#define metamacro_if_eq0_11(...) metamacro_expand_ -#define metamacro_if_eq0_12(...) metamacro_expand_ -#define metamacro_if_eq0_13(...) metamacro_expand_ -#define metamacro_if_eq0_14(...) metamacro_expand_ -#define metamacro_if_eq0_15(...) metamacro_expand_ -#define metamacro_if_eq0_16(...) metamacro_expand_ -#define metamacro_if_eq0_17(...) metamacro_expand_ -#define metamacro_if_eq0_18(...) metamacro_expand_ -#define metamacro_if_eq0_19(...) metamacro_expand_ -#define metamacro_if_eq0_20(...) metamacro_expand_ - -#define metamacro_if_eq1(VALUE) metamacro_if_eq0(metamacro_dec(VALUE)) -#define metamacro_if_eq2(VALUE) metamacro_if_eq1(metamacro_dec(VALUE)) -#define metamacro_if_eq3(VALUE) metamacro_if_eq2(metamacro_dec(VALUE)) -#define metamacro_if_eq4(VALUE) metamacro_if_eq3(metamacro_dec(VALUE)) -#define metamacro_if_eq5(VALUE) metamacro_if_eq4(metamacro_dec(VALUE)) -#define metamacro_if_eq6(VALUE) metamacro_if_eq5(metamacro_dec(VALUE)) -#define metamacro_if_eq7(VALUE) metamacro_if_eq6(metamacro_dec(VALUE)) -#define metamacro_if_eq8(VALUE) metamacro_if_eq7(metamacro_dec(VALUE)) -#define metamacro_if_eq9(VALUE) metamacro_if_eq8(metamacro_dec(VALUE)) -#define metamacro_if_eq10(VALUE) metamacro_if_eq9(metamacro_dec(VALUE)) -#define metamacro_if_eq11(VALUE) metamacro_if_eq10(metamacro_dec(VALUE)) -#define metamacro_if_eq12(VALUE) metamacro_if_eq11(metamacro_dec(VALUE)) -#define metamacro_if_eq13(VALUE) metamacro_if_eq12(metamacro_dec(VALUE)) -#define metamacro_if_eq14(VALUE) metamacro_if_eq13(metamacro_dec(VALUE)) -#define metamacro_if_eq15(VALUE) metamacro_if_eq14(metamacro_dec(VALUE)) -#define metamacro_if_eq16(VALUE) metamacro_if_eq15(metamacro_dec(VALUE)) -#define metamacro_if_eq17(VALUE) metamacro_if_eq16(metamacro_dec(VALUE)) -#define metamacro_if_eq18(VALUE) metamacro_if_eq17(metamacro_dec(VALUE)) -#define metamacro_if_eq19(VALUE) metamacro_if_eq18(metamacro_dec(VALUE)) -#define metamacro_if_eq20(VALUE) metamacro_if_eq19(metamacro_dec(VALUE)) - -// metamacro_if_eq_recursive expansions -#define metamacro_if_eq_recursive0(VALUE) \ - metamacro_concat(metamacro_if_eq_recursive0_, VALUE) - -#define metamacro_if_eq_recursive0_0(...) __VA_ARGS__ metamacro_consume_ -#define metamacro_if_eq_recursive0_1(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_2(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_3(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_4(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_5(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_6(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_7(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_8(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_9(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_10(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_11(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_12(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_13(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_14(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_15(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_16(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_17(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_18(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_19(...) metamacro_expand_ -#define metamacro_if_eq_recursive0_20(...) metamacro_expand_ - -#define metamacro_if_eq_recursive1(VALUE) metamacro_if_eq_recursive0(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive2(VALUE) metamacro_if_eq_recursive1(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive3(VALUE) metamacro_if_eq_recursive2(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive4(VALUE) metamacro_if_eq_recursive3(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive5(VALUE) metamacro_if_eq_recursive4(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive6(VALUE) metamacro_if_eq_recursive5(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive7(VALUE) metamacro_if_eq_recursive6(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive8(VALUE) metamacro_if_eq_recursive7(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive9(VALUE) metamacro_if_eq_recursive8(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive10(VALUE) metamacro_if_eq_recursive9(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive11(VALUE) metamacro_if_eq_recursive10(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive12(VALUE) metamacro_if_eq_recursive11(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive13(VALUE) metamacro_if_eq_recursive12(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive14(VALUE) metamacro_if_eq_recursive13(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive15(VALUE) metamacro_if_eq_recursive14(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive16(VALUE) metamacro_if_eq_recursive15(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive17(VALUE) metamacro_if_eq_recursive16(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive18(VALUE) metamacro_if_eq_recursive17(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive19(VALUE) metamacro_if_eq_recursive18(metamacro_dec(VALUE)) -#define metamacro_if_eq_recursive20(VALUE) metamacro_if_eq_recursive19(metamacro_dec(VALUE)) - -// metamacro_take expansions -#define metamacro_take0(...) -#define metamacro_take1(...) metamacro_head(__VA_ARGS__) -#define metamacro_take2(...) metamacro_head(__VA_ARGS__), metamacro_take1(metamacro_tail(__VA_ARGS__)) -#define metamacro_take3(...) metamacro_head(__VA_ARGS__), metamacro_take2(metamacro_tail(__VA_ARGS__)) -#define metamacro_take4(...) metamacro_head(__VA_ARGS__), metamacro_take3(metamacro_tail(__VA_ARGS__)) -#define metamacro_take5(...) metamacro_head(__VA_ARGS__), metamacro_take4(metamacro_tail(__VA_ARGS__)) -#define metamacro_take6(...) metamacro_head(__VA_ARGS__), metamacro_take5(metamacro_tail(__VA_ARGS__)) -#define metamacro_take7(...) metamacro_head(__VA_ARGS__), metamacro_take6(metamacro_tail(__VA_ARGS__)) -#define metamacro_take8(...) metamacro_head(__VA_ARGS__), metamacro_take7(metamacro_tail(__VA_ARGS__)) -#define metamacro_take9(...) metamacro_head(__VA_ARGS__), metamacro_take8(metamacro_tail(__VA_ARGS__)) -#define metamacro_take10(...) metamacro_head(__VA_ARGS__), metamacro_take9(metamacro_tail(__VA_ARGS__)) -#define metamacro_take11(...) metamacro_head(__VA_ARGS__), metamacro_take10(metamacro_tail(__VA_ARGS__)) -#define metamacro_take12(...) metamacro_head(__VA_ARGS__), metamacro_take11(metamacro_tail(__VA_ARGS__)) -#define metamacro_take13(...) metamacro_head(__VA_ARGS__), metamacro_take12(metamacro_tail(__VA_ARGS__)) -#define metamacro_take14(...) metamacro_head(__VA_ARGS__), metamacro_take13(metamacro_tail(__VA_ARGS__)) -#define metamacro_take15(...) metamacro_head(__VA_ARGS__), metamacro_take14(metamacro_tail(__VA_ARGS__)) -#define metamacro_take16(...) metamacro_head(__VA_ARGS__), metamacro_take15(metamacro_tail(__VA_ARGS__)) -#define metamacro_take17(...) metamacro_head(__VA_ARGS__), metamacro_take16(metamacro_tail(__VA_ARGS__)) -#define metamacro_take18(...) metamacro_head(__VA_ARGS__), metamacro_take17(metamacro_tail(__VA_ARGS__)) -#define metamacro_take19(...) metamacro_head(__VA_ARGS__), metamacro_take18(metamacro_tail(__VA_ARGS__)) -#define metamacro_take20(...) metamacro_head(__VA_ARGS__), metamacro_take19(metamacro_tail(__VA_ARGS__)) - -// metamacro_drop expansions -#define metamacro_drop0(...) __VA_ARGS__ -#define metamacro_drop1(...) metamacro_tail(__VA_ARGS__) -#define metamacro_drop2(...) metamacro_drop1(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop3(...) metamacro_drop2(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop4(...) metamacro_drop3(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop5(...) metamacro_drop4(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop6(...) metamacro_drop5(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop7(...) metamacro_drop6(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop8(...) metamacro_drop7(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop9(...) metamacro_drop8(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop10(...) metamacro_drop9(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop11(...) metamacro_drop10(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop12(...) metamacro_drop11(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop13(...) metamacro_drop12(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop14(...) metamacro_drop13(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop15(...) metamacro_drop14(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop16(...) metamacro_drop15(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop17(...) metamacro_drop16(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop18(...) metamacro_drop17(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop19(...) metamacro_drop18(metamacro_tail(__VA_ARGS__)) -#define metamacro_drop20(...) metamacro_drop19(metamacro_tail(__VA_ARGS__)) diff --git a/LookInside/Read/LKReadHierarchyController.h b/LookInside/Read/LKReadHierarchyController.h deleted file mode 100644 index 0f8b2b15..00000000 --- a/LookInside/Read/LKReadHierarchyController.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKReadHierarchyController.h -// Lookin -// -// Created by Li Kai on 2019/5/13. -// https://lookin.work -// - -#import "LKHierarchyController.h" - -@interface LKReadHierarchyController : LKHierarchyController - -@end diff --git a/LookInside/Read/LKReadHierarchyController.m b/LookInside/Read/LKReadHierarchyController.m deleted file mode 100644 index c7ec6def..00000000 --- a/LookInside/Read/LKReadHierarchyController.m +++ /dev/null @@ -1,33 +0,0 @@ -// -// LKReadHierarchyController.m -// Lookin -// -// Created by Li Kai on 2019/5/13. -// https://lookin.work -// - -#import "LKReadHierarchyController.h" -#import "LKReadHierarchyDataSource.h" -#import "LookinDisplayItem.h" - -@interface LKReadHierarchyController () - -@end - -@implementation LKReadHierarchyController - -- (void)hierarchyView:(LKHierarchyView *)view needToCancelPreviewOfItem:(LookinDisplayItem *)item { - item.noPreview = YES; - [((LKReadHierarchyDataSource *)self.dataSource).itemDidChangeNoPreview sendNext:nil]; -} - -- (void)hierarchyView:(LKHierarchyView *)view needToShowPreviewOfItem:(LookinDisplayItem *)item { - [item enumerateSelfAndAncestors:^(LookinDisplayItem *item, BOOL *stop) { - if (item.noPreview) { - item.noPreview = NO; - } - }]; - [((LKReadHierarchyDataSource *)self.dataSource).itemDidChangeNoPreview sendNext:nil]; -} - -@end diff --git a/LookInside/Read/LKReadHierarchyController.swift b/LookInside/Read/LKReadHierarchyController.swift new file mode 100644 index 00000000..debd3d54 --- /dev/null +++ b/LookInside/Read/LKReadHierarchyController.swift @@ -0,0 +1,35 @@ +// +// LKReadHierarchyController.swift +// Lookin +// +// Created by Li Kai on 2019/5/13. +// https://lookin.work +// + +import AppKit + +/// The reader's hierarchy list. Hiding or showing an item's preview only +/// changes the in-memory tree; there is no app to tell. +@objc(LKReadHierarchyController) +final class LKReadHierarchyController: LKHierarchyController { + @objc(hierarchyView:needToCancelPreviewOfItem:) + func hierarchyView(_: LKHierarchyView?, needToCancelPreviewOf item: LookinDisplayItem?) { + item?.noPreview = true + notifyNoPreviewDidChange() + } + + @objc(hierarchyView:needToShowPreviewOfItem:) + func hierarchyView(_: LKHierarchyView?, needToShowPreviewOf item: LookinDisplayItem?) { + // A preview shows only when none of its ancestors hides theirs. + item?.enumerateSelfAndAncestors { item, _ in + if item.noPreview { + item.noPreview = false + } + } + notifyNoPreviewDidChange() + } + + private func notifyNoPreviewDidChange() { + dataSource?.itemDidChangeNoPreview.send() + } +} diff --git a/LookInside/Read/LKReadHierarchyDataSource.h b/LookInside/Read/LKReadHierarchyDataSource.h deleted file mode 100644 index f05b7143..00000000 --- a/LookInside/Read/LKReadHierarchyDataSource.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKReadHierarchyDataSource.h -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKHierarchyDataSource.h" - -@class LookinHierarchyFile, LKPreferenceManager; - -@interface LKReadHierarchyDataSource : LKHierarchyDataSource - -- (instancetype)initWithFile:(LookinHierarchyFile *)file preferenceManager:(LKPreferenceManager *)manager; - -@end diff --git a/LookInside/Read/LKReadHierarchyDataSource.m b/LookInside/Read/LKReadHierarchyDataSource.m deleted file mode 100644 index 40a3dc73..00000000 --- a/LookInside/Read/LKReadHierarchyDataSource.m +++ /dev/null @@ -1,62 +0,0 @@ -// -// LKReadHierarchyDataSource.m -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKReadHierarchyDataSource.h" -#import "LookinHierarchyFile.h" -#import "LookinDisplayItem.h" -#import "LKPreferenceManager.h" -#import "LookinHierarchyInfo.h" -#import "LookinDisplayItem+LookinClient.h" - -@interface LKReadHierarchyDataSource () - -@property(nonatomic, strong) LKPreferenceManager *readPreferenceManager; - -@end - -@implementation LKReadHierarchyDataSource - -- (instancetype)initWithFile:(LookinHierarchyFile *)file preferenceManager:(LKPreferenceManager *)manager { - if (self = [self init]) { - self.readPreferenceManager = manager; - - [self reloadWithHierarchyInfo:file.hierarchyInfo keepState:NO]; - - if (file.soloScreenshots.count || file.groupScreenshots.count) { - BOOL prefersViewOID = [LKHelper appInfoLooksLikeMacTarget:file.hierarchyInfo.appInfo]; - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - __block NSData *soloData = nil; - __block NSData *groupData = nil; - [[obj availableObjectOidsPreferView:prefersViewOID] enumerateObjectsUsingBlock:^(NSNumber *oidNumber, NSUInteger idx, BOOL *stop) { - soloData = file.soloScreenshots[oidNumber]; - groupData = file.groupScreenshots[oidNumber]; - if (soloData || groupData) { - *stop = YES; - } - }]; - - if (soloData) { - NSImage *soloImage = [[NSImage alloc] initWithData:soloData]; - obj.soloScreenshot = soloImage; - } - - if (groupData) { - NSImage *groupImage = [[NSImage alloc] initWithData:groupData]; - obj.groupScreenshot = groupImage; - } - }]; - } - } - return self; -} - -- (LKPreferenceManager *)preferenceManager { - return self.readPreferenceManager; -} - -@end diff --git a/LookInside/Read/LKReadHierarchyDataSource.swift b/LookInside/Read/LKReadHierarchyDataSource.swift new file mode 100644 index 00000000..9e5b6757 --- /dev/null +++ b/LookInside/Read/LKReadHierarchyDataSource.swift @@ -0,0 +1,49 @@ +// +// LKReadHierarchyDataSource.swift +// Lookin +// +// Created by Li Kai on 2019/5/12. +// https://lookin.work +// + +import AppKit + +/// The hierarchy of a `.lookin` file: the tree the file holds, with the +/// stored screenshots put back on the items. It uses the reader window's own +/// preference set. +@objc(LKReadHierarchyDataSource) +final class LKReadHierarchyDataSource: LKHierarchyDataSource { + private let readPreferenceManager: LKPreferenceManager + + @objc(initWithFile:preferenceManager:) + init(file: LookinHierarchyFile, preferenceManager manager: LKPreferenceManager) { + readPreferenceManager = manager + super.init() + + reload(with: file.hierarchyInfo, keepState: false) + + let solo = file.soloScreenshots ?? [:] + let group = file.groupScreenshots ?? [:] + guard !solo.isEmpty || !group.isEmpty else { return } + // A Mac capture keys its screenshots by the view's oid, an iOS one by + // the layer's. + let prefersViewOID = LKHelper.appInfoLooksLikeMacTarget(file.hierarchyInfo?.appInfo) + for item in flatItems ?? [] { + let screenshots = LKReadScreenshotLookup.screenshots( + forOids: item.availableObjectOidsPreferView(prefersViewOID), + solo: solo, + group: group + ) + if let data = screenshots.solo { + item.soloScreenshot = NSImage(data: data) + } + if let data = screenshots.group { + item.groupScreenshot = NSImage(data: data) + } + } + } + + override func preferenceManager() -> LKPreferenceManager! { + readPreferenceManager + } +} diff --git a/LookInside/Read/LKReadScreenshotLookup.swift b/LookInside/Read/LKReadScreenshotLookup.swift new file mode 100644 index 00000000..035a17d2 --- /dev/null +++ b/LookInside/Read/LKReadScreenshotLookup.swift @@ -0,0 +1,31 @@ +// +// LKReadScreenshotLookup.swift +// LookInside +// +// Which of a `.lookin` file's stored screenshots belong to a display item. +// + +import Foundation + +enum LKReadScreenshotLookup { + /// The solo and group screenshot data stored for one item. + /// + /// `oids` are the item's object ids in preference order. The first oid + /// that has either screenshot wins, and both screenshots are taken from + /// that oid only: a solo screenshot of one object is never paired with + /// the group screenshot of another. + static func screenshots( + forOids oids: [NSNumber], + solo: [NSNumber: Data]?, + group: [NSNumber: Data]? + ) -> (solo: Data?, group: Data?) { + for oid in oids { + let soloData = solo?[oid] + let groupData = group?[oid] + if soloData != nil || groupData != nil { + return (soloData, groupData) + } + } + return (nil, nil) + } +} diff --git a/LookInside/Read/LKReadViewController.h b/LookInside/Read/LKReadViewController.h deleted file mode 100644 index beac68fb..00000000 --- a/LookInside/Read/LKReadViewController.h +++ /dev/null @@ -1,22 +0,0 @@ -// -// LKReadViewController.h -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LookinHierarchyFile, LKPreferenceManager, LKReadHierarchyDataSource, LKHierarchyView; - -@interface LKReadViewController : LKBaseViewController - -- (instancetype)initWithFile:(LookinHierarchyFile *)file preferenceManager:(LKPreferenceManager *)manager; - -@property(nonatomic, strong) LKReadHierarchyDataSource *hierarchyDataSource; - -/// 获取当前的 hierarchyView -- (LKHierarchyView *)currentHierarchyView; - -@end diff --git a/LookInside/Read/LKReadViewController.m b/LookInside/Read/LKReadViewController.m deleted file mode 100644 index 6f5320fb..00000000 --- a/LookInside/Read/LKReadViewController.m +++ /dev/null @@ -1,149 +0,0 @@ -// -// LKReadViewController.m -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKReadViewController.h" -#import "LKSplitView.h" -#import "LKDashboardViewController.h" -#import "LKReadHierarchyController.h" -#import "LKReadHierarchyDataSource.h" -#import "LookinHierarchyFile.h" -#import "LKPreviewController.h" -#import "LKTipsView.h" -#import "LKReadWindowController.h" -#import "LookinHierarchyFile.h" -#import "LookinHierarchyInfo.h" -#import "LKPreferenceManager.h" -#import "LKNavigationManager.h" -#import "LKMeasureController.h" - -@interface LKReadViewController () - -@property(nonatomic, strong) LKSplitView *splitView; -@property(nonatomic, strong) NSView *splitLeftView; -@property(nonatomic, strong) NSView *splitRightView; - -@property(nonatomic, strong) LKDashboardViewController *dashboardController; -@property(nonatomic, strong) LKReadHierarchyController *hierarchyController; -@property(nonatomic, strong) LKPreviewController *previewController; -@property(nonatomic, strong) LKMeasureController *measureController; -@property(nonatomic, strong) LKYellowTipsView *focusTipView; - -@end - -@implementation LKReadViewController - -- (instancetype)initWithFile:(LookinHierarchyFile *)file preferenceManager:(LKPreferenceManager *)manager { - if (self = [self initWithContainerView:nil]) { - self.hierarchyDataSource = [[LKReadHierarchyDataSource alloc] initWithFile:file preferenceManager:manager]; - - self.hierarchyController = [[LKReadHierarchyController alloc] initWithDataSource:self.hierarchyDataSource]; - [self addChildViewController:self.hierarchyController]; - self.splitLeftView = self.hierarchyController.view; - [self.splitView addArrangedSubview:self.splitLeftView]; - - self.splitRightView = [LKBaseView new]; - [self.splitView addArrangedSubview:self.splitRightView]; - - self.previewController = [[LKPreviewController alloc] initWithDataSource:self.hierarchyDataSource]; - [self.splitRightView addSubview:self.previewController.view]; - [self addChildViewController:self.previewController]; - - self.dashboardController = [[LKDashboardViewController alloc] initWithReadDataSource:self.hierarchyDataSource]; - [self.splitRightView addSubview:self.dashboardController.view]; - [self addChildViewController:self.dashboardController]; - - self.measureController = [[LKMeasureController alloc] initWithDataSource:self.hierarchyDataSource]; - self.measureController.view.hidden = YES; - [self.splitRightView addSubview:self.measureController.view]; - [self addChildViewController:self.measureController]; - - self.focusTipView = [LKYellowTipsView new]; - self.focusTipView.image = NSImageMake(@"icon_info"); - self.focusTipView.title = NSLocalizedString(@"Currently in focus mode", nil); - self.focusTipView.hidden = YES; - self.focusTipView.buttonText = NSLocalizedString(@"Exit", nil); - self.focusTipView.target = self; - self.focusTipView.clickAction = @selector(_handleExitFocusTipView); - [self.view addSubview:self.focusTipView]; - - [manager.measureState subscribe:self action:@selector(_handleMeasureStateChange:) relatedObject:nil]; - - @weakify(self); - [RACObserve(self.hierarchyDataSource, state) subscribeNext:^(NSNumber * _Nullable x) { - @strongify(self); - LKHierarchyDataSourceState state = x.unsignedIntegerValue; - BOOL isFocus = (state == LKHierarchyDataSourceStateFocus); - self.focusTipView.hidden = !isFocus; - if (isFocus) { - [self.focusTipView startAnimation]; - } else { - [self.focusTipView endAnimation]; - } - [self.view setNeedsLayout:YES]; - }]; - } - return self; -} - -- (NSView *)makeContainerView { - self.splitView = [LKSplitView new]; - self.splitView.didFinishFirstLayout = ^(LKSplitView *view) { - [view setPosition:350 ofDividerAtIndex:0]; - }; - self.splitView.arrangesAllSubviews = NO; - self.splitView.vertical = YES; - self.splitView.dividerStyle = NSSplitViewDividerStyleThin; - self.splitView.delegate = self; - return self.splitView; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - $(self.previewController.view).fullFrame; - $(self.dashboardController.view).width(DashboardViewWidth).right(0).fullHeight; - $(self.measureController.view).width(MeasureViewWidth).right(DashboardHorInset).fullHeight; - - CGFloat windowTitleHeight = [LKNavigationManager sharedInstance].windowTitleBarHeight; - __block CGFloat tipsY = windowTitleHeight + 10; - [$(self.focusTipView).visibles.array enumerateObjectsUsingBlock:^(LKTipsView *tipsView, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat midX = self.hierarchyController.view.$width + (self.previewController.view.$width - DashboardViewWidth) / 2.0; - $(tipsView).sizeToFit.y(tipsY).midX(midX); - tipsY = tipsView.$maxY + 5; - }]; -} - -- (LKHierarchyView *)currentHierarchyView { - return self.hierarchyController.hierarchyView; -} - -- (void)_handleMeasureStateChange:(LookinMsgActionParams *)param { - LookinMeasureState state = param.integerValue; - BOOL isMeasure = (state != LookinMeasureState_no); - self.dashboardController.view.hidden = isMeasure; - self.measureController.view.hidden = !isMeasure; -} - -- (void)_handleExitFocusTipView { - [[self hierarchyDataSource] endFocus]; -} - -#pragma mark - - -- (BOOL)splitView:(NSSplitView *)splitView canCollapseSubview:(NSView *)subview { - return NO; -} - -- (CGFloat)splitView:(NSSplitView *)splitView constrainMinCoordinate:(CGFloat)proposedMinimumPosition ofSubviewAt:(NSInteger)dividerIndex { - return 200; -} - -- (CGFloat)splitView:(NSSplitView *)splitView constrainMaxCoordinate:(CGFloat)proposedMaximumPosition ofSubviewAt:(NSInteger)dividerIndex { - return 700; -} - -@end diff --git a/LookInside/Read/LKReadViewController.swift b/LookInside/Read/LKReadViewController.swift new file mode 100644 index 00000000..edfe14c8 --- /dev/null +++ b/LookInside/Read/LKReadViewController.swift @@ -0,0 +1,139 @@ +// +// LKReadViewController.swift +// Lookin +// +// Created by Li Kai on 2019/5/12. +// https://lookin.work +// + +import AppKit + +/// The reader window's content: the hierarchy list on the left; the +/// preview, the dashboard or the measure panel on the right; and a tip +/// while the tree is in focus mode. +@objc(LKReadViewController) +final class LKReadViewController: LKBaseViewController, NSSplitViewDelegate { + /// Implicitly unwrapped as the Objective-C property was; Swift callers + /// bind it with `guard let`. + @objc var hierarchyDataSource: LKReadHierarchyDataSource! + + private let splitView = LKSplitView() + private let hierarchyController: LKReadHierarchyController + private let splitRightView = LKBaseView() + private let previewController: LKPreviewController + private let dashboardController: LKDashboardViewController + private let measureController: LKMeasureController + private let focusTipView = LKYellowTipsView() + private var stateObservation: NSKeyValueObservation? + + @objc(initWithFile:preferenceManager:) + init(file: LookinHierarchyFile, preferenceManager manager: LKPreferenceManager) { + let dataSource = LKReadHierarchyDataSource(file: file, preferenceManager: manager) + hierarchyDataSource = dataSource + hierarchyController = LKReadHierarchyController(dataSource: dataSource) + previewController = LKPreviewController(dataSource: dataSource) + dashboardController = LKDashboardViewController(readDataSource: dataSource) + measureController = LKMeasureController(dataSource: dataSource) + super.init(containerView: nil) + + addChild(hierarchyController) + splitView.addArrangedSubview(hierarchyController.view) + splitView.addArrangedSubview(splitRightView) + + splitRightView.addSubview(previewController.view) + addChild(previewController) + + splitRightView.addSubview(dashboardController.view) + addChild(dashboardController) + + measureController.view.isHidden = true + splitRightView.addSubview(measureController.view) + addChild(measureController) + + focusTipView.image = NSImage(named: "icon_info") + focusTipView.title = NSLocalizedString("Currently in focus mode", comment: "") + focusTipView.isHidden = true + focusTipView.buttonText = NSLocalizedString("Exit", comment: "") + focusTipView.target = self + focusTipView.clickAction = #selector(handleExitFocusTipView) + view.addSubview(focusTipView) + + manager.measureState.subscribe(self, action: #selector(handleMeasureStateChange(_:)), relatedObject: nil) + + stateObservation = dataSource.observe(\.state, options: [.initial, .new]) { [weak self] dataSource, _ in + MainActor.assumeIsolated { + self?.updateFocusTip(isFocus: dataSource.state == .focus) + } + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func makeContainerView() -> NSView { + splitView.didFinishFirstLayout = { view in + view.setPosition(350, ofDividerAt: 0) + } + splitView.arrangesAllSubviews = false + splitView.isVertical = true + splitView.dividerStyle = .thin + splitView.delegate = self + return splitView + } + + override func viewDidLayout() { + super.viewDidLayout() + LKViewFrameLayout(previewController.view).fullFrame() + LKViewFrameLayout(dashboardController.view).width(DashboardViewWidth).right(0).fullHeight() + LKViewFrameLayout(measureController.view).width(MeasureViewWidth).right(DashboardHorInset).fullHeight() + + var tipsY = LKNavigationManager.shared.windowTitleBarHeight + 10 + let visibleTips = [focusTipView].filter { !$0.isHidden && $0.superview != nil && $0.alphaValue >= 0.01 } + for tipsView in visibleTips { + let midX = hierarchyController.view.bounds.width + (previewController.view.bounds.width - DashboardViewWidth) / 2 + LKViewFrameLayout(tipsView).sizeToFit().y(tipsY).midX(midX) + tipsY = tipsView.frame.maxY + 5 + } + } + + /// The reader's hierarchy list. + @objc func currentHierarchyView() -> LKHierarchyView? { + hierarchyController.hierarchyView + } + + private func updateFocusTip(isFocus: Bool) { + focusTipView.isHidden = !isFocus + if isFocus { + focusTipView.startAnimation() + } else { + focusTipView.endAnimation() + } + view.needsLayout = true + } + + @objc private func handleMeasureStateChange(_ param: LookinMsgActionParams) { + let isMeasure = param.integerValue != LookinMeasureState.no.rawValue + dashboardController.view.isHidden = isMeasure + measureController.view.isHidden = !isMeasure + } + + @objc private func handleExitFocusTipView() { + hierarchyDataSource.endFocus() + } + + // MARK: - NSSplitViewDelegate + + func splitView(_: NSSplitView, canCollapseSubview _: NSView) -> Bool { + false + } + + func splitView(_: NSSplitView, constrainMinCoordinate _: CGFloat, ofSubviewAt _: Int) -> CGFloat { + 200 + } + + func splitView(_: NSSplitView, constrainMaxCoordinate _: CGFloat, ofSubviewAt _: Int) -> CGFloat { + 700 + } +} diff --git a/LookInside/Read/LKReadWindowController.h b/LookInside/Read/LKReadWindowController.h deleted file mode 100644 index f96430fe..00000000 --- a/LookInside/Read/LKReadWindowController.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// LKReadWindowController.h -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKWindowController.h" - -@class LookinHierarchyFile, LKPreferenceManager, LookinArchiveDocument; - -@interface LKReadWindowController : LKWindowController - -/// Reader windows are owned by `LookinArchiveDocument` exclusively -/// (Phase F removed the legacy `-initWithFile:` direct entry point — -/// `LKNavigationManager.showReaderWithHierarchyFile:title:` now wraps -/// in-memory hierarchies in an untitled Archive Doc, and disk-backed -/// archives flow through `NSDocumentController.openDocumentWithContentsOfURL:`). -- (instancetype)initWithDocument:(LookinArchiveDocument *)document; - -/// This window's own preference set: the reader's view settings, including -/// the show-backing-layers toggle, are per document window. A document that -/// can rebuild its tree reads the toggle from here. -@property(nonatomic, strong, readonly) LKPreferenceManager *preferenceManager; - -@end diff --git a/LookInside/Read/LKReadWindowController.m b/LookInside/Read/LKReadWindowController.m deleted file mode 100644 index 2dd8b67a..00000000 --- a/LookInside/Read/LKReadWindowController.m +++ /dev/null @@ -1,251 +0,0 @@ -// -// LKReadWindowController.m -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - -#import "LKReadWindowController.h" -#import "LKReadViewController.h" -#import "LKWindowToolbarHelper.h" -#import "LookinHierarchyFile.h" -#import "LKPreferenceManager.h" -#import "LKReadHierarchyDataSource.h" -#import "LookinHierarchyInfo.h" -#import "LKWindow.h" -#import "LKMenuPopoverSettingController.h" -#import "LKPreviewView.h" -#import "LKHierarchyView.h" -#import "LookinArchiveDocument.h" -#import "LKHelper.h" -#import "LKBaseView.h" -#import "LKLabel.h" - -/// What the window shows while a document is still importing in the -/// background: an indeterminate spinner and one line of text. -@interface LKReadLoadingView : LKBaseView -@end - -@implementation LKReadLoadingView { - NSProgressIndicator *_indicator; - LKLabel *_titleLabel; -} - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _indicator = [NSProgressIndicator new]; - _indicator.indeterminate = YES; - _indicator.style = NSProgressIndicatorStyleSpinning; - _indicator.controlSize = NSControlSizeRegular; - [self addSubview:_indicator]; - [_indicator startAnimation:nil]; - - _titleLabel = [LKLabel new]; - _titleLabel.textColor = [NSColor secondaryLabelColor]; - _titleLabel.font = NSFontMake(14); - _titleLabel.stringValue = NSLocalizedString(@"Loading capture…", nil); - [self addSubview:_titleLabel]; - } - return self; -} - -- (void)layout { - [super layout]; - [_indicator sizeToFit]; - $(_titleLabel).sizeToFit.x(_indicator.$maxX + 8).midY(_indicator.$midY); - $(_indicator, _titleLabel).groupHorAlign.midY(self.$height * .5); -} - -@end - -@interface LKReadWindowController () - -@property(nonatomic, strong) LKReadViewController *viewController; - -@property(nonatomic, strong) NSMutableDictionary *toolbarItemsMap; - -@property(nonatomic, strong, readwrite) LKPreferenceManager *preferenceManager; - -@end - -@implementation LKReadWindowController - -- (instancetype)initWithDocument:(LookinArchiveDocument *)document { - NSSize screenSize = [NSScreen mainScreen].frame.size; - LKWindow *window = [[LKWindow alloc] initWithContentRect:NSMakeRect(0, 0, screenSize.width * .7, screenSize.height * .7) styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskMiniaturizable|NSWindowStyleMaskResizable|NSWindowStyleMaskFullSizeContentView backing:NSBackingStoreBuffered defer:YES]; - window.tabbingMode = NSWindowTabbingModeDisallowed; - if (@available(macOS 11.0, *)) { - window.toolbarStyle = NSWindowToolbarStyleUnified; - } - window.minSize = NSMakeSize(800, 500); - [window center]; - - if (self = [self initWithWindow:window]) { - self.preferenceManager = [LKPreferenceManager new]; - if (!document.hierarchyFile) { - // The document is still importing in the background (an Xcode - // capture): hold the window on a placeholder until the file lands. - window.contentView = [LKReadLoadingView new]; - } - // Every file the document produces gets a reader built in place: the - // one that lands after the import, and each rebuild the backing-layer - // toggle asks for. - @weakify(self); - [[[RACObserve(document, hierarchyFile) ignore:nil] distinctUntilChanged] subscribeNext:^(LookinHierarchyFile *file) { - @strongify(self); - [self _showHierarchyFile:file]; - }]; - // Flipping the toggle changes the node set itself, so — as in a live - // session — the tree is rebuilt whole, by the document, when it can. - [self.preferenceManager.showBackingLayers subscribe:self - action:@selector(_handleShowBackingLayersDidChange:) - relatedObject:nil]; - } - return self; -} - -- (void)_handleShowBackingLayersDidChange:(LookinMsgActionParams *)param { - LookinArchiveDocument *document = self.document; - if (![document canRebuildHierarchyFile]) { - return; - } - // Back to the placeholder; the new file's reader replaces it. Toolbar - // items belong to one toolbar, so they are made afresh for the new one. - self.contentViewController = nil; - _viewController = nil; - self.toolbarItemsMap = nil; - self.window.toolbar = nil; - self.window.contentView = [LKReadLoadingView new]; - [document rebuildHierarchyFileShowingBackingLayers:param.boolValue]; -} - -/// Builds the reader for `file` in this window: the view controller, the -/// measure button's enabled state, and the toolbar, whose items need the -/// data source to exist. -- (void)_showHierarchyFile:(LookinHierarchyFile *)file { - NSWindow *window = self.window; - _viewController = [[LKReadViewController alloc] initWithFile:file preferenceManager:self.preferenceManager]; - // Setting contentViewController sizes the window to the view; keep the - // size the window already has. - self.viewController.view.frame = window.contentView.bounds; - window.contentView = self.viewController.view; - self.contentViewController = self.viewController; - - @weakify(self); - [RACObserve(self.viewController.hierarchyDataSource, selectedItem) subscribeNext:^(id _Nullable x) { - @strongify(self); - NSButton *measureButton = (NSButton *)self.toolbarItemsMap[LKToolBarIdentifier_Measure].view; - BOOL canMeasure = !!x; - measureButton.enabled = canMeasure; - }]; - - NSToolbar *toolbar = [[NSToolbar alloc] init]; - toolbar.displayMode = NSToolbarDisplayModeIconAndLabel; - toolbar.sizeMode = NSToolbarSizeModeRegular; - toolbar.delegate = self; - window.toolbar = toolbar; -} - -#pragma mark - NSToolbarDelegate - -- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar { - return [self toolbarDefaultItemIdentifiers:toolbar]; -} - -- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar { - return @[LKToolBarIdentifier_AppInReadMode, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Dimension, LKToolBarIdentifier_Rotation, LKToolBarIdentifier_Setting, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Scale, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Measure]; -} - -- (nullable NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSToolbarItemIdentifier)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag { - NSToolbarItem *item = self.toolbarItemsMap[itemIdentifier]; - if (!item) { - if (!self.toolbarItemsMap) { - self.toolbarItemsMap = [NSMutableDictionary dictionary]; - } - if ([itemIdentifier isEqualToString:LKToolBarIdentifier_AppInReadMode]) { - item = [[LKWindowToolbarHelper sharedInstance] makeAppInReadModeItemWithAppInfo:self.viewController.hierarchyDataSource.rawHierarchyInfo.appInfo]; - } else { - item = [[LKWindowToolbarHelper sharedInstance] makeToolBarItemWithIdentifier:itemIdentifier preferenceManager:self.preferenceManager]; - } - self.toolbarItemsMap[itemIdentifier] = item; - - if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_Setting]) { - item.label = NSLocalizedString(@"View", nil); - item.target = self; - item.action = @selector(_handleSetting:); - } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_Rotation]) { - item.target = self; - item.action = @selector(_handleFreeRotation); - } - } - return item; -} -#pragma mark - Event Handler - -- (void)_handleSetting:(NSButton *)button { - NSPopover *popover = [[NSPopover alloc] init]; - popover.behavior = NSPopoverBehaviorTransient; - popover.animates = NO; - popover.contentSize = NSMakeSize(IsEnglish ? 270 : 350, 260); - LookinAppInfo *inspectedAppInfo = self.viewController.hierarchyDataSource.rawHierarchyInfo.appInfo; - popover.contentViewController = [[LKMenuPopoverSettingController alloc] initWithPreferenceManager:self.preferenceManager - isMacTarget:[LKHelper appInfoLooksLikeMacTarget:inspectedAppInfo]]; - [popover showRelativeToRect:NSMakeRect(0, 0, button.bounds.size.width, button.bounds.size.height) ofView:button preferredEdge:NSRectEdgeMaxY]; -} - -- (void)_handleFreeRotation { - LKPreferenceManager *manager = self.preferenceManager; - BOOL boolValue = manager.freeRotation.currentBOOLValue; - [manager.freeRotation setBOOLValue:!boolValue ignoreSubscriber:nil]; -} - -#pragma mark - - -- (void)appMenuManagerDidSelectDimension { - if (self.preferenceManager.previewDimension.currentIntegerValue == LookinPreviewDimension2D) { - [self.preferenceManager.previewDimension setIntegerValue:LookinPreviewDimension3D ignoreSubscriber:nil]; - } else { - [self.preferenceManager.previewDimension setIntegerValue:LookinPreviewDimension2D ignoreSubscriber:nil]; - } -} - -- (void)appMenuManagerDidSelectZoomIn { - LKPreferenceManager *manager = self.preferenceManager; - double currentScale = manager.previewScale.currentDoubleValue; - double targetScale = MIN(MAX(currentScale + 0.1, LookinPreviewMinScale), LookinPreviewMaxScale); - [manager.previewScale setDoubleValue:targetScale ignoreSubscriber:nil]; -} - -- (void)appMenuManagerDidSelectZoomOut { - LKPreferenceManager *manager = self.preferenceManager; - double currentScale = manager.previewScale.currentDoubleValue; - double targetScale = MIN(MAX(currentScale - 0.1, LookinPreviewMinScale), LookinPreviewMaxScale); - [manager.previewScale setDoubleValue:targetScale ignoreSubscriber:nil]; -} - -- (void)appMenuManagerDidSelectDecreaseInterspace { - LKPreferenceManager *manager = self.preferenceManager; - double currentValue = manager.zInterspace.currentDoubleValue; - double newValue = currentValue - 0.1; - newValue = MIN(MAX(newValue, LookinPreviewMinZInterspace), LookinPreviewMaxZInterspace); - [manager.zInterspace setDoubleValue:newValue ignoreSubscriber:nil]; -} - -- (void)appMenuManagerDidSelectIncreaseInterspace { - LKPreferenceManager *manager = self.preferenceManager; - double currentValue = manager.zInterspace.currentDoubleValue; - double newValue = currentValue + 0.1; - newValue = MIN(MAX(newValue, LookinPreviewMinZInterspace), LookinPreviewMaxZInterspace); - [manager.zInterspace setDoubleValue:newValue ignoreSubscriber:nil]; -} - -- (void)appMenuManagerDidSelectExpansionIndex:(NSUInteger)index { - [self.viewController.hierarchyDataSource adjustExpansionByIndex:index referenceDict:nil selectedItem:nil]; -} - -- (void)appMenuManagerDidSelectFilter { - [[self.viewController currentHierarchyView] activateSearchBar]; -} - -@end diff --git a/LookInside/Read/LKReadWindowController.swift b/LookInside/Read/LKReadWindowController.swift new file mode 100644 index 00000000..5a3529c7 --- /dev/null +++ b/LookInside/Read/LKReadWindowController.swift @@ -0,0 +1,282 @@ +// +// LKReadWindowController.swift +// Lookin +// +// Created by Li Kai on 2019/5/12. +// https://lookin.work +// + +import AppKit + +/// The window of a `LookinArchiveDocument`. +/// +/// Reader windows are owned by the document exclusively: +/// `LKNavigationManager.showReaderWithHierarchyFile:title:` wraps in-memory +/// hierarchies in an untitled document, and archives on disk open through +/// `NSDocumentController.openDocumentWithContentsOfURL:`. +@objc(LKReadWindowController) +final class LKReadWindowController: LKWindowController, NSToolbarDelegate { + /// This window's own preference set: the reader's view settings, including + /// the show-backing-layers toggle, are per document window. A document that + /// can rebuild its tree reads the toggle from here. + @objc let preferenceManager = LKPreferenceManager() + + private var viewController: LKReadViewController? + private var toolbarItemsMap: [String: NSToolbarItem] = [:] + private var hierarchyFileObservation: NSKeyValueObservation? + private var selectedItemObservation: NSKeyValueObservation? + private weak var shownFile: LookinHierarchyFile? + + @objc(initWithDocument:) + init(document: LookinArchiveDocument) { + let screenSize = NSScreen.main?.frame.size ?? .zero + let window = LKWindow( + contentRect: NSRect(x: 0, y: 0, width: screenSize.width * 0.7, height: screenSize.height * 0.7), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: true + ) + window.tabbingMode = .disallowed + window.toolbarStyle = .unified + window.minSize = NSSize(width: 800, height: 500) + window.center() + + super.init(window: window) + + if document.hierarchyFile == nil { + // The document is still importing in the background (an Xcode + // capture): hold the window on a placeholder until the file lands. + window.contentView = LKReadLoadingView() + } + // Every file the document produces gets a reader built in place: the + // one that lands after the import, and each rebuild the backing-layer + // toggle asks for. + hierarchyFileObservation = document.observe(\.hierarchyFile, options: [.initial, .new]) { [weak self] document, _ in + MainActor.assumeIsolated { + self?.hierarchyFileDidChange(document.hierarchyFile) + } + } + // Flipping the toggle changes the node set itself, so — as in a live + // session — the tree is rebuilt whole, by the document, when it can. + preferenceManager.showBackingLayers.subscribe( + self, + action: #selector(handleShowBackingLayersDidChange(_:)), + relatedObject: nil + ) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + private func hierarchyFileDidChange(_ file: LookinHierarchyFile?) { + // Each new file once; the same file set again is not a new reader. + guard let file, file !== shownFile else { return } + shownFile = file + showHierarchyFile(file) + } + + @objc private func handleShowBackingLayersDidChange(_ param: LookinMsgActionParams) { + guard let document = document as? LookinArchiveDocument, document.canRebuildHierarchyFile() else { + return + } + // Back to the placeholder; the new file's reader replaces it. Toolbar + // items belong to one toolbar, so they are made afresh for the new one. + contentViewController = nil + viewController = nil + selectedItemObservation = nil + toolbarItemsMap = [:] + window?.toolbar = nil + window?.contentView = LKReadLoadingView() + document.rebuildHierarchyFile(showingBackingLayers: param.boolValue) + } + + /// Builds the reader for `file` in this window: the view controller, the + /// measure button's enabled state, and the toolbar, whose items need the + /// data source to exist. + private func showHierarchyFile(_ file: LookinHierarchyFile) { + guard let window else { return } + let viewController = LKReadViewController(file: file, preferenceManager: preferenceManager) + self.viewController = viewController + // Setting contentViewController sizes the window to the view; keep the + // size the window already has. + viewController.view.frame = window.contentView?.bounds ?? .zero + window.contentView = viewController.view + contentViewController = viewController + + selectedItemObservation = viewController.hierarchyDataSource.observe(\.selectedItem, options: [.initial, .new]) { [weak self] dataSource, _ in + MainActor.assumeIsolated { + let measureButton = self?.toolbarItemsMap[LKToolBarIdentifier_Measure]?.view as? NSButton + measureButton?.isEnabled = dataSource.selectedItem != nil + } + } + + let toolbar = NSToolbar() + toolbar.displayMode = .iconAndLabel + toolbar.sizeMode = .regular + toolbar.delegate = self + window.toolbar = toolbar + } + + // MARK: - NSToolbarDelegate + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + } + + func toolbarDefaultItemIdentifiers(_: NSToolbar) -> [NSToolbarItem.Identifier] { + [ + LKToolBarIdentifier_AppInReadMode, + NSToolbarItem.Identifier.flexibleSpace.rawValue, + LKToolBarIdentifier_Dimension, + LKToolBarIdentifier_Rotation, + LKToolBarIdentifier_Setting, + NSToolbarItem.Identifier.flexibleSpace.rawValue, + LKToolBarIdentifier_Scale, + NSToolbarItem.Identifier.flexibleSpace.rawValue, + LKToolBarIdentifier_Measure, + ].map { NSToolbarItem.Identifier($0) } + } + + func toolbar( + _: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar _: Bool + ) -> NSToolbarItem? { + let identifier = itemIdentifier.rawValue + if let item = toolbarItemsMap[identifier] { + return item + } + let helper = LKWindowToolbarHelper.shared + let item: NSToolbarItem? + if identifier == LKToolBarIdentifier_AppInReadMode { + item = helper.makeAppInReadModeItem(with: viewController?.hierarchyDataSource.rawHierarchyInfo?.appInfo) + } else { + item = helper.makeToolBarItem(identifier: identifier, preferenceManager: preferenceManager) + } + guard let item else { return nil } + toolbarItemsMap[identifier] = item + + if identifier == LKToolBarIdentifier_Setting { + item.label = NSLocalizedString("View", comment: "") + item.target = self + item.action = #selector(handleSetting(_:)) + } else if identifier == LKToolBarIdentifier_Rotation { + item.target = self + item.action = #selector(handleFreeRotation) + } + return item + } + + // MARK: - Events + + @objc private func handleSetting(_ sender: Any?) { + // The toolbar sends its item's view as the sender. + guard let button = sender as? NSView else { return } + let popover = NSPopover() + popover.behavior = .transient + popover.animates = false + popover.contentSize = NSSize(width: LKHelper.isEnglish() ? 270 : 350, height: 260) + let inspectedAppInfo = viewController?.hierarchyDataSource.rawHierarchyInfo?.appInfo + popover.contentViewController = LKMenuPopoverSettingController( + preferenceManager: preferenceManager, + isMacTarget: LKHelper.appInfoLooksLikeMacTarget(inspectedAppInfo) + ) + popover.show(relativeTo: NSRect(origin: .zero, size: button.bounds.size), of: button, preferredEdge: .maxY) + } + + @objc private func handleFreeRotation() { + let freeRotation: LookinBOOLMsgAttribute = preferenceManager.freeRotation + freeRotation.setBOOLValue(!freeRotation.currentBOOLValue, ignoreSubscriber: nil) + } + + // MARK: - LKAppMenuManagerDelegate + + @objc func appMenuManagerDidSelectDimension() { + let dimension: LookinIntegerMsgAttribute = preferenceManager.previewDimension + let target: LookinPreviewDimension = dimension.currentIntegerValue == LookinPreviewDimension.dimension2D.rawValue ? .dimension3D : .dimension2D + dimension.setIntegerValue(Int(target.rawValue), ignoreSubscriber: nil) + } + + @objc func appMenuManagerDidSelectZoomIn() { + stepScale(by: LKToolbarRules.step) + } + + @objc func appMenuManagerDidSelectZoomOut() { + stepScale(by: -LKToolbarRules.step) + } + + @objc func appMenuManagerDidSelectDecreaseInterspace() { + stepInterspace(by: -LKToolbarRules.step) + } + + @objc func appMenuManagerDidSelectIncreaseInterspace() { + stepInterspace(by: LKToolbarRules.step) + } + + @objc func appMenuManagerDidSelectExpansionIndex(_ index: UInt) { + viewController?.hierarchyDataSource.adjustExpansion(by: Int(index), referenceDict: nil, selectedItem: nil) + } + + @objc func appMenuManagerDidSelectFilter() { + viewController?.currentHierarchyView()?.activateSearchBar() + } + + private func stepScale(by delta: Double) { + let scale: LookinDoubleMsgAttribute = preferenceManager.previewScale + let value = LKToolbarRules.stepped( + scale.currentDoubleValue, + by: delta, + lower: Double(LookinPreviewMinScale), + upper: Double(LookinPreviewMaxScale) + ) + scale.setDoubleValue(value, ignoreSubscriber: nil) + } + + private func stepInterspace(by delta: Double) { + let interspace: LookinDoubleMsgAttribute = preferenceManager.zInterspace + let value = LKToolbarRules.stepped( + interspace.currentDoubleValue, + by: delta, + lower: Double(LookinPreviewMinZInterspace), + upper: Double(LookinPreviewMaxZInterspace) + ) + interspace.setDoubleValue(value, ignoreSubscriber: nil) + } +} + +/// What the window shows while a document is still importing in the +/// background: an indeterminate spinner and one line of text. +final class LKReadLoadingView: LKBaseView { + private let indicator = NSProgressIndicator() + private let titleLabel = LKLabel() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + indicator.isIndeterminate = true + indicator.style = .spinning + indicator.controlSize = .regular + addSubview(indicator) + indicator.startAnimation(nil) + + titleLabel.textColor = .secondaryLabelColor + titleLabel.font = NSFont.systemFont(ofSize: 14) + titleLabel.stringValue = NSLocalizedString("Loading capture…", comment: "") + addSubview(titleLabel) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + indicator.sizeToFit() + LKViewFrameLayout(titleLabel).sizeToFit().x(indicator.frame.maxX + 8).midY(indicator.frame.midY) + let views: [NSView] = [indicator, titleLabel] + LKViewFrameLayout.groupHorAlign(views) + LKViewFrameLayout.groupMidY(views, bounds.height * 0.5) + } +} diff --git a/LookInside/Read/LookinArchiveCoding.swift b/LookInside/Read/LookinArchiveCoding.swift new file mode 100644 index 00000000..38ecca84 --- /dev/null +++ b/LookInside/Read/LookinArchiveCoding.swift @@ -0,0 +1,57 @@ +// +// LookinArchiveCoding.swift +// LookInside +// +// Turns a `LookinHierarchyFile` into the bytes of a `.lookin` file and back. +// The format is the keyed archive Lookin has always written: the class +// names and coding keys belong to the LookinCore model and must not change, +// so files written by older hosts open here and files written here open in +// older hosts. +// + +import Foundation + +enum LookinArchiveCoding { + /// The document type `LookinArchiveDocument` reads and writes. + static let typeIdentifier = "com.lookin.lookin" + + /// Archives `file` with secure coding, as the Objective-C reader did. + static func data(of file: LookinHierarchyFile) throws -> Data { + try NSKeyedArchiver.archivedData(withRootObject: file, requiringSecureCoding: true) + } + + /// Unarchives and verifies a `.lookin` file. + /// + /// Older archives are not secure-coding clean, so the unarchiver does + /// not require it; that also keeps the "allowed classes" warnings out of + /// the log. A root object of another class fails the same way as an + /// unreadable archive. A file outside the supported server versions + /// fails with the version error from `+verifyHierarchyFile:`. + static func hierarchyFile(from data: Data) throws -> LookinHierarchyFile { + let unarchiver: NSKeyedUnarchiver + do { + unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + } catch { + throw innerError + } + unarchiver.requiresSecureCoding = false + let root = unarchiver.decodeObject(forKey: NSKeyedArchiveRootObjectKey) + unarchiver.finishDecoding() + guard let file = root as? LookinHierarchyFile else { + throw innerError + } + if let verifyError = LookinHierarchyFile.verifyHierarchyFile(file) { + throw verifyError + } + return file + } + + /// `LookinErr_Inner`. + static var innerError: NSError { + NSError( + domain: LookinErrorDomain, + code: LookinErrCode_Inner, + userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("The operation failed due to an inner error.", comment: "")] + ) + } +} diff --git a/LookInside/Read/LookinArchiveDocument.h b/LookInside/Read/LookinArchiveDocument.h deleted file mode 100644 index 930597b3..00000000 --- a/LookInside/Read/LookinArchiveDocument.h +++ /dev/null @@ -1,30 +0,0 @@ -// -// LookinArchiveDocument.h -// LookInside -// -// Phase C of multi-document support: NSDocument that wraps a `.lookin` -// hierarchy archive on disk. Read-only by user intent; opening through -// NSDocumentController integrates with Recent Documents, Open Recent, -// proxy icon dragging, and Save As / Move to / Versions. -// - -#import - -@class LookinHierarchyFile; - -@interface LookinArchiveDocument : NSDocument - -@property(nonatomic, strong) LookinHierarchyFile *hierarchyFile; - -/// Whether the document can rebuild `hierarchyFile` for a different -/// show-backing-layers setting. A `.lookin` archive cannot: its tree is what -/// the server sent. An imported Xcode capture can, from the decoded capture -/// it keeps after import. -- (BOOL)canRebuildHierarchyFile; - -/// Rebuilds `hierarchyFile` for the setting and assigns the new file when it -/// is ready; the window observes the property and rebuilds its reader. A -/// no-op unless canRebuildHierarchyFile. -- (void)rebuildHierarchyFileShowingBackingLayers:(BOOL)showBackingLayers NS_SWIFT_NAME(rebuildHierarchyFile(showingBackingLayers:)); - -@end diff --git a/LookInside/Read/LookinArchiveDocument.m b/LookInside/Read/LookinArchiveDocument.m deleted file mode 100644 index 30fddeaa..00000000 --- a/LookInside/Read/LookinArchiveDocument.m +++ /dev/null @@ -1,89 +0,0 @@ -// -// LookinArchiveDocument.m -// LookInside -// - -#import "LookinArchiveDocument.h" -#import "LookinHierarchyFile.h" -#import "LKReadWindowController.h" -#import "LookinDefines.h" - -@implementation LookinArchiveDocument - -+ (BOOL)autosavesInPlace { - return NO; -} - -+ (BOOL)preservesVersions { - return NO; -} - -+ (BOOL)usesUbiquitousStorage { - return NO; -} - -- (NSArray *)writableTypesForSaveOperation:(NSSaveOperationType)saveOperation { - // Phase E: archives are read-only snapshots; suppress Save / Save As / Duplicate. - return @[]; -} - -- (void)makeWindowControllers { - LKReadWindowController *wc = [[LKReadWindowController alloc] initWithDocument:self]; - [self addWindowController:wc]; -} - -- (BOOL)canRebuildHierarchyFile { - return NO; -} - -- (void)rebuildHierarchyFileShowingBackingLayers:(BOOL)showBackingLayers { - // An archive holds the tree the server sent; there is nothing to rebuild from. -} - -- (NSData *)dataOfType:(NSString *)typeName error:(NSError **)outError { - if (!self.hierarchyFile) { - NSAssert(NO, @""); - if (outError) { - *outError = LookinErr_Inner; - } - return nil; - } - - if ([typeName isEqualToString:@"com.lookin.lookin"]) { - NSData *data = [NSKeyedArchiver archivedDataWithRootObject:self.hierarchyFile requiringSecureCoding:YES error:outError]; - return data; - } - - if (outError) { - *outError = LookinErr_Inner; - } - return nil; -} - -- (BOOL)readFromData:(NSData *)data ofType:(NSString *)typeName error:(NSError **)outError { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - // .lookin archives are non-secure NSKeyedArchiver payloads holding LookinHierarchyFile graphs. - // Stay on the legacy API to avoid the NSSecureCoding "[NSObject class] allowed list" runtime spam. - LookinHierarchyFile *hierarchyFile = [NSKeyedUnarchiver unarchiveObjectWithData:data]; -#pragma clang diagnostic pop - if (!hierarchyFile) { - if (outError) { - *outError = LookinErr_Inner; - } - return NO; - } - - NSError *verifyError = [LookinHierarchyFile verifyHierarchyFile:hierarchyFile]; - if (verifyError) { - if (outError) { - *outError = verifyError; - } - return NO; - } - - self.hierarchyFile = hierarchyFile; - return YES; -} - -@end diff --git a/LookInside/Read/LookinArchiveDocument.swift b/LookInside/Read/LookinArchiveDocument.swift new file mode 100644 index 00000000..64a285c9 --- /dev/null +++ b/LookInside/Read/LookinArchiveDocument.swift @@ -0,0 +1,71 @@ +// +// LookinArchiveDocument.swift +// LookInside +// +// The NSDocument for a `.lookin` hierarchy archive. Archives are read-only +// snapshots; opening them through NSDocumentController gives Recent +// Documents, Open Recent and proxy-icon dragging. Info.plist names this +// class for the `com.lookin.lookin` type, so its Objective-C name must not +// change. +// + +import AppKit + +@objc(LookinArchiveDocument) +class LookinArchiveDocument: NSDocument { + /// The archive's hierarchy. The window controller observes it (KVO) and + /// builds a reader for every file assigned. + @objc dynamic var hierarchyFile: LookinHierarchyFile? + + override class var autosavesInPlace: Bool { + false + } + + override class var preservesVersions: Bool { + false + } + + override class var usesUbiquitousStorage: Bool { + false + } + + override func writableTypes(for _: NSDocument.SaveOperationType) -> [String] { + // Archives are read-only snapshots: no Save, Save As or Duplicate. + [] + } + + override func makeWindowControllers() { + addWindowController(LKReadWindowController(document: self)) + } + + /// Whether the document can rebuild `hierarchyFile` for a different + /// show-backing-layers setting. A `.lookin` archive cannot: its tree is what + /// the server sent. An imported Xcode capture can, from the decoded capture + /// it keeps after import. + @objc func canRebuildHierarchyFile() -> Bool { + false + } + + /// Rebuilds `hierarchyFile` for the setting and assigns the new file when it + /// is ready; the window observes the property and rebuilds its reader. A + /// no-op unless canRebuildHierarchyFile. + @objc(rebuildHierarchyFileShowingBackingLayers:) + func rebuildHierarchyFile(showingBackingLayers _: Bool) { + // An archive holds the tree the server sent; there is nothing to rebuild from. + } + + override func data(ofType typeName: String) throws -> Data { + guard let hierarchyFile else { + assertionFailure("No hierarchy to save") + throw LookinArchiveCoding.innerError + } + guard typeName == LookinArchiveCoding.typeIdentifier else { + throw LookinArchiveCoding.innerError + } + return try LookinArchiveCoding.data(of: hierarchyFile) + } + + override func read(from data: Data, ofType _: String) throws { + hierarchyFile = try LookinArchiveCoding.hierarchyFile(from: data) + } +} diff --git a/LookInside/ShortCocoa/ShortCocoa+Layout.h b/LookInside/ShortCocoa/ShortCocoa+Layout.h deleted file mode 100644 index 4a219275..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+Layout.h +++ /dev/null @@ -1,306 +0,0 @@ -// -// ShortCocoa+Layout.h -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoaCore.h" - -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_MAC -#import -#endif - -NS_ASSUME_NONNULL_BEGIN - -/** - 如果注释中不做特殊说明,则此文件里这些方法均同时支持 UIView/NSView 和 CALayer - - @warning 使用这一系列布局方法时会自动像素对齐来获得更好的渲染效果,比如在一个屏幕 scale 为 2 的设备上,你写下 $(view).x(0.3),则实际上 view.frame.origin 会被设置为 0.5,详情参看 ShortCocoa+Layout.m 里的 CGFloatSnapToPixel 的注释 - */ -@interface ShortCocoa (Layout) - -#pragma mark - 基础方法 - -/// 等价于调用系统控件的 sizeToFit 方法,支持的被包装对象:UIView(iOS)、NSControl(macOS), -- (ShortCocoa *)sizeToFit; - -/// 修改 frame.size.width -- (ShortCocoa * (^)(CGFloat))width; -/// 修改 frame.size.height -- (ShortCocoa * (^)(CGFloat))height; -/// 修改 frame.size -- (ShortCocoa * (^)(CGSize))size; -/// 修改 frame -- (ShortCocoa * (^)(CGRect))frame; -/// 修改 frame.origin.x,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))x; -/// 修改 frame 的 midX 位置,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))midX; -/// 修改 frame 的 maxX 位置,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))maxX; -/// 修改 frame.origin.y,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))y; -/// 修改 frame 的 midY 位置,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))midY; -/// 修改 frame 的 maxY 位置,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))maxY; -/// 修改 frame.origin,size 不会被改变 -- (ShortCocoa * (^)(CGPoint))origin; -/// 修改 frame 的 origin,使得右侧和 superview 的右侧的距离为传入值,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))right; -///修改 frame 的 origin,使得底部和 superview 的底部的距离为传入值,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))bottom; -/// 修改 frame 的 origin,使得在 superview 里水平居中,size 不会被改变 -- (ShortCocoa *)horAlign; -/// 修改 frame 的 origin,使得在 superview 里垂直居中,size 不会被改变 -- (ShortCocoa *)verAlign; -/// 修改 frame 的 origin,使得在 superview 里同时保持垂直、水平居中,size 不会被改变 -- (ShortCocoa *)centerAlign; -/// 修改 frame 使得在水平方向上撑满父元素,即把 x 设置为 0,把 width 设置为 superview/superlayer 的 width -- (ShortCocoa *)fullWidth; -/// 修改 frame 使得在竖直方向上撑满父元素,即把 y 设置为 0,把 height 设置为 superview/superlayer 的 height -- (ShortCocoa *)fullHeight; -/// 修改 frame 为 superview.bounds 或 superlayer.bounds -- (ShortCocoa *)fullFrame; - -/// 偏移 frame 的 origin,size 不会被改变 -- (ShortCocoa * (^)(CGFloat x, CGFloat y))offset; -/// 偏移 frame 的 origin.x,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))offsetX; -/// 偏移 frame 的 origin.y,size 不会被改变 -- (ShortCocoa * (^)(CGFloat))offsetY; - -#pragma mark - to 系列 - -/// 调整左侧边的位置,其它三条边位置不动 -- (ShortCocoa * (^)(CGFloat))toX; -/// 调整右侧边的位置,其它三条边位置不动,origin 不会被改变 -- (ShortCocoa * (^)(CGFloat))toMaxX; -/// 调整顶部边的位置,其它三条边位置不动 -- (ShortCocoa * (^)(CGFloat))toY; -/// 调整底部边的位置,其它三条边位置不动,origin 不会被改变 -- (ShortCocoa * (^)(CGFloat))toMaxY; -/// 保持其它三条边位置不动的情况下,仅调整右侧边的位置,使得右侧距离 superview 右侧的距离为传入值,origin 不会被改变 -- (ShortCocoa * (^)(CGFloat))toRight; -/// 保持其它三条边位置不动的情况下,仅调整底部边的位置,使得底部距离 superview 底部的距离为传入值,origin 不会被改变 -- (ShortCocoa * (^)(CGFloat))toBottom; - - -#pragma mark - Group Set 系列 - -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整 frame.origin.x - @code - // 1)布局靠左的 label 会被平移至 x 为 50 的位置 - // 2)另一个 label 会被平移相同的距离 - // 3)两个 label 之间的距离不会被改变,它们的 size 也不会被改变 - $(label1, label2).groupX(50); - @endcode - */ -- (ShortCocoa * (^)(CGFloat))groupX; -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整 frame 的 midX - @code - // 1)两个 label 会被平移至它们整体的 midX 为 50 的位置 - // 2)两个 label 之间的距离不会改变,它们的 size 也不会被改变 - $(label1, label2).groupMidX(50); - @endcode - */ -- (ShortCocoa * (^)(CGFloat))groupMidX; -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整 frame 的 maxX - @code - // 1)布局靠右的 label 会被平移至 maxX 为 50 的位置 - // 2)另一个 label 会被平移相同的距离 - // 3)两个 label 之间的距离不会被改变,它们的 size 也不会被改变 - $(label1, label2).groupMaxX(50); - @endcode - */ -- (ShortCocoa * (^)(CGFloat))groupMaxX; -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整 frame.origin.y - @code - // 1)布局靠上的 label 会被平移至 y 为 50 的位置 - // 2)另一个 label 会被平移相同的距离 - // 3)两个 label 之间的距离不会被改变,它们的 size 也不会被改变 - $(label1, label2).groupY(50); - @endcode - */ -- (ShortCocoa * (^)(CGFloat))groupY; -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整 frame 的 midY - @code - // 1)两个 label 会被平移至它们整体的 midY 为 50 的位置 - // 2)两个 label 之间的距离不会改变,它们的 size 也不会被改变 - $(label1, label2).groupMidY(50); - @endcode - */ -- (ShortCocoa * (^)(CGFloat))groupMidY; -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整 frame 的 maxX - @code - // 1)布局靠下的 label 会被平移至 maxY 为 50 的位置 - // 2)另一个 label 会被平移相同的距离 - // 3)两个 label 之间的距离不会被改变,它们的 size 也不会被改变 - $(label1, label2).groupMaxY(50); - @endcode - */ -- (ShortCocoa * (^)(CGFloat))groupMaxY; -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整 frame.origin - @code - // 1)两个 label 会被平移至它们整体的左上角 origin 为传入值的位置 - // 2)两个 label 之间的距离不会被改变,它们的 size 也不会被改变 - $(label1, label2).groupOrigin(point); - @endcode - */ -- (ShortCocoa * (^)(CGPoint))groupOrigin; -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整它们右侧距离 superview 右侧的值 - @code - // 1)布局靠右的 label 会被平移至它的右侧和 superview 右侧之间距离为 50 的位置 - // 2)另一个 label 会被平移相同的距离 - // 3)两个 label 之间的距离不会被改变,它们的 size 也不会被改变 - $(label1, label2).groupRight(50); - @endcode - */ -- (ShortCocoa * (^)(CGFloat))groupRight; -/** - 包装了多个对象时,被包装的 view/layer 会被看作一个整体来调整它们底部距离 superview 底部的值 - @code - // 1)布局靠下的 label 会被平移至它的底部和 superview 底部之间距离为 50 的位置 - // 2)另一个 label 会被平移相同的距离 - // 3)两个 label 之间的距离不会被改变,它们的 size 也不会被改变 - $(label1, label2).groupBottom(50); - @endcode - */ -- (ShortCocoa * (^)(CGFloat))groupBottom; -/** - 包装了多个对象时,被包装的 view/layer 会整体水平居中 - @code - // 1)两个 label 会被平移至它们整体的 midX 为 superview 的水平中点位置 - // 2)两个 label 之间的距离不会改变,它们的 size 也不会被改变 - $(label1, label2).groupHorAlign; - @endcode - */ -- (ShortCocoa *)groupHorAlign; -/** - 包装了多个对象时,被包装的 view/layer 会整体垂直居中 - @code - // 1)两个 label 会被平移至它们整体的 midY 为 superview 的垂直中点位置 - // 2)两个 label 之间的距离不会改变,它们的 size 也不会被改变 - $(label1, label2).groupVerAlign; - @endcode - */ -- (ShortCocoa *)groupVerAlign; -/** - 包装了多个对象时,被包装的 view/layer 会整体垂直、水平居中 - @code - // 1)两个 label 会被平移至它们整体的 midX、midY 为 superview 的中点位置 - // 2)两个 label 之间的距离不会改变,它们的 size 也不会被改变 - $(label1, label2).groupCenterAlign(); - @endcode - */ -- (ShortCocoa *)groupCenterAlign; - -#pragma mark - Group Get 系列 - -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 frame 的 minX 值(即被包装的所有对象的 frame 的 x 的最小值) -- (CGFloat)$groupX; -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 midX 值 -- (CGFloat)$groupMidX; -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 frame 的 maxX 值(即被包装的所有对象的 frame 的 maxX 的最大值) -- (CGFloat)$groupMaxX; -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 frame 的 minY 值(即被包装的所有对象的 frame 的 y 的最小值) -- (CGFloat)$groupY; -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 midY 值 -- (CGFloat)$groupMidY; -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 frame 的 maxY 值(即被包装的所有对象的 frame 的 maxY 的最大值) -- (CGFloat)$groupMaxY; -/// 返回一个 CGPoint,值为 {$groupX, $groupY} -- (CGPoint)$groupOrigin; -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 size 值 -- (CGSize)$groupSize; -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 width 值 -- (CGFloat)$groupWidth; -/// 被包装的所有 view/layer 会被看作一个整体,该方法返回这个整体的 height 值 -- (CGFloat)$groupHeight; - -#pragma mark - sizeThatFits 系列 - -/** - 将高度设置为当前自身宽度下 sizeThatFits: 返回的高度值,即 [view sizeThatFits:CGSizeMake(CGRectGetWidth(view.bounds), CGFLOAT_MAX)].height - @note 支持的被包装对象:UIView(iOS)、NSControl(macOS) - */ -- (ShortCocoa *)heightToFit; -/** - 将高度设置为当前自身宽度下 sizeThatFits: 返回的高度值,即 [view sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGRectGetHeight(view.bounds))].width - @note 支持的被包装对象:UIView(iOS)、NSControl(macOS) - */ -- (ShortCocoa *)widthToFit; - -@end - -#if TARGET_OS_IPHONE -@interface UIView (ShortCocoaLayout) -#elif TARGET_OS_MAC -@interface NSView (ShortCocoaLayout) -#endif - -/// 等价于 view.frame.origin.x -- (CGFloat)$x; -/// 等价于 CGRectGetMidX(view.frame) -- (CGFloat)$midX; -/// 等价于 CGRectGetMaxX(view.frame) -- (CGFloat)$maxX; -/// 等价于 view.origin.y -- (CGFloat)$y; -/// 等价于 CGRectGetMidY(view.frame) -- (CGFloat)$midY; -/// 等价于 CGRectGetMaxY(view.frame) -- (CGFloat)$maxY; -/// 等价于 view.frame.size.width -- (CGFloat)$width; -/// 等价于 view.frame.size.height -- (CGFloat)$height; -/// 等价于 view.frame.size -- (CGSize)$size; - -#if TARGET_OS_IPHONE -/// 等价于 [view sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)] -- (CGSize)$bestSize; -/// 等价于 [view sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)].width -- (CGFloat)$bestWidth; -/// 等价于 [view sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)].height -- (CGFloat)$bestHeight; -#endif - -@end - -@interface CALayer (ShortCocoaLayout) - -/// 等价于 layer.frame.origin.x -- (CGFloat)$x; -/// 等价于 CGRectGetMidX(layer.frame) -- (CGFloat)$midX; -/// 等价于 CGRectGetMaxX(view.frame) -- (CGFloat)$maxX; -/// 等价于 view.origin.y -- (CGFloat)$y; -/// 等价于 CGRectGetMidY(view.frame) -- (CGFloat)$midY; -/// 等价于 CGRectGetMaxY(view.frame) -- (CGFloat)$maxY; -/// 等价于 view.frame.size.width -- (CGFloat)$width; -/// 等价于 view.frame.size.height -- (CGFloat)$height; -/// 等价于 view.frame.size -- (CGSize)$size; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ShortCocoa/ShortCocoa+Layout.m b/LookInside/ShortCocoa/ShortCocoa+Layout.m deleted file mode 100644 index 4c520314..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+Layout.m +++ /dev/null @@ -1,1159 +0,0 @@ -// -// ShortCocoa+Layout.m -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoa+Layout.h" -#import "ShortCocoa+Private.h" - -#if TARGET_OS_IPHONE - #define NS_UI_View UIView -#elif TARGET_OS_MAC - #define NS_UI_View NSView -#endif - -@implementation ShortCocoa (Layout) - -#pragma mark - 基础方法 - -- (ShortCocoa *)sizeToFit { -#if TARGET_OS_IPHONE - [self unpack:[UIView class] do:^(UIView *view, BOOL *stop) { - [view sizeToFit]; - }]; -#elif TARGET_OS_MAC - [self unpack:[NSControl class] do:^(NSControl *control, BOOL *stop) { - [control sizeToFit]; - }]; -#endif - return self; -} - -- (ShortCocoa * (^)(CGFloat))width { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGRect rect = view.frame; - rect.size.width = CGFloatSnapToPixel(value); - view.frame = rect; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGRect rect = layer.frame; - rect.size.width = CGFloatSnapToPixel(value); - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))height { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGRect rect = view.frame; - rect.size.height = CGFloatSnapToPixel(value); - view.frame = rect; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGRect rect = layer.frame; - rect.size.height = CGFloatSnapToPixel(value); - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGSize))size { - return ^(CGSize value) { - return self.width(value.width).height(value.height); - }; -} - -- (ShortCocoa * (^)(CGRect))frame { - return ^(CGRect value) { - return self.origin(value.origin).size(value.size); - }; -} - -- (ShortCocoa * (^)(CGFloat))x { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGRect rect = view.frame; - rect.origin.x = CGFloatSnapToPixel(value); - view.frame = rect; - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGRect rect = layer.frame; - rect.origin.x = CGFloatSnapToPixel(value); - layer.frame = rect; - }]; - - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))y { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGRect rect = view.frame; - rect.origin.y = CGFloatSnapToPixel(value); - view.frame = rect; - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGRect rect = layer.frame; - rect.origin.y = CGFloatSnapToPixel(value); - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGPoint))origin { - return ^(CGPoint value) { - return self.x(value.x).y(value.y); - }; -} - -- (ShortCocoa * (^)(CGFloat, CGFloat))offset { - return ^(CGFloat x, CGFloat y) { - if (isnan(x)) { - NSAssert(NO, @"传入了 NaN"); - x = 0; - } - if (isnan(y)) { - NSAssert(NO, @"传入了 NaN"); - y = 0; - } - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGRect rect = view.frame; - rect.origin.x = CGFloatSnapToPixel(CGRectGetMinX(view.frame) + x); - rect.origin.y = CGFloatSnapToPixel(CGRectGetMinY(view.frame) + y); - view.frame = rect; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGRect rect = layer.frame; - rect.origin.x = CGFloatSnapToPixel(CGRectGetMinX(layer.frame) + x); - rect.origin.y = CGFloatSnapToPixel(CGRectGetMinY(layer.frame) + y); - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))midX { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGFloat width = CGRectGetWidth(view.bounds); - ShortCocoaMake(view).x(value - width / 2); - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGFloat width = CGRectGetWidth(layer.bounds); - ShortCocoaMake(layer).x(value - width / 2); - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))maxX { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGFloat width = CGRectGetWidth(view.bounds); - ShortCocoaMake(view).x(value - width); - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGFloat width = CGRectGetWidth(layer.bounds); - ShortCocoaMake(layer).x(value - width); - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))midY { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGFloat height = CGRectGetHeight(view.bounds); - ShortCocoaMake(view).y(value - height / 2); - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGFloat height = CGRectGetHeight(layer.bounds); - ShortCocoaMake(layer).y(value - height / 2); - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))maxY { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGFloat height = CGRectGetHeight(view.bounds); - ShortCocoaMake(view).y(value - height); - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGFloat height = CGRectGetHeight(layer.bounds); - ShortCocoaMake(layer).y(value - height); - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))right { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - if (view.superview) { - CGFloat superWidth = CGRectGetWidth(view.superview.bounds); - ShortCocoaMake(view).maxX(superWidth - value); - } else { - NSAssert(NO, @"必须存在 superview 才可使用该方法"); - } - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - if (layer.superlayer) { - CGFloat superWidth = CGRectGetWidth(layer.superlayer.bounds); - ShortCocoaMake(layer).maxX(superWidth - value); - } else { - NSAssert(NO, @"必须存在 superlayer 才可使用该方法"); - } - }]; - - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))bottom { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } -#if TARGET_OS_IPHONE - [self unpackClassA:[UIView class] doA:^(UIView *view, BOOL *stop) { - if (view.superview) { - CGFloat superHeight = CGRectGetHeight(view.superview.bounds); - ShortCocoaMake(view).maxY(superHeight - value); - } else { - NSAssert(NO, @"必须存在 superview 才可使用该方法"); - } -#elif TARGET_OS_MAC - [self unpackClassA:[NSView class] doA:^(NSView *view, BOOL *stop) { - if (view.superview) { - CGFloat superHeight = view.superview.bounds.size.height; - if (view.superview.isFlipped) { - ShortCocoaMake(view).maxY(superHeight - value); - } else { - ShortCocoaMake(view).y(value); - } - } else { - NSAssert(NO, @"必须存在 superview 才可使用该方法"); - } -#endif - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - if (layer.superlayer) { - if (layer.superlayer.contentsAreFlipped) { - CGFloat superHeight = CGRectGetHeight(layer.superlayer.bounds); - ShortCocoaMake(layer).maxY(superHeight - value); - } else { - ShortCocoaMake(layer).y(value); - } - } else { - NSAssert(NO, @"必须存在 superlayer 才可使用该方法"); - } - }]; - return self; - }; -} - -- (ShortCocoa *)horAlign { - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - if (view.superview) { - CGFloat superWidth = CGRectGetWidth(view.superview.bounds); - ShortCocoaMake(view).midX(superWidth / 2); - } else { - NSAssert(NO, @"必须存在 superview 才可使用该方法"); - } - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - if (layer.superlayer) { - CGFloat superWidth = CGRectGetWidth(layer.superlayer.bounds); - ShortCocoaMake(layer).midX(superWidth / 2); - } else { - NSAssert(NO, @"必须存在 superlayer 才可使用该方法"); - } - }]; - return self; -} - -- (ShortCocoa *)verAlign { - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - if (view.superview) { - CGFloat superHeight = CGRectGetHeight(view.superview.bounds); - ShortCocoaMake(view).midY(superHeight / 2); - } else { - NSAssert(NO, @"必须存在 superview 才可使用该方法"); - } - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - if (layer.superlayer) { - CGFloat superHeight = CGRectGetHeight(layer.superlayer.bounds); - ShortCocoaMake(layer).midY(superHeight / 2); - } else { - NSAssert(NO, @"必须存在 superlayer 才可使用该方法"); - } - }]; - return self; -} - -- (ShortCocoa *)centerAlign { - return self.verAlign.horAlign; -} - -- (ShortCocoa *)fullWidth { - self.x(0).toRight(0); - return self; -} - -- (ShortCocoa *)fullHeight { - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - if (view.superview) { - CGFloat superHeight = CGRectGetHeight(view.superview.bounds); - ShortCocoaMake(view).height(superHeight).y(0); - } else { - NSAssert(NO, @"必须存在 superview 才可使用该方法"); - } - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - if (layer.superlayer) { - CGFloat superHeight = CGRectGetHeight(layer.superlayer.bounds); - ShortCocoaMake(layer).height(superHeight).y(0); - } else { - NSAssert(NO, @"必须存在 superlayer 才可使用该方法"); - } - }]; - return self; -} - -- (ShortCocoa *)fullFrame { - return self.fullWidth.fullHeight; -} - -- (ShortCocoa * (^)(CGFloat))offsetX { - return ^(CGFloat x) { - self.offset(x, 0); - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))offsetY { - return ^(CGFloat y) { - self.offset(0, y); - return self; - }; -} - -#pragma mark - Group Set 系列 - -- (ShortCocoa * (^)(CGFloat))groupX { - return ^(CGFloat value) { - self.offsetX(value - self.$groupX); - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))groupMidX { - return ^(CGFloat value) { - self.offsetX(value - self.$groupMidX); - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))groupMaxX { - return ^(CGFloat value) { - self.offsetX(value - self.$groupMaxX); - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))groupY { - return ^(CGFloat value) { - self.offsetY(value - self.$groupY); - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))groupMidY { - return ^(CGFloat value) { - self.offsetY(value - self.$groupMidY); - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))groupMaxY { - return ^(CGFloat value) { - self.offsetY(value - self.$groupMaxY); - return self; - }; -} - -- (ShortCocoa * (^)(CGPoint))groupOrigin { - return ^(CGPoint value) { - self.offset(value.x - self.$groupX, value.y - self.$groupY); - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))groupRight { - return ^(CGFloat value) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return self; - } - - __block CGFloat superWidth = 0; - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - superWidth = CGRectGetWidth(view.superview.bounds); - *stop = YES; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - superWidth = CGRectGetWidth(layer.superlayer.bounds); - *stop = YES; - }]; - self.groupMaxX(superWidth - value); - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))groupBottom { - return ^(CGFloat value) { -#if TARGET_OS_IPHONE - CALayer *superlayer; - if (![self allPackedViewsAndLayersAreInTheSameCoordinateWithSuperlayer:&superlayer superview:nil]) { - return self; - } - if (superlayer.contentsAreFlipped) { - self.groupMaxY(CGRectGetHeight(superlayer.bounds) - value); - } else { - self.groupY(value); - } - return self; -#elif TARGET_OS_MAC - CALayer *superlayer; - NSView *superview; - if (![self allPackedViewsAndLayersAreInTheSameCoordinateWithSuperlayer:&superlayer superview:&superview]) { - return self; - } - if (superlayer) { - if (superlayer.contentsAreFlipped) { - self.groupMaxY(CGRectGetHeight(superlayer.bounds) - value); - } else { - self.groupY(value); - } - } else if (superview) { - if (superview.isFlipped) { - self.groupMaxY(CGRectGetHeight(superlayer.bounds) - value); - } else { - self.groupY(value); - } - } - return self; -#endif - }; -} - -- (ShortCocoa *)groupHorAlign { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return self; - } - - __block CGFloat superWidth = 0; - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - superWidth = CGRectGetWidth(view.superview.bounds); - *stop = YES; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - superWidth = CGRectGetWidth(layer.superlayer.bounds); - *stop = YES; - }]; - self.groupMidX(superWidth / 2); - return self; -} - -- (ShortCocoa *)groupVerAlign { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return self; - } - - __block CGFloat superHeight = 0; - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - superHeight = CGRectGetHeight(view.superview.bounds); - *stop = YES; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - superHeight = CGRectGetHeight(layer.superlayer.bounds); - *stop = YES; - }]; - self.groupMidY(superHeight / 2); - return self; -} - -- (ShortCocoa *)groupCenterAlign { - return self.groupVerAlign.groupHorAlign; -} - -#pragma mark - Group Get 系列 - -- (CGFloat)$groupX { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return 0; - } - } - __block CGFloat minX = 0; - __block BOOL hasDeterminedMinX = NO; - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - minX = hasDeterminedMinX ? MIN(minX, CGRectGetMinX(view.frame)) : CGRectGetMinX(view.frame); - hasDeterminedMinX = YES; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - minX = hasDeterminedMinX ? MIN(minX, CGRectGetMinX(layer.frame)) : CGRectGetMinX(layer.frame); - hasDeterminedMinX = YES; - }]; - return minX; -} - -- (CGFloat)$groupMidX { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return 0; - } - } - CGFloat minX = self.$groupX; - CGFloat maxX = self.$groupMaxX; - CGFloat midX = minX + (maxX - minX) / 2; - return midX; -} - -- (CGFloat)$groupMaxX { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return 0; - } - } - __block CGFloat maxX = 0; - __block BOOL hasDeterminedMaxX = NO; - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - maxX = hasDeterminedMaxX ? MAX(maxX, CGRectGetMaxX(view.frame)) : CGRectGetMaxX(view.frame); - hasDeterminedMaxX = YES; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - maxX = hasDeterminedMaxX ? MAX(maxX, CGRectGetMaxX(layer.frame)) : CGRectGetMaxX(layer.frame); - hasDeterminedMaxX = YES; - }]; - return maxX; -} - -- (CGFloat)$groupY { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return 0; - } - } - __block CGFloat minY = 0; - __block BOOL hasDeterminedMinY = NO; - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - minY = hasDeterminedMinY ? MIN(minY, CGRectGetMinY(view.frame)) : CGRectGetMinY(view.frame); - hasDeterminedMinY = YES; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - minY = hasDeterminedMinY ? MIN(minY, CGRectGetMinY(layer.frame)) : CGRectGetMinY(layer.frame); - hasDeterminedMinY = YES; - }]; - return minY; -} - -- (CGFloat)$groupMidY { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return 0; - } - } - CGFloat minY = self.$groupY; - CGFloat maxY = self.$groupMaxY; - CGFloat midY = minY + (maxY - minY) / 2; - return midY; -} - -- (CGFloat)$groupMaxY { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return 0; - } - } - __block CGFloat maxY = 0; - __block BOOL hasDeterminedMaxY = NO; - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - maxY = hasDeterminedMaxY ? MAX(maxY, CGRectGetMaxY(view.frame)) : CGRectGetMaxY(view.frame); - hasDeterminedMaxY = YES; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - maxY = hasDeterminedMaxY ? MAX(maxY, CGRectGetMaxY(layer.frame)) : CGRectGetMaxY(layer.frame); - hasDeterminedMaxY = YES; - }]; - return maxY; -} - -- (CGPoint)$groupOrigin { - CGPoint origin = CGPointMake(self.$groupX, self.$groupY); - return origin; -} - -- (CGFloat)$groupWidth { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return 0; - } - } - CGFloat maxX = self.$groupMaxX; - CGFloat minX = self.$groupX; - CGFloat width = maxX - minX; - return width; -} - -- (CGFloat)$groupHeight { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return 0; - } - } - CGFloat maxY = self.$groupMaxY; - CGFloat minY = self.$groupY; - CGFloat height = maxY - minY; - return height; -} - -- (CGSize)$groupSize { - if ([self filteredGet:[NS_UI_View class], [CALayer class], nil].count > 1) { - if (![self allPackedViewsAndLayersAreInTheSameCoordinate]) { - return CGSizeZero; - } - } - CGFloat width = self.$groupWidth; - CGFloat height = self.$groupHeight; - CGSize size = CGSizeMake(width, height); - return size; -} - -#pragma mark - to 系列 - -- (ShortCocoa * (^)(CGFloat))toX { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGFloat safeValue = value; - if (safeValue > CGRectGetMaxX(view.frame)) { - safeValue = CGRectGetMaxX(view.frame); - } - - CGRect rect = view.frame; - CGFloat width = CGRectGetMaxX(rect) - safeValue; - rect.size.width = CGFloatSnapToPixel(width); - rect.origin.x = CGFloatSnapToPixel(safeValue); - view.frame = rect; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGFloat safeValue = value; - if (safeValue > CGRectGetMaxX(layer.frame)) { - safeValue = CGRectGetMaxX(layer.frame); - } - - CGRect rect = layer.frame; - CGFloat width = CGFloatSnapToPixel(CGRectGetMaxX(rect) - safeValue); - rect.size.width = width; - rect.origin.x = CGFloatSnapToPixel(safeValue); - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))toMaxX { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGFloat safeValue = value; - if (safeValue < CGRectGetMinX(view.frame)) { - safeValue = CGRectGetMinX(view.frame); - } - - CGRect rect = view.frame; - CGFloat width = CGFloatSnapToPixel(safeValue - CGRectGetMinX(rect)); - rect.size.width = width; - view.frame = rect; - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGFloat safeValue = value; - if (safeValue < CGRectGetMinX(layer.frame)) { - safeValue = CGRectGetMinX(layer.frame); - } - - CGRect rect = layer.frame; - CGFloat width = CGFloatSnapToPixel(safeValue - CGRectGetMinX(rect)); - rect.size.width = width; - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))toY { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGFloat safeValue = value; - if (safeValue > CGRectGetMaxY(view.frame)) { - safeValue = CGRectGetMaxY(view.frame); - } - - CGRect rect = view.frame; - CGFloat height = CGRectGetMaxY(rect) - safeValue; - rect.size.height = CGFloatSnapToPixel(height); - rect.origin.y = CGFloatSnapToPixel(safeValue); - view.frame = rect; - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGFloat safeValue = value; - if (safeValue > CGRectGetMaxY(layer.frame)) { - safeValue = CGRectGetMaxY(layer.frame); - } - - CGRect rect = layer.frame; - CGFloat height = CGRectGetMaxY(rect) - safeValue; - rect.size.height = CGFloatSnapToPixel(height); - rect.origin.y = CGFloatSnapToPixel(safeValue); - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))toMaxY { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGFloat safeValue = value; - if (safeValue < CGRectGetMinY(view.frame)) { - safeValue = CGRectGetMinY(view.frame); - } - - CGRect rect = view.frame; - CGFloat height = safeValue - CGRectGetMinY(rect); - rect.size.height = CGFloatSnapToPixel(height); - view.frame = rect; - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGFloat safeValue = value; - if (safeValue < CGRectGetMinY(layer.frame)) { - safeValue = CGRectGetMinY(layer.frame); - } - - CGRect rect = layer.frame; - CGFloat height = safeValue - CGRectGetMinY(rect); - rect.size.height = CGFloatSnapToPixel(height); - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))toRight { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } - - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - CGRect rect = view.frame; - CGFloat width = CGFloatSnapToPixel(CGRectGetWidth(view.superview.bounds) - CGRectGetMinX(rect) - value); - if (width < 0) { - width = 0; - } - rect.size.width = width; - view.frame = rect; - - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - CGRect rect = layer.frame; - CGFloat width = CGFloatSnapToPixel(CGRectGetWidth(layer.superlayer.bounds) - CGRectGetMinX(rect) - value); - if (width < 0) { - width = 0; - } - rect.size.width = width; - layer.frame = rect; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))toBottom { - return ^(CGFloat value) { - if (isnan(value)) { - NSAssert(NO, @"传入了 NaN"); - value = 0; - } -#if TARGET_OS_IPHONE - [self unpackClassA:[UIView class] doA:^(UIView *view, BOOL *stop) { - if (view.superview) { - CGFloat maxY = CGRectGetHeight(view.superview.bounds) - value; - ShortCocoaMake(view).toMaxY(maxY); - } else { - NSAssert(NO, @"必须存在 superview 才可使用该方法"); - } -#elif TARGET_OS_MAC - [self unpackClassA:[NSView class] doA:^(NSView *view, BOOL *stop) { - if (view.superview) { - if (view.superview.isFlipped) { - CGFloat maxY = CGRectGetHeight(view.superview.bounds) - value; - ShortCocoaMake(view).toMaxY(maxY); - } else { - ShortCocoaMake(view).toY(value); - } - } else { - NSAssert(NO, @"必须存在 superview 才可使用该方法"); - } -#endif - } classB:[CALayer class] doB:^(CALayer *layer, BOOL *stop) { - if (layer.superlayer) { - if (layer.superlayer.contentsAreFlipped) { - CGFloat maxY = CGRectGetHeight(layer.superlayer.bounds) - value; - ShortCocoaMake(layer).toMaxY(maxY); - } else { - ShortCocoaMake(layer).toY(value); - } - } else { - NSAssert(NO, @"必须存在 superlayer 才可使用该方法"); - } - }]; - return self; - }; -} - -#pragma mark - sizeThatFits 系列 - -- (ShortCocoa *)heightToFit { -#if TARGET_OS_IPHONE - [self unpack:[UIView class] do:^(UIView *view, BOOL *stop) { -#elif TARGET_OS_MAC - [self unpack:[NSControl class] do:^(NSControl *view, BOOL *stop) { -#endif - CGFloat width = CGRectGetWidth(view.bounds); - CGFloat height = [view sizeThatFits:CGSizeMake(width, CGFLOAT_MAX)].height; - ShortCocoaMake(view).height(height); - }]; - return self; -} - -- (ShortCocoa *)widthToFit { -#if TARGET_OS_IPHONE - [self unpack:[UIView class] do:^(UIView *view, BOOL *stop) { -#elif TARGET_OS_MAC - [self unpack:[NSControl class] do:^(NSControl *view, BOOL *stop) { -#endif - CGFloat height = CGRectGetHeight(view.bounds); - CGFloat width = [view sizeThatFits:CGSizeMake(CGFLOAT_MAX, height)].width; - ShortCocoaMake(view).width(width); - }]; - return self; -} - -#pragma mark - Private - -- (CGSize)getBestSize { - // 此方法不考虑 self.get 是数组的情况,下面其余 5 个方法也是一样 - __block CGSize size = CGSizeZero; -#if TARGET_OS_IPHONE - [self unpack:[UIView class] do:^(UIView *view, BOOL *stop) { -#elif TARGET_OS_MAC - [self unpack:[NSControl class] do:^(NSControl *view, BOOL *stop) { -#endif - size = [view sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)]; - }]; - return size; -} - -- (CGFloat)getBestWidth { - return self.getBestSize.width; -} - -- (CGFloat)getBestHeight { - return self.getBestSize.height; -} - -/** - 检查是否所有 view 和 layer 都处于相同的坐标系里(有共同的 superview/superlayer) - */ -- (BOOL)allPackedViewsAndLayersAreInTheSameCoordinateWithSuperlayer:(inout CALayer **)superlayerPointer superview:(inout NS_UI_View **)superviewPointer { - if (!self.get) { - return NO; - } - __block BOOL validated = YES; - - __block NS_UI_View *superview = nil; - __block CALayer *superlayer = nil; - - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View * _Nonnull view, BOOL *stop) { - if (!view.superview) { - validated = NO; - NSAssert(NO, @"superview 不存在"); - *stop = YES; - } - if (superview) { - if (view.superview != superview) { - validated = NO; - NSAssert(NO, @"包装了多个 View 对象,但它们没有相同的 superview"); - *stop = YES; - } - } else { - superview = view.superview; - } - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - if (!layer.superlayer) { - validated = NO; - NSAssert(NO, @"superlayer 不存在"); - *stop = YES; - } - if (superlayer) { - if (layer.superlayer != superlayer) { - validated = NO; - NSAssert(NO, @"包装了多个 CALayer 对象,但它们没有相同的 superlayer"); - *stop = YES; - } - } else { - superlayer = layer.superlayer; - } - }]; - - if (validated) { - if (superview && superlayer && superview.layer != superlayer) { - if (!superview.layer) { - validated = NO; -#if TARGET_OS_IPHONE - NSAssert(NO, @"同时包装了 View 和 Layer 对象,但其中某些 View 的 layer 属性为 nil"); -#elif TARGET_OS_MAC - NSAssert(NO, @"同时包装了 View 和 Layer 对象,但其中某些 View 的 layer 属性为 nil,是否忘记设置 wantsLayer 为 YES?"); -#endif - } else if (superview.layer != superlayer) { - validated = NO; - NSAssert(NO, @"同时包装了 View 和 Layer 对象,但这些 View 和 Layer 没有相同的 superlayer"); - } - } - } - - if (validated) { - if (superlayer) { - if (superlayerPointer) { - *superlayerPointer = superlayer; - } - } - if (superview) { - if (superviewPointer) { - *superviewPointer = superview; - } - if (!superlayer) { - if (superlayerPointer) { - *superlayerPointer = superview.layer; - } - } - } - } - - return validated; -} - -- (BOOL)allPackedViewsAndLayersAreInTheSameCoordinate { - return [self allPackedViewsAndLayersAreInTheSameCoordinateWithSuperlayer:nil superview:nil]; -} - -- (NSArray *)filteredGet:(Class)aClass, ... { - if (!aClass) { - return nil; - } - NSMutableArray *classes = [NSMutableArray array];; - [classes addObject:aClass]; - - va_list args; - va_start(args, aClass); - id arg; - while ((1)) { - arg = va_arg(args, id); - if (arg) { - [classes addObject:arg]; - } else { - // 传入了 nil - break; - } - } - va_end(args); - - NSMutableArray *filtered = [NSMutableArray array]; - id initialGet = self.get; - if (ShortCocoaEqualClass(initialGet, NSArray)) { - [initialGet enumerateObjectsUsingBlock:^(id _Nonnull get, NSUInteger idx, BOOL * _Nonnull stop) { - [classes enumerateObjectsUsingBlock:^(Class _Nonnull className, NSUInteger idx, BOOL * _Nonnull innerStop) { - if (ShortCocoaEqualClass(get, className)) { - [filtered addObject:get]; - *innerStop = YES; - } - }]; - }]; - } else { - [classes enumerateObjectsUsingBlock:^(Class _Nonnull className, NSUInteger idx, BOOL * _Nonnull stop) { - if (ShortCocoaEqualClass(initialGet, className)) { - [filtered addObject:initialGet]; - *stop = YES; - } - }]; - } - return filtered.count ? filtered : nil; -} - -/** - 调整传入值的大小以使其在当前设备上可以像素对齐 - - @note 假如是一个屏幕 scale 为 2 的设备,当你设置一个 view 的 origin 为 0.7pt 时,则它实际会被渲染为 1.4px,而 1.4px 不是整数,这将导致这个 view 看起来有些模糊,这个现象对于 UILabel 等文字控件的显示尤其明显。而该方法可以把 0.7 修正为 1.0,从而解决这个问题。 - @note 数值将被 ceil() 向上取整,比如 scale 为 2 时:0.1=>0.5,1.3=>1.5 - - @note 这个像素对齐的优化策略来自于 QMUI iOS: - https://github.com/QMUI/QMUI_iOS/ - https://github.com/QMUI/QMUI_iOS/blob/master/QMUIKit/QMUICore/QMUICommonDefines.h - */ -CG_INLINE CGFloat CGFloatSnapToPixel(CGFloat rawValue) { - if (rawValue == CGFLOAT_MIN || rawValue == CGFLOAT_MAX) { - // CGFLOAT_MIN, CGFLOAT_MAX 一般被当做一个标志位来用,所以不对它们进行处理 - return rawValue; - } -#if TARGET_OS_IPHONE - CGFloat screenScale = [[UIScreen mainScreen] scale]; -#elif TARGET_OS_MAC - CGFloat screenScale = [[NSScreen mainScreen] backingScaleFactor]; -#endif - CGFloat parsedValue = ceil(rawValue * screenScale) / screenScale; - return parsedValue; -} - -@end - -#if TARGET_OS_IPHONE -@implementation UIView (ShortCocoaLayout) -#elif TARGET_OS_MAC -@implementation NSView (ShortCocoaLayout) -#endif - -- (CGFloat)$x { - return ShortCocoaMake(self).$groupX; -} -- (CGFloat)$midX { - return ShortCocoaMake(self).$groupMidX; -} -- (CGFloat)$maxX { - return ShortCocoaMake(self).$groupMaxX; -} -- (CGFloat)$y { - return ShortCocoaMake(self).$groupY; -} -- (CGFloat)$midY { - return ShortCocoaMake(self).$groupMidY; -} -- (CGFloat)$maxY { - return ShortCocoaMake(self).$groupMaxY; -} -- (CGFloat)$width { - return ShortCocoaMake(self).$groupWidth; -} -- (CGFloat)$height { - return ShortCocoaMake(self).$groupHeight; -} -- (CGSize)$size { - return ShortCocoaMake(self).$groupSize; -} -#if TARGET_OS_IPHONE -- (CGSize)$bestSize { - return ShortCocoaMake(self).getBestSize; -} -- (CGFloat)$bestWidth { - return ShortCocoaMake(self).getBestWidth; -} -- (CGFloat)$bestHeight { - return ShortCocoaMake(self).getBestHeight; -} -#endif - -@end - -@implementation CALayer (ShortCocoaLayout) - -- (CGFloat)$x { - return ShortCocoaMake(self).$groupX; -} -- (CGFloat)$midX { - return ShortCocoaMake(self).$groupMidX; -} -- (CGFloat)$maxX { - return ShortCocoaMake(self).$groupMaxX; -} -- (CGFloat)$y { - return ShortCocoaMake(self).$groupY; -} -- (CGFloat)$midY { - return ShortCocoaMake(self).$groupMidY; -} -- (CGFloat)$maxY { - return ShortCocoaMake(self).$groupMaxY; -} -- (CGFloat)$width { - return ShortCocoaMake(self).$groupWidth; -} -- (CGFloat)$height { - return ShortCocoaMake(self).$groupHeight; -} -- (CGSize)$size { - return ShortCocoaMake(self).$groupSize; -} - -@end diff --git a/LookInside/ShortCocoa/ShortCocoa+Others.h b/LookInside/ShortCocoa/ShortCocoa+Others.h deleted file mode 100644 index c04b6c5b..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+Others.h +++ /dev/null @@ -1,95 +0,0 @@ -// -// ShortCocoa+Others.h -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoaCore.h" -#import "ShortCocoaDefines.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_MAC -#import -#endif - -NS_ASSUME_NONNULL_BEGIN - -@interface ShortCocoa (Others) - -/** - 设置 UILabel / UIButton 的文字颜色,或者给 NSAttributedString 添加 NSForegroundColorAttributeName - - @note 支持的被包装对象:UILabel, UIButton, 以及 ShortCocoaString - @note 传入值支持 UIColor 或 RGB, RGBA, HEX 或 @"red" 等字符串,详见 ShortCocoaColor 定义 - - @code - - // 等价于 [[UILabel new] setTextColor:redColor] - $(UILabel).textColor(redColor); - - // 等价于 [[UIButton new] setTitleColor:redColor forState:UIControlStateNormal] - $(UIButton).textColor(redColor); - - // 等价于 [NSAttributedString alloc] initWithString:@"abc" attributes:@{NSForegroundColorAttributeName:color} - $(@"abc").textColor(color).attrString; - - @endcode - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaColor))textColor; - -/** - 设置 UILabel / UIButton 的文字字体,或者给 NSAttributedString 添加 NSFontAttributeName - - @note 支持的被包装对象:UILabel, UIButton, 以及 ShortCocoaString - @note 传入值支持 UIFont、@"15" 这种字符串、或 @15 这种 NSNumber,详见 ShortCocoaFont 定义 - - @code - - // 等价于 [[UILabel new] setFont:fontObject] - $(UILabel).font(fontObject); - - // 等价于 [UIButton new].titleLabel.font = [UIFont systemFontOfSize:15]; - $(UIButton).font(@15); - - // 等价于 [NSAttributedString alloc] initWithString:@"abc" attributes:@{NSFontAttributeName:font} - $(@"abc").font(font).attrString; - - @endcode - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaFont))font; - -/** - 设置 UILabel / UIButton 的文字为 NSTextAlignmentLeft,或者给 NSAttributedString 的 NSParagraphStyle 设置文字对齐方式为 NSTextAlignmentLeft - */ -- (ShortCocoa *)textAlignLeft; -/** - 设置 UILabel / UIButton 的文字为 NSTextAlignmentCenter,或者给 NSAttributedString 的 NSParagraphStyle 设置文字对齐方式为 NSTextAlignmentCenter - */ -- (ShortCocoa *)textAlignCenter; -/** - 设置 UILabel / UIButton 的文字为 NSTextAlignmentRight,或者给 NSAttributedString 的 NSParagraphStyle 设置文字对齐方式为 NSTextAlignmentRight - */ -- (ShortCocoa *)textAlignRight; - -/** - 设置 UILabel / UIButton 的 lineBreakMode,或者给 NSAttributedString 的 NSParagraphStyle 设置 lineBreakMode - */ -- (ShortCocoa * (^)(NSLineBreakMode))lineBreakMode; - -/** - 将被包装对象中非 nil 的对象放到一个数组中并返回,如果被包装对象全部为 nil 则返回 nil - - @code - // => @[@"a", @"b"] - $(@"a", nil, @"b").array; - - //=> @[visibleView],visibles 方法 剔除被隐藏的 view - $(visibleView, nil, hiddenView).visibles.array; - @endcode - */ -- (NSArray *)array; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ShortCocoa/ShortCocoa+Others.m b/LookInside/ShortCocoa/ShortCocoa+Others.m deleted file mode 100644 index 23db0109..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+Others.m +++ /dev/null @@ -1,120 +0,0 @@ -// -// ShortCocoa+Others.m -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoa+Others.h" -#import "ShortCocoa+Private.h" - -@implementation ShortCocoa (Others) - -- (ShortCocoa * (^)(ShortCocoaColor))textColor { - return ^(ShortCocoaColor color) { - id UI_or_NS_Color = [ShortCocoaHelper colorFromShortCocoaColor:color]; - if (self.cachedAttrString.length) { - [self.cachedAttrString addAttribute:NSForegroundColorAttributeName value:UI_or_NS_Color range:NSMakeRange(0, self.cachedAttrString.length)]; - - } else { -#if TARGET_OS_IPHONE - UIColor *textColor = [ShortCocoaHelper colorFromShortCocoaColor:color]; - [self unpackClassA:[UIButton class] doA:^(UIButton * _Nonnull obj, BOOL *stop) { - [obj setTitleColor:textColor forState:UIControlStateNormal]; - } classB:[UILabel class] doB:^(UILabel * _Nonnull obj, BOOL *stop) { - [obj setTextColor:textColor]; - }]; -#endif - } - return self; - }; -} - -- (ShortCocoa * (^)(ShortCocoaFont))font { - return ^(ShortCocoaFont value) { - id UI_or_NS_Font = [ShortCocoaHelper fontFromShortCocoaFont:value]; - if (self.cachedAttrString.length) { - [self.cachedAttrString addAttribute:NSFontAttributeName value:UI_or_NS_Font range:NSMakeRange(0, self.cachedAttrString.length)]; - - } else { -#if TARGET_OS_IPHONE - if (UI_or_NS_Font) { - [self unpackClassA:[UIButton class] doA:^(UIButton * _Nonnull obj, BOOL *stop) { - obj.titleLabel.font = UI_or_NS_Font; - } classB:[UILabel class] doB:^(UILabel * _Nonnull obj, BOOL *stop) { - obj.font = UI_or_NS_Font; - }]; - } -#elif TARGET_OS_MAC - -#endif - } - return self; - }; -} - -- (NSArray *)array { - id selfGet = self.get; - if (!selfGet) { - return nil; - } - if ([selfGet isKindOfClass:[NSArray class]]) { - return selfGet; - } - return @[selfGet]; -} - -- (ShortCocoa *)textAlignLeft { - self.textAlign(NSTextAlignmentLeft); - return self; -} - -- (ShortCocoa *)textAlignCenter { - self.textAlign(NSTextAlignmentCenter); - return self; -} - -- (ShortCocoa *)textAlignRight { - self.textAlign(NSTextAlignmentRight); - return self; -} - -- (ShortCocoa * (^)(NSTextAlignment alignment))textAlign { - return ^(NSTextAlignment alignment) { - if (self.cachedAttrString.length) { - NSMutableParagraphStyle *paraStyle = [ShortCocoaHelper paragraphStyleForAttributedString:self.cachedAttrString]; - paraStyle.alignment = alignment; - [self.cachedAttrString addAttribute:NSParagraphStyleAttributeName value:paraStyle.copy range:NSMakeRange(0, self.cachedAttrString.length)]; - } else { -#if TARGET_OS_IPHONE - [self unpackClassA:[UIButton class] doA:^(UIButton * _Nonnull obj, BOOL *stop) { - obj.titleLabel.textAlignment = alignment; - } classB:[UILabel class] doB:^(UILabel * _Nonnull obj, BOOL *stop) { - obj.textAlignment = alignment; - }]; -#endif - } - return self; - }; -} - -- (ShortCocoa * (^)(NSLineBreakMode))lineBreakMode { - return ^(NSLineBreakMode mode) { - if (self.cachedAttrString.length) { - NSMutableParagraphStyle *paraStyle = [ShortCocoaHelper paragraphStyleForAttributedString:self.cachedAttrString]; - paraStyle.lineBreakMode = mode; - [self.cachedAttrString addAttribute:NSParagraphStyleAttributeName value:paraStyle.copy range:NSMakeRange(0, self.cachedAttrString.length)]; - } else { -#if TARGET_OS_IPHONE - [self unpackClassA:[UIButton class] doA:^(UIButton * _Nonnull obj, BOOL *stop) { - obj.titleLabel.lineBreakMode = mode; - } classB:[UILabel class] doB:^(UILabel * _Nonnull obj, BOOL *stop) { - obj.lineBreakMode = mode; - }]; -#endif - } - return self; - }; -} - -@end diff --git a/LookInside/ShortCocoa/ShortCocoa+Private.h b/LookInside/ShortCocoa/ShortCocoa+Private.h deleted file mode 100644 index b2e8838f..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+Private.h +++ /dev/null @@ -1,75 +0,0 @@ -// -// ShortCocoa+Private.h -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoaCore.h" -#import "ShortCocoaDefines.h" -#import "TargetConditionals.h" - -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_MAC -#import -#endif - -#define ShortCocoaEqualClass(object, CLASS) [object isKindOfClass:[CLASS class]] - -NS_ASSUME_NONNULL_BEGIN - -@interface ShortCocoa (Private) - -/** - 遍历 self.get,并将其中类型为 classA 的对象传入 handlerA 中 - */ -- (void)unpack:(Class)classA do:(void (^)(id obj, BOOL *stop))handlerA; - -/** - 遍历 self.get,并将其中类型为 classA 的对象传入 handlerA 中,将类型为 classB 的对象传入 handlerB 中 - */ -- (void)unpackClassA:(Class)classA doA:(void (^)(id obj, BOOL *stop))handlerA classB:(nullable Class)classB doB:(nullable void (^)(id obj, BOOL *stop))handlerB; - -/// 辅助进行字符串相关的处理 -@property(nonatomic, strong) NSMutableAttributedString *cachedAttrString; - -@end - -@interface ShortCocoaHelper : NSObject - -// 获取 string 的 NSParagraphStyle,如果不存在则创建一个新的 -+ (NSMutableParagraphStyle *)paragraphStyleForAttributedString:(NSAttributedString *)string; - -/// 将 ShortCocoaQuad 转换为 @[@10, @20, @30, @40] 这样的数组 -+ (nullable NSArray *)fourNumbersFromShortCocoaQuad:(nullable ShortCocoaQuad)obj; - -/** - 将 ShortCocoaString 转换为 NSMutableAttributedString - @note 如果传入参数不合法则返回 nil - */ -+ (nullable NSMutableAttributedString *)attrStringFromShortCocoaString:(nullable ShortCocoaString)obj; - -#if TARGET_OS_IPHONE - -/// 将 ShortCocoaColor 转换为 UIColor -+ (nullable UIColor *)colorFromShortCocoaColor:(nullable ShortCocoaColor)obj; -/// 将 ShortCocoaImage 转换为 UIImage -+ (nullable UIImage *)imageFromShortCocoaImage:(nullable ShortCocoaImage)obj; -/// 将 ShortCocoaFont 转换为 UIFont -+ (nullable UIFont *)fontFromShortCocoaFont:(nullable ShortCocoaFont)obj; - -#elif TARGET_OS_MAC - -/// 将 ShortCocoaColor 转换为 NSColor -+ (nullable NSColor *)colorFromShortCocoaColor:(nullable ShortCocoaColor)obj; -/// 将 ShortCocoaImage 转换为 NSImage -+ (nullable NSImage *)imageFromShortCocoaImage:(nullable ShortCocoaImage)obj; -/// 将 ShortCocoaFont 转换为 NSFont -+ (nullable NSFont *)fontFromShortCocoaFont:(nullable ShortCocoaFont)obj; - -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ShortCocoa/ShortCocoa+Private.m b/LookInside/ShortCocoa/ShortCocoa+Private.m deleted file mode 100644 index 34a0b207..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+Private.m +++ /dev/null @@ -1,416 +0,0 @@ -// -// ShortCocoa+Private.m -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoa+Private.h" -#import - -@implementation ShortCocoa (Private) - -- (void)unpack:(Class)classA do:(void (^)(id, BOOL *))handlerA { - [self unpackClassA:classA doA:handlerA classB:nil doB:nil]; -} - -- (void)unpackClassA:(Class)classA doA:(void (^)(id, BOOL *))handlerA classB:(Class)classB doB:(void (^)(id, BOOL *))handlerB { - if (!self.get) { - return; - } - if (ShortCocoaEqualClass(self.get, NSArray)) { - [self.get enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (ShortCocoaEqualClass(obj, classA)) { - if (handlerA) { - handlerA(obj, stop); - } - } else if (ShortCocoaEqualClass(obj, classB)) { - if (handlerB) { - handlerB(obj, stop); - } - } - }]; - } else { - BOOL shouldStop; - if (ShortCocoaEqualClass(self.get, classA)) { - if (handlerA) { - handlerA(self.get, &shouldStop); - } - } else if (ShortCocoaEqualClass(self.get, classB)) { - if (handlerB) { - handlerB(self.get, &shouldStop); - } - } - } -} - -static char kAssociatedObjectKey_ShortCocoaCachedAttrStringKey; -- (NSMutableAttributedString *)cachedAttrString { - NSMutableAttributedString *string = objc_getAssociatedObject(self, &kAssociatedObjectKey_ShortCocoaCachedAttrStringKey); - if (!string) { - string = [ShortCocoaHelper attrStringFromShortCocoaString:self.get]; - [self setCachedAttrString:string]; - } - return string; -} -- (void)setCachedAttrString:(NSMutableAttributedString *)cachedAttrString { - objc_setAssociatedObject(self, &kAssociatedObjectKey_ShortCocoaCachedAttrStringKey, cachedAttrString, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -@end - -@implementation ShortCocoaHelper - -+ (NSMutableParagraphStyle *)paragraphStyleForAttributedString:(NSAttributedString *)string { - NSRange effectiveRange; - NSParagraphStyle *existingParaStyle = [string attribute:NSParagraphStyleAttributeName atIndex:0 longestEffectiveRange:&effectiveRange inRange:NSMakeRange(0, string.length)]; - NSMutableParagraphStyle *newParaStyle = nil; - if (existingParaStyle && effectiveRange.length == string.length) { - newParaStyle = [existingParaStyle mutableCopy]; - } else { - newParaStyle = [[NSMutableParagraphStyle alloc] init]; - } - return newParaStyle; -} - -+ (NSArray *)fourNumbersFromShortCocoaQuad:(ShortCocoaQuad)obj { - if (ShortCocoaEqualClass(obj, NSNumber)) { - // @20 - return @[obj, obj, obj, obj]; - } else if (ShortCocoaEqualClass(obj, NSString)) { - // @"12, 14, 15, 17"、@"13" 这种 - NSArray *numbers = [self numberArrayFromString:obj]; - return [self fourNumbersFromShortCocoaQuad:numbers]; - - } else if (ShortCocoaEqualClass(obj, NSArray)) { - __block BOOL isValid = YES; - [(NSArray *)obj enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (!ShortCocoaEqualClass(obj, NSNumber)) { - isValid = NO; - *stop = YES; - } - }]; - if (isValid) { - NSArray *numbers = obj; - if (numbers.count == 1) { - // @[@20] - return @[numbers[0], numbers[0], numbers[0], numbers[0]]; - - } else if (numbers.count == 2) { - // @[@20, @30] - return @[numbers[0], numbers[1], numbers[0], numbers[1]]; - - } else if (numbers.count == 3) { - // @[@20, @30, @40] - return @[numbers[0], numbers[1], numbers[2], numbers[1]]; - - } else if (numbers.count == 4) { - // @[@20, @30, @40, @50] - return numbers; - } - } - } - NSAssert(NO, @"传入的参数无法识别,支持的参数列表请参看 ShortCocoaQuad 的注释"); - return nil; -} - -+ (NSMutableAttributedString *)attrStringFromShortCocoaString:(ShortCocoaString)obj { - __block NSMutableAttributedString *string = nil; - if (ShortCocoaEqualClass(obj, NSString)) { - string = [[NSMutableAttributedString alloc] initWithString:obj]; - - } else if (ShortCocoaEqualClass(obj, NSAttributedString)) { - string = [obj mutableCopy]; - - } else if (ShortCocoaEqualClass(obj, NSNumber)) { - string = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%@", obj]]; - - } else if (ShortCocoaEqualClass(obj, NSArray)) { - [obj enumerateObjectsUsingBlock:^(id _Nonnull comp, NSUInteger idx, BOOL * _Nonnull stop) { - if (ShortCocoaEqualClass(comp, NSString)) { - if (!string) { - string = [[NSMutableAttributedString alloc] init]; - } - NSAttributedString *append = [[NSAttributedString alloc] initWithString:comp]; - [string appendAttributedString:append]; - - } else if (ShortCocoaEqualClass(comp, NSAttributedString)) { - if (!string) { - string = [[NSMutableAttributedString alloc] init]; - } - [string appendAttributedString:comp]; - - } else if (ShortCocoaEqualClass(comp, NSNumber)) { - if (!string) { - string = [[NSMutableAttributedString alloc] init]; - } - NSAttributedString *append = [[NSAttributedString alloc] initWithString:[NSString stringWithFormat:@"%@", comp]]; - [string appendAttributedString:append]; - } - }]; - } else if (ShortCocoaEqualClass(obj, ShortCocoa)) { - string = ((ShortCocoa *)obj).cachedAttrString; - } - - return string; -} - -#if TARGET_OS_IPHONE - -+ (nullable UIColor *)colorFromShortCocoaColor:(ShortCocoaColor)obj { - if (!obj) { - return nil; - } - if (ShortCocoaEqualClass(obj, UIColor)) { - // UIColor - return obj; - } - if (ShortCocoaEqualClass(obj, NSString)) { - // @"red" 这种表意字符串 - NSDictionary *dict = [self colorStringDictionary]; - if (dict[obj]) { - return dict[obj]; - } - // @"122, 33, 344" 这种字符串 - NSArray *array = [self numberArrayFromString:obj]; - if (array.count == 3 || array.count == 4) { - // 如果没有 alpha 值,则默认为 1(即不透明) - CGFloat alpha = (array.count == 4) ? [array[3] floatValue] : 1; - CGFloat r = [array[0] floatValue]; - CGFloat g = [array[1] floatValue]; - CGFloat b = [array[2] floatValue]; - return [UIColor colorWithRed:r/255.0 green:g/255.0 blue:b/255.0 alpha:alpha]; - } - // Hex 字符串 - UIColor *finalColor = [self colorFromHexString:obj]; - if (finalColor) { - return finalColor; - } - } - NSAssert(NO, @"传入的颜色参数无法识别,支持的参数列表请参看 ShortCocoaColor 的注释"); - return nil; -} - -+ (nullable UIImage *)imageFromShortCocoaImage:(nullable ShortCocoaImage)obj { - if (!obj || ShortCocoaEqualClass(obj, UIImage)) { - return obj; - } - if (ShortCocoaEqualClass(obj, NSString)) { - return [UIImage imageNamed:obj]; - } - NSAssert(NO, @"传入的图片参数无法识别,支持的参数类型为:1)UIImage 对象。 2)NSString,比如 @\"icon\" 等价于 [UIImage imageNamed:@\"icon\"]"); - return nil; -} - -+ (UIFont *)fontFromShortCocoaFont:(ShortCocoaFont)obj { - if (ShortCocoaEqualClass(obj, UIFont)) { - // UIFont - return obj; - } - if (ShortCocoaEqualClass(obj, NSNumber)) { - // @12 - CGFloat fontSize = [obj doubleValue]; - UIFont *fontObj = [UIFont systemFontOfSize:fontSize]; - return fontObj; - } - if (ShortCocoaEqualClass(obj, NSString)) { - // @"12" - NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; - [numberFormatter setNumberStyle:NSNumberFormatterDecimalStyle]; - NSNumber *number = [numberFormatter numberFromString:obj]; - CGFloat fontSize = [number doubleValue]; - UIFont *fontObj = [UIFont systemFontOfSize:fontSize]; - return fontObj; - } - NSAssert(NO, @"传入的字体参数无法识别,支持的参数类型为:1)UIFont 对象。 2)字符串或 NSNumber,比如 @\"15\"、@15 等价于 [UIFont systemFontOfSize:15]"); - return nil; -} - -#elif TARGET_OS_MAC - -+ (nullable NSColor *)colorFromShortCocoaColor:(ShortCocoaColor)obj { - if (!obj) { - return nil; - } - if (ShortCocoaEqualClass(obj, NSColor)) { - // NSColor - return obj; - } - if (ShortCocoaEqualClass(obj, NSString)) { - // @"red" 这种表意字符串 - NSDictionary *dict = [self colorStringDictionary]; - if (dict[obj]) { - return dict[obj]; - } - // @"122, 33, 344" 这种字符串 - NSArray *array = [self numberArrayFromString:obj]; - if (array.count == 3 || array.count == 4) { - // 如果没有 alpha 值,则默认为 1(即不透明) - CGFloat alpha = (array.count == 4) ? [array[3] floatValue] : 1; - CGFloat r = [array[0] floatValue]; - CGFloat g = [array[1] floatValue]; - CGFloat b = [array[2] floatValue]; - return [NSColor colorWithRed:r/255.0 green:g/255.0 blue:b/255.0 alpha:alpha]; - } - // Hex 字符串 - NSColor *finalColor = [self colorFromHexString:obj]; - if (finalColor) { - return finalColor; - } - } - NSAssert(NO, @"传入的颜色参数无法识别,支持的参数列表请参看 ShortCocoaColor 的注释"); - return nil; -} - -+ (nullable NSImage *)imageFromShortCocoaImage:(nullable ShortCocoaImage)obj { - if (!obj || ShortCocoaEqualClass(obj, NSImage)) { - return obj; - } - if (ShortCocoaEqualClass(obj, NSString)) { - return [NSImage imageNamed:obj]; - } - NSAssert(NO, @"传入的图片参数无法识别,支持的参数类型为:1)NSImage 对象。 2)NSString,比如 @\"icon\" 等价于 [NSImage imageNamed:@\"icon\"]"); - return nil; -} - -+ (NSFont *)fontFromShortCocoaFont:(ShortCocoaFont)obj { - if (ShortCocoaEqualClass(obj, NSFont)) { - // UIFont - return obj; - } - if (ShortCocoaEqualClass(obj, NSNumber)) { - // @12 - CGFloat fontSize = [obj doubleValue]; - NSFont *fontObj = [NSFont systemFontOfSize:fontSize]; - return fontObj; - } - if (ShortCocoaEqualClass(obj, NSString)) { - // @"12" - NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; - [numberFormatter setNumberStyle:NSNumberFormatterDecimalStyle]; - NSNumber *number = [numberFormatter numberFromString:obj]; - CGFloat fontSize = [number doubleValue]; - NSFont *fontObj = [NSFont systemFontOfSize:fontSize]; - return fontObj; - } - NSAssert(NO, @"传入的字体参数无法识别,支持的参数类型为:1)NSFont 对象。 2)字符串或 NSNumber,比如 @\"15\"、@15 等价于 [NSFont systemFontOfSize:15]"); - return nil; -} - -#endif - -#pragma mark - Private - -/// ShortCocoaColor 支持类似 @"red" 这种表意字符串,这里定义了具体的支持列表以及对应的色值 -+ (NSDictionary *)colorStringDictionary { - static NSDictionary *ShortCocoaLiteralColorDict; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ -#if TARGET_OS_IPHONE - ShortCocoaLiteralColorDict = @{ - @"clear": [UIColor clearColor], - @"white": [UIColor whiteColor], - @"black": [UIColor blackColor], - @"gray": [UIColor colorWithRed:179/255.0 green:179/255.0 blue:179/255.0 alpha:1], - @"red": [UIColor colorWithRed:250/255.0 green:58/255.0 blue:58/255.0 alpha:1], - @"green": [UIColor colorWithRed:159/255.0 green:214/255.0 blue:97/255.0 alpha:1], - @"blue": [UIColor colorWithRed:49/255.0 green:189/255.0 blue:243/255.0 alpha:1], - @"yellow": [UIColor colorWithRed:255/255.0 green:207/255.0 blue:71/255.0 alpha:1] - }; -#elif TARGET_OS_MAC - ShortCocoaLiteralColorDict = @{ - @"clear": [NSColor clearColor], - @"white": [NSColor whiteColor], - @"black": [NSColor blackColor], - @"gray": [NSColor colorWithRed:179/255.0 green:179/255.0 blue:179/255.0 alpha:1], - @"red": [NSColor colorWithRed:250/255.0 green:58/255.0 blue:58/255.0 alpha:1], - @"green": [NSColor colorWithRed:159/255.0 green:214/255.0 blue:97/255.0 alpha:1], - @"blue": [NSColor colorWithRed:49/255.0 green:189/255.0 blue:243/255.0 alpha:1], - @"yellow": [NSColor colorWithRed:255/255.0 green:207/255.0 blue:71/255.0 alpha:1] - }; -#endif - }); - return ShortCocoaLiteralColorDict; -} - - -/// 将 Hex 格式的字符串转换为 UIColor -#if TARGET_OS_IPHONE -+ (UIColor *)colorFromHexString:(NSString *)hexString { -#elif TARGET_OS_MAC -+ (NSColor *)colorFromHexString:(NSString *)hexString { -#endif - CGFloat (^getColorComponent)(NSString *string, NSUInteger start, NSUInteger length) = ^CGFloat(NSString *string, NSUInteger start, NSUInteger length) { - NSString *substring = [string substringWithRange: NSMakeRange(start, length)]; - NSString *fullHex = length == 2 ? substring : [NSString stringWithFormat: @"%@%@", substring, substring]; - unsigned hexComponent; - [[NSScanner scannerWithString: fullHex] scanHexInt: &hexComponent]; - return hexComponent / 255.0; - }; - - - NSString *colorString = [[hexString stringByReplacingOccurrencesOfString: @"#" withString: @""] uppercaseString]; - CGFloat alpha, red, blue, green; - switch ([colorString length]) { - case 3: // #RGB - alpha = 1.0f; - red = getColorComponent(colorString, 0, 1); - green = getColorComponent(colorString, 1, 1); - blue = getColorComponent(colorString, 2, 1); - break; - case 4: // #ARGB - alpha = getColorComponent(colorString, 0, 1); - red = getColorComponent(colorString, 1, 1); - green = getColorComponent(colorString, 2, 1); - blue = getColorComponent(colorString, 3, 1); - break; - case 6: // #RRGGBB - alpha = 1.0f; - red = getColorComponent(colorString, 0, 2); - green = getColorComponent(colorString, 2, 2); - blue = getColorComponent(colorString, 4, 2); - break; - case 8: // #AARRGGBB - alpha = getColorComponent(colorString, 0, 2); - red = getColorComponent(colorString, 2, 2); - green = getColorComponent(colorString, 4, 2); - blue = getColorComponent(colorString, 6, 2); - break; - default: - return nil; - } -#if TARGET_OS_IPHONE - return [UIColor colorWithRed:red green:green blue:blue alpha:alpha]; -#elif TARGET_OS_MAC - return [NSColor colorWithRed:red green:green blue:blue alpha:alpha]; -#endif -} - -/// 将类似 @"12, 13, 14" 这种字符串转换为 @[@12, @13, @14] 这种数组 -+ (NSArray *)numberArrayFromString:(NSString *)string { - if (!string.length) { - return nil; - } - // 去除字符串首尾的空白字符 - string = [string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - // 支持的分隔符 - NSString *separator = @","; - NSArray *components = [string componentsSeparatedByString:separator]; - - NSMutableArray *numbers = [NSMutableArray array]; - [components enumerateObjectsUsingBlock:^(NSString * _Nonnull string, NSUInteger idx, BOOL * _Nonnull stop) { - NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init]; - [numberFormatter setNumberStyle:NSNumberFormatterDecimalStyle]; - NSNumber *number = [numberFormatter numberFromString:string]; - - if (number != nil) { - [numbers addObject:number]; - } - }]; - if (numbers.count) { - return numbers; - } - return nil; -} - -@end diff --git a/LookInside/ShortCocoa/ShortCocoa+String.h b/LookInside/ShortCocoa/ShortCocoa+String.h deleted file mode 100644 index 471f9e96..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+String.h +++ /dev/null @@ -1,147 +0,0 @@ -// -// ShortCocoa+String.h -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoaCore.h" -#import "ShortCocoaDefines.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface ShortCocoa (String) - -/** - 将被包装对象转换为 NSString 并返回 - - @code - $(@"abc").string; // => @"abc" - @endcode - */ -- (nullable NSString *)string; - -/** - 将被包装对象转换为 NSAttributedString 并返回 - - @code - // => [[NSAttributedString alloc] initWithString:@"abc"] - $(@"abc").attrString; - @endcode - */ -- (nullable NSAttributedString *)attrString; - -/** - 将被包装对象转换为 NSMutableAttributedString 并返回 - - @code - // => [[NSMutableAttributedString alloc] initWithString:@"abc"] - $(@"abc").mAttrString; - @endcode - */ -- (nullable NSMutableAttributedString *)mAttrString; - -/** - 在开头添加字符串 - - @note 传入参数支持 ShortCocoa, NSString, NSAttributedString, NSNumber,详见 ShortCocoaString - @code - $(@"bc").prepend(@"a").string; // => @"abc" - @endcode - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaString))prepend; - -/** - 在指定位置添加字符串 - - @note 传入参数支持 ShortCocoa, NSString, NSAttributedString, NSNumber,详见 ShortCocoaString - @code - $(@"ac").insert(@"b", 1).string; // => @"abc" - @endcode - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaString, NSUInteger location))insert; - -/** - 在末尾添加字符串 - - @note 传入参数支持 ShortCocoa, NSString, NSAttributedString, NSNumber,详见 ShortCocoaString - @code - $(@"ab").add(@"c").string; // => @"abc" - @endcode - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaString))add; - -/** - 在末尾添加一张图片 - - @note 图片传入值支持 UIImage 或 NSString(图片名字),详见 ShortCocoaImage - @note 通过 baselineOffset 调整图片的上下偏移(值越大则图片越靠上),通过 marginLeft 和 marginRight 调整图片左右的间距 - @attention 内部实现是通过 NSTextAttachment 的原理添加图片 - - @code - // 末尾添加 image 图片,图片的 baselineOffset 为 2,距离左侧文字距离为 3,距离右侧文字距离为 4 - $(@"abc").addImage(image, 2, 3, 4); - @endcode - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaImage image, CGFloat baselineOffset, CGFloat marginLeft, CGFloat marginRight))addImage; - -/** - 在末尾增加一段长度为 width 的空白图片,一般用来精确调整字符之间的间距 - */ -- (ShortCocoa * (^)(CGFloat width))addSpace; - -/** - 设置行高 - - @note 具体实现是添加一个 NSParagraphStyle 并设置它的 minimumLineHeight/maximumLineHeight - - @code - $(@"abc").lineHeight(30).attrString; - @endcode - */ -- (ShortCocoa * (^)(CGFloat))lineHeight; - -/** - 添加 NSBaselineOffsetAttributeName - - @code - // => 等价于 [addAttributes:@{NSBaselineOffsetAttributeName: @2} range:NSMakeRange(0, @"abc".length)] - $(@"abc").baselineOffset(2).attrString; - @endcode - */ -- (ShortCocoa * (^)(CGFloat))baselineOffset; - -/** - 添加 NSBaselineOffsetAttributeName (NSUnderlineStyleSingle) - - @code - // => 等价于 [addAttributes:@{NSBaselineOffsetAttributeName: @(NSUnderlineStyleSingle)} range:NSMakeRange(0, @"abc".length)] - $(@"abc").strikethrough.attrString; - @endcode - */ -- (ShortCocoa *)strikethrough; - -/** - 添加 NSKernAttributeName - - @code - // => 等价于 [addAttributes:@{NSKernAttributeName: @2} range:NSMakeRange(0, @"abc".length)] - $(@"abc").kern(2).attrString; - @endcode - */ -- (ShortCocoa * (^)(CGFloat))kern; - -/** - 添加 NSKernAttributeName - - @code - // => 等价于 [addAttributes:@{NSUnderlineStyleAttributeName: @(NSUnderlineStyleSingle)} range:NSMakeRange(0, @"abc".length)] - $(@"abc").underline.attrString; - @endcode - */ -- (ShortCocoa *)underline; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ShortCocoa/ShortCocoa+String.m b/LookInside/ShortCocoa/ShortCocoa+String.m deleted file mode 100644 index 27679bb1..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+String.m +++ /dev/null @@ -1,153 +0,0 @@ -// -// ShortCocoa+String.m -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoa+String.h" -#import "ShortCocoa+Private.h" - -@implementation ShortCocoa (String) - -- (NSString *)string { - NSString *string = self.cachedAttrString.string; - return string; -} - -- (NSAttributedString *)attrString { - NSAttributedString *string = [self.cachedAttrString copy]; - return string; -} - -- (NSMutableAttributedString *)mAttrString { - return self.cachedAttrString; -} - -- (ShortCocoa * (^)(ShortCocoaString))prepend { - return ^(ShortCocoaString string) { - if (string) { - NSMutableAttributedString *attrString = [ShortCocoaHelper attrStringFromShortCocoaString:string]; - [self.cachedAttrString insertAttributedString:attrString atIndex:0]; - } - return self; - }; -} - -- (ShortCocoa * (^)(_Nullable ShortCocoaString, NSUInteger location))insert { - return ^(ShortCocoaString string, NSUInteger location) { - if (string) { - NSMutableAttributedString *attrString = [ShortCocoaHelper attrStringFromShortCocoaString:string]; - [self.cachedAttrString insertAttributedString:attrString atIndex:location]; - } - return self; - }; -} - -- (ShortCocoa * (^)(ShortCocoaString))add { - return ^(ShortCocoaString string) { - if (string) { - NSMutableAttributedString *attrString = [ShortCocoaHelper attrStringFromShortCocoaString:string]; - [self.cachedAttrString appendAttributedString:attrString]; - } - return self; - }; -} - -- (ShortCocoa * (^)(ShortCocoaImage image, CGFloat baselineOffset, CGFloat marginLeft, CGFloat marginRight))addImage { - return ^(ShortCocoaImage image, CGFloat baselineOffset, CGFloat marginLeft, CGFloat marginRight) { - id UI_or_NS_Image = [ShortCocoaHelper imageFromShortCocoaImage:image]; - NSAttributedString *attrString = [self attributedStringWithImage:UI_or_NS_Image baselineOffset:baselineOffset leftMargin:marginLeft rightMargin:marginRight]; - [self.cachedAttrString appendAttributedString:attrString]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))addSpace { - return ^(CGFloat width) { - NSAttributedString *attrString = [self attributedStringWithFixedSpace:width]; - [self.cachedAttrString appendAttributedString:attrString]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))lineHeight { - return ^(CGFloat lineHeight) { - if ([self.cachedAttrString length]) { - NSMutableParagraphStyle *paraStyle = [ShortCocoaHelper paragraphStyleForAttributedString:self.cachedAttrString]; - paraStyle.minimumLineHeight = lineHeight; - paraStyle.maximumLineHeight = lineHeight; - [self.cachedAttrString addAttribute:NSParagraphStyleAttributeName value:paraStyle.copy range:NSMakeRange(0, self.cachedAttrString.length)]; - } - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))baselineOffset { - return ^(CGFloat value) { - if (self.cachedAttrString.length) { - [self.cachedAttrString addAttribute:NSBaselineOffsetAttributeName value:@(value) range:NSMakeRange(0, self.cachedAttrString.length)]; - } - return self; - }; -} - -- (ShortCocoa *)strikethrough { - if (self.cachedAttrString.length) { - [self.cachedAttrString addAttribute:NSStrikethroughStyleAttributeName value:@(NSUnderlineStyleSingle) range:NSMakeRange(0, self.cachedAttrString.length)]; - } - return self; -} - -- (ShortCocoa * (^)(CGFloat))kern { - return ^(CGFloat value) { - if (self.cachedAttrString.length) { - [self.cachedAttrString addAttribute:NSKernAttributeName value:@(value) range:NSMakeRange(0, self.cachedAttrString.length)]; - } - return self; - }; -} - -- (ShortCocoa *)underline { - if (self.cachedAttrString.length) { - [self.cachedAttrString addAttribute:NSUnderlineStyleAttributeName value:@(NSUnderlineStyleSingle) range:NSMakeRange(0, self.cachedAttrString.length)]; - } - return self; -} - -#pragma mark - Private - -#if TARGET_OS_IPHONE -- (NSAttributedString *)attributedStringWithImage:(UIImage *)image baselineOffset:(CGFloat)baselineOffset leftMargin:(CGFloat)leftMargin rightMargin:(CGFloat)rightMargin { -#elif TARGET_OS_MAC -- (NSAttributedString *)attributedStringWithImage:(NSImage *)image baselineOffset:(CGFloat)baselineOffset leftMargin:(CGFloat)leftMargin rightMargin:(CGFloat)rightMargin { -#endif - if (!image) { - return nil; - } - NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; - attachment.image = image; - attachment.bounds = CGRectMake(0, 0, image.size.width, image.size.height); - NSMutableAttributedString *string = [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy]; - [string addAttribute:NSBaselineOffsetAttributeName value:@(baselineOffset) range:NSMakeRange(0, string.length)]; - if (leftMargin > 0) { - [string insertAttributedString:[self attributedStringWithFixedSpace:leftMargin] atIndex:0]; - } - if (rightMargin > 0) { - [string appendAttributedString:[self attributedStringWithFixedSpace:rightMargin]]; - } - return string; -} - -- (NSAttributedString *)attributedStringWithFixedSpace:(CGFloat)width { -#if TARGET_OS_IPHONE - UIGraphicsBeginImageContext(CGSizeMake(width, 1)); - UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); -#elif TARGET_OS_MAC - NSImage *image = [[NSImage alloc] initWithSize:NSMakeSize(width, 1)]; -#endif - return [self attributedStringWithImage:image baselineOffset:0 leftMargin:0 rightMargin:0]; -} - -@end diff --git a/LookInside/ShortCocoa/ShortCocoa+UIToolkit.h b/LookInside/ShortCocoa/ShortCocoa+UIToolkit.h deleted file mode 100644 index 2e552c1d..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+UIToolkit.h +++ /dev/null @@ -1,240 +0,0 @@ -// -// ShortCocoa+UIToolkit.h -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoaCore.h" -#import "ShortCocoaDefines.h" -#import "TargetConditionals.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_MAC -#import -#endif - -@interface ShortCocoa (UIToolkit) - - -/** - 使得当前方法调用链上的后续方法仅对可见的 view/layer 生效 - - @note "view 可见"的定义是:view && !view.hidden && view.superview && view.alpha >= 0.01,layer 类似 - - @code - - // horAlign 方法仅对 visibleLabel 生效,hiddenLabel 不会受到任何影响 - $(visibleLabel, hiddenLabel).visibles.horAlign; - - // => @[visibleView] - $(visibleView, hiddenView).visibles.array; - - @endcode - */ -- (ShortCocoa *)visibles; - -/** - 设置 view 的 alpha,或 CALayer 的 opacity - - @note 支持的被包装对象:UIView/NSView, CALayer - @note 范围是 0 ~ 1 - @note 该方法和 alpha 方法完全等价 - */ -- (ShortCocoa * (^)(CGFloat))opacity; - -/** - 设置 view 的 alpha,或 CALayer 的 opacity - - @note 支持的被包装对象:UIView/NSView, CALayer - @note 范围是 0 ~ 1 - @note 该方法和 opacity 方法完全等价 - */ -- (ShortCocoa * (^)(CGFloat))alpha; - -/** - 等价于设置 hidden 属性为 YES - @note 支持的被包装对象:UIView/NSView, CALayer - */ -- (ShortCocoa *)hide; - -/** - 等价于设置 hidden 属性为 NO - @note 支持的被包装对象:UIView/NSView, CALayer - */ -- (ShortCocoa *)show; - -#if TARGET_OS_IPHONE - -/** - 将 UIView / CALayer 添加到另一个 UIView / CALayer 上,即代替 addSubview: 和 addSublayer: 方法 - - @note 支持的被包装对象:UIView, CALayer - - @code - $(view).addTo(view2); // 等价于 [view2 addSubview:view] - $(layer).addTo(layer2);// 等价于 [layer2 addSublayer:layer] - $(view).addTo(layer); // 等价于 [layer addSublayer:view.layer] - $(layer).addTo(view); // 等价于 [view.layer addSublayer:layer] - @endcode - */ -- (ShortCocoa * (^)(_Nullable id))addTo; - -/** - 设置 UIControl 的点击(UIControlEventTouchUpInside)响应事件,等价于 addTarget:action:forControlEvents: - @note 支持的被包装对象:UIControl - */ -- (ShortCocoa * (^)(id target, SEL action))onTap; - -/** - 等价于 UILabel 的 numberOfLines - @note 支持的被包装对象:UILabel - */ -- (ShortCocoa * (^)(NSInteger))lines; - -/** - 等价于 UIView 的 clipsToBounds 或 CALayer 的 masksToBounds; - @note 支持的被包装对象:UIView, CALayer - */ -- (ShortCocoa * (^)(BOOL))clipsToBounds; - -/** - 等价于 UIView 的 clipsToBounds 或 CALayer 的 masksToBounds; - @note 支持的被包装对象:UIView, CALayer - */ -- (ShortCocoa * (^)(BOOL))masksToBounds; - -/** - 设置 UIView / CALayer 的 backgroundColor - - @note 支持的被包装对象:UIView, CALayer - @note 传入值支持 UIColor 或 RGB, RGBA, HEX 或 @"red" 等字符串,详见 ShortCocoaColor 定义 - - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaColor))bgColor; - -/** - 设置 UIView / CALayer 的 borderColor - - @note 支持的被包装对象:UIView, CALayer - @note 传入值支持 UIColor 或 RGB, RGBA, HEX 或 @"red" 等字符串,详见 ShortCocoaColor 定义 - @note $(view).borderColor() 等价于 $(view.layer).borderColor() - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaColor))borderColor; - -/** - 设置 UIView / CALayer 的 borderWidth - - @note 支持的被包装对象:UIView, CALayer - @note $(view).borderWidth(1) 等价于 $(view.layer).borderWidth(1) - */ -- (ShortCocoa * (^)(CGFloat))borderWidth; - -/** - 设置 UIView / CALayer 的 cornerRadius - - @note 支持的被包装对象:UIView, CALayer - @note $(view).corners() 等价于 $(view.layer).corners() - */ -- (ShortCocoa * (^)(CGFloat))corners; - -/** - 设置 UILabel / UIButton 的文字 - - @note 支持的被包装对象:UILabel, UIButton - @note 传入值支持 NSString, NSAttributedString 或 NSNumber,详见 ShortCocoaString - - @code - - // 等价于 [[UILabel new] setText:@"abc"] - $(UILabel).text(@"abc"); - - // 等价于 [[UILabel new] setAttributedText:attrString] - $(UILabel).text(attrString); - - // 等价于 [[UIButton new] setTitle:@"abc" forState:UIControlStateNormal] - $(UIButton).text(@"abc"); - - // 等价于 [[UIButton new] setAttributedTitle:attrString forState:UIControlStateNormal] - $(UIButton).text(attrString); - - @endcode - */ -- (ShortCocoa * (^)(_Nullable ShortCocoaString))text; - -/** - 设置 UIImageView / UIButton 的图片 - - @note 支持的被包装对象:UIImageView, UIButton - @note 传入值支持 UIImage 或 NSString(图片名字),详见 ShortCocoaImage - - @code - - // 等价于 [[UIButton new] setImage:image forState:UIControlStateNormal] - $(UIButton).image(image); - - // 等价于 [[UIImageView new] setImage:image] - $(UIImageView).image(image); - - @endcode - */ -- (ShortCocoa * (^)(ShortCocoaImage _Nullable))image; - - -/** - 等价于 view.userInteractionEnabled - - @note 支持的被包装对象:UIView - */ -- (ShortCocoa * (^)(BOOL))userInteractionEnabled; - -/** - 设置 UIScrollView.scrollIndicatorInsets.top - @note 支持的被包装对象:UIScrollView - */ -- (ShortCocoa * (^)(CGFloat))indicatorInsetTop; -/** - 设置 UIScrollView.scrollIndicatorInsets.left - @note 支持的被包装对象:UIScrollView - */ -- (ShortCocoa * (^)(CGFloat))indicatorInsetLeft; -/** - 设置 UIScrollView.scrollIndicatorInsets.bottom - @note 支持的被包装对象:UIScrollView - */ -- (ShortCocoa * (^)(CGFloat))indicatorInsetBottom; -/** - 设置 UIScrollView.scrollIndicatorInsets.right - @note 支持的被包装对象:UIScrollView - */ -- (ShortCocoa * (^)(CGFloat))indicatorInsetRight; - -/** - 设置 UIScrollView.contentInset 或 UIButton.contentEdgeInsets - @note 支持的被包装对象:UIScrollView, UIButton - */ -- (ShortCocoa * (^)(ShortCocoaQuad))insets; -/** - 设置 UIScrollView.contentInset 或 UIButton.contentEdgeInsets 的 top 值 - @note 支持的被包装对象:UIScrollView, UIButton - */ -- (ShortCocoa * (^)(CGFloat))insetTop; -/** - 设置 UIScrollView.contentInset 或 UIButton.contentEdgeInsets 的 left 值 - @note 支持的被包装对象:UIScrollView, UIButton - */ -- (ShortCocoa * (^)(CGFloat))insetLeft; -/** - 设置 UIScrollView.contentInset 或 UIButton.contentEdgeInsets 的 bottom 值 - @note 支持的被包装对象:UIScrollView, UIButton - */ -- (ShortCocoa * (^)(CGFloat))insetBottom; -/** - 设置 UIScrollView.contentInset 或 UIButton.contentEdgeInsets 的 right 值 - @note 支持的被包装对象:UIScrollView, UIButton - */ -- (ShortCocoa * (^)(CGFloat))insetRight; - -#endif - -@end diff --git a/LookInside/ShortCocoa/ShortCocoa+UIToolkit.m b/LookInside/ShortCocoa/ShortCocoa+UIToolkit.m deleted file mode 100644 index d5b1e06c..00000000 --- a/LookInside/ShortCocoa/ShortCocoa+UIToolkit.m +++ /dev/null @@ -1,397 +0,0 @@ -// -// ShortCocoa+UIToolkit.m -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoa+UIToolkit.h" -#import "ShortCocoa+Private.h" - -#if TARGET_OS_IPHONE - #define NS_UI_View UIView -#elif TARGET_OS_MAC - #define NS_UI_View NSView -#endif - -@implementation ShortCocoa (UIToolkit) - -- (ShortCocoa *)visibles { - if (ShortCocoaEqualClass(_get, NS_UI_View)) { - if (![self isVisibleView:_get]) { - _get = nil; - } - } else if (ShortCocoaEqualClass(_get, CALayer)) { - if (![self isVisibleLayer:_get]) { - _get = nil; - } - } else if (ShortCocoaEqualClass(_get, NSArray)) { - NSMutableArray *visibleOnes = [NSMutableArray arrayWithCapacity:((NSArray *)_get).count]; - [(NSArray *)_get enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if ([self isVisibleViewOrLayer:obj]) { - [visibleOnes addObject:obj]; - } - }]; - _get = visibleOnes.count > 1 ? visibleOnes.copy : [visibleOnes firstObject]; - } - return self; -} - -- (ShortCocoa * (^)(CGFloat))opacity { - return ^(CGFloat value) { -#if TARGET_OS_IPHONE - [self unpackClassA:[UIView class] doA:^(UIView *view, BOOL *stop) { - view.alpha = value; -#elif TARGET_OS_MAC - [self unpackClassA:[NSView class] doA:^(NSView *view, BOOL *stop) { - view.alphaValue = value; -#endif - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - layer.opacity = value; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))alpha { - return ^(CGFloat value) { - return self.opacity(value); - }; -} - -- (ShortCocoa *)hide { - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - view.hidden = YES; - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - layer.hidden = YES; - }]; - return self; -} - -- (ShortCocoa *)show { - [self unpackClassA:[NS_UI_View class] doA:^(NS_UI_View *view, BOOL *stop) { - view.hidden = NO; - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - layer.hidden = NO; - }]; - return self; -} - -#if TARGET_OS_IPHONE - -- (ShortCocoa * (^)(id container))addTo { - return ^(id container) { - if (!container) { - return self; - } - [self unpackClassA:[UIView class] doA:^(UIView * _Nonnull view, BOOL *stop) { - if (ShortCocoaEqualClass(container, UIView)) { - [container addSubview:view]; - } else if (ShortCocoaEqualClass(container, CALayer)) { - [container addSublayer:view.layer]; - } - - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - if (ShortCocoaEqualClass(container, UIView)) { - [((UIView *)container).layer addSublayer:layer]; - } else if (ShortCocoaEqualClass(container, CALayer)) { - [container addSublayer:layer]; - } - }]; - return self; - }; -} - -- (ShortCocoa * (^)(id target, SEL action))onTap { - return ^(id target, SEL action) { - [self unpack:[UIControl class] do:^(UIControl * _Nonnull control, BOOL *stop) { - [control addTarget:target action:action forControlEvents:UIControlEventTouchUpInside]; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(BOOL))userInteractionEnabled { - return ^(BOOL enabled) { - [self unpack:[UIView class] do:^(UIView * _Nonnull obj, BOOL *stop) { - obj.userInteractionEnabled = enabled; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(BOOL))clipsToBounds { - return ^(BOOL boolValue) { - [self unpackClassA:[UIView class] doA:^(UIView *view, BOOL *stop) { - view.clipsToBounds = boolValue; - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - layer.masksToBounds = boolValue; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(BOOL))masksToBounds { - return ^(BOOL boolValue) { - self.clipsToBounds(boolValue); - return self; - }; -} - -- (ShortCocoa * (^)(id))bgColor { - return ^(id value) { - id UI_or_NS_Color = [ShortCocoaHelper colorFromShortCocoaColor:value]; - [self unpackClassA:[UIView class] doA:^(UIView *view, BOOL *stop) { - view.backgroundColor = UI_or_NS_Color; - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - layer.backgroundColor = [UI_or_NS_Color CGColor]; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(id))borderColor { - return ^(id value) { - id UI_or_NS_Color = [ShortCocoaHelper colorFromShortCocoaColor:value]; - [self unpackClassA:[UIView class] doA:^(UIView *view, BOOL *stop) { - view.layer.borderColor = [UI_or_NS_Color CGColor]; - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - layer.borderColor = [UI_or_NS_Color CGColor]; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))borderWidth { - return ^(CGFloat value) { - [self unpackClassA:[UIView class] doA:^(UIView *view, BOOL *stop) { - view.layer.borderWidth = value; - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - layer.borderWidth = value; - }]; - return self; - }; -} - - - -- (ShortCocoa * (^)(CGFloat))corners { - return ^(CGFloat value) { - [self unpackClassA:[UIView class] doA:^(UIView *view, BOOL *stop) { - view.layer.cornerRadius = value; - } classB:[CALayer class] doB:^(CALayer * _Nonnull layer, BOOL *stop) { - layer.cornerRadius = value; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(ShortCocoaImage _Nullable))image { - return ^(ShortCocoaImage _Nullable image) { - id UI_or_NS_Image = [ShortCocoaHelper imageFromShortCocoaImage:image]; - [self unpackClassA:[UIButton class] doA:^(UIButton * _Nonnull button, BOOL *stop) { - [button setImage:UI_or_NS_Image forState:UIControlStateNormal]; - } classB:[UIImageView class] doB:^(UIImageView * _Nonnull imageView, BOOL *stop) { - [imageView setImage:UI_or_NS_Image]; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))indicatorInsetTop { - return ^(CGFloat insetTop) { - [self unpack:[UIScrollView class] do:^(UIScrollView * _Nonnull obj, BOOL *stop) { - UIEdgeInsets indicatorInsets = obj.scrollIndicatorInsets; - indicatorInsets.top = insetTop; - obj.scrollIndicatorInsets = indicatorInsets; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))indicatorInsetLeft { - return ^(CGFloat insetLeft) { - [self unpack:[UIScrollView class] do:^(UIScrollView * _Nonnull obj, BOOL *stop) { - UIEdgeInsets indicatorInsets = obj.scrollIndicatorInsets; - indicatorInsets.left = insetLeft; - obj.scrollIndicatorInsets = indicatorInsets; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))indicatorInsetBottom { - return ^(CGFloat insetBottom) { - [self unpack:[UIScrollView class] do:^(UIScrollView * _Nonnull obj, BOOL *stop) { - UIEdgeInsets indicatorInsets = obj.scrollIndicatorInsets; - indicatorInsets.bottom = insetBottom; - obj.scrollIndicatorInsets = indicatorInsets; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))indicatorInsetRight { - return ^(CGFloat insetRight) { - [self unpack:[UIScrollView class] do:^(UIScrollView * _Nonnull obj, BOOL *stop) { - UIEdgeInsets indicatorInsets = obj.scrollIndicatorInsets; - indicatorInsets.right = insetRight; - obj.scrollIndicatorInsets = indicatorInsets; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(ShortCocoaQuad))insets { - return ^(ShortCocoaQuad insets) { - NSArray *insetsNumbers = [ShortCocoaHelper fourNumbersFromShortCocoaQuad:insets]; - if (insetsNumbers.count == 4) { - UIEdgeInsets uiEdgeInsets = UIEdgeInsetsMake([insetsNumbers[0] doubleValue], [insetsNumbers[1] doubleValue], [insetsNumbers[2] doubleValue], [insetsNumbers[3] doubleValue]); - - [self unpackClassA:[UIScrollView class] doA:^(UIScrollView * _Nonnull obj, BOOL *stop) { - obj.contentInset = uiEdgeInsets; - } classB:[UIButton class] doB:^(UIButton * _Nonnull obj, BOOL *stop) { - obj.contentEdgeInsets = uiEdgeInsets; - }]; - } - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))insetTop { - return ^(CGFloat insetTop) { - [self unpackClassA:[UIScrollView class] doA:^(UIScrollView * _Nonnull obj, BOOL *stop) { - UIEdgeInsets insets = obj.contentInset; - insets.top = insetTop; - obj.contentInset = insets; - - } classB:[UIButton class] doB:^(UIButton * _Nonnull obj, BOOL *stop) { - UIEdgeInsets insets = obj.contentEdgeInsets; - insets.top = insetTop; - obj.contentEdgeInsets = insets; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))insetLeft { - return ^(CGFloat insetLeft) { - [self unpackClassA:[UIScrollView class] doA:^(UIScrollView * _Nonnull obj, BOOL *stop) { - UIEdgeInsets insets = obj.contentInset; - insets.left = insetLeft; - obj.contentInset = insets; - - } classB:[UIButton class] doB:^(UIButton * _Nonnull obj, BOOL *stop) { - UIEdgeInsets insets = obj.contentEdgeInsets; - insets.left = insetLeft; - obj.contentEdgeInsets = insets; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))insetBottom { - return ^(CGFloat insetBottom) { - [self unpackClassA:[UIScrollView class] doA:^(UIScrollView * _Nonnull obj, BOOL *stop) { - UIEdgeInsets insets = obj.contentInset; - insets.bottom = insetBottom; - obj.contentInset = insets; - - } classB:[UIButton class] doB:^(UIButton * _Nonnull obj, BOOL *stop) { - UIEdgeInsets insets = obj.contentEdgeInsets; - insets.bottom = insetBottom; - obj.contentEdgeInsets = insets; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(CGFloat))insetRight { - return ^(CGFloat insetRight) { - [self unpackClassA:[UIScrollView class] doA:^(UIScrollView * _Nonnull obj, BOOL *stop) { - UIEdgeInsets insets = obj.contentInset; - insets.right = insetRight; - obj.contentInset = insets; - - } classB:[UIButton class] doB:^(UIButton * _Nonnull obj, BOOL *stop) { - UIEdgeInsets insets = obj.contentEdgeInsets; - insets.right = insetRight; - obj.contentEdgeInsets = insets; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(NSInteger))lines { - return ^(NSInteger maxValue) { - [self unpack:[UILabel class] do:^(UILabel * _Nonnull label, BOOL *stop) { - label.numberOfLines = maxValue; - }]; - return self; - }; -} - -- (ShortCocoa * (^)(id))text { - return ^(id string) { - [self unpackClassA:[UILabel class] doA:^(UILabel * _Nonnull obj, BOOL *stop) { - if (!string) { - obj.text = nil; - obj.attributedText = nil; - } else if (ShortCocoaEqualClass(string, NSString)) { - obj.text = string; - } else if (ShortCocoaEqualClass(string, NSAttributedString)) { - obj.attributedText = string; - } else if (ShortCocoaEqualClass(string, NSNumber)) { - obj.text = [NSString stringWithFormat:@"%@", string]; - } else { - NSAssert(NO, @"传入的 string 不是 NSString、NSAttributedString 或 NSNumber"); - } - - } classB:[UIButton class] doB:^(UIButton * _Nonnull obj, BOOL *stop) { - if (!string) { - [obj setTitle:nil forState:UIControlStateNormal]; - [obj setAttributedTitle:nil forState:UIControlStateNormal]; - } else if (ShortCocoaEqualClass(string, NSString)) { - [obj setAttributedTitle:nil forState:UIControlStateNormal]; - [obj setTitle:string forState:UIControlStateNormal]; - } else if (ShortCocoaEqualClass(string, NSAttributedString)) { - [obj setTitle:nil forState:UIControlStateNormal]; - [obj setAttributedTitle:string forState:UIControlStateNormal]; - } else if (ShortCocoaEqualClass(string, NSNumber)) { - [obj setAttributedTitle:nil forState:UIControlStateNormal]; - [obj setTitle:[NSString stringWithFormat:@"%@", string] forState:UIControlStateNormal]; - } else { - NSAssert(NO, @"传入的 string 不是 NSString、NSAttributedString 或 NSNumber"); - } - }]; - return self; - }; -} - -#endif - -#pragma mark - Private - -- (BOOL)isVisibleView:(NS_UI_View *)view { -#if TARGET_OS_IPHONE - return view && !view.hidden && view.superview && view.alpha >= 0.01; -#elif TARGET_OS_MAC - return view && !view.hidden && view.superview && view.alphaValue >= 0.01; -#endif -} - -- (BOOL)isVisibleLayer:(CALayer *)layer { - return layer && !layer.hidden && layer.superlayer && layer.opacity >= 0.01; -} - -- (BOOL)isVisibleViewOrLayer:(id)object { - if (ShortCocoaEqualClass(object, NS_UI_View)) { - return [self isVisibleView:object]; - } else if (ShortCocoaEqualClass(object, CALayer)) { - return [self isVisibleLayer:object]; - } else { - return NO; - } -} - -@end diff --git a/LookInside/ShortCocoa/ShortCocoa.h b/LookInside/ShortCocoa/ShortCocoa.h deleted file mode 100644 index 9b16a1b3..00000000 --- a/LookInside/ShortCocoa/ShortCocoa.h +++ /dev/null @@ -1,80 +0,0 @@ -// -// ShortCocoa.h -// ShortCocoa -// -// Copyright © 2018 hughkli. All rights reserved. -// - -#import "ShortCocoaCore.h" -#import "ShortCocoa+Layout.h" -#import "ShortCocoa+String.h" -#import "ShortCocoa+UIToolkit.h" -#import "ShortCocoa+Others.h" - -/** - 快速教程 - - 【attributedString 拼接】 - // 生成一个 NSAttributedString,并且设置 font 为 20 大小的系统字体,设置 foregroundColor 为 rgb(10, 20, 30) - $(@"abc").font(@20).textColor(@"10, 20, 30").attrString; - - // 生成一个 NSMutableAttributedString,并且设置其 font, textColor,lineHeight,然后增加下划线效果(即 NSUnderlineStyleAttributeName) - $(@"abc").font(UIFontObj).textColor(UIColorObj).lineHeight(22).underline.mAttrString; - - // 生成一个包含图片的 NSAttributedString,内容为:@"ca[图片]b" - $(@"a").addImage(UIImageObj, 0, 1, 2).add(@"b").prepend(@"c").attrString; - - // 生成一个 NSAttributedString,内容为:@"a[空白间距]bc[图片]",其中 @"a"是12号红色字体,@"b"是13号绿色字体,@"c"是14号蓝色字体,然后这个字符串整体行高为 23pt - $(@"a").textColor(@"red").font(@12) - .addSpace(5) - .add($(@"b").textColor(@"green").font(@13)) - .add($(@"c").textColor(@"blue").font(@14)) - .addImage(UIImageObj, 0, 0, 0) - .lineHeight(23).attrString; - - - 【frame 布局】 - // 等价于 view.frame = CGRectMake(30, 40, 10, 20) - $(view).width(10).height(20).x(30).y(40); - - // 调用 sizeToFit,然后在 superview 里垂直居中、y 值设置为 20 - $(view).sizeToFit.horAlign.y(20); - - // 把 label 宽度设置为 “button 的宽度值”、把高度设置为”view 的高度值 + 10“、水平移动到 midX 为 30 的位置、在 superview 里垂直居中 - $(label).width(button.$width).height(view.$height + 10).midX(30).verAlign; - - // 把 view1、view2、view3 整体在 superview 里水平居中,然后整体移动到距离 superview 底部为 20 的位置 - // tips:你可以像这样同时包装多个对象整体调整,即使其中某个对象为 nil 也无所谓 - $(view1, view2, view3).groupHorAlign.groupBottom(20); - - - 【view 操作】 - // 创建一个 UIButton 并设置它的一些属性,最后赋值给 self.button - self.button = $(UIButton).textColor(@"red").font(@12).text(@"hello").alpha(0.5).addTo(self.view).get; - - // 将 label, button, imageView 的 hidden 属性设置为 YES - $(label, button, imageView).hide; - - */ - - -/** - 该框架带来的好处不赘述,该框架可能带来的坏处是: - 1)为了看懂你使用 ShortCocoa 书写的代码,你的同事也必须花时间了解 ShortCocoa 的语法。 - (你可以考虑避免使用某些好用但学习成本较高的方法,比如 heightToFit,或上面的 attributedString 拼接教程中的最后一个例子) - - 2)为了参数灵活而丢失了编译期类型检查,比如 $(button).textColor(@"red") 方法可以传入 UIColor, @"red", @"#aabbcc" 等各种合法对象,但如果你传入了诸如 UIFont 等错误类型也仍然可以通过编译。 - (Debug 模式下,这些类型错误会在运行时通过 NSAssert 警告你) - - 3)增加了维护复杂度,比如之前你们可以通过全文搜索 "hidden =" 来快速定位到所有可能更改 hidden 属性的代码,但现在由于多了 $(view).hide 这个语法,仅仅搜索 "hidden =" 就不够了。 - (该框架主要用在“attrString 拼接”、“布局”、“view 操作”三个场景中,你可以避免在 “view 操作” 场景中使用该框架,因为操作 view 的代码本来就不复杂,因此使用该框架带来的效率提升也就不是特别明显。而 attrString 和布局的原生代码很繁琐又往往集中在一处不需全文搜索,因此很适合使用该框架处理) - */ - - -/** - 可能的命名空间冲突: - 1)该框架默认占用了 $() 这个语法,请确保你的项目中没有别的 #define $(...) 。你也可以更改为其他语法,比如你想用 "ooo(label, button)" 这种语法,那直接把下面 define 里的 "$" 改成 "ooo" 即可 - 2)该框架给 UIView/NSView 增加了以下分类方法:$x, $midX, $midY, $y, $midY, $maxY, $width, $height, $size, $bestSize, $bestWidth, $bestHeight,详见 ShortCocoa+Layout.h - 3)该框架给 CALayer 增加了以下分类方法:$x, $midX, $midY, $y, $midY, $maxY, $width, $height, $size, 详见 ShortCocoa+Layout.h - */ -#define $(...) ShortCocoaMake(__VA_ARGS__) diff --git a/LookInside/ShortCocoa/ShortCocoaCore.h b/LookInside/ShortCocoa/ShortCocoaCore.h deleted file mode 100644 index bab51a96..00000000 --- a/LookInside/ShortCocoa/ShortCocoaCore.h +++ /dev/null @@ -1,90 +0,0 @@ -// -// ShortCocoaCore.h -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/** - 通过 $(...) 将任何对象包装为 ShortCocoa 对象,进而使用链式语法 - */ -@interface ShortCocoa : NSObject { - @protected - id _get; -} - -/** - 将一个或多个对象包装为一个 ShortCocoa 对象 - - @warning 传入的 count 值必须和传入的 object 的参数数量保持一致,否则会导致 Crash - @note 不推荐使用这个繁琐的语法,请使用 $(...) 这个宏 - - @param count 在 object 里传入的参数数量 - @param object 被包装的对象 - */ -- (instancetype)initWithObjectsCount:(uint)count objects:(nullable id)object,...; - -/** - 获取被 ShortCocoa 包装的原始对象 - - 一种常见的使用场景: - @code - self.label = $(UILabel).text(@"Hello").addTo(self.view).get; - @endcode - - 各种情况的返回值: - @code - - $(self.label).get; // => self.label - - $(label1, nil, label2).get; // => @[label1, label2] - - $(UIView).get; // => [UIView new],即返回一个 UIView 的实例 - - @endcode - - */ -@property(nonatomic, strong, readonly, nullable) id get; - -@end - -/** - 便利的 ShortCocoa 初始化方法,该参数的第一个传入值 something 可以为对象也可以为 Class,从而实现了 $(UIView) 这种写法 - @note 该方法因为有 ShortCocoa 的前缀命名空间因此更加安全,主要在 ShortCocoa 内部使用。外部请使用更便利的 $(...) 方法 - */ -#define ShortCocoaMake(something, ...) [[ShortCocoa alloc] initWithObjectsCount:(ShortCocoaCountArgs(ShortCocoaMakeInstance([something self]), ##__VA_ARGS__)) objects:ShortCocoaMakeInstance([something self]), ##__VA_ARGS__] - -/** - 在编译时计算传入的参数数量,从而实现 $(label1, label2) 这种写法,即参数数量不确定而又无需在末尾标注 nil 等符号 - @note 如果没有传入任何参数则返回 1 - @warning 最多支持传入 63 个参数,并非无限 - */ -#define ShortCocoaCountArgs(...) ShortCocoaCountArgs1(__VA_ARGS__,ShortCocoaCountArgs2()) -#define ShortCocoaCountArgs1(...) ShortCocoaCountArgs3(__VA_ARGS__) -#define ShortCocoaCountArgs3( \ -_1, _2, _3, _4, _5, _6, _7, _8, _9,_10, \ -_11,_12,_13,_14,_15,_16,_17,_18,_19,_20, \ -_21,_22,_23,_24,_25,_26,_27,_28,_29,_30, \ -_31,_32,_33,_34,_35,_36,_37,_38,_39,_40, \ -_41,_42,_43,_44,_45,_46,_47,_48,_49,_50, \ -_51,_52,_53,_54,_55,_56,_57,_58,_59,_60, \ -_61,_62,_63,N,...) N -#define ShortCocoaCountArgs2() \ -63,62,61,60, \ -59,58,57,56,55,54,53,52,51,50, \ -49,48,47,46,45,44,43,42,41,40, \ -39,38,37,36,35,34,33,32,31,30, \ -29,28,27,26,25,24,23,22,21,20, \ -19,18,17,16,15,14,13,12,11,10, \ -9,8,7,6,5,4,3,2,1,0 - -/** - 如果传入 Class 则返回该 Class 的一个实例,否则直接将传入的参数返回 - */ -id ShortCocoaMakeInstance(id object); - -NS_ASSUME_NONNULL_END diff --git a/LookInside/ShortCocoa/ShortCocoaCore.m b/LookInside/ShortCocoa/ShortCocoaCore.m deleted file mode 100644 index 8c527a8f..00000000 --- a/LookInside/ShortCocoa/ShortCocoaCore.m +++ /dev/null @@ -1,59 +0,0 @@ -// -// ShortCocoaCore.m -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -#import "ShortCocoaCore.h" -#import - -@implementation ShortCocoa - -@synthesize get = _get; - -- (instancetype)initWithObjectsCount:(uint)count objects:(nullable id)object,... { - if (self = [self init]) { - if (count == 1) { - _get = object; - - } else if (count > 1) { - int currentIndex = 1; - - NSMutableArray *array = nil; - if (object) { - array = [NSMutableArray array]; - [array addObject:object]; - } - - va_list args; - va_start(args, object); - id arg; - while (currentIndex < count) { - currentIndex++; - - arg = va_arg(args, id); - if (!arg) { - // 传入了 nil - continue; - } - if (!array) { - array = [NSMutableArray array]; - } - [array addObject:arg]; - } - va_end(args); - _get = array.count > 1 ? [array copy] : [array firstObject]; - } - } - return self; -} - -id ShortCocoaMakeInstance(id object) { - if (object_isClass(object)) { - return [object new]; - } - return object; -} - -@end diff --git a/LookInside/ShortCocoa/ShortCocoaDefines.h b/LookInside/ShortCocoa/ShortCocoaDefines.h deleted file mode 100644 index 936027c7..00000000 --- a/LookInside/ShortCocoa/ShortCocoaDefines.h +++ /dev/null @@ -1,52 +0,0 @@ -// -// ShortCocoaDefines.h -// ShortCocoa -// -// Copyright © 2018年 hughkli. All rights reserved. -// - -/** - * 属于 ShortCocoaColor 的对象: - * 1)UIColor(iOS) 或 NSColor(macOS) - * 2)RGB/RGBA 格式的字符串,中间用逗号隔开,RGB 的范围是 0 ~ 255,A 的范围是 0 ~ 1。比如:@"155, 233, 245",@"0, 255, 3, 0.5" - * 3)HEX 格式的字符串,格式可以是 RGB、ARGB、RRGGBB、AARRGGBB,比如:@"ff00ff" - * 4)@"red"等表意字符串,支持列表:@"clear", @"white", @"black", @"gray", @"red", @"green", @"blue", @"yellow" - */ -typedef id ShortCocoaColor; - -/** - * 属于 ShortCocoaQuad 的对象: - * 1)字符串,用逗号","隔开,允许 1 ~ 4 个值,比如 @"10, 15, 20, 25" - * 2)NSNumber 对象,比如 @20 - * 3)NSArray 对象,允许 1 ~ 4 个值,比如 @[@10, @20, @30, @40] - * - * 四个值:@"10, 20, 30, 40" - * 三个值:@"10, 20, 30" 等价于 @"10, 20, 30, 20" - * 两个值:@"10, 20" 等价于 @"10, 20, 10, 20" - * 一个值:@"10" 或 @10 等价于 @"10, 10, 10, 10" - */ -typedef id ShortCocoaQuad; - -/** - * 属于 ShortCocoaImage 的对象: - * 1)UIImage(iOS) 或 NSImage(macOS) - * 2)NSString,比如 @"icon" 等价于 [UIImage imageNamed:@"icon"] 或 [NSImage imageNamed:@"icon"] - */ -typedef id ShortCocoaImage; - -/** - * 属于 ShortCocoaFont 的对象: - * 1)UIFont - * 2)字符串或 NSNumber,比如 @"15"、@15 等价于 [UIFont systemFontOfSize:15] - */ -typedef id ShortCocoaFont; - -/** - * 属于 ShortCocoaString 的对象: - * 1)NSString - * 2)NSAttributedString - * 3)NSNumber,比如 @(20.3) 将被看做是 @"20.3" - * 4)NSArray,比如 @[@"a", @2],它将被拼为 @"a2"。该数组里支持 NSString, NSAttributedString, NSNumber 对象。 - * 5)ShortCocoa,比如 $(@"abc"),它将被转换为 @"abc"。比如 $(attrString),它将被转换为 attrString - */ -typedef id ShortCocoaString; diff --git a/LookInside/Static/LKMessageManager.h b/LookInside/Static/LKMessageManager.h deleted file mode 100644 index 09b9bc0e..00000000 --- a/LookInside/Static/LKMessageManager.h +++ /dev/null @@ -1,32 +0,0 @@ -// -// LKMessageManager.h -// LookinClient -// -// Created by likai.123 on 2023/10/30. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -extern NSString *const LKMessage_Jobs; -extern NSString *const LKMessage_SwiftSubspec; - -@interface LKMessageManager : NSObject - -+ (instancetype)sharedInstance; - -- (void)addMessage:(NSString *)message; - -- (void)removeMessage:(NSString *)message; - -- (NSArray *)queryMessages; - -#if DEBUG -- (void)reset; -#endif - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/Static/LKMessageManager.m b/LookInside/Static/LKMessageManager.m deleted file mode 100644 index 315ab273..00000000 --- a/LookInside/Static/LKMessageManager.m +++ /dev/null @@ -1,70 +0,0 @@ -// -// LKMessageManager.m -// LookinClient -// -// Created by likai.123 on 2023/10/30. -// Copyright © 2023 hughkli. All rights reserved. -// - -#import "LKMessageManager.h" - -NSString *const LKMessage_Jobs = @"LKMessage_Jobs"; -NSString *const LKMessage_SwiftSubspec = @"LKMessage_SwiftSubspec"; - -@interface LKMessageManager () - -@property(nonatomic, strong) NSMutableSet *messages; - -@end - -@implementation LKMessageManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKMessageManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -- (instancetype)init { - if (self = [super init]) { - self.messages = [NSMutableSet set]; - -// BOOL hasReadJobs = [[NSUserDefaults standardUserDefaults] boolForKey:@"LKMessageManager_HasReadJobs"]; -// if (!hasReadJobs) { -// [self addMessage:LKMessage_Jobs]; -// } - } - return self; -} - -- (void)addMessage:(nonnull NSString *)message { - [self.messages addObject:message]; -} - -- (void)removeMessage:(nonnull NSString *)message { - [self.messages removeObject:message]; - - if ([message isEqualToString:LKMessage_Jobs]) { - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"LKMessageManager_HasReadJobs"]; - } -} - -- (NSArray *)queryMessages { - NSArray *ret = [[self.messages allObjects] lookin_sortedArrayByStringLength]; - return ret; -} - -#if DEBUG -- (void)reset { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"LKMessageManager_HasReadJobs"]; -} -#endif - -@end diff --git a/LookInside/Static/LKMessageManager.swift b/LookInside/Static/LKMessageManager.swift new file mode 100644 index 00000000..fc789b57 --- /dev/null +++ b/LookInside/Static/LKMessageManager.swift @@ -0,0 +1,56 @@ +// +// LKMessageManager.swift +// LookInside +// +// Created by likai.123 on 2023/10/30. +// Copyright © 2023 hughkli. All rights reserved. +// + +import Foundation + +/// The notices the inspector window shows under its toolbar message +/// button. Identifiers are `LKMessage_Jobs` and `LKMessage_SwiftSubspec` +/// (LKStaticConstants.swift). +@objc(LKMessageManager) +final class LKMessageManager: NSObject { + /// Set once the jobs notice was dismissed. + private static let hasReadJobsDefaultsKey = "LKMessageManager_HasReadJobs" + + private static let shared = LKMessageManager() + + @objc class func sharedInstance() -> LKMessageManager { + shared + } + + private var messages = Set() + + override private init() { + super.init() + } + + @objc(addMessage:) + func addMessage(_ message: String) { + messages.insert(message) + } + + @objc(removeMessage:) + func removeMessage(_ message: String) { + messages.remove(message) + if message == LKMessage_Jobs { + UserDefaults.standard.set(true, forKey: Self.hasReadJobsDefaultsKey) + } + } + + /// The current messages, shortest first. + @objc func queryMessages() -> [String] { + // A stable sort over an unordered set: ties keep whatever order the + // set yields, as NSSet's -allObjects did. + Array(messages).sorted { $0.utf16.count < $1.utf16.count } + } + + #if DEBUG + @objc func reset() { + UserDefaults.standard.removeObject(forKey: Self.hasReadJobsDefaultsKey) + } + #endif +} diff --git a/LookInside/Static/LKReloadItemAndChildrenUpdateTaskMaker.h b/LookInside/Static/LKReloadItemAndChildrenUpdateTaskMaker.h deleted file mode 100644 index d3fb492d..00000000 --- a/LookInside/Static/LKReloadItemAndChildrenUpdateTaskMaker.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LKReloadItemAndChildrenUpdateTaskMaker.h -// LookinClient -// -// Created by likai.123 on 2024/3/3. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import -#import "LookinStaticAsyncUpdateTask.h" - -@class LKStaticAsyncUpdateManager; - -@interface LKReloadItemAndChildrenUpdateTaskMaker : NSObject - -/// Phase A 引入:由调用方传入的 per-instance update manager,用于 isUpdating 检查。 -/// 若传 nil 则 fallback 到 +sharedInstance。 -+ (NSArray *)makeWithItem:(LookinDisplayItem *)item - updateManager:(LKStaticAsyncUpdateManager *)updateManager; - -@end diff --git a/LookInside/Static/LKReloadItemAndChildrenUpdateTaskMaker.m b/LookInside/Static/LKReloadItemAndChildrenUpdateTaskMaker.m deleted file mode 100644 index 46759fad..00000000 --- a/LookInside/Static/LKReloadItemAndChildrenUpdateTaskMaker.m +++ /dev/null @@ -1,52 +0,0 @@ -// -// LKReloadItemAndChildrenUpdateTaskMaker.m -// LookinClient -// -// Created by likai.123 on 2024/3/3. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import "LKReloadItemAndChildrenUpdateTaskMaker.h" -#import "LKStaticAsyncUpdateManager.h" -#import "LKInspectableApp.h" -#import "LKVersionComparer.h" -#import "LookinDisplayItem+LookinClient.h" -#import "LookInside-Swift.h" - -@implementation LKReloadItemAndChildrenUpdateTaskMaker - -+ (NSArray *)makeWithItem:(LookinDisplayItem *)item - updateManager:(LKStaticAsyncUpdateManager *)updateManager { - if (!item || updateManager.isUpdating) { - NSAssert(NO, @""); - return nil; - } - LookinAppInfo *currentAppInfo = updateManager.inspectableApp.appInfo; - NSString *serverVersion = currentAppInfo.serverReadableVersion; - BOOL supported = [LKVersionComparer compareWithExpectedVersion:@"1.2.7" realVersion:serverVersion]; - if (!supported) { - AlertErrorText(NSLocalizedString(@"Operation failed.", nil), NSLocalizedString(@"Please upgrade the LookinServer SDK version in your iOS project to 1.2.7 or higher.", nil), CurrentKeyWindow); - return nil; - } - if ([LKHelper appInfoLooksLikeMacTarget:currentAppInfo] && - [item lk_isSwiftUISupportRelated] && - ![[LKSwiftUISupportGatekeeper sharedInstance] allowProtectedFeatureAccessForWindow:CurrentKeyWindow]) { - return nil; - } - BOOL prefersViewOID = [LKHelper appInfoLooksLikeMacTarget:currentAppInfo]; - unsigned long oid = [item bestObjectOidPreferView:prefersViewOID]; - if (!oid) { - return nil; - } - LookinStaticAsyncUpdateTask *task = [LookinStaticAsyncUpdateTask new]; - task.oid = oid; - task.taskType = LookinStaticAsyncUpdateTaskTypeNoScreenshot; - task.attrRequest = LookinDetailUpdateTaskAttrRequest_NotNeed; - task.needBasisVisualInfo = YES; - task.needSubitems = YES; - task.frameSize = item.frame.size; - task.clientReadableVersion = [LKHelper lookinReadableVersion]; - return @[task]; -} - -@end diff --git a/LookInside/Static/LKReloadSingleItemUpdateTaskMaker.h b/LookInside/Static/LKReloadSingleItemUpdateTaskMaker.h deleted file mode 100644 index 40f86069..00000000 --- a/LookInside/Static/LKReloadSingleItemUpdateTaskMaker.h +++ /dev/null @@ -1,21 +0,0 @@ -// -// LKReloadSingleItemUpdateTaskMaker.h -// LookinClient -// -// Created by likai.123 on 2024/3/3. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import -#import "LookinStaticAsyncUpdateTask.h" - -@class LKStaticAsyncUpdateManager; - -@interface LKReloadSingleItemUpdateTaskMaker : NSObject - -/// Phase A 引入:由调用方传入的 per-instance update manager,用于 isUpdating 检查。 -/// 若传 nil 则 fallback 到 +sharedInstance。 -+ (NSArray *)makeWithItem:(LookinDisplayItem *)item - updateManager:(LKStaticAsyncUpdateManager *)updateManager; - -@end diff --git a/LookInside/Static/LKReloadSingleItemUpdateTaskMaker.m b/LookInside/Static/LKReloadSingleItemUpdateTaskMaker.m deleted file mode 100644 index f523b820..00000000 --- a/LookInside/Static/LKReloadSingleItemUpdateTaskMaker.m +++ /dev/null @@ -1,76 +0,0 @@ -// -// LKReloadSingleItemUpdateTaskMaker.m -// LookinClient -// -// Created by likai.123 on 2024/3/3. -// Copyright © 2024 hughkli. All rights reserved. -// - -#import "LKReloadSingleItemUpdateTaskMaker.h" -#import "LKStaticAsyncUpdateManager.h" -#import "LKInspectableApp.h" -#import "LKVersionComparer.h" -#import "LookinDisplayItem+LookinClient.h" -#import "LookInside-Swift.h" - -@implementation LKReloadSingleItemUpdateTaskMaker - -+ (NSArray *)makeWithItem:(LookinDisplayItem *)item - updateManager:(LKStaticAsyncUpdateManager *)updateManager { - if (!item || updateManager.isUpdating) { - NSAssert(NO, @""); - return nil; - } - LookinAppInfo *currentAppInfo = updateManager.inspectableApp.appInfo; - NSString *serverVersion = currentAppInfo.serverReadableVersion; - BOOL supported = [LKVersionComparer compareWithExpectedVersion:@"1.2.7" realVersion:serverVersion]; - if (!supported) { - AlertErrorText(NSLocalizedString(@"Operation failed.", nil), NSLocalizedString(@"Please upgrade the LookinServer SDK version in your iOS project to 1.2.7 or higher.", nil), CurrentKeyWindow); - return nil; - } - if ([LKHelper appInfoLooksLikeMacTarget:currentAppInfo] && - [item lk_isSwiftUISupportRelated] && - ![[LKSwiftUISupportGatekeeper sharedInstance] allowProtectedFeatureAccessForWindow:CurrentKeyWindow]) { - return nil; - } - NSMutableArray *tasks = [NSMutableArray array]; - - if (item.doNotFetchScreenshotReason == LookinFetchScreenshotPermitted) { - LookinStaticAsyncUpdateTask *task = [self taskFromItem:item appInfo:currentAppInfo]; - if (task) { - task.taskType = LookinStaticAsyncUpdateTaskTypeGroupScreenshot; - [tasks addObject:task]; - } - - if (item.isExpandable) { - LookinStaticAsyncUpdateTask *task2 = [self taskFromItem:item appInfo:currentAppInfo]; - if (task2) { - task2.taskType = LookinStaticAsyncUpdateTaskTypeSoloScreenshot; - [tasks addObject:task2]; - } - } - } else { - LookinStaticAsyncUpdateTask *task = [self taskFromItem:item appInfo:currentAppInfo]; - if (task) { - task.taskType = LookinStaticAsyncUpdateTaskTypeNoScreenshot; - [tasks addObject:task]; - } - } - [tasks firstObject].needBasisVisualInfo = YES; - return tasks; -} - -+ (LookinStaticAsyncUpdateTask *)taskFromItem:(LookinDisplayItem *)item appInfo:(LookinAppInfo *)appInfo { - BOOL prefersViewOID = [LKHelper appInfoLooksLikeMacTarget:appInfo]; - unsigned long oid = [item bestObjectOidPreferView:prefersViewOID]; - if (!oid) { - return nil; - } - LookinStaticAsyncUpdateTask *task = [LookinStaticAsyncUpdateTask new]; - task.oid = oid; - task.frameSize = item.frame.size; - task.clientReadableVersion = [LKHelper lookinReadableVersion]; - return task; -} - -@end diff --git a/LookInside/Static/LKStaticAsyncUpdateManager.h b/LookInside/Static/LKStaticAsyncUpdateManager.h deleted file mode 100644 index 476bae96..00000000 --- a/LookInside/Static/LKStaticAsyncUpdateManager.h +++ /dev/null @@ -1,60 +0,0 @@ -// -// LKStaticAsyncUpdateManager.h -// Lookin -// -// Created by Li Kai on 2019/2/19. -// https://lookin.work -// - -#import - -@class LookinDisplayItem, LookinStaticDisplayItem, LKStaticHierarchyDataSource, LKInspectableApp; - -@protocol LKStaticAsyncUpdateManagerDelegate - -/// 当剩余未完成的 task 数量变化时,该方法会被调用。表示当前一共有 totalCount 个任务,其中 finishedCount 已经完成。 -- (void)detailUpdateTasksTotalCount:(NSUInteger)totalCount finishedCount:(NSUInteger)finishedCount; - -- (void)detailUpdateReceivedError:(NSError *)error; - -@end - -@interface LKStaticAsyncUpdateManager : NSObject - -/// Designated initializer. Owner provides the hierarchy data source and the -/// inspectable app this manager dispatches RPCs against. -- (instancetype)initWithHierarchyDataSource:(LKStaticHierarchyDataSource *)dataSource - inspectableApp:(LKInspectableApp *)app; - -/// 由 owner(window controller / live doc)注入的 hierarchy data source(weak,owner 持强引用)。 -@property(nonatomic, weak) LKStaticHierarchyDataSource *hierarchyDataSource; - -/// 由 owner 注入的 inspectable app(weak)。Phase F 起所有 RPC 路径完全依赖该字段, -/// 不再有兜底全局。owner(LKStaticWindowController._applyHierarchyInfo:forApp:keepState:) -/// 在新 hierarchy 到达时刷新该字段。 -@property(nonatomic, weak) LKInspectableApp *inspectableApp; - -@property(nonatomic, weak) id delegate; - -/// 关闭“极速模式”时,reload 之后应该调用该方法来拉取所有 items 的 detail 数据 -- (void)updateAll; - -/// 打开“极速模式”时,每次 displayingItems 变化时,都应该调用该方法来拉取可见 items 的 detail 数据(已经拉取过 detail 的 items 不会再次被拉取) -- (void)updateForDisplayingItems; - -/// 终止拉取 -- (void)endUpdating; - -- (void)updateAfterModifyingDisplayItem:(LookinStaticDisplayItem *)displayItem; -/// updateAfterModifyingDisplayItem 的更新进度,data 是 RACTwoTuple,分别为已经收到的图像数量、总的图像数量 -@property(nonatomic, strong, readonly) RACSubject *modifyingUpdateProgressSignal; -/// updateAfterModifyingDisplayItem 遇到了错误,data 为 NSError -@property(nonatomic, strong, readonly) RACSubject *modifyingUpdateErrorSignal; - -- (BOOL)isUpdating; - -/// 重新拉取一个 displayItem 的数据(图像+Attr),不包括子图层 -- (void)reloadSingleDisplayItem:(LookinDisplayItem *)item; -- (void)reloadDisplayItemAndChildren:(LookinDisplayItem *)rootItem; - -@end diff --git a/LookInside/Static/LKStaticAsyncUpdateManager.m b/LookInside/Static/LKStaticAsyncUpdateManager.m deleted file mode 100644 index c04cf959..00000000 --- a/LookInside/Static/LKStaticAsyncUpdateManager.m +++ /dev/null @@ -1,586 +0,0 @@ -// -// LKStaticAsyncUpdateManager.m -// Lookin -// -// Created by Li Kai on 2019/2/19. -// https://lookin.work -// - -#import "LKStaticAsyncUpdateManager.h" -#import "LookinDisplayItem.h" -#import "LKInspectableApp.h" -#import "LKStaticHierarchyDataSource.h" -#import "LookinDisplayItemDetail.h" -#import "LKPreferenceManager.h" -#import "LKProgressIndicatorView.h" -#import "LookinHierarchyInfo.h" -#import "LookinStaticAsyncUpdateTask.h" -#import "LKNavigationManager.h" -#import "LKPerformanceReporter.h" -#import "LookinDisplayItem+LookinClient.h" -#import "LKPreferenceManager.h" -#import "LKVersionComparer.h" -#import "LKReloadSingleItemUpdateTaskMaker.h" -#import "LKReloadItemAndChildrenUpdateTaskMaker.h" -#import "LookInside-Swift.h" -#import "LKStaticWindowController.h" - -@interface LKDetailUpdateRequest : NSObject - -@property(nonatomic, copy) NSArray *packages; -/// 已经收到回复的 task 的数量(但受限于目前的设计,无法知道具体是哪些 task 收到了回复) -/// detail 有 failureCode 时,也仍然会被算到 finishedTasksCount 里面 -@property(nonatomic, assign) NSInteger finishedTasksCount; -@property(nonatomic, assign) NSInteger tasksTotalCount; -@property(nonatomic, assign) NSInteger failedTasksCount; - -@end - -@implementation LKDetailUpdateRequest - -- (BOOL)queryIfContainsTask:(LookinStaticAsyncUpdateTask *)task { - for (LookinStaticAsyncUpdateTasksPackage *pack in self.packages) { - if ([pack.tasks containsObject:task]) { - return YES; - } - } - return NO; -} - -- (void)removeTaskWithItem:(LookinDisplayItem *)item { - NSSet *itemOids = [NSSet setWithArray:[item availableObjectOidsPreferView:NO]]; - for (LookinStaticAsyncUpdateTasksPackage *pack in self.packages) { - pack.tasks = [pack.tasks lookin_filter:^BOOL(LookinStaticAsyncUpdateTask *task) { - if ([itemOids containsObject:@(task.oid)]) { - return NO; - } else { - return YES; - } - }]; - } -} - -- (NSInteger)tasksTotalCount { - NSInteger count = 0; - for (LookinStaticAsyncUpdateTasksPackage *pack in self.packages) { - count += pack.tasks.count; - } - return count; -} - -@end - -@interface LKStaticAsyncUpdateManager () - -/// 已经成功收到了所有回复的 request -@property(nonatomic, strong) NSMutableArray *succeededRequests; -/// 已经发送出去、尚未结束的 request -@property(nonatomic, strong) LKDetailUpdateRequest *ongoingRequest; - -@end - -@implementation LKStaticAsyncUpdateManager - -static BOOL LKAllowSwiftUISupportAccessForDisplayItem(LookinDisplayItem *item, LookinAppInfo *appInfo) { - if (![LKHelper appInfoLooksLikeMacTarget:appInfo]) { - return YES; - } - if (![item lk_isSwiftUISupportRelated]) { - return YES; - } - return [[LKSwiftUISupportGatekeeper sharedInstance] allowProtectedFeatureAccessForWindow:CurrentKeyWindow]; -} - -static BOOL LKCurrentHierarchyContainsSwiftUIItems(LKStaticHierarchyDataSource *dataSource) { - __block BOOL containsSwiftUI = NO; - [dataSource.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { - if ([item lk_isSwiftUISupportRelated]) { - containsSwiftUI = YES; - *stop = YES; - } - }]; - return containsSwiftUI; -} - -- (instancetype)init { - return [self initWithHierarchyDataSource:nil inspectableApp:nil]; -} - -- (instancetype)initWithHierarchyDataSource:(LKStaticHierarchyDataSource *)dataSource - inspectableApp:(LKInspectableApp *)app { - if (self = [super init]) { - _hierarchyDataSource = dataSource; - _inspectableApp = app; - self.succeededRequests = [NSMutableArray array]; - _modifyingUpdateProgressSignal = [RACSubject subject]; - _modifyingUpdateErrorSignal = [RACSubject subject]; - - @weakify(self); - [[self dataSource].willReloadHierarchyInfo subscribeNext:^(id _Nullable x) { - @strongify(self); - [self.succeededRequests removeAllObjects]; - }]; - } - return self; -} - -- (void)updateAll { - NSAssert(!LKPreferenceManager.mainManager.fastMode.currentBOOLValue, @""); - - LKInspectableApp *app = [self resolvedInspectableApp]; - if (!app || !self.dataSource.flatItems.count) { - return; - } - [self endUpdating]; - - NSArray *newTasks = [self makeMaximumTasks]; - if (newTasks.count == 0) { - return; - } - [self sendTasks:newTasks completion:nil]; -} - -- (void)endUpdating { - if (!self.ongoingRequest) { - return; - } - NSLog(@"AsyncUpdate - endUpdating"); - // 这句会触发 sendTasks 方法里的 completed 事件,进而导致 delegate 被通知 - [[self resolvedInspectableApp] cancelHierarchyDetailFetching]; -} - -- (NSArray *)makeMaximumTasks { - // tasks 里的元素顺序很重要:index 更小的 task 会优先被拉取回来展示。所以我们优先把用户可见的图层加进来,这样用户体验更好 - NSMutableArray *tasks = [(NSArray *)self.dataSource.displayingFlatItems lookin_map:^id(NSUInteger idx, LookinDisplayItem *item) { - if (item.isUserCustom) { - return nil; - } - if (item.doNotFetchScreenshotReason == LookinFetchScreenshotPermitted) { - // Mirrors -appropriateScreenshot: a node showing its group - // screenshot must not have the solo one fetched first, or the - // preview waits on an image it will never display. - if (item.isExpandable && item.isExpanded && item.hasPixelBearingSubitems) { - return [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeSoloScreenshot]; - } else { - return [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeGroupScreenshot]; - } - } else { - return [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeNoScreenshot]; - } - }].mutableCopy; - - [self.dataSource.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { - if (item.isUserCustom) { - return; - } - if (item.doNotFetchScreenshotReason == LookinFetchScreenshotPermitted) { - LookinStaticAsyncUpdateTask *task = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeGroupScreenshot]; - if (task && ![tasks containsObject:task]) { - [tasks addObject:task]; - } - - if (item.isExpandable) { - LookinStaticAsyncUpdateTask *task2 = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeSoloScreenshot]; - if (task2 && ![tasks containsObject:task2]) { - [tasks addObject:task2]; - } - } - } else { - LookinStaticAsyncUpdateTask *task = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeNoScreenshot]; - if (task && ![tasks containsObject:task]) { - [tasks addObject:task]; - } - } - }]; - return tasks.copy; -} - -- (NSArray *)_makePackagesFromTasks:(NSArray *)tasks { - NSMutableArray *packages = [NSMutableArray array]; - NSMutableArray *bufferTasks = [NSMutableArray array]; - - __block NSUInteger packageTotalArea = 0; - NSUInteger packageMaxArea = 2000000; - NSUInteger packageMaxTasksCount = 100; - [tasks enumerateObjectsUsingBlock:^(LookinStaticAsyncUpdateTask * _Nonnull task, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat currentArea = task.frameSize.width * task.frameSize.height; - if ((packageTotalArea + currentArea > packageMaxArea) || bufferTasks.count >= packageMaxTasksCount) { - if (bufferTasks.count > 0) { - packageTotalArea = 0; - LookinStaticAsyncUpdateTasksPackage *package = [LookinStaticAsyncUpdateTasksPackage new]; - package.tasks = bufferTasks; - [packages addObject:package]; - [bufferTasks removeAllObjects]; - } - } - - packageTotalArea += currentArea; - [bufferTasks addObject:task]; - }]; - - if (bufferTasks.count) { - LookinStaticAsyncUpdateTasksPackage *package = [LookinStaticAsyncUpdateTasksPackage new]; - package.tasks = bufferTasks; - [packages addObject:package]; - } - return packages.copy; -} - -- (LookinStaticAsyncUpdateTask *)_taskFromDisplayItem:(LookinDisplayItem *)item type:(LookinStaticAsyncUpdateTaskType)type { - if (!LKAllowSwiftUISupportAccessForDisplayItem(item, self.dataSource.rawHierarchyInfo.appInfo)) { - return nil; - } - - unsigned long oid = 0; - switch (item.resolvedNodeKind) { - case LookinDisplayItemNodeKindWindow: - case LookinDisplayItemNodeKindWindowScene: - oid = item.windowObject.oid; - break; - case LookinDisplayItemNodeKindLayoutGuide: - case LookinDisplayItemNodeKindCell: - oid = item.kindObject.oid; - break; - case LookinDisplayItemNodeKindCustom: - break; - case LookinDisplayItemNodeKindView: - case LookinDisplayItemNodeKindLayer: - // A view's outer layer rides layerObject like any other layer node. - case LookinDisplayItemNodeKindViewOuterLayer: - // So does a backing layer node (backing-layer-toggle proposal). - case LookinDisplayItemNodeKindBackingLayer: - case LookinDisplayItemNodeKindUnspecified: { - // macOS 上优先使用 viewObject.oid,因为服务端的 detail handler 能为所有 NSView 对象 - // (包括 layer-backed 的 SwiftUI 视图)截图。使用 layerObject.oid 可能访问到已释放的 layer。 - // iOS 上优先使用 layerObject.oid,以匹配上游行为;但当独立的 BackingLayer 子节点 - // 占用了 layer oid(backing-layer-toggle 开启)时,view 节点必须改走 view oid。 - BOOL preferViewOid = [LKHelper appInfoLooksLikeMacTarget:self.dataSource.rawHierarchyInfo.appInfo] - || [item lk_ownsSeparateBackingLayerNode]; - if (preferViewOid && item.viewObject.oid) { - oid = item.viewObject.oid; - } else if (item.layerObject.oid) { - oid = item.layerObject.oid; - } else if (item.viewObject.oid) { - oid = item.viewObject.oid; - } - break; - } - } - if (oid == 0) { - return nil; - } - - LookinStaticAsyncUpdateTask *task = [LookinStaticAsyncUpdateTask new]; - task.oid = oid; - task.frameSize = item.frame.size; - task.taskType = type; - task.needBasisVisualInfo = YES; - task.clientReadableVersion = [LKHelper lookinReadableVersion]; - return task; -} - -- (void)updateAfterModifyingDisplayItem:(LookinDisplayItem *)displayItem { - LKInspectableApp *app = [self resolvedInspectableApp]; - if (!app) { - return; - } - if (!displayItem) { - NSAssert(NO, @""); - return; - } - - NSMutableArray *tasks = [NSMutableArray array]; - [displayItem enumerateSelfAndAncestors:^(LookinDisplayItem *item, BOOL *stop) { - if (item.doNotFetchScreenshotReason != LookinFetchScreenshotPermitted) { - return; - } - if (item == displayItem && item.subitems.count) { - LookinStaticAsyncUpdateTask *task = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeSoloScreenshot]; - if (task) { - [tasks addObject:task]; - } - } - LookinStaticAsyncUpdateTask *task2 = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeGroupScreenshot]; - if (task2) { - [tasks addObject:task2]; - } - }]; - - [self.modifyingUpdateProgressSignal sendNext:[RACTwoTuple tupleWithObjectsFromArray:@[@0, @0]]]; - - @weakify(self); - NSUInteger screenshotsTotalCount = tasks.count; - __block NSUInteger receivedScreenshotsCount = 0; - [[app fetchModificationPatchWithTasks:tasks] subscribeNext:^(LookinDisplayItemDetail *detail) { - @strongify(self); - [self.dataSource modifyWithDisplayItemDetail:detail]; - - if (detail.groupScreenshot) { - receivedScreenshotsCount++; - } - if (detail.soloScreenshot) { - receivedScreenshotsCount++; - } - [self.modifyingUpdateProgressSignal sendNext:[RACTwoTuple tupleWithObjectsFromArray:@[@(receivedScreenshotsCount), @(screenshotsTotalCount)]]]; - - } error:^(NSError * _Nullable error) { - @strongify(self); - NSAssert(NO, @""); - [self.modifyingUpdateProgressSignal sendError:error]; - - } completed:^{ - @strongify(self); - NSAssert(screenshotsTotalCount == screenshotsTotalCount, @""); - [self.modifyingUpdateProgressSignal sendNext:[RACTwoTuple tupleWithObjectsFromArray:@[@(screenshotsTotalCount), @(screenshotsTotalCount)]]]; - }]; -} - -- (LKStaticHierarchyDataSource *)dataSource { - return self.hierarchyDataSource; -} - -- (LKInspectableApp *)resolvedInspectableApp { - // Phase F: every update manager is owned by exactly one Live Doc / - // window controller; the deprecated single-slot global fallback is - // gone, so this collapses to the directly injected reference. - return self.inspectableApp; -} - -- (NSArray *)makeMinimumTasksForItems:(NSArray *)items { - NSArray *tasks = [items lookin_map:^id(NSUInteger idx, LookinDisplayItem *item) { - if (item.isUserCustom) { - return nil; - } - if (item.appropriateScreenshot != nil) { - // 已经有图像了,无需再拉取(而且既然有图像了,那么 attrs 必然也有了) - return nil; - } - - LookinStaticAsyncUpdateTask *newTask = nil; - if (item.doNotFetchScreenshotReason == LookinFetchScreenshotPermitted) { - // 该图层应该有图像(但是现在没有),所以应该拉取图像 - if (item.isExpandable && item.isExpanded) { - newTask = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeSoloScreenshot]; - } else { - newTask = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeGroupScreenshot]; - } - } else { - // 该图层确实不应该有图像 - if (item.attributesGroupList.count > 0) { - // 有 attr 了,说明已经拉取过了,无需再次拉取 - return nil; - } else { - // 拉取 attr - newTask = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeNoScreenshot]; - } - } - if (!newTask) { - return nil; - } - - /// Client 1.0.7 & Server 1.2.7 开始支持 attrRequest 这个参数 - if (item.attributesGroupList.count == 0) { - newTask.attrRequest = LookinDetailUpdateTaskAttrRequest_Need; - } else { - newTask.attrRequest = LookinDetailUpdateTaskAttrRequest_NotNeed; - } - - for (LKDetailUpdateRequest *req in self.succeededRequests) { - if ([req queryIfContainsTask:newTask]) { - // 该 task 已经请求成功过,不再重复请求 - return nil; - } - } - return newTask; - }]; - return tasks; -} - -- (void)updateForDisplayingItems { - NSAssert(LKPreferenceManager.mainManager.fastMode.currentValue, @""); - - LKInspectableApp *app = [self resolvedInspectableApp]; - if (!app) { - return; - } - NSArray *items = self.dataSource.displayingFlatItems; - if (items.count == 0) { - return; - } - NSArray *newTasks = [self makeMinimumTasksForItems:items]; - if (newTasks.count == 0) { - return; - } - [self sendTasks:newTasks completion:nil]; -} - -- (void)sendTasks:(NSArray *)newTasks completion:(void (^)(void))completionBlock { - LKInspectableApp *app = [self resolvedInspectableApp]; - if (!app || newTasks.count == 0) { - return; - } - // 相同请求不能并发,因此必须先把之前的请求先取消掉 - [self endUpdating]; - - NSArray *packages = [self _makePackagesFromTasks:newTasks]; - self.ongoingRequest = [LKDetailUpdateRequest new]; - self.ongoingRequest.packages = packages; - - [self notifyTasksCountToDelegate]; - - NSLog(@"AsyncUpdate - Will send %@ tasks.", @(newTasks.count)); - - @weakify(self); - [[app fetchHierarchyDetailWithTaskPackages:packages] subscribeNext:^(NSArray *details) { - @strongify(self); - [details enumerateObjectsUsingBlock:^(LookinDisplayItemDetail * _Nonnull detail, NSUInteger idx, BOOL * _Nonnull stop) { - if (detail.failureCode != LookinDisplayItemDetailFailureCodeNone) { - // ObjectGone is routine: the inspected app released the - // object after the hierarchy build (TextKit 2 fragment views, - // portals and other short-lived internals go away on their - // own). The node keeps the data captured at build time, so - // only real faults feed the "Some layer data failed to - // transmit" alert. - BOOL objectIsGone = (detail.failureCode == LookinDisplayItemDetailFailureCodeObjectGone); - if (!objectIsGone) { - self.ongoingRequest.failedTasksCount += 1; - } - LookinDisplayItem *failedItem = [self.dataSource displayItemWithOid:detail.displayItemOid]; - NSLog(@"AsyncUpdate - task %@: oid %lu, item %@ (nodeKind %ld, view %@ %@, layer %@ %@)", - objectIsGone ? @"skipped, object gone" : @"failed", - (unsigned long)detail.displayItemOid, - failedItem ? failedItem.title : @"", - (long)failedItem.resolvedNodeKind, - failedItem.viewObject.rawClassName ?: @"-", - failedItem.viewObject.memoryAddress ?: @"-", - failedItem.layerObject.rawClassName ?: @"-", - failedItem.layerObject.memoryAddress ?: @"-"); - } else { - [self.dataSource modifyWithDisplayItemDetail:detail]; - } - }]; - self.ongoingRequest.finishedTasksCount += details.count; - [self notifyTasksCountToDelegate]; - - } error:^(NSError * _Nullable error) { - @strongify(self); - self.ongoingRequest = nil; - [self notifyTasksCountToDelegate]; - - NSString *msgTitle = [NSString stringWithFormat:NSLocalizedString(@"Request timeout, layer data transmission failed.", nil)]; - NSMutableString *msgDetail = [NSMutableString stringWithString:NSLocalizedString(@"Perhaps your iOS app is paused with breakpoint in Xcode, blocked by other tasks in main thread, or moved to background state.\nToo large screenshots may also lead to this error.", nil)]; - if ([LKSwiftUISupportGatekeeper sharedInstance].activationState == LKSwiftUISupportActivationStateActivated && - LKCurrentHierarchyContainsSwiftUIItems(self.dataSource)) { - [msgDetail appendFormat:@"\n%@", NSLocalizedString(@"First-time loading of SwiftUI details may take longer because LookInside needs to collect and match SwiftUI debug data.", nil)]; - } - error = LookinErrorMake(msgTitle, msgDetail); - [[RACScheduler mainThreadScheduler] afterDelay:1 schedule:^{ - // 此时可能 StaticViewController 还没来得及被初始化导致错误 tips 显示不出来,所以稍等一下 - [self.delegate detailUpdateReceivedError:error]; - }]; - } completed:^{ - // 注意,用户手动取消请求后,也会走到这里 - @strongify(self); - if (self.ongoingRequest) { - BOOL userCancel = (self.ongoingRequest.tasksTotalCount > self.ongoingRequest.finishedTasksCount); - if (!userCancel) { - [self.succeededRequests addObject:self.ongoingRequest]; - } - - if (self.ongoingRequest.failedTasksCount > 0) { - NSError *error = LookinErrorMake(NSLocalizedString(@"Some layer data failed to transmit.", nil), NSLocalizedString(@"It may be due to changes in the layer structure within the iOS app. You can try reloading the entire structure in LookInside.", nil)); - [self.delegate detailUpdateReceivedError:error]; - } - - self.ongoingRequest = nil; - } else { - NSAssert(NO, @""); - } - [self notifyTasksCountToDelegate]; - [LKPerformanceReporter.sharedInstance didComplete]; - - if (completionBlock) { - completionBlock(); - } - }]; -} - -- (void)notifyTasksCountToDelegate { - NSUInteger totalCount = 0; - - for (LookinStaticAsyncUpdateTasksPackage *pack in self.ongoingRequest.packages) { - totalCount += pack.tasks.count; - } - NSUInteger finishedCount = self.ongoingRequest.finishedTasksCount; - - NSLog(@"AsyncUpdate - notify delagate: %@/%@", @(finishedCount), @(totalCount)); - [self.delegate detailUpdateTasksTotalCount:totalCount finishedCount:finishedCount]; -} - -- (void)reloadSingleDisplayItem:(LookinDisplayItem *)item { - NSArray *tasks = [LKReloadSingleItemUpdateTaskMaker makeWithItem:item updateManager:self]; - if (tasks.count == 0) { - return; - } - [self sendTasks:tasks completion:nil]; -} - -- (void)reloadDisplayItemAndChildren:(LookinDisplayItem *)rootItem { - // 先更新 rootItem 的 basis + attr + subitems - NSArray *tasks = [LKReloadItemAndChildrenUpdateTaskMaker makeWithItem:rootItem updateManager:self]; - if (tasks.count == 0) { - return; - } - [rootItem enumerateSelfAndChildren:^(LookinDisplayItem * _Nonnull item) { - // 把 screenshots 以及完成的任务记录都删掉,因为稍后我们要重新拉取 - // 虽然待会儿返回的新 children 是新的 displayItem 实例,但新的 displayItem 实例的 oid 很可能没有变化,而 task 的 equal 是根据 oid 来计算的 - item.soloScreenshot = nil; - item.groupScreenshot = nil; - [self.succeededRequests enumerateObjectsUsingBlock:^(LKDetailUpdateRequest * _Nonnull req, NSUInteger idx, BOOL * _Nonnull stop) { - [req removeTaskWithItem:item]; - }]; - }]; - - [self sendTasks:tasks completion:^{ - // 更新 rootItem 以及 children 的图层详情 - [self updateAfterReloadingItemAndChildren:rootItem]; - }]; -} - -- (void)updateAfterReloadingItemAndChildren:(LookinDisplayItem *)rootItem { - // 拉取 rootItem 以及 children 的 screenshots + attr - if (LKPreferenceManager.mainManager.fastMode.currentBOOLValue) { - [self updateForDisplayingItems]; - } else { - NSMutableArray *tasks = [NSMutableArray array]; - [rootItem enumerateSelfAndChildren:^(LookinDisplayItem * _Nonnull item) { - if (item.isUserCustom) { - return; - } - if (item.doNotFetchScreenshotReason == LookinFetchScreenshotPermitted) { - LookinStaticAsyncUpdateTask *groupTask = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeGroupScreenshot]; - if (groupTask) { - [tasks addObject:groupTask]; - } - if (item.isExpandable) { - LookinStaticAsyncUpdateTask *soloTask = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeSoloScreenshot]; - if (soloTask) { - [tasks addObject:soloTask]; - } - } - } else { - LookinStaticAsyncUpdateTask *attrTask = [self _taskFromDisplayItem:item type:LookinStaticAsyncUpdateTaskTypeNoScreenshot]; - if (attrTask) { - [tasks addObject:attrTask]; - } - } - }]; - [self sendTasks:tasks completion:nil]; - } -} - -- (BOOL)isUpdating { - return self.ongoingRequest != nil; -} - -@end diff --git a/LookInside/Static/LKStaticAsyncUpdateManager.swift b/LookInside/Static/LKStaticAsyncUpdateManager.swift new file mode 100644 index 00000000..816a4b7a --- /dev/null +++ b/LookInside/Static/LKStaticAsyncUpdateManager.swift @@ -0,0 +1,638 @@ +// +// LKStaticAsyncUpdateManager.swift +// LookInside +// +// Created by Li Kai on 2019/2/19. +// https://lookin.work +// + +import AppKit +import LookInsideHostCore + +@objc(LKStaticAsyncUpdateManagerDelegate) +protocol LKStaticAsyncUpdateManagerDelegate: NSObjectProtocol { + /// Called whenever the number of unfinished tasks changes: `totalCount` + /// tasks in all, `finishedCount` of them done. + @objc(detailUpdateTasksTotalCount:finishedCount:) + func detailUpdateTasksTotalCount(_ totalCount: UInt, finishedCount: UInt) + + @objc(detailUpdateReceivedError:) + func detailUpdateReceivedError(_ error: Error) +} + +/// Progress of `updateAfterModifyingDisplayItem(_:)`. +enum LKModifyingUpdateEvent { + /// `received` of `total` screenshots arrived. + case progress(received: Int, total: Int) + case failure(Error) +} + +/// Fetches the details (screenshots, attributes, sub items) of the items in +/// one inspector window's hierarchy from the inspected app. +@objc(LKStaticAsyncUpdateManager) +@MainActor +final class LKStaticAsyncUpdateManager: NSObject { + /// Set by the owner (window controller / live document), which holds it + /// strongly. + @objc weak var hierarchyDataSource: LKStaticHierarchyDataSource? + + /// The app every request goes to. The owner refreshes it when a new + /// hierarchy arrives or the document reconnects. + @objc weak var inspectableApp: LKInspectableApp? + + @objc weak var delegate: LKStaticAsyncUpdateManagerDelegate? + + /// Events of `updateAfterModifyingDisplayItem(_:)`. + let modifyingUpdates = AsyncBroadcaster() + + /// Requests that received every reply. + private var succeededRequests: [LKDetailUpdateRequest] = [] + /// The request sent and not yet ended. + private var ongoingRequest: LKDetailUpdateRequest? + + private let dataSourceSignals = LKSyncSubscriptionBag() + + @objc(initWithHierarchyDataSource:inspectableApp:) + init(hierarchyDataSource: LKStaticHierarchyDataSource?, inspectableApp: LKInspectableApp?) { + self.hierarchyDataSource = hierarchyDataSource + self.inspectableApp = inspectableApp + super.init() + dataSourceSignals.observe(hierarchyDataSource?.willReloadHierarchyInfo) { [weak self] in + self?.succeededRequests.removeAll() + } + } + + @objc override convenience init() { + self.init(hierarchyDataSource: nil, inspectableApp: nil) + } + + private var dataSource: LKStaticHierarchyDataSource? { + hierarchyDataSource + } + + // MARK: - Public + + /// With fast mode off: after a reload, fetches the details of every item. + @objc func updateAll() { + assert(!LKPreferenceManager.shared.fastMode.currentBOOLValue) + guard inspectableApp != nil, let dataSource, !dataSource.flatItems.isEmpty else { + return + } + endUpdating() + let newTasks = makeMaximumTasks() + guard !newTasks.isEmpty else { return } + send(newTasks) + } + + /// With fast mode on: fetches the details of the visible items that do + /// not have them yet. Call it whenever the displayed items change. + @objc func updateForDisplayingItems() { + assert(LKPreferenceManager.shared.fastMode.currentBOOLValue) + guard inspectableApp != nil else { return } + let items = dataSource?.displayingFlatItems ?? [] + guard !items.isEmpty else { return } + let newTasks = makeMinimumTasks(for: items) + guard !newTasks.isEmpty else { return } + send(newTasks) + } + + /// Stops the running fetch. + @objc func endUpdating() { + guard ongoingRequest != nil else { return } + NSLog("AsyncUpdate - endUpdating") + // Completes the running details request synchronously, which runs + // its completion below and notifies the delegate. + inspectableApp?.cancelHierarchyDetailFetching() + } + + /// A method rather than a property: Swift callers use `isUpdating()`. + @objc func isUpdating() -> Bool { + ongoingRequest != nil + } + + /// Fetches the screenshots of `displayItem` (solo and group) and the + /// group screenshots of its ancestors after an attribute was modified. + /// Progress goes to `modifyingUpdates`. + @objc(updateAfterModifyingDisplayItem:) + func updateAfterModifyingDisplayItem(_ displayItem: LookinDisplayItem?) { + guard let app = inspectableApp else { return } + guard let displayItem else { + assertionFailure() + return + } + + var tasks: [LookinStaticAsyncUpdateTask] = [] + displayItem.enumerateSelfAndAncestors { item, _ in + guard item.doNotFetchScreenshotReason == .fetchScreenshotPermitted else { return } + if item === displayItem, !(item.subitems ?? []).isEmpty, + let task = self.task(from: item, type: .soloScreenshot) + { + tasks.append(task) + } + if let task = self.task(from: item, type: .groupScreenshot) { + tasks.append(task) + } + } + + modifyingUpdates.yield(.progress(received: 0, total: 0)) + + let screenshotsTotalCount = tasks.count + var receivedScreenshotsCount = 0 + startRequest(app: app, type: LookinRequestTypeAttrModificationPatch, tasks: tasks) { [weak self] event in + guard let self else { return } + switch event { + case let .value(value): + guard let detail = value as? LookinDisplayItemDetail else { return } + dataSource?.modify(with: detail) + if detail.groupScreenshot != nil { + receivedScreenshotsCount += 1 + } + if detail.soloScreenshot != nil { + receivedScreenshotsCount += 1 + } + modifyingUpdates.yield(.progress(received: receivedScreenshotsCount, total: screenshotsTotalCount)) + case let .failure(error): + assertionFailure("\(error)") + modifyingUpdates.yield(.failure(error)) + case .completion: + modifyingUpdates.yield(.progress(received: screenshotsTotalCount, total: screenshotsTotalCount)) + } + } + } + + /// Fetches one item's screenshots and attributes again, not its sub items. + @objc(reloadSingleDisplayItem:) + func reloadSingleDisplayItem(_ item: LookinDisplayItem?) { + let tasks = makeReloadSingleItemTasks(item) + guard !tasks.isEmpty else { return } + send(tasks) + } + + /// Fetches an item and its sub items again, then their details. + @objc(reloadDisplayItemAndChildren:) + func reloadDisplayItemAndChildren(_ rootItem: LookinDisplayItem?) { + // First the root item's basis, attributes and sub items. + let tasks = makeReloadItemAndChildrenTasks(rootItem) + guard let rootItem, !tasks.isEmpty else { return } + rootItem.enumerateSelfAndChildren { item in + // Drop the screenshots and the record of finished tasks: they are + // fetched again below. The new children are new instances, but + // their oids (which task equality uses) are likely the same. + item.soloScreenshot = nil + item.groupScreenshot = nil + for request in self.succeededRequests { + request.removeTasks(of: item) + } + } + send(tasks) { [weak self] in + self?.updateAfterReloadingItemAndChildren(rootItem) + } + } + + // MARK: - Tasks + + private func makeMaximumTasks() -> [LookinStaticAsyncUpdateTask] { + guard let dataSource else { return [] } + // Order matters: earlier tasks are fetched first, so the visible + // items go first. + var tasks: [LookinStaticAsyncUpdateTask] = (dataSource.displayingFlatItems ?? []).compactMap { item in + if item.isUserCustom() { + return nil + } + if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { + // Mirrors -appropriateScreenshot: a node showing its group + // screenshot must not have the solo one fetched first, or the + // preview waits on an image it will never display. + if item.isExpandable, item.isExpanded, item.hasPixelBearingSubitems() { + return task(from: item, type: .soloScreenshot) + } + return task(from: item, type: .groupScreenshot) + } + return task(from: item, type: .noScreenshot) + } + + func appendIfNew(_ task: LookinStaticAsyncUpdateTask?) { + if let task, !tasks.contains(task) { + tasks.append(task) + } + } + for item in dataSource.flatItems ?? [] where !item.isUserCustom() { + if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { + appendIfNew(task(from: item, type: .groupScreenshot)) + if item.isExpandable { + appendIfNew(task(from: item, type: .soloScreenshot)) + } + } else { + appendIfNew(task(from: item, type: .noScreenshot)) + } + } + return tasks + } + + private func makeMinimumTasks(for items: [LookinDisplayItem]) -> [LookinStaticAsyncUpdateTask] { + items.compactMap { item -> LookinStaticAsyncUpdateTask? in + if item.isUserCustom() { + return nil + } + if item.lkOptionalAppropriateScreenshot != nil { + // Has its image, and so its attributes too. + return nil + } + let newTask: LookinStaticAsyncUpdateTask? + if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { + // Should have an image but does not: fetch it. + if item.isExpandable, item.isExpanded { + newTask = task(from: item, type: .soloScreenshot) + } else { + newTask = task(from: item, type: .groupScreenshot) + } + } else if (item.attributesGroupList ?? []).isEmpty { + // No image by design; fetch the attributes once. + newTask = task(from: item, type: .noScreenshot) + } else { + return nil + } + guard let newTask else { return nil } + + // attrRequest is supported since Client 1.0.7 and Server 1.2.7. + newTask.attrRequest = (item.attributesGroupList ?? []).isEmpty ? .need : .notNeed + + if succeededRequests.contains(where: { $0.contains(newTask) }) { + // Already fetched successfully. + return nil + } + return newTask + } + } + + private func updateAfterReloadingItemAndChildren(_ rootItem: LookinDisplayItem) { + // The root item's and its children's screenshots and attributes. + if LKPreferenceManager.shared.fastMode.currentBOOLValue { + updateForDisplayingItems() + return + } + var tasks: [LookinStaticAsyncUpdateTask] = [] + rootItem.enumerateSelfAndChildren { item in + guard !item.isUserCustom() else { return } + if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { + if let groupTask = self.task(from: item, type: .groupScreenshot) { + tasks.append(groupTask) + } + if item.isExpandable, let soloTask = self.task(from: item, type: .soloScreenshot) { + tasks.append(soloTask) + } + } else if let attrTask = self.task(from: item, type: .noScreenshot) { + tasks.append(attrTask) + } + } + send(tasks) + } + + private func task(from item: LookinDisplayItem, type: LookinStaticAsyncUpdateTaskType) -> LookinStaticAsyncUpdateTask? { + let appInfo = dataSource?.rawHierarchyInfo?.appInfo + guard Self.allowsSwiftUISupportAccess(for: item, appInfo: appInfo) else { + return nil + } + + var oid: UInt = 0 + switch item.resolvedNodeKind() { + case .window, .windowScene: + oid = item.windowObject?.oid ?? 0 + case .layoutGuide, .cell: + oid = item.kindObject?.oid ?? 0 + case .custom: + break + default: + // View, layer, a view's outer layer and a backing layer node all + // ride layerObject / viewObject. + // + // On macOS the view oid comes first: the Server's detail handler + // can screenshot every NSView (layer-backed SwiftUI views + // included), and the layer oid may point at a released layer. On + // iOS the layer oid comes first, as upstream does, unless a + // separate BackingLayer node owns the layer oid (show backing + // layers on): then the view node has to use the view oid. + let viewOid = item.viewObject?.oid ?? 0 + let layerOid = item.layerObject?.oid ?? 0 + let prefersViewOid = LKHelper.appInfoLooksLikeMacTarget(appInfo) || item.lk_ownsSeparateBackingLayerNode() + if prefersViewOid, viewOid != 0 { + oid = viewOid + } else if layerOid != 0 { + oid = layerOid + } else if viewOid != 0 { + oid = viewOid + } + } + guard oid != 0 else { return nil } + + let task = LookinStaticAsyncUpdateTask() + task.oid = oid + task.frameSize = item.frame.size + task.taskType = type + task.needBasisVisualInfo = true + task.clientReadableVersion = LKHelper.lookinReadableVersion() + return task + } + + private static func allowsSwiftUISupportAccess(for item: LookinDisplayItem, appInfo: LookinAppInfo?) -> Bool { + guard LKHelper.appInfoLooksLikeMacTarget(appInfo), item.lk_isSwiftUISupportRelated() else { + return true + } + return LKSwiftUISupportGatekeeper.sharedInstance().allowProtectedFeatureAccess(for: NSApplication.shared.keyWindow) + } + + private static func packages(from tasks: [LookinStaticAsyncUpdateTask]) -> [LookinStaticAsyncUpdateTasksPackage] { + let areas = tasks.map { Double($0.frameSize.width * $0.frameSize.height) } + return DetailTaskPackaging.packageRanges(areas: areas).map { range in + let package = LookinStaticAsyncUpdateTasksPackage() + package.tasks = Array(tasks[range]) + return package + } + } + + // MARK: - Requests + + /// Sends a request whose events arrive synchronously on the main + /// thread. `endUpdating()` relies on that: cancelling the details + /// request completes it before the next request starts. + private func startRequest( + app: LKInspectableApp, + type: Int, + tasks: NSArray, + onEvent: @escaping (LKAppResponseEvent) -> Void + ) { + guard let channel = app.channel else { + onEvent(.failure(LKConnectionError.noConnect)) + return + } + _ = LKInspectableApp.startRequest(on: channel, type: UInt32(type), data: tasks, onEvent: onEvent) + } + + private func startRequest( + app: LKInspectableApp, + type: Int, + tasks: [LookinStaticAsyncUpdateTask], + onEvent: @escaping (LKAppResponseEvent) -> Void + ) { + startRequest(app: app, type: type, tasks: tasks as NSArray, onEvent: onEvent) + } + + private func send(_ newTasks: [LookinStaticAsyncUpdateTask], completion: (() -> Void)? = nil) { + guard let app = inspectableApp, !newTasks.isEmpty else { return } + // The same request cannot run twice at once: cancel the previous one. + endUpdating() + + let packages = Self.packages(from: newTasks) + let request = LKDetailUpdateRequest(packages: packages) + ongoingRequest = request + notifyTasksCountToDelegate() + + NSLog("AsyncUpdate - Will send %@ tasks.", NSNumber(value: newTasks.count)) + + startRequest(app: app, type: LookinRequestTypeHierarchyDetails, tasks: packages as NSArray) { [weak self] event in + guard let self else { return } + switch event { + case let .value(value): + receive(details: value as? [LookinDisplayItemDetail] ?? []) + case .failure: + fail() + case .completion: + // A request the user cancelled ends here too. + complete() + completion?() + } + } + } + + private func receive(details: [LookinDisplayItemDetail]) { + for detail in details { + if detail.failureCode != LookinDisplayItemDetailFailureCode.none.rawValue { + // ObjectGone is routine: the inspected app released the + // object after the hierarchy build (TextKit 2 fragment views, + // portals and other short-lived internals go away on their + // own). The node keeps the data captured at build time, so + // only real faults feed the "Some layer data failed to + // transmit" alert. + let objectIsGone = detail.failureCode == LookinDisplayItemDetailFailureCode.objectGone.rawValue + if !objectIsGone { + ongoingRequest?.failedTasksCount += 1 + } + let failedItem = dataSource?.displayItem(withOid: detail.displayItemOid) + let outcome: String = objectIsGone ? "skipped, object gone" : "failed" + let title: String = failedItem?.title() ?? "" + let nodeKind: Int = failedItem?.resolvedNodeKind().rawValue ?? 0 + let viewClass: String = failedItem?.viewObject?.rawClassName() ?? "-" + let viewAddress: String = failedItem?.viewObject?.memoryAddress ?? "-" + let layerClass: String = failedItem?.layerObject?.rawClassName() ?? "-" + let layerAddress: String = failedItem?.layerObject?.memoryAddress ?? "-" + NSLog( + "AsyncUpdate - task %@: oid %lu, item %@ (nodeKind %ld, view %@ %@, layer %@ %@)", + outcome as NSString, + detail.displayItemOid, + title as NSString, + nodeKind, + viewClass as NSString, + viewAddress as NSString, + layerClass as NSString, + layerAddress as NSString + ) + } else { + dataSource?.modify(with: detail) + } + } + ongoingRequest?.finishedTasksCount += details.count + notifyTasksCountToDelegate() + } + + private func fail() { + ongoingRequest = nil + notifyTasksCountToDelegate() + + let title = NSLocalizedString("Request timeout, layer data transmission failed.", comment: "") + var detail = NSLocalizedString( + "Perhaps your iOS app is paused with breakpoint in Xcode, blocked by other tasks in main thread, or moved to background state.\nToo large screenshots may also lead to this error.", + comment: "" + ) + if LKSwiftUISupportGatekeeper.sharedInstance().activationState == .activated, + (dataSource?.flatItems ?? []).contains(where: { $0.lk_isSwiftUISupportRelated() }) + { + detail += "\n" + NSLocalizedString( + "First-time loading of SwiftUI details may take longer because LookInside needs to collect and match SwiftUI debug data.", + comment: "" + ) + } + let error = LKStaticErrors.make(title: title, detail: detail) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + // The static view controller may not be set up yet to show the + // tip, so wait a little. + self.delegate?.detailUpdateReceivedError(error) + } + } + + private func complete() { + if let request = ongoingRequest { + let userCancelled = request.tasksTotalCount > request.finishedTasksCount + if !userCancelled { + succeededRequests.append(request) + } + if request.failedTasksCount > 0 { + let error = LKStaticErrors.make( + title: NSLocalizedString("Some layer data failed to transmit.", comment: ""), + detail: NSLocalizedString( + "It may be due to changes in the layer structure within the iOS app. You can try reloading the entire structure in LookInside.", + comment: "" + ) + ) + delegate?.detailUpdateReceivedError(error) + } + ongoingRequest = nil + } else { + assertionFailure() + } + notifyTasksCountToDelegate() + LKPerformanceReporter.sharedInstance().didComplete() + } + + private func notifyTasksCountToDelegate() { + let totalCount = ongoingRequest?.tasksTotalCount ?? 0 + let finishedCount = ongoingRequest?.finishedTasksCount ?? 0 + NSLog("AsyncUpdate - notify delagate: %@/%@", NSNumber(value: finishedCount), NSNumber(value: totalCount)) + delegate?.detailUpdateTasksTotalCount(UInt(totalCount), finishedCount: UInt(max(finishedCount, 0))) + } +} + +// MARK: - Reload tasks + +extension LKStaticAsyncUpdateManager { + /// The tasks that reload one item: its group screenshot (and solo one + /// when it has children), or its attributes alone when it has no + /// screenshot. Empty when the Server is too old or the item is a + /// SwiftUI item the license does not cover. + fileprivate func makeReloadSingleItemTasks(_ item: LookinDisplayItem?) -> [LookinStaticAsyncUpdateTask] { + guard let item, canReload(item) else { return [] } + let appInfo = inspectableApp?.appInfo + var tasks: [LookinStaticAsyncUpdateTask] = [] + if item.doNotFetchScreenshotReason == .fetchScreenshotPermitted { + if let task = Self.reloadTask(from: item, appInfo: appInfo) { + task.taskType = .groupScreenshot + tasks.append(task) + } + if item.isExpandable, let task = Self.reloadTask(from: item, appInfo: appInfo) { + task.taskType = .soloScreenshot + tasks.append(task) + } + } else if let task = Self.reloadTask(from: item, appInfo: appInfo) { + task.taskType = .noScreenshot + tasks.append(task) + } + tasks.first?.needBasisVisualInfo = true + return tasks + } + + /// The task that reloads an item's basis and sub items (no screenshot, + /// no attributes). + fileprivate func makeReloadItemAndChildrenTasks(_ item: LookinDisplayItem?) -> [LookinStaticAsyncUpdateTask] { + guard let item, canReload(item), + let task = Self.reloadTask(from: item, appInfo: inspectableApp?.appInfo) + else { + return [] + } + task.taskType = .noScreenshot + task.attrRequest = .notNeed + task.needBasisVisualInfo = true + task.needSubitems = true + return [task] + } + + /// Whether `item` may be reloaded: not while updating, not from a + /// Server older than 1.2.7 (after an alert), and not for a SwiftUI item + /// the license does not cover. + private func canReload(_ item: LookinDisplayItem) -> Bool { + if isUpdating() { + assertionFailure() + return false + } + let appInfo = inspectableApp?.appInfo + guard LKVersionComparer.compare(expectedVersion: "1.2.7", realVersion: (appInfo?.serverReadableVersion as String?) ?? "") else { + let error = LKStaticErrors.make( + title: NSLocalizedString("Operation failed.", comment: ""), + detail: NSLocalizedString("Please upgrade the LookinServer SDK version in your iOS project to 1.2.7 or higher.", comment: "") + ) + LKStaticErrors.alert(error, in: NSApplication.shared.keyWindow) + return false + } + if LKHelper.appInfoLooksLikeMacTarget(appInfo), + item.lk_isSwiftUISupportRelated(), + !LKSwiftUISupportGatekeeper.sharedInstance().allowProtectedFeatureAccess(for: NSApplication.shared.keyWindow) + { + return false + } + return true + } + + private static func reloadTask(from item: LookinDisplayItem, appInfo: LookinAppInfo?) -> LookinStaticAsyncUpdateTask? { + let oid = item.bestObjectOidPreferView(LKHelper.appInfoLooksLikeMacTarget(appInfo)) + guard oid != 0 else { return nil } + let task = LookinStaticAsyncUpdateTask() + task.oid = oid + task.frameSize = item.frame.size + task.clientReadableVersion = LKHelper.lookinReadableVersion() + return task + } +} + +// MARK: - LKDetailUpdateRequest + +/// One details request: its packages and how many of their tasks replied. +private final class LKDetailUpdateRequest { + private(set) var packages: [LookinStaticAsyncUpdateTasksPackage] + /// Tasks that received a reply, failed ones included. Which ones is not + /// known, only how many. + var finishedTasksCount = 0 + var failedTasksCount = 0 + + init(packages: [LookinStaticAsyncUpdateTasksPackage]) { + self.packages = packages + } + + var tasksTotalCount: Int { + packages.reduce(0) { $0 + ($1.tasks?.count ?? 0) } + } + + func contains(_ task: LookinStaticAsyncUpdateTask) -> Bool { + packages.contains { ($0.tasks ?? []).contains(task) } + } + + func removeTasks(of item: LookinDisplayItem) { + let itemOids = Set(item.availableObjectOidsPreferView(false).map(\.uintValue)) + for package in packages { + package.tasks = (package.tasks ?? []).filter { !itemOids.contains($0.oid) } + } + } +} + +// MARK: - Errors + +/// The NSError the Objective-C `LookinErrorMake` / `AlertError` macros made +/// and showed. +enum LKStaticErrors { + static func make(title: String, detail: String) -> NSError { + NSError(domain: LookinErrorDomain, code: LookinErrCode_Default, userInfo: [ + NSLocalizedDescriptionKey: title, + NSLocalizedRecoverySuggestionErrorKey: detail, + ]) + } + + /// Shows `error` as a sheet on `window`, unless it is a discard error. + static func alert(_ error: Error, in window: NSWindow?) { + let error = error as NSError + guard error.code != LookinErrCode_Discard else { return } + let alert = NSAlert(error: error) + if let window { + alert.beginSheetModal(for: window, completionHandler: nil) + } else { + // What the macro did with a nil window. + alert.perform(#selector(NSAlert.beginSheetModal(for:completionHandler:)), with: nil, with: nil) + } + } +} diff --git a/LookInside/Static/LKStaticConstants.swift b/LookInside/Static/LKStaticConstants.swift new file mode 100644 index 00000000..45a354b4 --- /dev/null +++ b/LookInside/Static/LKStaticConstants.swift @@ -0,0 +1,29 @@ +// +// LKStaticConstants.swift +// LookInside +// +// The constants of the inspector window and its 3D preview, shared by the +// classes that own them. +// + +import CoreGraphics + +/// Posted with a LookinDisplayItem as the object to show the console and +/// print that item's view in it. +let LKAppShowConsoleNotificationName = "LKAppShowConsoleNotificationName" + +/// Message identifiers of LKMessageManager. +let LKMessage_Jobs = "LKMessage_Jobs" +let LKMessage_SwiftSubspec = "LKMessage_SwiftSubspec" + +/// Bounds of LKPreviewView's scale and zInterspace (and of the preference +/// values that drive them). +let LookinPreviewMinScale: CGFloat = 0 +let LookinPreviewMaxScale: CGFloat = 1 +let LookinPreviewMinZInterspace: CGFloat = 0 +let LookinPreviewMaxZInterspace: CGFloat = 1 + +@objc enum LookinPreviewDimension: UInt { + case dimension2D + case dimension3D +} diff --git a/LookInside/Static/LKStaticHierarchyController.h b/LookInside/Static/LKStaticHierarchyController.h deleted file mode 100644 index c2e9596c..00000000 --- a/LookInside/Static/LKStaticHierarchyController.h +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKHierarchyViewController.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKHierarchyController.h" - -@interface LKStaticHierarchyController : LKHierarchyController - -@end diff --git a/LookInside/Static/LKStaticHierarchyController.m b/LookInside/Static/LKStaticHierarchyController.m deleted file mode 100644 index 928c3290..00000000 --- a/LookInside/Static/LKStaticHierarchyController.m +++ /dev/null @@ -1,31 +0,0 @@ -// -// LKHierarchyViewController.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKStaticHierarchyController.h" -#import "LKStaticHierarchyDataSource.h" -#import "LookinDisplayItem.h" - -@implementation LKStaticHierarchyController - -#pragma mark - LKHierarchyViewDelegate - -- (void)hierarchyView:(LKHierarchyView *)view needToCancelPreviewOfItem:(LookinDisplayItem *)item { - item.noPreview = YES; - [((LKStaticHierarchyDataSource *)self.dataSource).itemDidChangeNoPreview sendNext:nil]; -} - -- (void)hierarchyView:(LKHierarchyView *)view needToShowPreviewOfItem:(LookinDisplayItem *)item { - [item enumerateSelfAndAncestors:^(LookinDisplayItem *item, BOOL *stop) { - if (item.noPreview) { - item.noPreview = NO; - } - }]; - [((LKStaticHierarchyDataSource *)self.dataSource).itemDidChangeNoPreview sendNext:nil]; -} - -@end diff --git a/LookInside/Static/LKStaticHierarchyController.swift b/LookInside/Static/LKStaticHierarchyController.swift new file mode 100644 index 00000000..c8019953 --- /dev/null +++ b/LookInside/Static/LKStaticHierarchyController.swift @@ -0,0 +1,31 @@ +// +// LKStaticHierarchyController.swift +// LookInside +// +// Created by Li Kai on 2018/8/4. +// https://lookin.work +// + +import AppKit + +/// The hierarchy tree of a live inspector window. +@objc(LKStaticHierarchyController) +final class LKStaticHierarchyController: LKHierarchyController { + // MARK: - LKHierarchyViewDelegate + + @objc(hierarchyView:needToCancelPreviewOfItem:) + func hierarchyView(_: LKHierarchyView?, needToCancelPreviewOf item: LookinDisplayItem?) { + item?.noPreview = true + dataSource?.itemDidChangeNoPreview.send() + } + + @objc(hierarchyView:needToShowPreviewOfItem:) + func hierarchyView(_: LKHierarchyView?, needToShowPreviewOf item: LookinDisplayItem?) { + item?.enumerateSelfAndAncestors { item, _ in + if item.noPreview { + item.noPreview = false + } + } + dataSource?.itemDidChangeNoPreview.send() + } +} diff --git a/LookInside/Static/LKStaticHierarchyDataSource.h b/LookInside/Static/LKStaticHierarchyDataSource.h deleted file mode 100644 index be15c760..00000000 --- a/LookInside/Static/LKStaticHierarchyDataSource.h +++ /dev/null @@ -1,28 +0,0 @@ -// -// LKStaticHierarchyDataSource.h -// Lookin -// -// Created by Li Kai on 2018/12/21. -// https://lookin.work -// - -#import "LookinDefines.h" -#import "LKHierarchyDataSource.h" - -@class LookinDisplayItemDetail, LookinStaticDisplayItem, LookinAppInfo, LKStaticAsyncUpdateManager; - -@interface LKStaticHierarchyDataSource : LKHierarchyDataSource - -@property(nonatomic, strong, readonly) LookinAppInfo *appInfo; - -/// 反向引用 owner 链上的 update manager(weak,因为 owner 是 windowController) -@property(nonatomic, weak) LKStaticAsyncUpdateManager *asyncUpdateManager; - -#pragma mark - Signal - -/// 某些 item 的 frame 发生改变 -@property(nonatomic, strong, readonly) RACSubject *itemsDidChangeFrame; - -- (void)modifyWithDisplayItemDetail:(LookinDisplayItemDetail *)detail; - -@end diff --git a/LookInside/Static/LKStaticHierarchyDataSource.m b/LookInside/Static/LKStaticHierarchyDataSource.m deleted file mode 100644 index dc273f8b..00000000 --- a/LookInside/Static/LKStaticHierarchyDataSource.m +++ /dev/null @@ -1,211 +0,0 @@ -// -// LKStaticHierarchyDataSource.m -// Lookin -// -// Created by Li Kai on 2018/12/21. -// https://lookin.work -// - -#import "LKStaticHierarchyDataSource.h" -#import "LookinHierarchyInfo.h" -#import "LookinDisplayItem.h" -#import "LookinAttributesGroup.h" -#import "LookinAttribute.h" -#import "LKPreferenceManager.h" -#import "LKStaticAsyncUpdateManager.h" -#import "LookinDisplayItemDetail.h" -#import "LookinDisplayItem.h" -#import "LookinAppInfo.h" -#import "LKMessageManager.h" -#import "LKAppsManager.h" -#import "LKDanceUIAttrMaker.h" -#import "LKStaticWindowController.h" - -@interface LKStaticHierarchyDataSource () - -@property(nonatomic, assign) BOOL shouldIgnoreFastModeAutoUpdate; -@property(nonatomic, assign) BOOL isUsingDanceUI; - -@end - -@implementation LKStaticHierarchyDataSource - -- (instancetype)init { - if (self = [super init]) { - _itemsDidChangeFrame = [RACSubject subject]; - } - return self; -} - -#pragma mark - Public - -- (void)reloadWithHierarchyInfo:(LookinHierarchyInfo *)info keepState:(BOOL)keepState { - [super reloadWithHierarchyInfo:info keepState:keepState]; - - _appInfo = info.appInfo; - - NSAssert(info.appInfo.screenScale > 0, @""); - CGFloat screenScale = MAX(info.appInfo.screenScale, 1); - - // SCNNode 的图片的长和宽均不能超过 16384px,这里再随手减掉 100,注意单位是 px 不是 pt - CGFloat maxLengthInPx = LookinNodeImageMaxLengthInPx - 100; - [self.flatItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (!obj.isPixelBearing) { - // Guides, cells and a view's outer layer have no pixels of their - // own — fetching a screenshot would draw the owning view's content - // a second time. Skip the screenshot task but keep them in the - // preview. - obj.doNotFetchScreenshotReason = LookinDoNotFetchScreenshotForNoPixels; - return; - } - CGFloat widthInPx = obj.frame.size.width * screenScale; - CGFloat heightInPx = obj.frame.size.height * screenScale; - if (widthInPx > maxLengthInPx || heightInPx > maxLengthInPx) { - obj.doNotFetchScreenshotReason = LookinDoNotFetchScreenshotForTooLarge; - } - }]; - - [self updateMessageStatus]; - - BOOL shouldUpdateAll = (LKPreferenceManager.mainManager.fastMode.currentBOOLValue == NO); - if (shouldUpdateAll) { - [self.asyncUpdateManager updateAll]; - } -} - -- (void)modifyWithDisplayItemDetail:(LookinDisplayItemDetail *)detail { - if (!detail) { - return; - } - LookinDisplayItem *displayItem = [self displayItemWithOid:detail.displayItemOid]; - if (!displayItem) { - NSAssert(NO, @""); - return; - } - if (detail.customDisplayTitle) { - displayItem.customDisplayTitle = detail.customDisplayTitle; - } - if (detail.danceUISource) { - displayItem.danceuiSource = detail.danceUISource; - - if (!self.isUsingDanceUI) { - self.isUsingDanceUI = YES; - } - } - if (detail.groupScreenshot) { - displayItem.groupScreenshot = detail.groupScreenshot; - } - if (detail.soloScreenshot) { - displayItem.soloScreenshot = detail.soloScreenshot; - } - - if (detail.frameValue || detail.boundsValue) { - [self _modifyDisplayItem:displayItem newFrame:[detail.frameValue rectValue] newBounds:[detail.boundsValue rectValue]]; - } - - BOOL didChangeHiddenAlpha = NO; - if (detail.hiddenValue && detail.hiddenValue.boolValue != displayItem.isHidden) { - displayItem.isHidden = [detail.hiddenValue boolValue]; - didChangeHiddenAlpha = YES; - } - if (detail.alphaValue && detail.alphaValue.floatValue != displayItem.alpha) { - displayItem.alpha = [detail.alphaValue floatValue]; - didChangeHiddenAlpha = YES; - } - if (didChangeHiddenAlpha) { - [self.itemDidChangeHiddenAlphaValue sendNext:displayItem]; - } - - BOOL attrChanged = NO; - if (detail.attributesGroupList.count) { - displayItem.attributesGroupList = detail.attributesGroupList; - attrChanged = YES; - } - if (detail.customAttrGroupList.count) { - displayItem.customAttrGroupList = detail.customAttrGroupList; - attrChanged = YES; - } - if (attrChanged) { - [self.itemDidChangeAttrGroup sendNext:displayItem]; - } - if (detail.subitems && (displayItem.subitems.count > 0 || detail.subitems.count > 0)) { - // 如果没有这个标记位的话,待会儿的 buildDisplayingItem 在 fastMode 下会触发 update task,而此时上一个 update task 其实还没有结束(此时还在 sendTask 的 subscribe 阶段、还没有到 completion 阶段),因此就会同时有两个 update task,而 task 理论上是不能并发的。所以我们这里先简单的用一个标记位防止一下。 - self.shouldIgnoreFastModeAutoUpdate = YES; - - // 可能在 search 或 focus 状态,先退出,否则状态维护太麻烦 - switch (self.state) { - case LKHierarchyDataSourceStateFocus: - [self endFocus]; - break; - case LKHierarchyDataSourceStateSearch: - [self endSearch]; - break; - default: - break; - } - - displayItem.subitems = detail.subitems; - // 根据 subitems 属性打平为二维数组,同时给每个 item 设置 indentLevel - self.rawFlatItems = [LookinDisplayItem flatItemsFromHierarchicalItems:self.rawHierarchyInfo.displayItems]; - self.flatItems = self.rawFlatItems; - [self.didReloadHierarchyInfo sendNext:nil]; - - [displayItem enumerateSelfAndChildren:^(LookinDisplayItem * _Nonnull obj) { - if (obj == displayItem) { - return; - } - if (!obj.isUserCustom && !obj.shouldCaptureImage) { - [obj enumerateSelfAndChildren:^(LookinDisplayItem *item) { - item.noPreview = YES; - item.doNotFetchScreenshotReason = LookinDoNotFetchScreenshotForUserConfig; - }]; - } - if (obj.customInfo.danceuiSource.length > 0) { - [LKDanceUIAttrMaker makeDanceUIJumpAttribute:obj danceSource:obj.customInfo.danceuiSource]; - } - }]; - [self buildDisplayingFlatItems]; - self.shouldIgnoreFastModeAutoUpdate = NO; - } -} - -- (void)buildDisplayingFlatItems { - [super buildDisplayingFlatItems]; - if ([LKPreferenceManager mainManager].fastMode.currentBOOLValue && !self.shouldIgnoreFastModeAutoUpdate) { - [self.asyncUpdateManager updateForDisplayingItems]; - } -} - -- (LKPreferenceManager *)preferenceManager { - return [LKPreferenceManager mainManager]; -} - -#pragma mark - Private - -- (void)_modifyDisplayItem:(LookinDisplayItem *)item newFrame:(CGRect)frame newBounds:(CGRect)bounds { - if (!item) { - NSAssert(NO, @""); - return; - } - if (CGRectEqualToRect(item.frame, frame) && CGRectEqualToRect(item.bounds, bounds)) { - return; - } - item.frame = frame; - item.bounds = bounds; - - [self.itemsDidChangeFrame sendNext:item]; -} - -- (void)updateMessageStatus { - if (self.serverSideIsSwiftProject && self.appInfo.swiftEnabledInLookinServer == -1) { - [[LKMessageManager sharedInstance] addMessage:LKMessage_SwiftSubspec]; - } else { - [[LKMessageManager sharedInstance] removeMessage:LKMessage_SwiftSubspec]; - } -} - -- (BOOL)isReadOnly { - return NO; -} - -@end diff --git a/LookInside/Static/LKStaticHierarchyDataSource.swift b/LookInside/Static/LKStaticHierarchyDataSource.swift new file mode 100644 index 00000000..1d1333a4 --- /dev/null +++ b/LookInside/Static/LKStaticHierarchyDataSource.swift @@ -0,0 +1,214 @@ +// +// LKStaticHierarchyDataSource.swift +// LookInside +// +// Created by Li Kai on 2018/12/21. +// https://lookin.work +// + +import AppKit + +/// The data source of a live document: applies the details the update +/// tasks deliver and starts those tasks. +@objc(LKStaticHierarchyDataSource) +class LKStaticHierarchyDataSource: LKHierarchyDataSource { + @objc var appInfo: LookinAppInfo! { + storedAppInfo + } + + /// The update manager of the owning window controller. + @objc weak var asyncUpdateManager: LKStaticAsyncUpdateManager? + + /// Sends each item whose frame changed. Also reaches `events()` as + /// `.itemDidChangeFrame`. + final let itemsDidChangeFrame = LKSyncSignal() + + /// Keeps a detail's subitems rebuild from starting a fast-mode update + /// while the update task that delivered it is still running: update + /// tasks must not overlap. + private var storedAppInfo: LookinAppInfo? + private var shouldIgnoreFastModeAutoUpdate = false + private var isUsingDanceUI = false + private var frameSubscription: LKSyncSubscription? + + override init() { + super.init() + let broadcaster = eventBroadcaster + frameSubscription = itemsDidChangeFrame.observe { [weak broadcaster] item in + broadcaster?.yield(.itemDidChangeFrame(item)) + } + } + + // MARK: - Reload + + @objc(reloadWithHierarchyInfo:keepState:) + override func reload(with info: LookinHierarchyInfo!, keepState: Bool) { + super.reload(with: info, keepState: keepState) + + storedAppInfo = info.appInfo + + assert((info.appInfo?.screenScale ?? 0) > 0) + let screenScale = max(CGFloat(info.appInfo?.screenScale ?? 0), 1) + + // An SCNNode image is at most 16384 px on each side; keep 100 px + // clear of that. Pixels, not points. + let maxLengthInPx = CGFloat(LookinNodeImageMaxLengthInPx) - 100 + for item in flatItems ?? [] { + guard item.isPixelBearing() else { + // Guides, cells and a view's outer layer have no pixels of + // their own: a screenshot would draw the owning view's + // content a second time. No screenshot task, but they stay + // in the preview. + item.doNotFetchScreenshotReason = .doNotFetchScreenshotForNoPixels + continue + } + if item.frame.width * screenScale > maxLengthInPx || item.frame.height * screenScale > maxLengthInPx { + item.doNotFetchScreenshotReason = .doNotFetchScreenshotForTooLarge + } + } + + updateMessageStatus() + + if !LKPreferenceManager.shared.fastMode.currentBOOLValue { + MainActor.assumeIsolated { + asyncUpdateManager?.updateAll() + } + } + } + + @objc(modifyWithDisplayItemDetail:) + func modify(with detail: LookinDisplayItemDetail!) { + guard let detail else { + return + } + guard let item = displayItem(withOid: UInt(detail.displayItemOid)) else { + assertionFailure("no display item for a detail") + return + } + if let title = detail.customDisplayTitle { + item.customDisplayTitle = title + } + if let source = detail.danceUISource { + item.danceuiSource = source + isUsingDanceUI = true + } + if let screenshot = detail.groupScreenshot { + item.groupScreenshot = screenshot + } + if let screenshot = detail.soloScreenshot { + item.soloScreenshot = screenshot + } + + if detail.frameValue != nil || detail.boundsValue != nil { + modify(item, frame: detail.frameValue?.rectValue ?? .zero, bounds: detail.boundsValue?.rectValue ?? .zero) + } + + var didChangeHiddenAlpha = false + if let hidden = detail.hiddenValue, hidden.boolValue != item.isHidden { + item.isHidden = hidden.boolValue + didChangeHiddenAlpha = true + } + if let alpha = detail.alphaValue, alpha.floatValue != item.alpha { + item.alpha = alpha.floatValue + didChangeHiddenAlpha = true + } + if didChangeHiddenAlpha { + itemDidChangeHiddenAlphaValue.send(item) + } + + var didChangeAttributes = false + if let groups = detail.attributesGroupList, !groups.isEmpty { + item.attributesGroupList = groups + didChangeAttributes = true + } + if let groups = detail.customAttrGroupList, !groups.isEmpty { + item.customAttrGroupList = groups + didChangeAttributes = true + } + if didChangeAttributes { + itemDidChangeAttrGroup.send(item) + } + + if let subitems = detail.subitems, !(item.subitems ?? []).isEmpty || !subitems.isEmpty { + replaceSubitems(of: item, with: subitems) + } + } + + private func replaceSubitems(of item: LookinDisplayItem, with subitems: [LookinDisplayItem]) { + // Without this flag the buildDisplayingFlatItems below would start a + // fast-mode update task while the current one is still running (it + // is still in its subscription, not yet completed), and update tasks + // must not run concurrently. + shouldIgnoreFastModeAutoUpdate = true + + // Leave search or focus first; keeping their state across the + // rebuild is not worth it. + switch state { + case .focus: + endFocus() + case .search: + endSearch() + default: + break + } + + item.subitems = subitems + // Flatten the tree again; this also sets every item's indentLevel. + rawFlatItems = LookinDisplayItem.flatItems(fromHierarchicalItems: rawHierarchyInfo?.displayItems ?? []) + flatItems = rawFlatItems + didReloadHierarchyInfo.send() + + item.enumerateSelfAndChildren { node in + guard node !== item else { + return + } + if !node.isUserCustom(), !node.shouldCaptureImage { + node.enumerateSelfAndChildren { descendant in + descendant.noPreview = true + descendant.doNotFetchScreenshotReason = .doNotFetchScreenshotForUserConfig + } + } + if let source = node.customInfo?.danceuiSource, !source.isEmpty { + LKDanceUIAttrMaker.makeDanceUIJumpAttribute(node, danceSource: source) + } + } + buildDisplayingFlatItems() + shouldIgnoreFastModeAutoUpdate = false + } + + override func buildDisplayingFlatItems() { + super.buildDisplayingFlatItems() + if LKPreferenceManager.shared.fastMode.currentBOOLValue, !shouldIgnoreFastModeAutoUpdate { + MainActor.assumeIsolated { + asyncUpdateManager?.updateForDisplayingItems() + } + } + } + + override func preferenceManager() -> LKPreferenceManager! { + LKPreferenceManager.shared + } + + override func isReadOnly() -> Bool { + false + } + + // MARK: - Private + + private func modify(_ item: LookinDisplayItem, frame: CGRect, bounds: CGRect) { + if item.frame.equalTo(frame), item.bounds.equalTo(bounds) { + return + } + item.frame = frame + item.bounds = bounds + itemsDidChangeFrame.send(item) + } + + private func updateMessageStatus() { + if serverSideIsSwiftProject, appInfo?.swiftEnabledInLookinServer == -1 { + LKMessageManager.sharedInstance().addMessage(LKMessage_SwiftSubspec) + } else { + LKMessageManager.sharedInstance().removeMessage(LKMessage_SwiftSubspec) + } + } +} diff --git a/LookInside/Static/LKStaticLayout.swift b/LookInside/Static/LKStaticLayout.swift new file mode 100644 index 00000000..f7db2584 --- /dev/null +++ b/LookInside/Static/LKStaticLayout.swift @@ -0,0 +1,93 @@ +// +// LKStaticLayout.swift +// LookInside +// +// The few frame operations the inspector window's controllers used from +// ShortCocoa's `$(view)` layout chain, with the same rounding: every +// value set is rounded up to the main screen's pixel grid. +// + +import AppKit + +@MainActor +struct LKStaticLayout { + let view: NSView + + init(_ view: NSView) { + self.view = view + } + + /// Rounds up to the main screen's pixel grid; the extreme values used as + /// markers are left alone. + static func snapToPixel(_ value: CGFloat) -> CGFloat { + if value == .leastNormalMagnitude || value == .greatestFiniteMagnitude { + return value + } + let scale = NSScreen.main?.backingScaleFactor ?? 1 + return (value * scale).rounded(.up) / scale + } + + private func update(_ change: (inout CGRect) -> Void) { + var frame = view.frame + change(&frame) + view.frame = frame + } + + func x(_ value: CGFloat) { + update { $0.origin.x = Self.snapToPixel(value) } + } + + func y(_ value: CGFloat) { + update { $0.origin.y = Self.snapToPixel(value) } + } + + func width(_ value: CGFloat) { + update { $0.size.width = Self.snapToPixel(value) } + } + + func height(_ value: CGFloat) { + update { $0.size.height = Self.snapToPixel(value) } + } + + /// Places the view `value` from its superview's right edge. + func right(_ value: CGFloat) { + guard let superview = view.superview else { + assertionFailure("needs a superview") + return + } + x(superview.bounds.width - value - view.bounds.width) + } + + func midX(_ value: CGFloat) { + x(value - view.bounds.width / 2) + } + + /// Spans the superview's width. + func fullWidth() { + x(0) + let superWidth = view.superview?.bounds.width ?? 0 + update { $0.size.width = max(Self.snapToPixel(superWidth - $0.minX), 0) } + } + + /// Spans the superview's height. + func fullHeight() { + guard let superview = view.superview else { + assertionFailure("needs a superview") + return + } + height(superview.bounds.height) + y(0) + } + + func fullFrame() { + fullWidth() + fullHeight() + } + + /// Sizes an LKBaseView to what it needs with no limit. + func sizeToFit() { + guard let baseView = view as? LKBaseView else { return } + let size = baseView.sizeThatFits(NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)) + update { $0.size = size } + } +} diff --git a/LookInside/Static/LKStaticViewController.h b/LookInside/Static/LKStaticViewController.h deleted file mode 100644 index 8956abba..00000000 --- a/LookInside/Static/LKStaticViewController.h +++ /dev/null @@ -1,38 +0,0 @@ -// -// LKMainViewController.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import - -@class LKPreviewController, LKProgressIndicatorView, LKHierarchyView, LKStaticHierarchyDataSource, LKStaticAsyncUpdateManager; - -@interface LKStaticViewController : LKBaseViewController - -@property(nonatomic, strong, readonly) LKPreviewController *viewsPreviewController; - -@property(nonatomic, strong) LKProgressIndicatorView *progressView; - -@property(nonatomic, assign) BOOL showConsole; - -/// Per-doc data source. Owner (LKStaticWindowController) supplies it through -/// -initWithHierarchyDataSource:asyncUpdateManager: so it is non-nil during -/// the -setView: path that subscribes to its signals. -@property(nonatomic, weak, readonly) LKStaticHierarchyDataSource *hierarchyDataSource; - -/// Per-doc async update manager. Same lifetime guarantee as `hierarchyDataSource`. -@property(nonatomic, weak, readonly) LKStaticAsyncUpdateManager *asyncUpdateManager; - -/// Designated initializer. Both arguments must be non-nil; LKBaseViewController -/// triggers -setView: synchronously during -init, and -setView: subscribes to -/// the data source's signals, so they need to be wired before super-init runs. -- (instancetype)initWithHierarchyDataSource:(LKStaticHierarchyDataSource *)dataSource - asyncUpdateManager:(LKStaticAsyncUpdateManager *)updateManager; - -/// 获取当前的 hierarchyView -- (LKHierarchyView *)currentHierarchyView; - -@end diff --git a/LookInside/Static/LKStaticViewController.m b/LookInside/Static/LKStaticViewController.m deleted file mode 100644 index 2deaa51c..00000000 --- a/LookInside/Static/LKStaticViewController.m +++ /dev/null @@ -1,476 +0,0 @@ -// -// LKMainViewController.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LKStaticViewController.h" -#import "LKSplitView.h" -#import "LKStaticHierarchyDataSource.h" -#import "LKStaticHierarchyController.h" -#import "LKPreviewController.h" -#import "LKDashboardViewController.h" -#import "LKLaunchViewController.h" -#import "LKProgressIndicatorView.h" -#import "LKWindowController.h" -#import "LKStaticWindowController.h" -#import "LKTipsView.h" -#import "LKInspectableApp.h" -#import "LKStaticAsyncUpdateManager.h" -#import "LKNavigationManager.h" -#import "LKConsoleViewController.h" -#import "LookinDisplayItem.h" -#import "LKPreferenceManager.h" -#import "LKMeasureController.h" -#import "LKHelper.h" -#import "LookinLiveDocument.h" -#import "LookinHierarchyInfo.h" - -NSString *const LKAppShowConsoleNotificationName = @"LKAppShowConsoleNotificationName"; - -@interface LKStaticViewController () - -@property(nonatomic, strong) LKSplitView *mainSplitView; -@property(nonatomic, strong) LKSplitView *rightSplitView; -@property(nonatomic, strong) LKBaseView *splitTopView; - -@property(nonatomic, strong) LKTipsView *imageSyncTipsView; -@property(nonatomic, strong) LKRedTipsView *tooLargeToSyncScreenshotTipsView; -@property(nonatomic, strong) LKTipsView *userConfigNoPreviewTipsView; -@property(nonatomic, strong) LKTipsView *noPreviewTipView; -@property(nonatomic, strong) LKTipsView *customViewTipView; -@property(nonatomic, strong) LKYellowTipsView *focusTipView; -@property(nonatomic, strong) LKTipsView *fastModeTipView; - -@property(nonatomic, strong) LKDashboardViewController *dashboardController; -@property(nonatomic, strong) LKStaticHierarchyController *hierarchyController; -@property(nonatomic, strong) LKConsoleViewController *consoleController; -@property(nonatomic, strong) LKMeasureController *measureController; - -@end - -@implementation LKStaticViewController - -- (instancetype)initWithHierarchyDataSource:(LKStaticHierarchyDataSource *)dataSource - asyncUpdateManager:(LKStaticAsyncUpdateManager *)updateManager { - NSAssert(dataSource && updateManager, - @"LKStaticViewController requires non-nil per-doc data source and update manager"); - // LKBaseViewController.-initWithContainerView: synchronously assigns - // self.view (and therefore runs -setView:) before -init returns. The - // setView body subscribes to the data source's signals, so the weak - // ivars must be wired before super-init starts. self is already - // zero-allocated by +alloc and NSViewController's init does not return - // a different instance, so writing to the weak backing ivars at this - // point is safe. - _hierarchyDataSource = dataSource; - _asyncUpdateManager = updateManager; - return [super init]; -} - -- (NSView *)makeContainerView { - self.mainSplitView = [LKSplitView new]; - self.mainSplitView.didFinishFirstLayout = ^(LKSplitView *view) { - CGFloat x = MIN(MAX(350, view.bounds.size.width * .3), 700); - [view setPosition:x ofDividerAtIndex:0]; - }; - self.mainSplitView.arrangesAllSubviews = NO; - self.mainSplitView.vertical = YES; - self.mainSplitView.dividerStyle = NSSplitViewDividerStyleThin; - self.mainSplitView.delegate = self; - return self.mainSplitView; -} - -- (void)setView:(NSView *)view { - [super setView:view]; - - LKPreferenceManager *preferenceManager = [LKPreferenceManager mainManager]; - [preferenceManager.measureState subscribe:self action:@selector(_handleMeasureStateChange:) relatedObject:nil]; - - LKStaticHierarchyDataSource *dataSource = self.hierarchyDataSource; - - self.hierarchyController = [[LKStaticHierarchyController alloc] initWithDataSource:dataSource]; - [self addChildViewController:self.hierarchyController]; - [self.mainSplitView addArrangedSubview:self.hierarchyController.view]; - // Phase A:沿 owner 链向下传递 per-instance update manager。 - self.hierarchyController.hierarchyView.asyncUpdateManager = self.asyncUpdateManager; - - self.rightSplitView = [LKSplitView new]; - self.rightSplitView.arrangesAllSubviews = YES; - self.rightSplitView.vertical = NO; - self.rightSplitView.dividerStyle = NSSplitViewDividerStyleThin; - self.rightSplitView.delegate = self; - [self.mainSplitView addArrangedSubview:self.rightSplitView]; - - self.splitTopView = [LKBaseView new]; - [self.rightSplitView addArrangedSubview:self.splitTopView]; - - _viewsPreviewController = [[LKPreviewController alloc] initWithDataSource:dataSource]; - self.viewsPreviewController.staticViewController = self; - self.viewsPreviewController.asyncUpdateManager = self.asyncUpdateManager; - [self.splitTopView addSubview:self.viewsPreviewController.view]; - [self addChildViewController:self.viewsPreviewController]; - - self.dashboardController = [[LKDashboardViewController alloc] initWithStaticDataSource:dataSource]; - self.dashboardController.asyncUpdateManager = self.asyncUpdateManager; - [self.splitTopView addSubview:self.dashboardController.view]; - [self addChildViewController:self.dashboardController]; - - // Phase F: as soon as the host window has a Live Doc attached, push - // it down to children that issue per-doc RPCs (dashboard / console). - @weakify(self); - [[[[NSNotificationCenter defaultCenter] rac_addObserverForName:NSWindowDidBecomeMainNotification - object:nil] startWith:nil] subscribeNext:^(id _) { - @strongify(self); - LookinLiveDocument *doc = [LookinLiveDocument documentInWindow:self.view.window]; - self.dashboardController.liveDocument = doc; - self.consoleController.liveDocument = doc; - }]; - - self.measureController = [[LKMeasureController alloc] initWithDataSource:dataSource]; - self.measureController.view.hidden = YES; - [self.splitTopView addSubview:self.measureController.view]; - [self addChildViewController:self.measureController]; - - self.imageSyncTipsView = [LKTipsView new]; - self.imageSyncTipsView.hidden = YES; - [self.view addSubview:self.imageSyncTipsView]; - - self.tooLargeToSyncScreenshotTipsView = [LKRedTipsView new]; - self.tooLargeToSyncScreenshotTipsView.image = NSImageMake(@"icon_info"); - self.tooLargeToSyncScreenshotTipsView.title = NSLocalizedString(@"Image is too large to be displayed.", nil); - self.tooLargeToSyncScreenshotTipsView.hidden = YES; - [self.view addSubview:self.tooLargeToSyncScreenshotTipsView]; - - self.focusTipView = [LKYellowTipsView new]; - self.focusTipView.image = NSImageMake(@"icon_info"); - self.focusTipView.title = NSLocalizedString(@"Currently in focus mode", nil); - self.focusTipView.hidden = YES; - self.focusTipView.buttonText = NSLocalizedString(@"Exit", nil); - self.focusTipView.target = self; - self.focusTipView.clickAction = @selector(_handleExitFocusTipView); - [self.view addSubview:self.focusTipView]; - - self.fastModeTipView = [LKTipsView new]; - self.fastModeTipView.image = NSImageMake(@"Icon_Inspiration_small"); - self.fastModeTipView.title = NSLocalizedString(@"Fast refresh mode is enabled, which may result in layer consistency issues.", nil); - self.fastModeTipView.hidden = YES; - self.fastModeTipView.buttonText = NSLocalizedString(@"Details", nil); - self.fastModeTipView.target = self; - self.fastModeTipView.clickAction = @selector(_handleFastModeTipViewClick); - [self.view addSubview:self.fastModeTipView]; - - self.noPreviewTipView = [LKTipsView new]; - self.noPreviewTipView.image = NSImageMake(@"icon_hide"); - self.noPreviewTipView.title = NSLocalizedString(@"The screenshot of selected item is not displayed.", nil); - self.noPreviewTipView.buttonText = NSLocalizedString(@"Display", nil); - self.noPreviewTipView.target = self; - self.noPreviewTipView.clickAction = @selector(_handleNoPreviewTipView); - self.noPreviewTipView.hidden = YES; - [self.view addSubview:self.noPreviewTipView]; - - self.customViewTipView = [LKTipsView new]; - self.customViewTipView.image = NSImageMake(@"Icon_Inspiration_small"); - // The view class name depends on the inspected app, which is unknown until a - // hierarchy arrives — handleSelectItemDidChange refreshes the title before showing it. - self.customViewTipView.title = [self _customViewTipTitle]; - self.customViewTipView.buttonText = NSLocalizedString(@"Details", nil); - self.customViewTipView.target = self; - self.customViewTipView.clickAction = @selector(handleCustomViewTipsView); - self.customViewTipView.hidden = YES; - [self.view addSubview:self.customViewTipView]; - - self.userConfigNoPreviewTipsView = [LKTipsView new]; - self.userConfigNoPreviewTipsView.image = NSImageMake(@"icon_hide"); - self.userConfigNoPreviewTipsView.title = NSLocalizedString(@"The screenshot is not displayed due to the config in iOS App.", nil); - self.userConfigNoPreviewTipsView.buttonText = NSLocalizedString(@"Details", nil); - self.userConfigNoPreviewTipsView.target = self; - self.userConfigNoPreviewTipsView.clickAction = @selector(_handleUserConfigNoPreviewTipView); - self.userConfigNoPreviewTipsView.hidden = YES; - [self.view addSubview:self.userConfigNoPreviewTipsView]; - - self.progressView = [LKProgressIndicatorView new]; - [self.view addSubview:self.progressView]; - - [RACObserve(dataSource, selectedItem) subscribeNext:^(LookinDisplayItem *item) { - @strongify(self); - [self handleSelectItemDidChange]; - }]; - - [[[RACSignal merge:@[RACObserve(dataSource, selectedItem), dataSource.itemDidChangeNoPreview]] skip:1] subscribeNext:^(id _Nullable x) { - @strongify(self); - LookinDisplayItem *item = dataSource.selectedItem; - BOOL shouldShowNoPreviewTip = item.inNoPreviewHierarchy && item.doNotFetchScreenshotReason != LookinDoNotFetchScreenshotForUserConfig; - if (shouldShowNoPreviewTip || !self.noPreviewTipView.hidden) { - self.noPreviewTipView.title = [NSString stringWithFormat:NSLocalizedString(@"The screenshot of selected %@ is not displayed.", nil), item.title]; - self.noPreviewTipView.bindingObject = item; - self.noPreviewTipView.hidden = !shouldShowNoPreviewTip; - [self.view setNeedsLayout:YES]; - } - }]; - - [RACObserve(dataSource, state) subscribeNext:^(NSNumber * _Nullable x) { - @strongify(self); - LKHierarchyDataSourceState state = x.unsignedIntegerValue; - BOOL isFocus = (state == LKHierarchyDataSourceStateFocus); - self.focusTipView.hidden = !isFocus; - if (isFocus) { - [self.focusTipView startAnimation]; - } else { - [self.focusTipView endAnimation]; - } - [self.view setNeedsLayout:YES]; - }]; - - // Phase F: image-sync tip's icon follows the owning Live Doc's - // inspectable app instead of the deprecated single-slot global. - // Re-evaluating on NSWindowDidBecomeMainNotification covers both the - // initial window↔doc bind and Phase D auto-reconnect swaps. - [[[[NSNotificationCenter defaultCenter] rac_addObserverForName:NSWindowDidBecomeMainNotification - object:nil] startWith:nil] subscribeNext:^(id _) { - @strongify(self); - LookinLiveDocument *doc = [LookinLiveDocument documentInWindow:self.view.window]; - if (doc.inspectableApp) { - [self.imageSyncTipsView setImageByAppInfo:doc.inspectableApp.appInfo]; - } - }]; - - LKStaticAsyncUpdateManager *updateMng = self.asyncUpdateManager; - [updateMng.modifyingUpdateProgressSignal subscribeNext:^(RACTwoTuple *x) { - @strongify(self); - NSUInteger received = ((NSNumber *)x.first).integerValue; - NSUInteger total = MAX(1, ((NSNumber *)x.second).integerValue); - CGFloat progress = (CGFloat)received / total; - if (progress >= 1) { - [self.progressView finishWithCompletion:nil]; - self.imageSyncTipsView.hidden = YES; - } else { - [self.progressView animateToProgress:MAX(progress, .2) duration:.1]; - self.imageSyncTipsView.hidden = NO; - self.imageSyncTipsView.title = [NSString stringWithFormat:NSLocalizedString(@"Updating screenshots… %@ / %@", nil), @(received), @(total)]; - [self.view setNeedsLayout:YES]; - } - }]; - [updateMng.modifyingUpdateErrorSignal subscribeNext:^(NSError *error) { - @strongify(self); - self.imageSyncTipsView.hidden = YES; - [self.progressView resetToZero]; - AlertError(error, self.view.window); - }]; - [preferenceManager.fastMode subscribe:self action:@selector(_handleFastModeChange:) relatedObject:nil sendAtOnce:YES]; - -} - -- (void)viewDidLayout { - [super viewDidLayout]; - $(self.dashboardController.view).width(DashboardViewWidth).right(0).fullHeight; - $(self.measureController.view).width(MeasureViewWidth).right(DashboardHorInset).fullHeight; - $(self.viewsPreviewController.view).fullFrame; - - CGFloat windowTitleHeight = [LKNavigationManager sharedInstance].windowTitleBarHeight; - - $(self.progressView).fullWidth.height(3).y(windowTitleHeight); - - __block CGFloat tipsY = windowTitleHeight + 10; - [$(self.connectionTipsView, self.imageSyncTipsView, self.tooLargeToSyncScreenshotTipsView, self.noPreviewTipView, self.focusTipView, self.userConfigNoPreviewTipsView, self.customViewTipView, self.fastModeTipView).visibles.array enumerateObjectsUsingBlock:^(LKTipsView *tipsView, NSUInteger idx, BOOL * _Nonnull stop) { - CGFloat midX = self.hierarchyController.view.$width + (self.viewsPreviewController.view.$width - DashboardViewWidth) / 2.0; - $(tipsView).sizeToFit.y(tipsY).midX(midX); - tipsY = tipsView.$maxY + 5; - }]; - - @weakify(self); - [[NSNotificationCenter.defaultCenter rac_addObserverForName:LKAppShowConsoleNotificationName object:nil] subscribeNext:^(NSNotification * _Nullable x) { - @strongify(self); - BOOL isFirstTimeToShowConsole = (self.consoleController == nil); - self.showConsole = true; - LookinDisplayItem *item = x.object; - if (isFirstTimeToShowConsole) { - // give a little time to initialize console - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self.consoleController submitWithObj:item.viewObject text:@"self"]; - }); - } else { - [self.consoleController submitWithObj:item.viewObject text:@"self"]; - } - }]; -} - -- (void)setShowConsole:(BOOL)showConsole { - _showConsole = showConsole; - if (showConsole) { - if (!self.consoleController) { - self.consoleController = [[LKConsoleViewController alloc] initWithHierarchyDataSource:self.hierarchyDataSource]; - // Phase F: console is created lazily, so it inherits the - // live doc binding from the current window at the moment of - // first show. - self.consoleController.liveDocument = [LookinLiveDocument documentInWindow:self.view.window]; - [self addChildViewController:self.consoleController]; - } - [self.rightSplitView addArrangedSubview:self.consoleController.view]; - - if (self.consoleController.view.bounds.size.height < 20) { - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [self.rightSplitView setPosition:(self.rightSplitView.bounds.size.height - 150) ofDividerAtIndex:0]; - }); - } - } else { - if (self.consoleController.view.superview) { - [self.rightSplitView removeArrangedSubview:self.consoleController.view]; - } else { - NSAssert(NO, @""); - } - } - - self.consoleController.isControllerShowing = showConsole; -} - -- (LKHierarchyView *)currentHierarchyView { - return self.hierarchyController.hierarchyView; -} - -- (LKHierarchyDataSource *)dataSource { - return self.hierarchyController.dataSource; -} - -#pragma mark - - -- (BOOL)splitView:(NSSplitView *)splitView canCollapseSubview:(NSView *)subview { - return NO; -} - -- (CGFloat)splitView:(NSSplitView *)splitView constrainMinCoordinate:(CGFloat)proposedMinimumPosition ofSubviewAt:(NSInteger)dividerIndex { - if (splitView == self.mainSplitView) { - return HierarchyMinWidth; - } else { - return splitView.bounds.size.height * .3; - } -} - -- (CGFloat)splitView:(NSSplitView *)splitView constrainMaxCoordinate:(CGFloat)proposedMaximumPosition ofSubviewAt:(NSInteger)dividerIndex { - if (splitView == self.mainSplitView) { - return MAX(splitView.bounds.size.width - DashboardViewWidth - 100, HierarchyMinWidth); - } else { - return splitView.bounds.size.height - 50; - } -} - -- (void)splitViewDidResizeSubviews:(NSNotification *)notification { - [self.view setNeedsLayout:YES]; -} - -#pragma mark - Event Handler - -- (void)_handleNoPreviewTipView { - [self.hierarchyController hierarchyView:nil needToShowPreviewOfItem:self.noPreviewTipView.bindingObject]; -} - -- (void)handleCustomViewTipsView { - [LKHelper showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"Legacy custom information guides are disabled in this community build. See the repository README instead.", nil)]; -} - -- (void)_handleExitFocusTipView { - [[self dataSource] endFocus]; -} - -- (void)_handleFastModeTipViewClick { - NSMenu *menu = [NSMenu new]; - - - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.title = NSLocalizedString(@"View feature description", nil); - menuItem.target = self; - menuItem.action = @selector(handleFastModeDocumentation); - [menu addItem:menuItem]; - - [menu addItem:[NSMenuItem separatorItem]]; - - NSMenuItem *menuItem2 = [NSMenuItem new]; - menuItem2.title = NSLocalizedString(@"Don't remind me again", nil); - menuItem2.target = self; - menuItem2.action = @selector(handleIgnoreFastModeTip); - [menu addItem:menuItem2]; - - [NSMenu popUpContextMenu:menu withEvent:NSApplication.sharedApplication.currentEvent forView:self.fastModeTipView.button]; -} - -- (void)handleFastModeDocumentation { - [LKHelper showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"Legacy fast mode documentation links are disabled in this community build. See the repository README instead.", nil)]; -} - -- (void)handleIgnoreFastModeTip { - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"IgnoreFastModeTips"]; - self.fastModeTipView.hidden = YES; -} - -- (void)_handleUserConfigNoPreviewTipView { - [LKHelper openCustomConfigWebsite]; -} - -- (void)_handleMeasureStateChange:(LookinMsgActionParams *)param { - LookinMeasureState state = param.integerValue; - BOOL isMeasure = (state != LookinMeasureState_no); - self.dashboardController.view.hidden = isMeasure; - self.measureController.view.hidden = !isMeasure; -} - -- (void)_handleFastModeChange:(LookinMsgActionParams *)param { - if (!param.boolValue) { - self.fastModeTipView.hidden = YES; - return; - } - BOOL shouldIgnoreTips = [[NSUserDefaults standardUserDefaults] boolForKey:@"IgnoreFastModeTips"]; - self.fastModeTipView.hidden = shouldIgnoreTips; - [self.view setNeedsLayout:YES]; -} - -- (void)handleSelectItemDidChange { - LookinDisplayItem *item = [[self dataSource] selectedItem]; - - { - BOOL showTips = (item && ![item appropriateScreenshot] && item.doNotFetchScreenshotReason == LookinDoNotFetchScreenshotForTooLarge); - BOOL shouldHide = !showTips; - if (self.tooLargeToSyncScreenshotTipsView.hidden != shouldHide) { - self.tooLargeToSyncScreenshotTipsView.hidden = shouldHide; - if (shouldHide) { - [self.tooLargeToSyncScreenshotTipsView endAnimation]; - } else { - [self.tooLargeToSyncScreenshotTipsView startAnimation]; - } - [self.view setNeedsLayout:YES]; - } - } - { - BOOL showTips = (item && item.isUserCustom); - BOOL shouldHide = !showTips; - if (showTips) { - self.customViewTipView.title = [self _customViewTipTitle]; - } - if (self.customViewTipView.hidden != shouldHide) { - self.customViewTipView.hidden = shouldHide; - [self.view setNeedsLayout:YES]; - } - } -} - -- (NSString *)_customViewTipTitle { - LookinAppInfo *inspectedAppInfo = [self dataSource].rawHierarchyInfo.appInfo; - return [NSString stringWithFormat:NSLocalizedString(@"This object may not be a %@ or CALayer.", nil), - [LKHelper viewClassNameForAppInfo:inspectedAppInfo]]; -} - -- (LKStaticWindowController *)_staticWindowController { - NSWindowController *windowController = self.view.window.windowController; - if ([windowController isKindOfClass:[LKStaticWindowController class]]) { - return (LKStaticWindowController *)windowController; - } else { - NSAssert(NO, @""); - return nil; - } -} - -- (BOOL)shouldShowConnectionTips { - return YES; -} - -@end diff --git a/LookInside/Static/LKStaticViewController.swift b/LookInside/Static/LKStaticViewController.swift new file mode 100644 index 00000000..bb1637b8 --- /dev/null +++ b/LookInside/Static/LKStaticViewController.swift @@ -0,0 +1,605 @@ +// +// LKStaticViewController.swift +// LookInside +// +// Created by Li Kai on 2018/8/4. +// https://lookin.work +// + +import AppKit + +/// The content of a live inspector window: the hierarchy tree on the left; +/// on the right the preview with the dashboard (or measure panel) over it, +/// and the console under them; tips float above the preview. +@objc(LKStaticViewController) +final class LKStaticViewController: LKBaseViewController, NSSplitViewDelegate { + /// Set when the fast mode tip is dismissed for good. + private static let ignoreFastModeTipsDefaultsKey = "IgnoreFastModeTips" + + /// The per-document data source, owned by LKStaticWindowController. + @objc private(set) weak var hierarchyDataSource: LKStaticHierarchyDataSource? + + /// The per-document update manager, owned by LKStaticWindowController. + @objc private(set) weak var asyncUpdateManager: LKStaticAsyncUpdateManager? + + @objc private(set) var viewsPreviewController: LKPreviewController! + + @objc var progressView: LKProgressIndicatorView! + + /// Shows or hides the console under the preview. Observable with KVO. + @objc dynamic var showConsole = false { + didSet { applyShowConsole() } + } + + private var mainSplitView: LKSplitView! + private var rightSplitView: LKSplitView! + private var splitTopView: LKBaseView! + + private var imageSyncTipsView: LKTipsView! + private var tooLargeToSyncScreenshotTipsView: LKRedTipsView! + private var userConfigNoPreviewTipsView: LKTipsView! + private var noPreviewTipView: LKTipsView! + private var customViewTipView: LKTipsView! + private var focusTipView: LKYellowTipsView! + private var fastModeTipView: LKTipsView! + private var serverUpgradeTipView: LKTipsView! + + /// Read with KVC by the DEBUG UI snapshots. + @objc private(set) var dashboardController: LKDashboardViewController! + private var hierarchyController: LKStaticHierarchyController! + private var consoleController: LKConsoleViewController? + private var measureController: LKMeasureController! + + private var notificationObservers: [NSObjectProtocol] = [] + private var keyValueObservations: [NSKeyValueObservation] = [] + private let dataSourceSignals = LKSyncSubscriptionBag() + private var modifyingUpdatesTask: Task? + + /// Both arguments must be non-nil. The superclass initializer sets the + /// view, and setting it builds the children that read them, so they are + /// stored first. + @objc(initWithHierarchyDataSource:asyncUpdateManager:) + init(hierarchyDataSource: LKStaticHierarchyDataSource?, asyncUpdateManager: LKStaticAsyncUpdateManager?) { + assert(hierarchyDataSource != nil && asyncUpdateManager != nil, + "LKStaticViewController requires non-nil per-doc data source and update manager") + self.hierarchyDataSource = hierarchyDataSource + self.asyncUpdateManager = asyncUpdateManager + super.init(containerView: nil) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + deinit { + modifyingUpdatesTask?.cancel() + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) + } + } + + override func makeContainerView() -> NSView { + let splitView = LKSplitView() + splitView.didFinishFirstLayout = { view in + let x = min(max(350, view.bounds.size.width * 0.3), 700) + view.setPosition(x, ofDividerAt: 0) + } + splitView.arrangesAllSubviews = false + splitView.isVertical = true + splitView.dividerStyle = .thin + splitView.delegate = self + mainSplitView = splitView + return splitView + } + + override func shouldShowConnectionTips() -> Bool { + true + } + + override var view: NSView { + get { super.view } + set { + super.view = newValue + buildContent() + } + } + + // MARK: - Building + + private func buildContent() { + let preferenceManager = LKPreferenceManager.shared + preferenceManager.measureState.subscribe(self, action: #selector(handleMeasureStateChange(_:)), relatedObject: nil) + + // The initializer requires a data source (see its assert). + let dataSource = hierarchyDataSource! + + hierarchyController = LKStaticHierarchyController(dataSource: dataSource) + addChild(hierarchyController) + mainSplitView.addArrangedSubview(hierarchyController.view) + // Hand the per-document update manager down the owner chain. + hierarchyController.hierarchyView.asyncUpdateManager = asyncUpdateManager + + rightSplitView = LKSplitView() + rightSplitView.arrangesAllSubviews = true + rightSplitView.isVertical = false + rightSplitView.dividerStyle = .thin + rightSplitView.delegate = self + mainSplitView.addArrangedSubview(rightSplitView) + + splitTopView = LKBaseView() + rightSplitView.addArrangedSubview(splitTopView) + + viewsPreviewController = LKPreviewController(dataSource: dataSource) + viewsPreviewController.staticViewController = self + viewsPreviewController.asyncUpdateManager = asyncUpdateManager + splitTopView.addSubview(viewsPreviewController.view) + addChild(viewsPreviewController) + + dashboardController = LKDashboardViewController(staticDataSource: dataSource) + dashboardController.asyncUpdateManager = asyncUpdateManager + splitTopView.addSubview(dashboardController.view) + addChild(dashboardController) + + // Once the window has a live document, hand it to the children that + // send per-document requests (dashboard, console). + observeWindowBecomingMain { [weak self] in + guard let self else { return } + let document = LookinLiveDocument.document(in: view.window) + dashboardController.liveDocument = document + consoleController?.liveDocument = document + } + + measureController = LKMeasureController(dataSource: dataSource) + measureController.view.isHidden = true + splitTopView.addSubview(measureController.view) + addChild(measureController) + + buildTips() + + progressView = LKProgressIndicatorView() + view.addSubview(progressView) + + observeDataSource(dataSource) + + // The image sync tip shows the icon of the owning document's app. + // Re-evaluating whenever a window becomes main covers both the first + // window-document binding and reconnecting to a new app. + observeWindowBecomingMain { [weak self] in + guard let self, let app = LookinLiveDocument.document(in: view.window)?.inspectableApp else { return } + imageSyncTipsView.setImage(byAppInfo: app.appInfo) + } + + if let updates = asyncUpdateManager?.modifyingUpdates.subscribe() { + modifyingUpdatesTask = Task { @MainActor [weak self] in + for await event in updates { + self?.handleModifyingUpdate(event) + } + } + } + + preferenceManager.fastMode.subscribe(self, action: #selector(handleFastModeChange(_:)), relatedObject: nil, sendAtOnce: true) + + notificationObservers.append(NotificationCenter.default.addObserver( + forName: NSNotification.Name(LKAppShowConsoleNotificationName), + object: nil, + queue: nil + ) { [weak self] note in + MainActor.assumeIsolated { + self?.showConsoleAndPrint(note.object as? LookinDisplayItem) + } + }) + } + + private func buildTips() { + imageSyncTipsView = LKTipsView() + imageSyncTipsView.isHidden = true + view.addSubview(imageSyncTipsView) + + tooLargeToSyncScreenshotTipsView = LKRedTipsView() + tooLargeToSyncScreenshotTipsView.image = NSImage(named: "icon_info") + tooLargeToSyncScreenshotTipsView.title = NSLocalizedString("Image is too large to be displayed.", comment: "") + tooLargeToSyncScreenshotTipsView.isHidden = true + view.addSubview(tooLargeToSyncScreenshotTipsView) + + focusTipView = LKYellowTipsView() + focusTipView.image = NSImage(named: "icon_info") + focusTipView.title = NSLocalizedString("Currently in focus mode", comment: "") + focusTipView.isHidden = true + focusTipView.buttonText = NSLocalizedString("Exit", comment: "") + focusTipView.target = self + focusTipView.clickAction = #selector(handleExitFocusTipView) + view.addSubview(focusTipView) + + fastModeTipView = LKTipsView() + fastModeTipView.image = NSImage(named: "Icon_Inspiration_small") + fastModeTipView.title = NSLocalizedString("Fast refresh mode is enabled, which may result in layer consistency issues.", comment: "") + fastModeTipView.isHidden = true + fastModeTipView.buttonText = NSLocalizedString("Details", comment: "") + fastModeTipView.target = self + fastModeTipView.clickAction = #selector(handleFastModeTipViewClick) + view.addSubview(fastModeTipView) + + noPreviewTipView = LKTipsView() + noPreviewTipView.image = NSImage(named: "icon_hide") + noPreviewTipView.title = NSLocalizedString("The screenshot of selected item is not displayed.", comment: "") + noPreviewTipView.buttonText = NSLocalizedString("Display", comment: "") + noPreviewTipView.target = self + noPreviewTipView.clickAction = #selector(handleNoPreviewTipView) + noPreviewTipView.isHidden = true + view.addSubview(noPreviewTipView) + + customViewTipView = LKTipsView() + customViewTipView.image = NSImage(named: "Icon_Inspiration_small") + // The view class name depends on the inspected app, which is unknown + // until a hierarchy arrives; handleSelectItemDidChange() refreshes + // the title before showing it. + customViewTipView.title = customViewTipTitle() + customViewTipView.buttonText = NSLocalizedString("Details", comment: "") + customViewTipView.target = self + customViewTipView.clickAction = #selector(handleCustomViewTipsView) + customViewTipView.isHidden = true + view.addSubview(customViewTipView) + + userConfigNoPreviewTipsView = LKTipsView() + userConfigNoPreviewTipsView.image = NSImage(named: "icon_hide") + userConfigNoPreviewTipsView.title = NSLocalizedString("The screenshot is not displayed due to the config in iOS App.", comment: "") + userConfigNoPreviewTipsView.buttonText = NSLocalizedString("Details", comment: "") + userConfigNoPreviewTipsView.target = self + userConfigNoPreviewTipsView.clickAction = #selector(handleUserConfigNoPreviewTipView) + userConfigNoPreviewTipsView.isHidden = true + view.addSubview(userConfigNoPreviewTipsView) + + serverUpgradeTipView = LKTipsView() + serverUpgradeTipView.image = NSImage(named: "Icon_Inspiration_small") + serverUpgradeTipView.title = NSLocalizedString("The app's integrator should upgrade LookInside Server.", comment: "Tip shown once per inspected app whose LookInside Server is 0.2.9 or older") + serverUpgradeTipView.buttonText = NSLocalizedString("OK", comment: "") + serverUpgradeTipView.target = self + serverUpgradeTipView.clickAction = #selector(handleServerUpgradeTipView) + serverUpgradeTipView.isHidden = true + view.addSubview(serverUpgradeTipView) + + // The 220 reply that carries the Server's release can arrive before + // or after this window gets its document. + observeWindowBecomingMain { [weak self] in + self?.updateServerUpgradeTip() + } + notificationObservers.append(NotificationCenter.default.addObserver( + forName: LKServerUpgradeHint.releaseDidArriveNotification, + object: nil, + queue: nil + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.updateServerUpgradeTip() + } + }) + } + + /// Shows the upgrade hint when this window's app reported an old Server + /// and no window showed the hint for that app during this launch. + private func updateServerUpgradeTip() { + guard serverUpgradeTipView.isHidden, + LKServerUpgradeHint.shouldShowHint(for: LookinLiveDocument.document(in: view.window)?.inspectableApp) + else { + return + } + serverUpgradeTipView.isHidden = false + view.needsLayout = true + } + + private func observeDataSource(_ dataSource: LKStaticHierarchyDataSource?) { + guard let dataSource else { return } + var isInitialSelection = true + keyValueObservations.append(dataSource.observe(\.selectedItem, options: [.initial]) { [weak self] _, _ in + guard let self else { return } + handleSelectItemDidChange() + if isInitialSelection { + isInitialSelection = false + } else { + updateNoPreviewTip() + } + }) + dataSourceSignals.observe(dataSource.itemDidChangeNoPreview) { [weak self] in + self?.updateNoPreviewTip() + } + keyValueObservations.append(dataSource.observe(\.state, options: [.initial]) { [weak self] dataSource, _ in + guard let self else { return } + let isFocus = dataSource.state == .focus + focusTipView.isHidden = !isFocus + if isFocus { + focusTipView.startAnimation() + } else { + focusTipView.endAnimation() + } + view.needsLayout = true + }) + } + + private func observeWindowBecomingMain(_ handler: @escaping @MainActor () -> Void) { + handler() + notificationObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeMainNotification, + object: nil, + queue: nil + ) { _ in + MainActor.assumeIsolated { + handler() + } + }) + } + + // MARK: - Layout + + override func viewDidLayout() { + super.viewDidLayout() + let dashboardLayout = LKStaticLayout(dashboardController.view) + dashboardLayout.width(DashboardViewWidth) + dashboardLayout.right(0) + dashboardLayout.fullHeight() + + let measureLayout = LKStaticLayout(measureController.view) + measureLayout.width(MeasureViewWidth) + measureLayout.right(DashboardHorInset) + measureLayout.fullHeight() + + LKStaticLayout(viewsPreviewController.view).fullFrame() + + let windowTitleHeight = LKNavigationManager.shared.windowTitleBarHeight + + let progressLayout = LKStaticLayout(progressView) + progressLayout.fullWidth() + progressLayout.height(3) + progressLayout.y(windowTitleHeight) + + let tips: [NSView?] = [ + connectionTipsView, + imageSyncTipsView, + tooLargeToSyncScreenshotTipsView, + noPreviewTipView, + focusTipView, + userConfigNoPreviewTipsView, + customViewTipView, + fastModeTipView, + serverUpgradeTipView, + ] + var tipsY = windowTitleHeight + 10 + for tipsView in tips.compactMap({ $0 }) where Self.isVisible(tipsView) { + let midX = hierarchyController.view.frame.width + (viewsPreviewController.view.frame.width - DashboardViewWidth) / 2 + let layout = LKStaticLayout(tipsView) + layout.sizeToFit() + layout.y(tipsY) + layout.midX(midX) + tipsY = tipsView.frame.maxY + 5 + } + } + + /// ShortCocoa's `visibles` filter. + private static func isVisible(_ view: NSView) -> Bool { + !view.isHidden && view.superview != nil && view.alphaValue >= 0.01 + } + + // MARK: - Console + + private func showConsoleAndPrint(_ item: LookinDisplayItem?) { + let isFirstTimeToShowConsole = consoleController == nil + showConsole = true + if isFirstTimeToShowConsole { + // Give the console a moment to set up. + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.consoleController?.submit(with: item?.viewObject, text: "self") + } + } else { + consoleController?.submit(with: item?.viewObject, text: "self") + } + } + + private func applyShowConsole() { + if showConsole { + let consoleController: LKConsoleViewController + if let existing = self.consoleController { + consoleController = existing + } else { + guard let hierarchyDataSource else { return } + consoleController = LKConsoleViewController(hierarchyDataSource: hierarchyDataSource) + // Made on first show, so it takes the window's live document + // at that moment. + consoleController.liveDocument = LookinLiveDocument.document(in: view.window) + addChild(consoleController) + self.consoleController = consoleController + } + rightSplitView.addArrangedSubview(consoleController.view) + + if consoleController.view.bounds.size.height < 20 { + DispatchQueue.main.async { + self.rightSplitView.setPosition(self.rightSplitView.bounds.size.height - 150, ofDividerAt: 0) + } + } + } else if let consoleView = consoleController?.view, consoleView.superview != nil { + rightSplitView.removeArrangedSubview(consoleView) + } else { + assertionFailure() + } + consoleController?.isControllerShowing = showConsole + } + + /// The hierarchy view of the tree on the left. + @objc func currentHierarchyView() -> LKHierarchyView? { + hierarchyController.hierarchyView + } + + private var dataSource: LKHierarchyDataSource? { + hierarchyController.dataSource + } + + // MARK: - NSSplitViewDelegate + + func splitView(_: NSSplitView, canCollapseSubview _: NSView) -> Bool { + false + } + + func splitView(_ splitView: NSSplitView, constrainMinCoordinate _: CGFloat, ofSubviewAt _: Int) -> CGFloat { + if splitView === mainSplitView { + return HierarchyMinWidth + } + return splitView.bounds.size.height * 0.3 + } + + func splitView(_ splitView: NSSplitView, constrainMaxCoordinate _: CGFloat, ofSubviewAt _: Int) -> CGFloat { + if splitView === mainSplitView { + return max(splitView.bounds.size.width - DashboardViewWidth - 100, HierarchyMinWidth) + } + return splitView.bounds.size.height - 50 + } + + func splitViewDidResizeSubviews(_: Notification) { + view.needsLayout = true + } + + // MARK: - Events + + private func handleModifyingUpdate(_ event: LKModifyingUpdateEvent) { + switch event { + case let .progress(received, total): + let progress = CGFloat(received) / CGFloat(max(1, total)) + if progress >= 1 { + progressView.finish(completion: nil) + imageSyncTipsView.isHidden = true + } else { + progressView.animate(toProgress: max(progress, 0.2), duration: 0.1) + imageSyncTipsView.isHidden = false + imageSyncTipsView.title = String( + format: NSLocalizedString("Updating screenshots… %@ / %@", comment: ""), + NSNumber(value: received), + NSNumber(value: total) + ) + view.needsLayout = true + } + case .failure: + // A failed screenshot patch is silent, as it always was; only + // the progress UI is cleared. + imageSyncTipsView.isHidden = true + progressView.resetToZero() + } + } + + private func updateNoPreviewTip() { + let item = hierarchyDataSource?.selectedItem + let shouldShowNoPreviewTip = (item?.inNoPreviewHierarchy ?? false) + && item?.doNotFetchScreenshotReason != .doNotFetchScreenshotForUserConfig + guard shouldShowNoPreviewTip || !noPreviewTipView.isHidden else { return } + noPreviewTipView.title = String( + format: NSLocalizedString("The screenshot of selected %@ is not displayed.", comment: ""), + item?.title() ?? "" + ) + noPreviewTipView.bindingObject = item + noPreviewTipView.isHidden = !shouldShowNoPreviewTip + view.needsLayout = true + } + + @objc private func handleNoPreviewTipView() { + hierarchyController.hierarchyView(nil, needToShowPreviewOf: noPreviewTipView.bindingObject as? LookinDisplayItem) + } + + @objc private func handleCustomViewTipsView() { + LKHelper.showDisabledExternalLinkAlert(withMessage: NSLocalizedString( + "Legacy custom information guides are disabled in this community build. See the repository README instead.", + comment: "" + )) + } + + @objc private func handleServerUpgradeTipView() { + serverUpgradeTipView.isHidden = true + view.needsLayout = true + } + + @objc private func handleExitFocusTipView() { + dataSource?.endFocus() + } + + @objc private func handleFastModeTipViewClick() { + let menu = NSMenu() + + let documentationItem = NSMenuItem() + documentationItem.title = NSLocalizedString("View feature description", comment: "") + documentationItem.target = self + documentationItem.action = #selector(handleFastModeDocumentation) + menu.addItem(documentationItem) + + menu.addItem(.separator()) + + let ignoreItem = NSMenuItem() + ignoreItem.title = NSLocalizedString("Don't remind me again", comment: "") + ignoreItem.target = self + ignoreItem.action = #selector(handleIgnoreFastModeTip) + menu.addItem(ignoreItem) + + if let event = NSApplication.shared.currentEvent { + NSMenu.popUpContextMenu(menu, with: event, for: fastModeTipView.button) + } + } + + @objc private func handleFastModeDocumentation() { + LKHelper.showDisabledExternalLinkAlert(withMessage: NSLocalizedString( + "Legacy fast mode documentation links are disabled in this community build. See the repository README instead.", + comment: "" + )) + } + + @objc private func handleIgnoreFastModeTip() { + UserDefaults.standard.set(true, forKey: Self.ignoreFastModeTipsDefaultsKey) + fastModeTipView.isHidden = true + } + + @objc private func handleUserConfigNoPreviewTipView() { + LKHelper.openCustomConfigWebsite() + } + + @objc private func handleMeasureStateChange(_ param: LookinMsgActionParams) { + let isMeasure = param.integerValue != LookinMeasureState.no.rawValue + dashboardController.view.isHidden = isMeasure + measureController.view.isHidden = !isMeasure + } + + @objc private func handleFastModeChange(_ param: LookinMsgActionParams) { + guard param.boolValue else { + fastModeTipView.isHidden = true + return + } + fastModeTipView.isHidden = UserDefaults.standard.bool(forKey: Self.ignoreFastModeTipsDefaultsKey) + view.needsLayout = true + } + + private func handleSelectItemDidChange() { + let item = dataSource?.selectedItem + + let showsTooLargeTip = item != nil + && item?.lkOptionalAppropriateScreenshot == nil + && item?.doNotFetchScreenshotReason == .doNotFetchScreenshotForTooLarge + if tooLargeToSyncScreenshotTipsView.isHidden != !showsTooLargeTip { + tooLargeToSyncScreenshotTipsView.isHidden = !showsTooLargeTip + if showsTooLargeTip { + tooLargeToSyncScreenshotTipsView.startAnimation() + } else { + tooLargeToSyncScreenshotTipsView.endAnimation() + } + view.needsLayout = true + } + + let showsCustomTip = item?.isUserCustom() ?? false + if showsCustomTip { + customViewTipView.title = customViewTipTitle() + } + if customViewTipView.isHidden != !showsCustomTip { + customViewTipView.isHidden = !showsCustomTip + view.needsLayout = true + } + } + + private func customViewTipTitle() -> String { + let inspectedAppInfo = hierarchyController?.dataSource?.rawHierarchyInfo?.appInfo + return String( + format: NSLocalizedString("This object may not be a %@ or CALayer.", comment: ""), + LKHelper.viewClassName(for: inspectedAppInfo) + ) + } +} diff --git a/LookInside/Static/LKStaticWindowController.h b/LookInside/Static/LKStaticWindowController.h deleted file mode 100644 index 04bb218f..00000000 --- a/LookInside/Static/LKStaticWindowController.h +++ /dev/null @@ -1,103 +0,0 @@ -// -// LKStaticWindowController.h -// Lookin -// -// Created by Li Kai on 2018/11/4. -// https://lookin.work -// - -#import "LKWindowController.h" -#import "LKMenuPopoverAppsListController.h" - -@class LKStaticViewController, LKStaticHierarchyDataSource, LKStaticAsyncUpdateManager, LKInspectableApp, RACSignal; - -/// Error domain for the refusals `-reloadHierarchySignal` raises on its own -/// behalf. Errors forwarded from the Peertalk round-trip keep their original -/// `LookinErrorDomain` code, so callers can tell "the host would not start a -/// reload" apart from "the target app failed the reload". -extern NSErrorDomain const LKStaticWindowControllerReloadErrorDomain; - -/// Who asked for a hierarchy reload. Recorded on the window controller at -/// the moment a reload actually begins, so an observer of the data -/// source's `didReloadHierarchyInfo` can attribute the reload it just saw. -/// -/// Reading it is race-free because the reload gate serializes reloads: a -/// second request cannot start while one is in flight, so the value can -/// only change between reloads, never during one. -typedef NS_ENUM(NSInteger, LKHierarchyReloadInitiator) { - /// A person acted in the inspector -- the toolbar button, the menu - /// item, or an automatic reload the host decided on. - LKHierarchyReloadInitiatorHost = 0, - /// A bridge client asked for it (the MCPBridge `hierarchy.refresh` - /// route). Clients filter on this to ignore their own echo. - LKHierarchyReloadInitiatorAgent = 1, -}; - -typedef NS_ENUM(NSInteger, LKStaticWindowControllerReloadErrorCode) { - /// A hierarchy fetch is already in flight on this window. - LKStaticWindowControllerReloadErrorAlreadyInProgress = 1, - /// The async detail sync is running. Reloading now would throw away - /// work already paid for, so the caller has to stop it first. - LKStaticWindowControllerReloadErrorDetailSyncInProgress = 2, - /// No inspectable app is bound to this window. - LKStaticWindowControllerReloadErrorNoInspectableApp = 3, - /// The window went away while the fetch was in flight, leaving no data - /// source to absorb the result. - LKStaticWindowControllerReloadErrorWindowClosed = 4, - /// The fetch finished without ever delivering a hierarchy and without - /// reporting an error -- reachable when the Peertalk channel tears down - /// mid-request. Distinct from `WindowClosed`: the window is still here, - /// the target app simply never answered. - LKStaticWindowControllerReloadErrorNoResponse = 5, -}; - -@interface LKStaticWindowController : LKWindowController - -@property(nonatomic, strong, readonly) LKStaticViewController *viewController; - -/// Phase A 引入:per-instance hierarchy data source(由本 windowController 持有)。 -@property(nonatomic, strong, readonly) LKStaticHierarchyDataSource *hierarchyDataSource; - -/// Phase A 引入:per-instance async update manager(由本 windowController 持有)。 -@property(nonatomic, strong, readonly) LKStaticAsyncUpdateManager *asyncUpdateManager; - -/// 由 owner(LookinLiveDocument)注入的 inspectable app(weak)。Phase F 起所有 -/// RPC 路径完全依赖该字段,不再有兜底全局。 -@property(nonatomic, weak) LKInspectableApp *inspectableApp; - -/// LookinLiveDocument 创建 windowController 时使用,在标准 -init 之上把 -/// inspectableApp 注入到本 controller 与其 asyncUpdateManager 上,这样后续 -/// 所有 RPC 调用都通过该 app。 -- (instancetype)initWithInspectableApp:(LKInspectableApp *)app; - -- (void)popupAllInspectableAppsWithSource:(MenuPopoverAppsListControllerEventSource)source; - -/// Reloads this window's hierarchy the way the toolbar reload button does: -/// same re-entrancy gate, same progress indicator, same `keepState:YES` -/// hand-off to the data source. The one thing it does NOT do is put up the -/// modal error sheet — that stays in the button's own handler, so callers -/// arriving from outside the UI (the MCPBridge `hierarchy.refresh` route) -/// can never drop a dialog on a user who did not ask for one. -/// -/// The fetch starts when this method is called, not when the returned -/// signal is subscribed to, and the result is replayed to every subscriber. -/// Sends the reloaded `LookinHierarchyInfo` once and completes; by the time -/// that value arrives `hierarchyDataSource` has already absorbed it, so -/// subscribers observe a settled hierarchy rather than racing it. -/// -/// Errors either in `LKStaticWindowControllerReloadErrorDomain` (this -/// window refused to start) or in `LookinErrorDomain` (the target app -/// failed the fetch). -/// -/// `initiator` is recorded on `lastReloadInitiator` only if the reload -/// actually starts, so a refused request never mislabels the reload that -/// refused it. -- (RACSignal *)reloadHierarchySignalWithInitiator:(LKHierarchyReloadInitiator)initiator; - -/// Who asked for the most recent reload that actually started. Meaningful -/// from the moment the fetch begins, which is before the data source -/// publishes `didReloadHierarchyInfo` -- so an observer of that subject -/// reads the value belonging to the reload it is being told about. -@property(nonatomic, assign, readonly) LKHierarchyReloadInitiator lastReloadInitiator; - -@end diff --git a/LookInside/Static/LKStaticWindowController.m b/LookInside/Static/LKStaticWindowController.m deleted file mode 100644 index ea90031b..00000000 --- a/LookInside/Static/LKStaticWindowController.m +++ /dev/null @@ -1,902 +0,0 @@ -// -// LKStaticWindowController.m -// Lookin -// -// Created by Li Kai on 2018/11/4. -// https://lookin.work -// - -#import "LKStaticWindowController.h" -#import "LKStaticViewController.h" -#import "LKMenuPopoverSettingController.h" -#import "LKStaticHierarchyDataSource.h" -#import "LKNavigationManager.h" -#import "LKPreferenceManager.h" -#import "LKAppsManager.h" -#import "LKInspectableApp.h" -#import "LKMenuPopoverAppsListController.h" -#import "LKProgressIndicatorView.h" -#import "LookinDisplayItem.h" -#import "LookinObject.h" -#import "LKWindowToolbarHelper.h" -#import "LKWindowToolbarAppButton.h" -#import "LKExportManager.h" -#import "LookinHierarchyInfo.h" -#import "LKExportAccessoryView.h" -#import "LKWindow.h" -#import "LKStaticAsyncUpdateManager.h" -#import "LookinHierarchyFile.h" -#import "LookinLiveDocument.h" -#import "LookinLiveDocumentController.h" -#import "LKPreviewView.h" -#import "LKHierarchyView.h" -#import "LKPerformanceReporter.h" -#import "LKMessageManager.h" -#import "LKHelper.h" -#import "LKSwiftUIHierarchyDisplayMode.h" -#import "LookInside-Swift.h" -#import - -NSErrorDomain const LKStaticWindowControllerReloadErrorDomain = @"LKStaticWindowControllerReloadErrorDomain"; - -static NSError *LKStaticWindowControllerReloadErrorMake(LKStaticWindowControllerReloadErrorCode code, NSString *description) { - return [NSError errorWithDomain:LKStaticWindowControllerReloadErrorDomain - code:code - userInfo:@{NSLocalizedDescriptionKey: description}]; -} - -@interface LKStaticWindowController () - -@property(nonatomic, strong) NSMutableDictionary *toolbarItemsMap; -@property(nonatomic, strong) LKGestureDebugWindowController *gestureDebugWindowController; - -/// 当拉取 hierarchy 和更新截图时,该属性为 YES -@property(nonatomic, assign) BOOL isFetchingHierarchy; -@property(nonatomic, assign) BOOL isFetchingDetails; - -@property(nonatomic, strong, readwrite) LKStaticHierarchyDataSource *hierarchyDataSource; -@property(nonatomic, strong, readwrite) LKStaticAsyncUpdateManager *asyncUpdateManager; - -@property(nonatomic, assign, readwrite) LKHierarchyReloadInitiator lastReloadInitiator; - -@end - -@implementation LKStaticWindowController - -- (instancetype)initWithInspectableApp:(LKInspectableApp *)app { - if (self = [self init]) { - // 由 LookinLiveDocument 调用,把 app 注入到本 wc 与其 asyncUpdateManager, - // 这样所有 RPC 请求(reload、modify 等)都路由到该 app 的 channel。 - self.inspectableApp = app; - self.asyncUpdateManager.inspectableApp = app; - } - return self; -} - -- (instancetype)init { - NSSize screenSize = [NSScreen mainScreen].frame.size; - LKWindow *window = [[LKWindow alloc] initWithContentRect:NSMakeRect(0, 0, screenSize.width * .7, screenSize.height * .7) styleMask:NSWindowStyleMaskTitled|NSWindowStyleMaskClosable|NSWindowStyleMaskMiniaturizable|NSWindowStyleMaskResizable|NSWindowStyleMaskFullSizeContentView backing:NSBackingStoreBuffered defer:YES]; - window.tabbingMode = NSWindowTabbingModeDisallowed; - window.titleVisibility = NSWindowTitleHidden; - if (@available(macOS 11.0, *)) { - window.toolbarStyle = NSWindowToolbarStyleUnified; - } - window.minSize = NSMakeSize(HierarchyMinWidth + DashboardViewWidth + 200, 500); - [window center]; - [window setFrameUsingName:LKWindowSizeName_Static]; - - if (self = [self initWithWindow:window]) { - // Phase F: persist this window's frame across launches using - // AppKit's window-controller autosave hook. The previous - // `LKNavigationManager.windowWillClose:` save path went away - // with `staticWindowController`, so saving moves here. - self.windowFrameAutosaveName = LKWindowSizeName_Static; - // 创建 viewController 之前先 new 自己的 dataSource + updateManager。 - _hierarchyDataSource = [[LKStaticHierarchyDataSource alloc] init]; - _asyncUpdateManager = [[LKStaticAsyncUpdateManager alloc] initWithHierarchyDataSource:_hierarchyDataSource - inspectableApp:nil]; - _hierarchyDataSource.asyncUpdateManager = _asyncUpdateManager; - - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(_handleSwiftUIModeDidChange:) - name:LKSwiftUIHierarchyDisplayModeDidChangeNotification - object:nil]; - - // Flipping the backing-layer toggle changes the node set itself, so it - // always goes through a full server-side reload (backing-layer-toggle - // proposal). - [[LKPreferenceManager mainManager].showBackingLayers subscribe:self - action:@selector(_handleShowBackingLayersDidChange:) - relatedObject:nil]; - - // LKStaticViewController's -setView: runs synchronously inside its - // designated init, so the per-doc data source / update manager are - // passed in up-front rather than via post-init property writes. - _viewController = [[LKStaticViewController alloc] initWithHierarchyDataSource:_hierarchyDataSource - asyncUpdateManager:_asyncUpdateManager]; - window.contentView = self.viewController.view; - self.contentViewController = self.viewController; - - NSToolbar *toolbar = [[NSToolbar alloc] init]; - toolbar.displayMode = NSToolbarDisplayModeIconAndLabel; - toolbar.sizeMode = NSToolbarSizeModeRegular; - toolbar.delegate = self; - window.toolbar = toolbar; - - NSToolbarItem *reloadItem = self.toolbarItemsMap[LKToolBarIdentifier_Reload]; - @weakify(self); - self.asyncUpdateManager.delegate = self; - - [[[RACSignal combineLatest:@[RACObserve(self, isFetchingHierarchy), - RACObserve(self, isFetchingDetails)]] distinctUntilChanged] subscribeNext:^(RACTuple * _Nullable x) { - @strongify(self); - [@[LKToolBarIdentifier_App, LKToolBarIdentifier_Console, LKToolBarIdentifier_FastMode] enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSToolbarItem *item = self.toolbarItemsMap[obj]; - if (self.isFetchingHierarchy || self.isFetchingDetails) { - item.enabled = NO; - } else { - item.enabled = YES; - } - }]; - }]; - [[RACObserve(self, isFetchingHierarchy) distinctUntilChanged] subscribeNext:^(NSNumber *x) { - reloadItem.enabled = ![x boolValue]; - }]; - - [RACObserve(self.hierarchyDataSource, selectedItem) subscribeNext:^(id _Nullable x) { - @strongify(self); - NSButton *measureButton = (NSButton *)self.toolbarItemsMap[LKToolBarIdentifier_Measure].view; - BOOL canMeasure = !!x; - measureButton.enabled = canMeasure; - }]; - - dispatch_async(dispatch_get_main_queue(), ^{ - [self _autoConnectAppIfRequested]; - }); - } - return self; -} - -- (void)_autoConnectAppIfRequested { - NSDictionary *environment = NSProcessInfo.processInfo.environment; - NSString *targetBundleID = environment[@"LOOKINSIDE_AUTO_CONNECT_BUNDLE_ID"]; - BOOL shouldConnectFirst = [environment[@"LOOKINSIDE_AUTO_CONNECT_FIRST"] boolValue]; - if (targetBundleID.length == 0 && !shouldConnectFirst) { - return; - } - - [self.viewController.progressView animateToProgress:InitialIndicatorProgressWhenFetchHierarchy]; - - @weakify(self); - [[[[LKAppsManager sharedInstance] fetchAppInfosWithImage:YES localInfos:nil] deliverOnMainThread] subscribeNext:^(NSArray *apps) { - @strongify(self); - LKInspectableApp *targetApp = nil; - if (targetBundleID.length > 0) { - targetApp = [apps lookin_firstFiltered:^BOOL(LKInspectableApp *app) { - return [app.appInfo.appBundleIdentifier isEqualToString:targetBundleID]; - }]; - } else if (shouldConnectFirst) { - targetApp = [apps lookin_firstFiltered:^BOOL(LKInspectableApp *app) { - return app.serverVersionError == nil && app.appInfo != nil; - }]; - } - - if (!targetApp) { - [self.viewController.progressView resetToZero]; - return; - } - - // Phase F: per-window inspectableApp is now the only source of - // truth — the deprecated single-slot global fallback is gone. - LKInspectableApp *currentApp = self.inspectableApp; - BOOL isTheSameApp = currentApp && [currentApp.appInfo isEqualToAppInfo:targetApp.appInfo]; - [[targetApp fetchHierarchyData] subscribeNext:^(LookinHierarchyInfo *info) { - [self _applyHierarchyInfo:info forApp:targetApp keepState:isTheSameApp]; - } error:^(NSError * _Nullable error) { - [self.viewController.progressView resetToZero]; - AlertError(error, self.window); - }]; - } error:^(NSError * _Nullable error) { - @strongify(self); - [self.viewController.progressView resetToZero]; - }]; -} - -- (void)popupAllInspectableAppsWithSource:(MenuPopoverAppsListControllerEventSource)source { - NSView *appItemView = [self.toolbarItemsMap objectForKey:LKToolBarIdentifier_App].view; - - @weakify(self); - [[[[LKAppsManager sharedInstance] fetchAppInfosWithImage:YES localInfos:nil] deliverOnMainThread] subscribeNext:^(NSArray *apps) { - @strongify(self); - LKMenuPopoverAppsListController *vc = [[LKMenuPopoverAppsListController alloc] initWithApps:apps source:source]; - NSPopover *popover = [[NSPopover alloc] init]; - @weakify(popover); - vc.didSelectApp = ^(LKInspectableApp *app) { - @strongify(self); - @strongify(popover); - [popover close]; - - if (app.serverVersionError) { - if (app.serverVersionError.code == LookinErrCode_ServerVersionTooLow) { - [LKHelper openLookinWebsiteWithPath:@"faq/server-version-too-low/"]; - } else { - [LKHelper openLookinWebsiteWithPath:@"faq/server-version-too-high/"]; - } - return; - } - - // Phase D (Q1=X): a different App means "open in a new window" - // rather than swapping this window's target. The current window - // keeps its identity (its `inspectableApp` and channel are - // untouched), and `LookinLiveDocumentController` handles - // channel-level de-duplication (Q3=P) so picking the *same* - // second App while it is already open just refocuses that other - // window. - LookinAppInfo *currentInfo = self.inspectableApp.appInfo; - BOOL isTheSameApp = currentInfo && [currentInfo isEqualToAppInfo:app.appInfo]; - - if (!isTheSameApp) { - // Open the new window first (empty state), then run the - // hierarchy fetch on *that* window's progress bar. This - // matches the Q1=X intent: the picker's window stays put - // and a fresh window animates its own progress while the - // tree streams in. Errors (including the 15s hierarchy - // timeout) surface as a sheet on the new window so the - // current window's session is never disturbed. - [[LookinLiveDocumentController sharedInstance] - openLiveDocumentForInspectableApp:app - completion:^(LookinLiveDocument *doc, - BOOL alreadyOpen, - NSError *openError) { - if (!doc) { - // Local — `AlertError` is an unparenthesised macro, - // so passing `openError ?: LookinErr_Inner` binds - // `.code` to the macro arg before the elvis runs and - // mistypes the operands. - NSError *errToShow = openError ? openError : LookinErr_Inner; - AlertError(errToShow, self.window); - return; - } - LKStaticWindowController *targetWC = (LKStaticWindowController *)doc.windowControllers.firstObject; - if (![targetWC isKindOfClass:[LKStaticWindowController class]]) { - return; - } - // Q3=P originally returned silently when `alreadyOpen` - // was YES on the theory that the user was just refocusing - // an existing window. A picker click always means - // "inspect now", though, so always fetch into the target - // doc's window. This also keeps the Phase D auto-reconnect - // case sane: if `self.inspectableApp` was nilled by a - // channel swap and `isTheSameApp` misfired into this - // branch, the user still gets a fresh hierarchy. - [targetWC.viewController.progressView animateToProgress:InitialIndicatorProgressWhenFetchHierarchy]; - [[app fetchHierarchyData] subscribeNext:^(LookinHierarchyInfo *info) { - if (!info) { - [targetWC.viewController.progressView resetToZero]; - AlertError(LookinErr_Inner, targetWC.window); - return; - } - [targetWC _applyHierarchyInfo:info forApp:app keepState:alreadyOpen]; - } error:^(NSError *fetchErr) { - [targetWC.viewController.progressView resetToZero]; - AlertError(fetchErr, targetWC.window); - }]; - }]; - return; - } - - // Same app — keep the historic in-place reload behavior. - [self.viewController.progressView animateToProgress:InitialIndicatorProgressWhenFetchHierarchy]; - [[app fetchHierarchyData] subscribeNext:^(LookinHierarchyInfo *info) { - [self _applyHierarchyInfo:info forApp:app keepState:YES]; - } error:^(NSError * _Nullable error) { - AlertError(error, self.window); - [self.viewController.progressView resetToZero]; - }]; - }; - - popover.behavior = NSPopoverBehaviorTransient; - popover.animates = NO; - popover.contentSize = vc.bestSize; - popover.contentViewController = vc; - [popover showRelativeToRect:NSMakeRect(0, 0, appItemView.bounds.size.width, appItemView.bounds.size.height) ofView:appItemView preferredEdge:NSRectEdgeMaxY]; - - } error:^(NSError * _Nullable error) { - NSAssert(NO, @"该方法不应该 sendError"); - }]; -} - -#pragma mark - NSToolbarDelegate - -- (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar { - return [self toolbarDefaultItemIdentifiers:toolbar]; -} - -- (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar { - NSMutableArray *ret = @[LKToolBarIdentifier_Reload, LKToolBarIdentifier_FastMode, LKToolBarIdentifier_App, LKToolBarIdentifier_SwiftUIMode, LKToolBarIdentifier_GestureDebug, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Dimension, LKToolBarIdentifier_Rotation, LKToolBarIdentifier_Setting, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Scale, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Measure, LKToolBarIdentifier_Console].mutableCopy; - if ([[[LKMessageManager sharedInstance] queryMessages] count] > 0) { - [ret addObject:LKToolBarIdentifier_Message]; - } - return [ret copy];; -} - -- (nullable NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSToolbarItemIdentifier)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag { - NSToolbarItem *item = self.toolbarItemsMap[itemIdentifier]; - if (!item) { - if (!self.toolbarItemsMap) { - self.toolbarItemsMap = [NSMutableDictionary dictionary]; - } - item = [[LKWindowToolbarHelper sharedInstance] makeToolBarItemWithIdentifier:itemIdentifier preferenceManager:[LKPreferenceManager mainManager]]; - self.toolbarItemsMap[itemIdentifier] = item; - - if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_Reload]) { - item.target = self; - item.action = @selector(_handleReload); - } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_GestureDebug]) { - item.target = self; - item.action = @selector(_handleGestureDebug); - } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_App]) { - item.target = self; - item.action = @selector(_handleApp); - // Phase F: bind this window's App icon to the owning Live - // Doc's inspectable app. KVO covers Phase D auto-reconnect - // because `LookinLiveDocument` swaps `inspectableApp` in - // place when the channel comes back. - LKWindowToolbarAppButton *appButton = (LKWindowToolbarAppButton *)item.view; - [[RACObserve(self, inspectableApp) takeUntil:item.rac_willDeallocSignal] - subscribeNext:^(LKInspectableApp *app) { - appButton.appInfo = app.appInfo; - }]; - } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_Rotation]) { - item.target = self; - item.action = @selector(_handleFreeRotation); - } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_Setting]) { - item.label = NSLocalizedString(@"View", nil); - item.target = self; - item.action = @selector(_handleSetting:); - } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_Console]) { - item.target = self; - item.action = @selector(_handleConsole); - - [[[RACObserve(self.viewController, showConsole) distinctUntilChanged] skip:1] subscribeNext:^(NSNumber *x) { - ((NSButton *)item.view).state = x.boolValue ? NSControlStateValueOn : NSControlStateValueOff; - }]; - } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_Message]) { - item.label = NSLocalizedString(@"Notifications", nil); - item.target = self; - item.action = @selector(_handleMessage:); - } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_FastMode]) { - item.target = self; - item.action = @selector(handleFastMode); - } - } - return item; -} - -#pragma mark - Event Handler - -- (void)_handleGestureDebug { - if (!self.gestureDebugWindowController) { - self.gestureDebugWindowController = [[LKGestureDebugWindowController alloc] initWithOwner:self]; - } - [self.gestureDebugWindowController showWindow:self]; - [self.gestureDebugWindowController.window makeKeyAndOrderFront:self]; -} - -- (void)_handleReload { - // The button carries two behaviors the shared core deliberately does - // not: while details are syncing it doubles as a stop button, and with - // no app bound it opens the app picker. Both are answers only a click - // deserves — a programmatic caller gets an error instead. - if (self.isFetchingDetails) { - // 停止拉取 - [self.asyncUpdateManager endUpdating]; - return; - } - - // Phase F: this window's bound app is the only source of truth. - if (!self.inspectableApp) { - [self popupAllInspectableAppsWithSource:MenuPopoverAppsListControllerEventSourceReloadButton]; - return; - } - - if (self.isFetchingHierarchy) { - return; - } - - @weakify(self); - [[self reloadHierarchySignalWithInitiator:LKHierarchyReloadInitiatorHost] subscribeError:^(NSError * _Nullable error) { - @strongify(self); - [[NSAlert alertWithError:error] beginSheetModalForWindow:self.window completionHandler:nil]; - }]; -} - -- (void)_handleShowBackingLayersDidChange:(LookinMsgActionParams *)param { - // No app bound or a reload already running: the new value still applies on - // the next manual reload, so refusals stay quiet. - [[self reloadHierarchySignalWithInitiator:LKHierarchyReloadInitiatorHost] subscribeError:^(NSError * _Nullable error) { - }]; -} - -- (RACSignal *)reloadHierarchySignalWithInitiator:(LKHierarchyReloadInitiator)initiator { - if (self.isFetchingHierarchy) { - return [RACSignal error:LKStaticWindowControllerReloadErrorMake( - LKStaticWindowControllerReloadErrorAlreadyInProgress, - NSLocalizedString(@"A hierarchy reload is already running for this window.", nil))]; - } - if (self.isFetchingDetails) { - return [RACSignal error:LKStaticWindowControllerReloadErrorMake( - LKStaticWindowControllerReloadErrorDetailSyncInProgress, - NSLocalizedString(@"Detail synchronization is still running. Wait for it to finish before reloading.", nil))]; - } - LKInspectableApp *app = self.inspectableApp; - if (!app) { - return [RACSignal error:LKStaticWindowControllerReloadErrorMake( - LKStaticWindowControllerReloadErrorNoInspectableApp, - NSLocalizedString(@"This window is not attached to an app.", nil))]; - } - - // Recorded here rather than at the top of the method: every path above - // returns without starting anything, and stamping the initiator on a - // refused request would misattribute the reload that refused it. - self.lastReloadInitiator = initiator; - self.isFetchingHierarchy = YES; - - [self.viewController.progressView animateToProgress:InitialIndicatorProgressWhenFetchHierarchy]; - - [LKPerformanceReporter.sharedInstance willStartReload]; - - // Replay rather than -createSignal: the fetch is already under way by - // the time this returns, so a subscriber that arrives late still gets - // the outcome, and two subscribers never mean two round-trips. - RACReplaySubject *resultSubject = [RACReplaySubject replaySubjectWithCapacity:1]; - // Tracks whether the subject has already been terminated, so the - // `completed` block can tell "the fetch delivered and finished" apart - // from "the fetch finished having delivered nothing". - __block BOOL didSettleResult = NO; - @weakify(self); - [[app fetchHierarchyData] subscribeNext:^(LookinHierarchyInfo *info) { - @strongify(self); - if (!self) { - // No data source left to hand the hierarchy to. Reporting - // success here would tell the caller the host holds this tree - // when nothing does. - didSettleResult = YES; - [resultSubject sendError:LKStaticWindowControllerReloadErrorMake( - LKStaticWindowControllerReloadErrorWindowClosed, - NSLocalizedString(@"The inspector window closed while the hierarchy was being fetched.", nil))]; - return; - } - [self.viewController.progressView finishWithCompletion:nil]; - [self.hierarchyDataSource reloadWithHierarchyInfo:info keepState:YES]; - self.isFetchingHierarchy = NO; - - [LKPerformanceReporter.sharedInstance didFetchHierarchy]; - - didSettleResult = YES; - [resultSubject sendNext:info]; - [resultSubject sendCompleted]; - - } error:^(NSError * _Nullable error) { - // error - @strongify(self); - [self.viewController.progressView resetToZero]; - self.isFetchingHierarchy = NO; - - didSettleResult = YES; - [resultSubject sendError:error ?: LookinErr_Inner]; - } completed:^{ - // A signal that completes without ever emitting is not hypothetical: - // `-_requestWithType:` flattenMaps a tuple stream that can complete - // empty when the channel tears down mid-request. Left unhandled it - // strands every party -- `isFetchingHierarchy` stays latched YES, - // which permanently disables this window's reload button, and a - // bridge caller awaiting `resultSubject` never gets a response frame. - @strongify(self); - if (didSettleResult) { - return; - } - [self.viewController.progressView resetToZero]; - self.isFetchingHierarchy = NO; - - [resultSubject sendError:LKStaticWindowControllerReloadErrorMake( - LKStaticWindowControllerReloadErrorNoResponse, - NSLocalizedString(@"The app finished the hierarchy request without returning a hierarchy.", nil))]; - }]; - return resultSubject; -} - -- (void)_handleApp { - // 停止可能存在的刷新倒计时 - [self popupAllInspectableAppsWithSource:MenuPopoverAppsListControllerEventSourceAppButton]; -} - -- (void)_handleSetting:(NSButton *)button { - NSPopover *popover = [[NSPopover alloc] init]; - popover.behavior = NSPopoverBehaviorTransient; - popover.animates = NO; - popover.contentSize = NSMakeSize(IsEnglish ? 270 : 350, 260); - LookinAppInfo *inspectedAppInfo = self.inspectableApp.appInfo; - popover.contentViewController = [[LKMenuPopoverSettingController alloc] initWithPreferenceManager:[LKPreferenceManager mainManager] - isMacTarget:[LKHelper appInfoLooksLikeMacTarget:inspectedAppInfo]]; - [popover showRelativeToRect:NSMakeRect(0, 0, button.bounds.size.width, button.bounds.size.height) ofView:button preferredEdge:NSRectEdgeMaxY]; -} - -- (void)_handleConsole { - self.viewController.showConsole = !self.viewController.showConsole; -} - -- (void)handleFastMode { - BOOL boolValue = [LKPreferenceManager mainManager].fastMode.currentBOOLValue; - [[LKPreferenceManager mainManager].fastMode setBOOLValue:!boolValue ignoreSubscriber:nil]; -} - -- (void)_handleMessage:(NSButton *)button { - NSMenu *menu = [NSMenu new]; - - NSArray *msgs = [[LKMessageManager sharedInstance] queryMessages]; - for (NSString *msg in msgs) { - if ([msg isEqualToString:LKMessage_Jobs]) { - [menu addItem:({ - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = [NSImage imageNamed:@"Icon_Inspiration_small"]; - menuItem.title = NSLocalizedString(@"Job openings…(China)", nil); - menuItem.target = self; - menuItem.action = @selector(handleJobsMenuItem); - menuItem; - })]; - [menu addItem:[NSMenuItem separatorItem]]; - continue; - } - - if ([msg isEqualToString:LKMessage_SwiftSubspec]) { - [menu addItem:({ - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = [NSImage imageNamed:@"Icon_Inspiration_small"]; - menuItem.title = NSLocalizedString(@"Your iOS project seems to use Swift, but you haven't turn on Swift optimization for LookInside", nil); - menuItem; - })]; - [menu addItem:({ - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.image = [[NSImage alloc] initWithSize:NSMakeSize(18, 1)]; - menuItem.title = NSLocalizedString(@"How to turn on…", nil); - menuItem.target = self; - menuItem.action = @selector(handleTurnOnSwift); - menuItem; - })]; - [menu addItem:[NSMenuItem separatorItem]]; - continue; - } - } - - if (menu.numberOfItems == 0) { - [menu addItem:({ - NSMenuItem *menuItem = [NSMenuItem new]; - menuItem.title = NSLocalizedString(@"No message", nil); - menuItem; - })]; - } - - [NSMenu popUpContextMenu:menu withEvent:[[NSApplication sharedApplication] currentEvent] forView:button]; -} - -- (void)_handleFreeRotation { - BOOL boolValue = [LKPreferenceManager mainManager].freeRotation.currentBOOLValue; - [[LKPreferenceManager mainManager].freeRotation setBOOLValue:!boolValue ignoreSubscriber:nil]; -} - -#pragma mark - - -- (void)appMenuManagerDidSelectReload { - if (self.isFetchingHierarchy) { - return; - } - if (self.isFetchingDetails) { - NSError *error = LookinErrorMake(NSLocalizedString(@"Cannot reload at this time", NIL), NSLocalizedString(@"Please wait until current sync is completed. You can get sync progress in the upper-left corner of this window.", nil)); - [[NSAlert alertWithError:error] beginSheetModalForWindow:self.window completionHandler:nil]; - return; - } - [self _handleReload]; -} - -- (void)appMenuManagerDidSelectDimension { - LKPreferenceManager *manager = [LKPreferenceManager mainManager]; - if (manager.previewDimension.currentIntegerValue == LookinPreviewDimension2D) { - [manager.previewDimension setIntegerValue:LookinPreviewDimension3D ignoreSubscriber:nil]; - } else { - [manager.previewDimension setIntegerValue:LookinPreviewDimension2D ignoreSubscriber:nil]; - } -} - -- (void)appMenuManagerDidSelectZoomIn { - LKPreferenceManager *manager = [LKPreferenceManager mainManager]; - double currentScale = manager.previewScale.currentDoubleValue; - double targetScale = MIN(MAX(currentScale + 0.1, LookinPreviewMinScale), LookinPreviewMaxScale); - [manager.previewScale setDoubleValue:targetScale ignoreSubscriber:nil]; -} - -- (void)appMenuManagerDidSelectZoomOut { - LKPreferenceManager *manager = [LKPreferenceManager mainManager]; - double currentScale = manager.previewScale.currentDoubleValue; - double targetScale = MIN(MAX(currentScale - 0.1, LookinPreviewMinScale), LookinPreviewMaxScale); - [manager.previewScale setDoubleValue:targetScale ignoreSubscriber:nil]; -} - -- (void)appMenuManagerDidSelectDecreaseInterspace { - LKPreferenceManager *manager = [LKPreferenceManager mainManager]; - double currentValue = manager.zInterspace.currentDoubleValue; - double newValue = currentValue - 0.1; - newValue = MIN(MAX(newValue, LookinPreviewMinZInterspace), LookinPreviewMaxZInterspace); - [manager.zInterspace setDoubleValue:newValue ignoreSubscriber:nil]; -} - -- (void)appMenuManagerDidSelectIncreaseInterspace { - LKPreferenceManager *manager = [LKPreferenceManager mainManager]; - double currentValue = manager.zInterspace.currentDoubleValue; - double newValue = currentValue + 0.1; - newValue = MIN(MAX(newValue, LookinPreviewMinZInterspace), LookinPreviewMaxZInterspace); - [manager.zInterspace setDoubleValue:newValue ignoreSubscriber:nil]; -} - -- (void)appMenuManagerDidSelectExpansionIndex:(NSUInteger)index { - [self.hierarchyDataSource adjustExpansionByIndex:index referenceDict:nil selectedItem:nil]; - [self.hierarchyDataSource persistExpansionStateToPreferences]; -} - -- (void)appMenuManagerDidSelectExport { - LKExportManager *exportManager = [LKExportManager sharedInstance]; - LookinHierarchyInfo *hierarchyInfo = self.hierarchyDataSource.rawHierarchyInfo; - - __block NSString *fileName; - __block NSData *exportedData = nil; - - LKExportAccessoryView *accessoryView = [LKExportAccessoryView new]; - $(accessoryView).sizeToFit; - [RACObserve([LKPreferenceManager mainManager], preferredExportCompression) subscribeNext:^(NSNumber *num) { - CGFloat compression = num.doubleValue; - exportedData = [exportManager dataFromHierarchyInfo:hierarchyInfo imageCompression:compression fileName:&fileName]; - accessoryView.dataSize = exportedData.length; - }]; - - NSSavePanel *panel = [NSSavePanel savePanel]; - panel.accessoryView = accessoryView; - [panel setNameFieldStringValue:fileName]; - [panel setAllowsOtherFileTypes:NO]; - panel.allowedContentTypes = @[[UTType typeWithIdentifier:@"com.lookin.lookin"] ?: UTTypeData]; - [panel setExtensionHidden:YES]; - [panel setCanCreateDirectories:YES]; - [panel beginSheetModalForWindow:self.window completionHandler:^(NSModalResponse result) { - if (result == NSModalResponseOK) { - NSString *path = [[panel URL] path]; - NSError *writeError; - if (!exportedData) { - NSAssert(NO, @"LookinClient - write fail, no data"); - return; - } - BOOL writeSucc = [exportedData writeToFile:path options:0 error:&writeError]; - if (!writeSucc) { - NSLog(@"LookinClient - write fail:%@", writeError); - } - } - }]; - -} - -- (void)appMenuManagerDidSelectOpenInNewWindow { - LookinHierarchyInfo *newHierarchyInfo = self.hierarchyDataSource.rawHierarchyInfo.copy; - LookinHierarchyFile *file = [LookinHierarchyFile new]; - file.serverVersion = newHierarchyInfo.serverVersion; - file.hierarchyInfo = newHierarchyInfo; - [[LKNavigationManager sharedInstance] showReaderWithHierarchyFile:file title:nil]; -} - -- (void)appMenuManagerDidSelectFilter { - [[self.viewController currentHierarchyView] activateSearchBar]; -} - -- (void)handleJobsMenuItem { - [LKHelper showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"This upstream community link is not available in this build.", nil)]; - [[LKMessageManager sharedInstance] removeMessage:LKMessage_Jobs]; -} - -- (void)handleTurnOnSwift { - [LKHelper showDisabledExternalLinkAlertWithMessage:NSLocalizedString(@"Legacy Swift integration guides are disabled in this community build. See the repository README instead.", nil)]; -} - - -/// LKStaticAsyncUpdateManagerDelegate -- (void)detailUpdateTasksTotalCount:(NSUInteger)totalCount finishedCount:(NSUInteger)finishedCount { - NSToolbarItem *reloadItem = self.toolbarItemsMap[LKToolBarIdentifier_Reload]; - NSButton *reloadButton = (NSButton *)reloadItem.view; - - BOOL isFetching = (totalCount > finishedCount); - - if (isFetching) { - if (self.isFetchingDetails) { - // 继续维持 fetch 状态 - } else { - // 进入 fetch 状态 - self.isFetchingDetails = YES; - - NSImage *image = NSImageMake(@"icon_stop"); - image.template = YES; - reloadButton.image = image; - } - reloadItem.label = [NSString stringWithFormat:@"%@ / %@", @(finishedCount), @(totalCount)]; - - } else { - if (self.isFetchingDetails) { - // 退出 fetch 状态 - self.isFetchingDetails = NO; - reloadItem.label = NSLocalizedString(@"Reload", nil); - - NSImage *image = NSImageMake(@"icon_reload"); - image.template = YES; - reloadButton.image = image; - } else { - // 继续维持“非 fetch”状态 - } - } -} - -- (void)detailUpdateReceivedError:(NSError *)error { - AlertError(error, self.window); -} - -#pragma mark - Hierarchy Reload Helpers - -- (void)_applyHierarchyInfo:(LookinHierarchyInfo *)info forApp:(LKInspectableApp *)app keepState:(BOOL)keepState { - [self.viewController.progressView finishWithCompletion:nil]; - // Phase D: bind the app to this window's owned state. Same-window - // reload paths (auto-connect, toolbar-same-app, SwiftUI mode toggle) - // all flow through here, so this is the single point that owns - // wiring `app` into the per-instance graph (`asyncUpdateManager` is - // what dispatches RPCs). - self.inspectableApp = app; - self.asyncUpdateManager.inspectableApp = app; - [self.hierarchyDataSource reloadWithHierarchyInfo:info keepState:keepState]; - - // Bump this bundle id to the front of the expansion-state LRU so that actively-inspected - // apps don't get evicted just because the user hasn't toggled anything yet in this session. - [[LKPreferenceManager mainManager] bumpExpansionStateBundleIdentifierToMostRecent:info.appInfo.appBundleIdentifier]; -} - -- (void)_handleSwiftUIModeDidChange:(NSNotification *)note { - // Phase F: per-doc app is now the only source of truth. - LKInspectableApp *app = self.inspectableApp; - if (!app) { - return; - } - - // Capture pre-fetch selection so we can restore (or migrate) after reload. - LookinDisplayItem *priorSelection = self.hierarchyDataSource.selectedItem; - NSString *priorSwiftUIID = priorSelection.customInfo.swiftUIDisplayItemID; - BOOL priorWasSwiftUI = [priorSwiftUIID hasPrefix:@"swiftui:"]; - - [app cancelHierarchyDetailFetching]; - [self.viewController.progressView animateToProgress:InitialIndicatorProgressWhenFetchHierarchy]; - @weakify(self); - [[app fetchHierarchyData] subscribeNext:^(LookinHierarchyInfo *info) { - @strongify(self); - // Mode toggle keeps the same target app, so keepState=YES preserves - // expansion / scroll where possible. Selection migration handled below. - [self _applyHierarchyInfo:info forApp:app keepState:YES]; - - if (priorWasSwiftUI) { - [self _restoreSwiftUISelectionWithPriorID:priorSwiftUIID]; - } - } error:^(NSError *error) { - @strongify(self); - [self.viewController.progressView resetToZero]; - AlertError(error, self.window); - }]; -} - -- (void)_restoreSwiftUISelectionWithPriorID:(NSString *)priorSelectionID { - if (priorSelectionID.length == 0) { - return; - } - - NSArray *flatItems = self.hierarchyDataSource.displayingFlatItems; - LookinDisplayItem *(^findByID)(NSString *) = ^LookinDisplayItem *(NSString *targetID) { - for (LookinDisplayItem *item in flatItems) { - if ([item.customInfo.swiftUIDisplayItemID isEqualToString:targetID]) { - return item; - } - } - return nil; - }; - - LookinDisplayItem *match = findByID(priorSelectionID); - if (match) { - // ID preserved in new tree — restore exactly. - self.hierarchyDataSource.selectedItem = match; - return; - } - - // ID gone (compact elided it). Find smallest pre-order index N greater - // than priorIndex that is present in the new tree. - NSInteger priorIndex = [self _swiftUIPreOrderIndexFromID:priorSelectionID]; - if (priorIndex < 0) { - return; - } - - LookinDisplayItem *fallback = nil; - NSInteger fallbackIndex = NSIntegerMax; - for (LookinDisplayItem *item in flatItems) { - NSString *itemID = item.customInfo.swiftUIDisplayItemID; - if (![itemID hasPrefix:@"swiftui:"]) { - continue; - } - NSInteger candidateIndex = [self _swiftUIPreOrderIndexFromID:itemID]; - if (candidateIndex > priorIndex && candidateIndex < fallbackIndex) { - fallbackIndex = candidateIndex; - fallback = item; - } - } - if (fallback) { - self.hierarchyDataSource.selectedItem = fallback; - [self _showSelectionMigratedToastForItem:fallback]; - } -} - -/// `swiftui::` 中末段索引;解析失败返回 -1。 -- (NSInteger)_swiftUIPreOrderIndexFromID:(NSString *)displayItemID { - NSRange lastColon = [displayItemID rangeOfString:@":" options:NSBackwardsSearch]; - if (lastColon.location == NSNotFound) { - return -1; - } - NSString *tail = [displayItemID substringFromIndex:NSMaxRange(lastColon)]; - NSScanner *scanner = [NSScanner scannerWithString:tail]; - NSInteger value = -1; - if (![scanner scanInteger:&value] || !scanner.atEnd) { - return -1; - } - return value; -} - -/// 临时浮层 toast — 非阻塞,2 秒后 fade out 并自动清理。 -/// 不能用 NSAlert sheet,modal sheet 会在 dismiss 之前阻塞用户再次切 segmented control。 -- (void)_showSelectionMigratedToastForItem:(LookinDisplayItem *)item { - NSString *typeName = item.customInfo.title ?: @"SwiftUI item"; - NSString *message = [NSString stringWithFormat: - NSLocalizedString(@"Selection moved to %@ (modifiers folded in compact mode).", - @"hierarchy.swiftui.selection.migrated.body"), - typeName]; - - NSView *host = self.viewController.view; - NSTextField *toast = [NSTextField labelWithString:message]; - toast.font = [NSFont systemFontOfSize:12 weight:NSFontWeightMedium]; - toast.textColor = [NSColor whiteColor]; - toast.alignment = NSTextAlignmentCenter; - toast.lineBreakMode = NSLineBreakByTruncatingTail; - toast.wantsLayer = YES; - toast.layer.backgroundColor = [[[NSColor blackColor] colorWithAlphaComponent:0.78] CGColor]; - toast.layer.cornerRadius = 6; - toast.translatesAutoresizingMaskIntoConstraints = NO; - [host addSubview:toast]; - [NSLayoutConstraint activateConstraints:@[ - [toast.centerXAnchor constraintEqualToAnchor:host.centerXAnchor], - [toast.bottomAnchor constraintEqualToAnchor:host.bottomAnchor constant:-32], - [toast.widthAnchor constraintLessThanOrEqualToAnchor:host.widthAnchor multiplier:0.7], - [toast.heightAnchor constraintGreaterThanOrEqualToConstant:28], - ]]; - // 2 秒后启动 0.3 秒 alpha → 0 动画,完成后 removeFromSuperview。 - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - [NSAnimationContext runAnimationGroup:^(NSAnimationContext *ctx) { - ctx.duration = 0.3; - toast.animator.alphaValue = 0; - } completionHandler:^{ - [toast removeFromSuperview]; - }]; - }); -} - -- (void)dealloc { - [NSNotificationCenter.defaultCenter removeObserver:self - name:LKSwiftUIHierarchyDisplayModeDidChangeNotification - object:nil]; -} - -@end diff --git a/LookInside/Static/LKStaticWindowController.swift b/LookInside/Static/LKStaticWindowController.swift new file mode 100644 index 00000000..958ec1aa --- /dev/null +++ b/LookInside/Static/LKStaticWindowController.swift @@ -0,0 +1,864 @@ +// +// LKStaticWindowController.swift +// LookInside +// +// Created by Li Kai on 2018/11/4. +// https://lookin.work +// +// The window of a live document: hierarchy, preview and dashboard of one +// inspected app, with the inspector toolbar. Owns the document's hierarchy +// data source and async update manager; every request goes to the app the +// owning LookinLiveDocument injects (`inspectableApp`). +// + +import AppKit +import LookInsideHostCore +import UniformTypeIdentifiers + +@objc(LKStaticWindowController) +@MainActor +final class LKStaticWindowController: LKWindowController, NSToolbarDelegate, @preconcurrency LKStaticAsyncUpdateManagerDelegate { + /// The refusals and failures of `reloadHierarchy(completion:)` that this + /// window raises itself. Errors from the request keep their + /// `LookinErrorDomain` code. + private enum ReloadError { + static let domain = "LKStaticWindowControllerReloadErrorDomain" + + /// A hierarchy fetch is already in flight on this window. + static let alreadyInProgress = 1 + /// The async detail sync is running. Reloading now would throw away + /// work already paid for, so the caller has to stop it first. + static let detailSyncInProgress = 2 + /// No inspectable app is bound to this window. + static let noInspectableApp = 3 + /// The window went away while the fetch was in flight, leaving no + /// data source to absorb the result. + static let windowClosed = 4 + /// The request ended without a hierarchy and without an error -- + /// reachable when the connection channel tears down mid-request. + static let noResponse = 5 + + static func make(_ code: Int, _ description: String) -> NSError { + NSError(domain: domain, code: code, userInfo: [NSLocalizedDescriptionKey: description]) + } + } + + /// What one hierarchy request sent first. + private enum HierarchyFetchResult { + /// Its first value; nil when that value is not a hierarchy. + case info(LookinHierarchyInfo?) + /// It ended without sending a value. + case noResponse + } + + /// Implicitly unwrapped for the Swift callers written against the + /// Objective-C header; set before init returns. + @objc private(set) var viewController: LKStaticViewController! + + /// Per-window hierarchy data source. + @objc private(set) var hierarchyDataSource: LKStaticHierarchyDataSource! + + /// Per-window async update manager. + @objc private(set) var asyncUpdateManager: LKStaticAsyncUpdateManager! + + /// Injected by the owning LookinLiveDocument, which also swaps it when + /// the app reconnects; every request uses it. + @objc dynamic weak var inspectableApp: LKInspectableApp? { + didSet { + updateAppButton() + } + } + + private var toolbarItemsMap: [NSToolbarItem.Identifier: NSToolbarItem] = [:] + private var gestureDebugWindowController: LKGestureDebugWindowController? + /// The reload item as it was when the window was set up; the fetching + /// state enables and disables this one. + private var fetchingStateReloadItem: NSToolbarItem? + private var observations: [NSKeyValueObservation] = [] + + /// True while the hierarchy and its screenshots are being fetched. + private var isFetchingHierarchy = false { + didSet { + guard oldValue != isFetchingHierarchy else { return } + updateToolbarForFetchingState() + } + } + + private var isFetchingDetails = false { + didSet { + guard oldValue != isFetchingDetails else { return } + updateToolbarForFetchingState() + } + } + + // MARK: - Init + + /// Used by LookinLiveDocument: the window, data source and update + /// manager, all bound to `app`. + @objc(initWithInspectableApp:) + init(inspectableApp app: LKInspectableApp?) { + let screenSize = NSScreen.main?.frame.size ?? .zero + let window = LKWindow( + contentRect: NSRect(x: 0, y: 0, width: screenSize.width * 0.7, height: screenSize.height * 0.7), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: true + ) + window.tabbingMode = .disallowed + window.titleVisibility = .hidden + window.toolbarStyle = .unified + window.minSize = NSSize(width: HierarchyMinWidth + DashboardViewWidth + 200, height: 500) + window.center() + window.setFrameUsingName(LKWindowSizeName_Static) + + super.init(window: window) + + // Saves this window's frame across launches. + windowFrameAutosaveName = LKWindowSizeName_Static + + let dataSource = LKStaticHierarchyDataSource() + let updateManager = LKStaticAsyncUpdateManager(hierarchyDataSource: dataSource, inspectableApp: nil) + dataSource.asyncUpdateManager = updateManager + hierarchyDataSource = dataSource + asyncUpdateManager = updateManager + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleSwiftUIModeDidChange(_:)), + name: .LKSwiftUIHierarchyDisplayModeDidChange, + object: nil + ) + + // Flipping the backing-layer toggle changes the node set itself, so + // it always goes through a full server-side reload. + LKPreferenceManager.shared.showBackingLayers.subscribe( + self, + action: #selector(handleShowBackingLayersDidChange(_:)), + relatedObject: nil + ) + + // The view controller builds its view inside its initializer, so the + // data source and update manager go in up front. + let viewController = LKStaticViewController(hierarchyDataSource: dataSource, asyncUpdateManager: updateManager) + self.viewController = viewController + window.contentView = viewController.view + contentViewController = viewController + + let toolbar = NSToolbar() + toolbar.displayMode = .iconAndLabel + toolbar.sizeMode = .regular + toolbar.delegate = self + window.toolbar = toolbar + + fetchingStateReloadItem = toolbarItemsMap[NSToolbarItem.Identifier(LKToolBarIdentifier_Reload)] + updateManager.delegate = self + updateToolbarForFetchingState() + + observations.append(dataSource.observe(\.selectedItem, options: [.initial, .new]) { [weak self] dataSource, _ in + MainActor.assumeIsolated { + let measureButton = self?.toolbarItemsMap[NSToolbarItem.Identifier(LKToolBarIdentifier_Measure)]?.view as? NSButton + measureButton?.isEnabled = dataSource.selectedItem != nil + } + }) + + DispatchQueue.main.async { + self.autoConnectAppIfRequested() + } + + // Every request (reload, modify…) goes to this app's channel. + inspectableApp = app + updateManager.inspectableApp = app + updateAppButton() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + private func updateToolbarForFetchingState() { + let isFetching = isFetchingHierarchy || isFetchingDetails + for identifier in [NSToolbarItem.Identifier(LKToolBarIdentifier_App), NSToolbarItem.Identifier(LKToolBarIdentifier_Console), NSToolbarItem.Identifier(LKToolBarIdentifier_FastMode)] { + toolbarItemsMap[identifier]?.isEnabled = !isFetching + } + fetchingStateReloadItem?.isEnabled = !isFetchingHierarchy + } + + private func updateAppButton() { + let appButton = toolbarItemsMap[NSToolbarItem.Identifier(LKToolBarIdentifier_App)]?.view as? LKWindowToolbarAppButton + appButton?.appInfo = inspectableApp?.appInfo + } + + // MARK: - Fetching + + /// Sends a hierarchy request to `app` and returns what it sent first. + private static func fetchHierarchy(from app: LKInspectableApp) async throws -> HierarchyFetchResult { + let responses = app.responses( + type: UInt32(LookinRequestTypeHierarchy), + data: LKInspectableApp.hierarchyRequestParameters() + ) + for try await value in responses { + return .info(value as? LookinHierarchyInfo) + } + return .noResponse + } + + private func alertError(_ error: Error?, in window: NSWindow?) { + let error = (error as NSError?) ?? LKConnectionError.inner + guard error.code != LookinErrCode_Discard, let window else { + return + } + NSAlert(error: error).beginSheetModal(for: window, completionHandler: nil) + } + + /// For the end-to-end runs: connects to the app named by + /// `LOOKINSIDE_AUTO_CONNECT_BUNDLE_ID`, or with + /// `LOOKINSIDE_AUTO_CONNECT_FIRST` to the first compatible app. + private func autoConnectAppIfRequested() { + let environment = ProcessInfo.processInfo.environment + let targetBundleID = environment["LOOKINSIDE_AUTO_CONNECT_BUNDLE_ID"] ?? "" + let shouldConnectFirst = ((environment["LOOKINSIDE_AUTO_CONNECT_FIRST"] ?? "") as NSString).boolValue + guard !targetBundleID.isEmpty || shouldConnectFirst else { + return + } + + viewController.progressView.animate(toProgress: InitialIndicatorProgressWhenFetchHierarchy) + + Task { [weak self] in + let apps = await LKAppsManager.shared.fetchAppInfos(needImages: true, localInfos: nil) + guard let self else { return } + let targetApp: LKInspectableApp? + if !targetBundleID.isEmpty { + targetApp = apps.first { $0.appInfo?.appBundleIdentifier == targetBundleID } + } else { + targetApp = apps.first { $0.serverVersionError == nil && $0.appInfo != nil } + } + guard let targetApp else { + viewController.progressView.resetToZero() + return + } + + let currentInfo = inspectableApp?.appInfo + let isTheSameApp = currentInfo.map { $0.isEqual(targetApp.appInfo) } ?? false + do { + if case let .info(info) = try await Self.fetchHierarchy(from: targetApp) { + applyHierarchyInfo(info, for: targetApp, keepState: isTheSameApp) + } + } catch { + viewController.progressView.resetToZero() + alertError(error, in: window) + } + } + } + + @objc(popupAllInspectableAppsWithSource:) + func popupAllInspectableApps(with source: MenuPopoverAppsListControllerEventSource) { + let appItemView = toolbarItemsMap[NSToolbarItem.Identifier(LKToolBarIdentifier_App)]?.view + + Task { [weak self] in + let apps = await LKAppsManager.shared.fetchAppInfos(needImages: true, localInfos: nil) + guard let self else { return } + let listController = LKMenuPopoverAppsListController(apps: apps, source: source) + let popover = NSPopover() + listController.didSelectApp = { [weak self, weak popover] app in + popover?.close() + guard let self else { return } + didSelectAppInPicker(app) + } + popover.behavior = .transient + popover.animates = false + popover.contentSize = listController.bestSize() + popover.contentViewController = listController + guard let appItemView else { return } + popover.show(relativeTo: appItemView.bounds, of: appItemView, preferredEdge: .maxY) + } + } + + private func didSelectAppInPicker(_ app: LKInspectableApp) { + if let versionError = app.serverVersionError { + if versionError.code == LookinErrCode_ServerVersionTooLow { + LKHelper.openLookinWebsite(withPath: "faq/server-version-too-low/") + } else { + LKHelper.openLookinWebsite(withPath: "faq/server-version-too-high/") + } + return + } + + // A different app opens in a new window rather than swapping this + // window's target; LookinLiveDocumentController brings forward a + // window already open on the same channel. + let currentInfo = inspectableApp?.appInfo + let isTheSameApp = currentInfo.map { $0.isEqual(app.appInfo) } ?? false + + guard isTheSameApp else { + // The new window shows its own progress while the tree streams + // in, and errors (including the hierarchy timeout) go to that + // window, so this window's session is never disturbed. A picker + // click always means "inspect now", so the hierarchy is fetched + // even when the document was already open. + let (document, alreadyOpen) = LookinLiveDocumentController.shared.openLiveDocument(for: app) + guard let targetController = document.windowControllers.first as? LKStaticWindowController else { + return + } + targetController.viewController.progressView.animate(toProgress: InitialIndicatorProgressWhenFetchHierarchy) + Task { + do { + guard case let .info(info) = try await Self.fetchHierarchy(from: app) else { + return + } + guard let info else { + targetController.viewController.progressView.resetToZero() + targetController.alertError(LKConnectionError.inner, in: targetController.window) + return + } + targetController.applyHierarchyInfo(info, for: app, keepState: alreadyOpen) + } catch { + targetController.viewController.progressView.resetToZero() + targetController.alertError(error, in: targetController.window) + } + } + return + } + + // The same app: reload in place, as before. + viewController.progressView.animate(toProgress: InitialIndicatorProgressWhenFetchHierarchy) + Task { + do { + if case let .info(info) = try await Self.fetchHierarchy(from: app) { + applyHierarchyInfo(info, for: app, keepState: true) + } + } catch { + alertError(error, in: window) + viewController.progressView.resetToZero() + } + } + } + + /// Reloads this window's hierarchy: re-entrancy gate, progress + /// indicator, `keepState: true` hand-off to the data source. Error + /// sheets stay with the callers, so the show-backing-layers switch can + /// reload without one. + /// + /// The request starts before this returns. `completion` gets the + /// reloaded hierarchy (after the data source took it), or an error in + /// the reload domain (this window refused to start or lost the result) + /// or `LookinErrorDomain` (the app failed the request); a refusal is + /// reported before this returns. + private func reloadHierarchy(completion: @escaping (Result) -> Void) { + if isFetchingHierarchy { + completion(.failure(ReloadError.make( + ReloadError.alreadyInProgress, + NSLocalizedString("A hierarchy reload is already running for this window.", comment: "") + ))) + return + } + if isFetchingDetails { + completion(.failure(ReloadError.make( + ReloadError.detailSyncInProgress, + NSLocalizedString("Detail synchronization is still running. Wait for it to finish before reloading.", comment: "") + ))) + return + } + guard let app = inspectableApp else { + completion(.failure(ReloadError.make( + ReloadError.noInspectableApp, + NSLocalizedString("This window is not attached to an app.", comment: "") + ))) + return + } + + isFetchingHierarchy = true + viewController.progressView.animate(toProgress: InitialIndicatorProgressWhenFetchHierarchy) + LKPerformanceReporter.sharedInstance().willStartReload() + + Task { [weak self] in + let result: HierarchyFetchResult + do { + result = try await Self.fetchHierarchy(from: app) + } catch { + self?.viewController.progressView.resetToZero() + self?.isFetchingHierarchy = false + completion(.failure(error as NSError)) + return + } + guard let self else { + // No data source left to hand the hierarchy to. Reporting + // success would tell the caller the Host holds this tree + // when nothing does. + completion(.failure(ReloadError.make( + ReloadError.windowClosed, + NSLocalizedString("The inspector window closed while the hierarchy was being fetched.", comment: "") + ))) + return + } + switch result { + case let .info(info): + viewController.progressView.finish(completion: nil) + hierarchyDataSource.reload(with: info, keepState: true) + isFetchingHierarchy = false + LKPerformanceReporter.sharedInstance().didFetchHierarchy() + completion(.success(info)) + case .noResponse: + // Left unhandled this would strand every party: the reload + // button stays disabled and the caller never hears back. + viewController.progressView.resetToZero() + isFetchingHierarchy = false + completion(.failure(ReloadError.make( + ReloadError.noResponse, + NSLocalizedString("The app finished the hierarchy request without returning a hierarchy.", comment: "") + ))) + } + } + } + + // MARK: - NSToolbarDelegate + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + toolbarDefaultItemIdentifiers(toolbar) + } + + func toolbarDefaultItemIdentifiers(_: NSToolbar) -> [NSToolbarItem.Identifier] { + var identifiers: [NSToolbarItem.Identifier] = [ + NSToolbarItem.Identifier(LKToolBarIdentifier_Reload), NSToolbarItem.Identifier(LKToolBarIdentifier_FastMode), NSToolbarItem.Identifier(LKToolBarIdentifier_App), NSToolbarItem.Identifier(LKToolBarIdentifier_SwiftUIMode), + NSToolbarItem.Identifier(LKToolBarIdentifier_GestureDebug), + .flexibleSpace, + NSToolbarItem.Identifier(LKToolBarIdentifier_Dimension), NSToolbarItem.Identifier(LKToolBarIdentifier_Rotation), NSToolbarItem.Identifier(LKToolBarIdentifier_Setting), + .flexibleSpace, + NSToolbarItem.Identifier(LKToolBarIdentifier_Scale), + .flexibleSpace, + NSToolbarItem.Identifier(LKToolBarIdentifier_Measure), NSToolbarItem.Identifier(LKToolBarIdentifier_Console), + ] + if !LKMessageManager.sharedInstance().queryMessages().isEmpty { + identifiers.append(NSToolbarItem.Identifier(LKToolBarIdentifier_Message)) + } + return identifiers + } + + func toolbar( + _: NSToolbar, + itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar _: Bool + ) -> NSToolbarItem? { + if let item = toolbarItemsMap[itemIdentifier] { + return item + } + guard let item = LKWindowToolbarHelper.shared.makeToolBarItem( + identifier: itemIdentifier.rawValue, + preferenceManager: LKPreferenceManager.shared + ) else { + return nil + } + toolbarItemsMap[itemIdentifier] = item + + switch item.itemIdentifier { + case NSToolbarItem.Identifier(LKToolBarIdentifier_Reload): + item.target = self + item.action = #selector(handleReload) + case NSToolbarItem.Identifier(LKToolBarIdentifier_GestureDebug): + item.target = self + item.action = #selector(handleGestureDebug) + case NSToolbarItem.Identifier(LKToolBarIdentifier_App): + item.target = self + item.action = #selector(handleApp) + // Shows the bound app's icon; `inspectableApp` updates it when + // LookinLiveDocument swaps in the reconnected app. + updateAppButton() + case NSToolbarItem.Identifier(LKToolBarIdentifier_Rotation): + item.target = self + item.action = #selector(handleFreeRotation) + case NSToolbarItem.Identifier(LKToolBarIdentifier_Setting): + item.label = NSLocalizedString("View", comment: "") + item.target = self + item.action = #selector(handleSetting(_:)) + case NSToolbarItem.Identifier(LKToolBarIdentifier_Console): + item.target = self + item.action = #selector(handleConsole) + observations.append(viewController.observe(\.showConsole, options: [.old, .new]) { [weak item] _, change in + guard let showConsole = change.newValue, change.oldValue != showConsole else { return } + MainActor.assumeIsolated { + (item?.view as? NSButton)?.state = showConsole ? .on : .off + } + }) + case NSToolbarItem.Identifier(LKToolBarIdentifier_Message): + item.label = NSLocalizedString("Notifications", comment: "") + item.target = self + item.action = #selector(handleMessage(_:)) + case NSToolbarItem.Identifier(LKToolBarIdentifier_FastMode): + item.target = self + item.action = #selector(handleFastMode) + default: + break + } + return item + } + + // MARK: - Toolbar actions + + @objc(_handleReload) + private func handleReload() { + // The button does two things the reload itself does not: while + // details sync it is a stop button, and with no app bound it opens + // the app picker. Only a click gets those; other callers get errors. + if isFetchingDetails { + // 停止拉取 + asyncUpdateManager.endUpdating() + return + } + guard inspectableApp != nil else { + popupAllInspectableApps(with: .reloadButton) + return + } + if isFetchingHierarchy { + return + } + reloadHierarchy { [weak self] result in + guard let self, case let .failure(error) = result, let window else { return } + NSAlert(error: error).beginSheetModal(for: window, completionHandler: nil) + } + } + + @objc(_handleShowBackingLayersDidChange:) + private func handleShowBackingLayersDidChange(_: LookinMsgActionParams) { + // No app bound or a reload already running: the new value still + // applies on the next manual reload, so refusals stay quiet. + reloadHierarchy { _ in } + } + + @objc(_handleApp) + private func handleApp() { + popupAllInspectableApps(with: .appButton) + } + + @objc(_handleSetting:) + private func handleSetting(_ button: NSButton) { + let popover = NSPopover() + popover.behavior = .transient + popover.animates = false + popover.contentSize = NSSize(width: LKHelper.isEnglish() ? 270 : 350, height: 260) + popover.contentViewController = LKMenuPopoverSettingController( + preferenceManager: LKPreferenceManager.shared, + isMacTarget: LKHelper.appInfoLooksLikeMacTarget(inspectableApp?.appInfo) + ) + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY) + } + + @objc(_handleGestureDebug) + private func handleGestureDebug() { + let controller = gestureDebugWindowController ?? LKGestureDebugWindowController(owner: self) + gestureDebugWindowController = controller + controller.showWindow(self) + controller.window?.makeKeyAndOrderFront(self) + } + + @objc(_handleConsole) + private func handleConsole() { + viewController.showConsole.toggle() + } + + @objc func handleFastMode() { + let fastMode = LKPreferenceManager.shared.fastMode + fastMode.setBOOLValue(!fastMode.currentBOOLValue, ignoreSubscriber: nil) + } + + @objc(_handleMessage:) + private func handleMessage(_ button: NSButton) { + let menu = NSMenu() + for message in LKMessageManager.sharedInstance().queryMessages() { + if message == LKMessage_Jobs { + let item = NSMenuItem() + item.image = NSImage(named: "Icon_Inspiration_small") + item.title = NSLocalizedString("Job openings…(China)", comment: "") + item.target = self + item.action = #selector(handleJobsMenuItem) + menu.addItem(item) + menu.addItem(.separator()) + } else if message == LKMessage_SwiftSubspec { + let tipItem = NSMenuItem() + tipItem.image = NSImage(named: "Icon_Inspiration_small") + tipItem.title = NSLocalizedString("Your iOS project seems to use Swift, but you haven't turn on Swift optimization for LookInside", comment: "") + menu.addItem(tipItem) + + let linkItem = NSMenuItem() + linkItem.image = NSImage(size: NSSize(width: 18, height: 1)) + linkItem.title = NSLocalizedString("How to turn on…", comment: "") + linkItem.target = self + linkItem.action = #selector(handleTurnOnSwift) + menu.addItem(linkItem) + menu.addItem(.separator()) + } + } + + if menu.numberOfItems == 0 { + let item = NSMenuItem() + item.title = NSLocalizedString("No message", comment: "") + menu.addItem(item) + } + + guard let event = NSApplication.shared.currentEvent else { return } + NSMenu.popUpContextMenu(menu, with: event, for: button) + } + + @objc(_handleFreeRotation) + private func handleFreeRotation() { + let freeRotation = LKPreferenceManager.shared.freeRotation + freeRotation.setBOOLValue(!freeRotation.currentBOOLValue, ignoreSubscriber: nil) + } + + @objc func handleJobsMenuItem() { + LKHelper.showDisabledExternalLinkAlert(withMessage: NSLocalizedString("This upstream community link is not available in this build.", comment: "")) + LKMessageManager.sharedInstance().removeMessage(LKMessage_Jobs) + } + + @objc func handleTurnOnSwift() { + LKHelper.showDisabledExternalLinkAlert(withMessage: NSLocalizedString("Legacy Swift integration guides are disabled in this community build. See the repository README instead.", comment: "")) + } + + // MARK: - LKAppMenuManagerDelegate + + @objc func appMenuManagerDidSelectReload() { + if isFetchingHierarchy { + return + } + if isFetchingDetails { + let error = NSError(domain: LookinErrorDomain, code: LookinErrCode_Default, userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("Cannot reload at this time", comment: ""), + NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString("Please wait until current sync is completed. You can get sync progress in the upper-left corner of this window.", comment: ""), + ]) + if let window { + NSAlert(error: error).beginSheetModal(for: window, completionHandler: nil) + } + return + } + handleReload() + } + + @objc func appMenuManagerDidSelectDimension() { + let dimension = LKPreferenceManager.shared.previewDimension + let dimension2D = Int(LookinPreviewDimension.dimension2D.rawValue) + let dimension3D = Int(LookinPreviewDimension.dimension3D.rawValue) + dimension.setIntegerValue(dimension.currentIntegerValue == dimension2D ? dimension3D : dimension2D, ignoreSubscriber: nil) + } + + @objc func appMenuManagerDidSelectZoomIn() { + adjustPreviewScale(by: 0.1) + } + + @objc func appMenuManagerDidSelectZoomOut() { + adjustPreviewScale(by: -0.1) + } + + private func adjustPreviewScale(by delta: Double) { + let scale = LKPreferenceManager.shared.previewScale + let target = min(max(scale.currentDoubleValue + delta, Double(LookinPreviewMinScale)), Double(LookinPreviewMaxScale)) + scale.setDoubleValue(target, ignoreSubscriber: nil) + } + + @objc func appMenuManagerDidSelectDecreaseInterspace() { + adjustZInterspace(by: -0.1) + } + + @objc func appMenuManagerDidSelectIncreaseInterspace() { + adjustZInterspace(by: 0.1) + } + + private func adjustZInterspace(by delta: Double) { + let interspace = LKPreferenceManager.shared.zInterspace + let target = min(max(interspace.currentDoubleValue + delta, Double(LookinPreviewMinZInterspace)), Double(LookinPreviewMaxZInterspace)) + interspace.setDoubleValue(target, ignoreSubscriber: nil) + } + + @objc func appMenuManagerDidSelectExpansionIndex(_ index: UInt) { + hierarchyDataSource.adjustExpansion(by: Int(index), referenceDict: nil, selectedItem: nil) + hierarchyDataSource.persistExpansionStateToPreferences() + } + + @objc func appMenuManagerDidSelectExport() { + guard let hierarchyInfo = hierarchyDataSource.rawHierarchyInfo else { + return + } + var fileName: NSString? + var exportedData: Data? + + let accessoryView = LKExportAccessoryView() + // The data is rebuilt whenever the accessory view changes the + // compression, for as long as the panel is up. + let compressionObservation = LKPreferenceManager.shared.observe(\.preferredExportCompression, options: [.initial, .new]) { manager, _ in + MainActor.assumeIsolated { + exportedData = LKExportManager.sharedInstance().data( + from: hierarchyInfo, + imageCompression: manager.preferredExportCompression, + fileName: &fileName + ) + accessoryView.dataSize = UInt(exportedData?.count ?? 0) + } + } + + // Manual frame layout: the panel cannot size the view itself. + accessoryView.setFrameSize(accessoryView.sizeThatFits(NSSize(width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude))) + + let panel = NSSavePanel() + panel.accessoryView = accessoryView + panel.nameFieldStringValue = (fileName as String?) ?? "" + panel.allowsOtherFileTypes = false + panel.allowedContentTypes = [UTType("com.lookin.lookin") ?? .data] + panel.isExtensionHidden = true + panel.canCreateDirectories = true + guard let window else { return } + panel.beginSheetModal(for: window) { response in + compressionObservation.invalidate() + guard response == .OK, let url = panel.url else { return } + guard let exportedData else { + assertionFailure("LookinClient - write fail, no data") + return + } + do { + try exportedData.write(to: url) + } catch { + NSLog("LookinClient - write fail:%@", error as NSError) + } + } + } + + @objc func appMenuManagerDidSelectOpenInNewWindow() { + let newHierarchyInfo = hierarchyDataSource.rawHierarchyInfo?.copy() as? LookinHierarchyInfo + let file = LookinHierarchyFile() + file.serverVersion = newHierarchyInfo?.serverVersion ?? 0 + file.hierarchyInfo = newHierarchyInfo + LKNavigationManager.shared.showReader(with: file, title: nil) + } + + @objc func appMenuManagerDidSelectFilter() { + viewController.currentHierarchyView()?.activateSearchBar() + } + + // MARK: - LKStaticAsyncUpdateManagerDelegate + + func detailUpdateTasksTotalCount(_ totalCount: UInt, finishedCount: UInt) { + let reloadItem = toolbarItemsMap[NSToolbarItem.Identifier(LKToolBarIdentifier_Reload)] + let reloadButton = reloadItem?.view as? NSButton + + if totalCount > finishedCount { + if !isFetchingDetails { + // 进入 fetch 状态 + isFetchingDetails = true + let image = NSImage(named: "icon_stop") + image?.isTemplate = true + reloadButton?.image = image + } + reloadItem?.label = "\(finishedCount) / \(totalCount)" + } else if isFetchingDetails { + // 退出 fetch 状态 + isFetchingDetails = false + reloadItem?.label = NSLocalizedString("Reload", comment: "") + let image = NSImage(named: "icon_reload") + image?.isTemplate = true + reloadButton?.image = image + } + } + + func detailUpdateReceivedError(_ error: Error) { + alertError(error, in: window) + } + + // MARK: - Hierarchy reloads + + /// Binds `app` to this window and loads `info`. Every same-window + /// reload (auto-connect, toolbar same app, SwiftUI mode) goes through + /// here. + private func applyHierarchyInfo(_ info: LookinHierarchyInfo?, for app: LKInspectableApp, keepState: Bool) { + viewController.progressView.finish(completion: nil) + inspectableApp = app + asyncUpdateManager.inspectableApp = app + hierarchyDataSource.reload(with: info, keepState: keepState) + + // Move this bundle id to the front of the expansion-state LRU, so + // inspected apps aren't evicted just because nothing was toggled yet. + LKPreferenceManager.shared.bumpExpansionStateBundleIdentifierToMostRecent(info?.appInfo?.appBundleIdentifier) + } + + @objc(_handleSwiftUIModeDidChange:) + private func handleSwiftUIModeDidChange(_: Notification) { + guard let app = inspectableApp else { + return + } + + // Remember the selection, to restore (or migrate) it after the reload. + let priorSwiftUIID = hierarchyDataSource.selectedItem?.customInfo?.swiftUIDisplayItemID + let priorWasSwiftUI = SwiftUISelectionMigration.isSwiftUIID(priorSwiftUIID) + + app.cancelHierarchyDetailFetching() + viewController.progressView.animate(toProgress: InitialIndicatorProgressWhenFetchHierarchy) + Task { [weak self] in + do { + let result = try await Self.fetchHierarchy(from: app) + guard let self, case let .info(info) = result else { return } + // The mode toggle keeps the target app, so keepState keeps + // expansion and scroll where possible. + applyHierarchyInfo(info, for: app, keepState: true) + if priorWasSwiftUI, let priorSwiftUIID { + restoreSwiftUISelection(priorIdentifier: priorSwiftUIID) + } + } catch { + guard let self else { return } + viewController.progressView.resetToZero() + alertError(error, in: window) + } + } + } + + private func restoreSwiftUISelection(priorIdentifier: String) { + let flatItems = hierarchyDataSource.displayingFlatItems ?? [] + let identifiers = flatItems.map { $0.customInfo?.swiftUIDisplayItemID } + switch SwiftUISelectionMigration.target(priorIdentifier: priorIdentifier, in: identifiers) { + case let .exact(index): + hierarchyDataSource.selectedItem = flatItems[index] + case let .migrated(index): + hierarchyDataSource.selectedItem = flatItems[index] + showSelectionMigratedToast(for: flatItems[index]) + case .none: + break + } + } + + /// A transient toast that fades out after 2 seconds. Not an alert + /// sheet: a modal sheet would block switching the segmented control + /// again until dismissed. + private func showSelectionMigratedToast(for item: LookinDisplayItem) { + let typeName = item.customInfo?.title ?? "SwiftUI item" + let message = String( + format: NSLocalizedString( + "Selection moved to %@ (modifiers folded in compact mode).", + comment: "hierarchy.swiftui.selection.migrated.body" + ), + typeName + ) + + guard let host = viewController.view as NSView? else { return } + let toast = NSTextField(labelWithString: message) + toast.font = .systemFont(ofSize: 12, weight: .medium) + toast.textColor = .white + toast.alignment = .center + toast.lineBreakMode = .byTruncatingTail + toast.wantsLayer = true + toast.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.78).cgColor + toast.layer?.cornerRadius = 6 + toast.translatesAutoresizingMaskIntoConstraints = false + host.addSubview(toast) + NSLayoutConstraint.activate([ + toast.centerXAnchor.constraint(equalTo: host.centerXAnchor), + toast.bottomAnchor.constraint(equalTo: host.bottomAnchor, constant: -32), + toast.widthAnchor.constraint(lessThanOrEqualTo: host.widthAnchor, multiplier: 0.7), + toast.heightAnchor.constraint(greaterThanOrEqualToConstant: 28), + ]) + // After 2 seconds, fade out over 0.3 seconds, then remove. + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + toast.animator().alphaValue = 0 + } completionHandler: { + toast.removeFromSuperview() + } + } + } +} diff --git a/LookInside/Static/Preview/LKDisplayItemNode.h b/LookInside/Static/Preview/LKDisplayItemNode.h deleted file mode 100644 index aa0d978e..00000000 --- a/LookInside/Static/Preview/LKDisplayItemNode.h +++ /dev/null @@ -1,31 +0,0 @@ -// -// LKDisplayItemNode.h -// Lookin -// -// Created by Li Kai on 2019/8/17. -// https://lookin.work -// - -#import - -@class LookinDisplayItem, LKPreferenceManager, LKHierarchyDataSource; - -@interface LKDisplayItemNode : SCNNode - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource; - -/// 在全部 displayItems 里的 idx -@property(nonatomic, assign) NSUInteger index; - -/// 3D 视图坐标原点对应的 frameToRoot 坐标点。 -/// 由 LKPreviewView 在每次 render 时根据"面积最大的顶层窗口 item"计算得出, -/// 这样最大窗口的中心始终落在 3D 视图的 (0, 0),其余较小的窗口围绕它分布。 -@property(nonatomic, assign) CGPoint referenceCenter; - -@property(nonatomic, weak) LKPreferenceManager *preferenceManager; - -@property(nonatomic, strong) LookinDisplayItem *displayItem; - -@property(nonatomic, assign) BOOL isDarkMode; - -@end diff --git a/LookInside/Static/Preview/LKDisplayItemNode.m b/LookInside/Static/Preview/LKDisplayItemNode.m deleted file mode 100644 index aac52f2b..00000000 --- a/LookInside/Static/Preview/LKDisplayItemNode.m +++ /dev/null @@ -1,430 +0,0 @@ -// -// LKDisplayItemNode.m -// Lookin -// -// Created by Li Kai on 2019/8/17. -// https://lookin.work -// - -#import "LKDisplayItemNode.h" -#import "LookinDisplayItem.h" -#import "LKPreferenceManager.h" -#import "LKHierarchyDataSource.h" -#import "LookinHierarchyInfo.h" -#import "LookinAppInfo.h" - -@interface LKDisplayItemNode () - -@property(nonatomic, strong) LKHierarchyDataSource *dataSource; - -@property(nonatomic, strong) SCNNode *contentNode; -@property(nonatomic, strong) SCNPlane *contentPlane; -/// Shows the screenshot when it covers only a region of the node (see -/// LookinDisplayItem.groupScreenshotRegion). The content plane stays the -/// size of the whole node underneath it — it is the plane hit-testing -/// selects by — and shows the background colour, or nothing. -@property(nonatomic, strong) SCNPlane *regionPlane; -@property(nonatomic, strong) SCNNode *regionNode; - -@property(nonatomic, strong) SCNGeometry *borderGeometry; -@property(nonatomic, strong) SCNNode *borderNode; -@property(nonatomic, strong) NSColor *borderColor; - -@property(nonatomic, strong) SCNNode *maskNode; -@property(nonatomic, strong) SCNPlane *maskPlane; - -@property(nonatomic, assign) BOOL isMacTarget; -@end - -/// Rendering order added to a coplanar overlay node's border so it draws after -/// every ordinary node. Paired with a depth-test-free material, this puts the -/// highlight box on top of whatever the owning view has stacked over the -/// region — a guide sitting under an opaque subview still shows its box. -/// Comfortably above index * 10 + 2 for any hierarchy a person can open. -static const NSInteger LookinPreviewOverlayBorderRenderingOrderBase = 1000000; - -@implementation LKDisplayItemNode - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource { -// NSLog(@"LKDisplayItemNode - init"); - - if (self = [super init]) { - self.dataSource = dataSource; - self.isMacTarget = [LKHelper appInfoLooksLikeMacTarget:self.dataSource.rawHierarchyInfo.appInfo]; - self.contentPlane = [SCNPlane geometry]; - self.contentPlane.firstMaterial.doubleSided = YES; - self.contentPlane.firstMaterial.lightingModelName = SCNLightingModelConstant; - self.contentNode = [SCNNode nodeWithGeometry:self.contentPlane]; - self.contentPlane.firstMaterial.diffuse.contents = [NSColor clearColor]; - self.contentNode.position = SCNVector3Make(0, 0, 0); - self.contentNode.name = @"screenshot"; - self.contentNode.categoryBitMask = LookinPreviewBitMask_NoLight; - [self addChildNode:self.contentNode]; - - self.regionPlane = [SCNPlane geometry]; - self.regionPlane.firstMaterial.doubleSided = YES; - self.regionPlane.firstMaterial.lightingModelName = SCNLightingModelConstant; - self.regionNode = [SCNNode nodeWithGeometry:self.regionPlane]; - self.regionNode.name = @"screenshot-region"; - // Just above the content plane, which shows the background colour - // beneath a partial screenshot. - self.regionNode.position = SCNVector3Make(0, 0, .0005); - self.regionNode.categoryBitMask = LookinPreviewBitMask_NoLight; - self.regionNode.hidden = YES; - [self addChildNode:self.regionNode]; - - // 注意这里并没有 add maskNode,需要的时候再 add - self.maskPlane = [SCNPlane geometry]; - self.maskPlane.firstMaterial.doubleSided = YES; - self.maskNode = [SCNNode nodeWithGeometry:self.maskPlane]; - self.maskNode.name = @"mask"; - self.maskNode.position = SCNVector3Make(0, 0, .001); - self.maskNode.categoryBitMask = LookinPreviewBitMask_HasLight; - - self.borderNode = [SCNNode node]; - self.borderNode.name = @"border"; - self.borderNode.position = SCNVector3Make(0, 0, .002); - self.borderNode.categoryBitMask = LookinPreviewBitMask_NoLight; - [self addChildNode:self.borderNode]; - } - return self; -} - -- (void)setPreferenceManager:(LKPreferenceManager *)preferenceManager { - _preferenceManager = preferenceManager; - [preferenceManager.showHiddenItems subscribe:self action:@selector(_renderVisibility) relatedObject:nil]; - [preferenceManager.showOutline subscribe:self action:@selector(_renderImageAndColor) relatedObject:nil]; - [preferenceManager.isQuickSelecting subscribe:self action:@selector(_renderVisibility) relatedObject:nil]; -} - -- (void)setIsDarkMode:(BOOL)isDarkMode { - _isDarkMode = isDarkMode; - [self _renderImageAndColor]; -} - -- (void)setReferenceCenter:(CGPoint)referenceCenter { - if (CGPointEqualToPoint(_referenceCenter, referenceCenter)) { - return; - } - _referenceCenter = referenceCenter; - if (self.displayItem) { - [self displayItem:self.displayItem propertyDidChange:LookinDisplayItemProperty_FrameToRoot]; - } -} - -- (void)setDisplayItem:(LookinDisplayItem *)displayItem { - _displayItem = displayItem; - -// NSLog(@"LKDisplayItemNode - setContentPlane"); - /// 不能把 contents 设置成 nil,否则某些场景下会发生内容渲染错乱的情况 - self.contentPlane.firstMaterial.diffuse.contents = displayItem.backgroundColor ? : [NSColor clearColor]; - // Nodes are recycled across renders, so the rendering order has to be - // recomputed whenever the item changes and not only when the index does. - [self _updateRenderingOrder]; - - /// 这一句会使得 displayItem:propertyDidChange: 被立即调用,参数是 LookinDisplayItemProperty_None - displayItem.previewItemDelegate = self; - -// self.name = [NSString stringWithFormat:@"DisplayItem(%@)", displayItem.title]; -} - -- (void)setBorderColor:(NSColor *)borderColor { - _borderColor = borderColor; - [self _renderborderColor]; -} - -- (void)setIndex:(NSUInteger)index { -// NSLog(@"LKDisplayItemNode - setIndex"); - - _index = index; - [self _updateRenderingOrder]; -} - -- (void)_updateRenderingOrder { - self.contentNode.renderingOrder = self.index * 10; - self.maskNode.renderingOrder = self.index * 10 + 1; - if (self.displayItem.shouldRenderAsCoplanarPreviewOverlay) { - self.borderNode.renderingOrder = LookinPreviewOverlayBorderRenderingOrderBase + self.index * 10 + 2; - } else { - self.borderNode.renderingOrder = self.index * 10 + 2; - } -} - -- (void)_renderborderColor { -// NSLog(@"LKDisplayItemNode - renderBorderColor"); - - self.borderGeometry.firstMaterial.diffuse.contents = self.borderColor; -} - -- (SCNGeometry *)_makeBorderGeometryWithPlaneNode:(SCNNode *)planeNode { - SCNVector3 max,min; - [planeNode getBoundingBoxMin:&min max:&max]; - CGFloat xx = max.x - min.x; - CGFloat yy = max.y - min.y; - SCNVector3 vec[] = { - max, - SCNVector3Make(max.x, max.y - yy, max.z), - SCNVector3Make(max.x - xx, max.y - yy, max.z), - SCNVector3Make(max.x - xx, max.y, max.z), - }; - GLubyte indexs[] = {0, 1, 1, 2, 2, 3, 3, 0}; - SCNGeometrySource *vecSource = [SCNGeometrySource geometrySourceWithVertices:vec count:4]; - NSData * indexData = [NSData dataWithBytes:indexs length:8]; - SCNGeometryElement *indexElement = [SCNGeometryElement geometryElementWithData:indexData primitiveType:SCNGeometryPrimitiveTypeLine primitiveCount:4 bytesPerIndex:sizeof(GLubyte)]; - SCNGeometry *geometry = [SCNGeometry geometryWithSources:@[vecSource] elements:@[indexElement]]; - geometry.firstMaterial.doubleSided = YES; - geometry.firstMaterial.lightingModelName = SCNLightingModelConstant; - if (self.displayItem.shouldRenderAsCoplanarPreviewOverlay) { - // The box marks out a region of the owning view, so it has to stay - // readable even when that region is covered by opaque subviews. Those - // subviews sit in front of it on the z axis, so the depth test would - // otherwise discard it. - geometry.firstMaterial.readsFromDepthBuffer = NO; - geometry.firstMaterial.writesToDepthBuffer = NO; - } - return geometry; -} - -- (void)_renderVisibility { -// NSLog(@"LKDisplayItemNode - renderVisibility"); - - BOOL displayingInHierarchy = self.displayItem.displayingInHierarchy; - BOOL inHiddenHierarchy = self.displayItem.inHiddenHierarchy; - BOOL showEvenWhenCollapsed = self.preferenceManager.isQuickSelecting.currentBOOLValue && !self.displayItem.superItem.preferToBeCollapsed; - BOOL showHiddenItems = self.preferenceManager.showHiddenItems.currentBOOLValue; - - BOOL canSelect; - if (inHiddenHierarchy && !showHiddenItems) { - self.contentNode.opacity = 0; - self.borderNode.opacity = 0; - self.maskNode.hidden = YES; - canSelect = NO; - - } else if (displayingInHierarchy) { - self.contentNode.opacity = 1; - self.borderNode.opacity = 1; - self.maskNode.hidden = NO; - canSelect = YES; - - } else { - self.contentNode.opacity = 0; - self.maskNode.hidden = YES; - if (showEvenWhenCollapsed) { - self.borderNode.opacity = 1; - canSelect = YES; - } else { - self.borderNode.opacity = 0; - canSelect = NO; - } - } - - if (self.displayItem.shouldRenderAsCoplanarPreviewOverlay) { - // Never hit-testable. A safe-area guide covers its whole view, and it - // now sits right on that view's plane, so leaving it selectable would - // mean every click landing on the view selects the guide instead. Such - // a node is selected from the hierarchy tree, the way Xcode does it. - canSelect = NO; - } - - if (canSelect) { - self.contentNode.categoryBitMask = LookinPreviewBitMask_Selectable|LookinPreviewBitMask_NoLight; - } else { - self.contentNode.categoryBitMask = LookinPreviewBitMask_Unselectable|LookinPreviewBitMask_NoLight; - } -} - -/// The region `screenshot` covers, or CGRectZero when it covers the whole -/// node — which is also the answer for an image that is neither of the -/// node's two screenshots, or a region that equals the bounds anyway. -- (CGRect)_regionOfScreenshot:(LookinImage *)screenshot { - if (!screenshot) { - return CGRectZero; - } - CGRect region = CGRectZero; - if (screenshot == self.displayItem.soloScreenshot) { - region = self.displayItem.soloScreenshotRegion; - } else if (screenshot == self.displayItem.groupScreenshot) { - region = self.displayItem.groupScreenshotRegion; - } - if (CGRectIsEmpty(region) || !LookinIsUsableRect(self.displayItem.bounds) || CGRectEqualToRect(region, self.displayItem.bounds)) { - return CGRectZero; - } - return region; -} - -/// Places the region plane over `region`, a rect in the node's bounds space. -/// It maps to root space the way a subview's frame does (see -/// -[LookinDisplayItem calculateFrameToRoot]): offset from the bounds -/// origin, measured from the bottom when the node is flipped, and the -/// y axis negated for iOS targets as the node's own position is. -- (void)_layoutRegionNodeWithRegion:(CGRect)region frameToRoot:(CGRect)frameToRoot { - CGFloat factor = 0.01; - CGRect bounds = self.displayItem.bounds; - CGFloat x = region.origin.x - bounds.origin.x; - CGFloat y; - if (self.displayItem.isFlipped) { - y = bounds.size.height - region.origin.y - region.size.height; - } else { - y = region.origin.y - bounds.origin.y; - } - CGFloat offsetX = (x + region.size.width / 2) - frameToRoot.size.width / 2; - CGFloat offsetY = (y + region.size.height / 2) - frameToRoot.size.height / 2; - self.regionPlane.width = region.size.width * factor; - self.regionPlane.height = region.size.height * factor; - SCNVector3 position = self.regionNode.position; - position.x = offsetX * factor; - position.y = (self.isMacTarget ? offsetY : -offsetY) * factor; - self.regionNode.position = position; -} - -- (void)_renderImageAndColor { -// NSLog(@"LKDisplayItemNode - renderImageAndColor"); - - BOOL isSelected = (self.dataSource.selectedItem == self.displayItem); - BOOL isHovered = (self.dataSource.hoveredItem == self.displayItem); - - LookinImage *appropriateScreenshot = self.displayItem.appropriateScreenshot; - if (appropriateScreenshot) { - NSAssert(MAX(appropriateScreenshot.representations.firstObject.pixelsWide, appropriateScreenshot.representations.firstObject.pixelsHigh) <= LookinNodeImageMaxLengthInPx , @"image is too large"); - } - CGRect region = [self _regionOfScreenshot:appropriateScreenshot]; - if (CGRectIsEmpty(region)) { - self.contentPlane.firstMaterial.diffuse.contents = appropriateScreenshot ?: (self.displayItem.backgroundColor ?: [NSColor clearColor]); - self.regionNode.hidden = YES; - } else { - // A partial screenshot: the node's own plane shows what the image - // does not cover, the region plane shows the image where it belongs. - self.contentPlane.firstMaterial.diffuse.contents = self.displayItem.backgroundColor ?: [NSColor clearColor]; - self.regionPlane.firstMaterial.diffuse.contents = appropriateScreenshot; - self.regionNode.hidden = NO; - [self _layoutRegionNodeWithRegion:region frameToRoot:[self.displayItem calculateFrameToRoot]]; - } - - BOOL tooLargeToFetchScreenshot = !appropriateScreenshot && self.displayItem.doNotFetchScreenshotReason == LookinDoNotFetchScreenshotForTooLarge; - - if (self.displayItem.shouldRenderAsCoplanarPreviewOverlay) { - // Draw the box only while the node is the one being looked at. These - // nodes are coplanar with the view that owns them and there is one on - // almost every view (safe area, layout margins), so outlining them all - // the time would bury the preview under duplicated rectangles. No mask - // either: the point is to outline the region, not to tint it. - self.borderColor = (isSelected || isHovered) ? LookinColorMake(100, 146, 199) : [NSColor clearColor]; - self.maskNode.opacity = 0; - return; - } - - // 更新 border 颜色 - if (isSelected || isHovered) { - if (tooLargeToFetchScreenshot) { - self.borderColor = LookinColorRGBAMake(255, 38, 0, .8); - } else { - self.borderColor = LookinColorMake(100, 146, 199); - } - } else if (self.preferenceManager.showOutline.currentBOOLValue) { - if (tooLargeToFetchScreenshot) { - self.borderColor = self.isDarkMode ? LookinColorRGBAMake(255, 38, 0, .5) : LookinColorRGBAMake(255, 38, 0, .6); - } else { - self.borderColor = self.isDarkMode ? LookinColorRGBAMake(160, 168, 189, .6) : LookinColorRGBAMake(120, 122, 124, .6); - } - } else { - self.borderColor = [NSColor clearColor]; - } - - // 更新 mask 颜色 - NSColor *maskColor = nil; - CGFloat maskOpacity = 0; - if (tooLargeToFetchScreenshot) { - maskColor = LookinColorMake(255, 38, 0); - if (isSelected) { - maskOpacity = .45; - } else if (isHovered) { - maskOpacity = .3; - } else { - maskOpacity = self.isDarkMode ? .17 : .2; - } - } else { - maskColor = LookinColorMake(110, 183, 255); - NSInteger level = [[LKPreferenceManager mainManager] imageContrastLevel]; - if (level < 0 || level > 2) { - NSAssert(NO, @""); - level = 0; - } - if (isSelected) { - maskOpacity = [@[@.35, @.6, @.85][level] doubleValue]; - } else if (isHovered) { - maskOpacity = [@[@.18, @.38, @.6][level] doubleValue]; - } else { - maskOpacity = 0; - } - } - if (maskOpacity > 0 && !self.maskNode.parentNode) { - [self insertChildNode:self.maskNode atIndex:1]; - } - self.maskNode.opacity = maskOpacity; - self.maskPlane.firstMaterial.diffuse.contents = maskColor; -} - -#pragma mark - - -- (void)displayItem:(LookinDisplayItem *)displayItem propertyDidChange:(LookinDisplayItemProperty)property { -// NSLog(@"LKDisplayItemNode - %@ did Change", @(property)); - - if (property == LookinDisplayItemProperty_None || property == LookinDisplayItemProperty_FrameToRoot) { - CGRect frameToRoot = [displayItem calculateFrameToRoot]; - - CGFloat originX = frameToRoot.origin.x; - CGFloat originY = frameToRoot.origin.y; - CGFloat width = frameToRoot.size.width; - CGFloat height = frameToRoot.size.height; - CGFloat xOffSet = -self.referenceCenter.x; - CGFloat yOffSet = self.referenceCenter.y; - - CGFloat transformedX = 0; - CGFloat transformedY = 0; - - if (self.isMacTarget) { - transformedX = (originX + width / 2 + xOffSet); - transformedY = (originY + height / 2 - yOffSet); - } else { - transformedX = (originX + width / 2 + xOffSet); - transformedY = (-(originY + height / 2) + yOffSet); - } - - CGFloat factor = 0.01; - - self.contentPlane.width = width * factor; - self.contentPlane.height = height * factor; - - self.maskPlane.width = self.contentPlane.width; - self.maskPlane.height = self.contentPlane.height; - if (!self.regionNode.hidden) { - [self _layoutRegionNodeWithRegion:[self _regionOfScreenshot:self.displayItem.appropriateScreenshot] frameToRoot:frameToRoot]; - } - - SCNVector3 position = self.position; - position.x = transformedX * factor; - position.y = transformedY * factor; - self.position = position; - - self.borderGeometry = [self _makeBorderGeometryWithPlaneNode:self.contentNode]; - self.borderNode.geometry = self.borderGeometry; - [self _renderborderColor]; - } - - if (property == LookinDisplayItemProperty_None || - property == LookinDisplayItemProperty_IsExpandable || - property == LookinDisplayItemProperty_IsExpanded || - property == LookinDisplayItemProperty_SoloScreenshot || - property == LookinDisplayItemProperty_GroupScreenshot || - property == LookinDisplayItemProperty_IsSelected || - property == LookinDisplayItemProperty_IsHovered || - property == LookinDisplayItemProperty_AvoidSyncScreenshot) { - [self _renderImageAndColor]; - } - - if (property == LookinDisplayItemProperty_None || property == LookinDisplayItemProperty_DisplayingInHierarchy || property == LookinDisplayItemProperty_InHiddenHierarchy) { - [self _renderVisibility]; - } -} - -@end diff --git a/LookInside/Static/Preview/LKDisplayItemNode.swift b/LookInside/Static/Preview/LKDisplayItemNode.swift new file mode 100644 index 00000000..c173ef2d --- /dev/null +++ b/LookInside/Static/Preview/LKDisplayItemNode.swift @@ -0,0 +1,418 @@ +// +// LKDisplayItemNode.swift +// LookInside +// +// Created by Li Kai on 2019/8/17. +// https://lookin.work +// + +import AppKit +import SceneKit + +/// One item's plane in the 3D preview: the screenshot (or background +/// colour), a tint mask for hover and selection, and an outline. +@objc(LKDisplayItemNode) +final class LKDisplayItemNode: SCNNode, LookinDisplayItemDelegate { + /// Rendering order added to a coplanar overlay node's border so it draws + /// after every ordinary node. Paired with a depth-test-free material, + /// this puts the highlight box on top of whatever the owning view has + /// stacked over the region — a guide sitting under an opaque subview + /// still shows its box. Comfortably above index * 10 + 2 for any + /// hierarchy a person can open. + private static let overlayBorderRenderingOrderBase = 1_000_000 + + /// Scene units per point. + private static let factor: CGFloat = 0.01 + + private let dataSource: LKHierarchyDataSource? + private let isMacTarget: Bool + + private let contentPlane = SCNPlane() + private let contentNode: SCNNode + /// Shows the screenshot when it covers only a region of the node (see + /// LookinDisplayItem.groupScreenshotRegion). The content plane stays the + /// size of the whole node underneath it — it is the plane hit-testing + /// selects by — and shows the background colour, or nothing. + private let regionPlane = SCNPlane() + private let regionNode: SCNNode + private let maskPlane = SCNPlane() + /// Added as a child only once it is first needed. + private let maskNode: SCNNode + private let borderNode = SCNNode() + private var borderGeometry: SCNGeometry? + private var borderColor: NSColor? { + didSet { renderBorderColor() } + } + + /// The node's index among all display items. + var index = 0 { + didSet { updateRenderingOrder() } + } + + /// The point of frameToRoot space at the origin of the 3D scene. Set by + /// LKPreviewView on every render from the largest top-level window item, + /// so that window's centre stays at (0, 0) and smaller windows sit + /// around it. + var referenceCenter: CGPoint = .zero { + didSet { + guard referenceCenter != oldValue, let displayItem else { return } + render(displayItem, changedProperty: .frameToRoot) + } + } + + weak var preferenceManager: LKPreferenceManager? { + didSet { + preferenceManager?.showHiddenItems.subscribe(self, action: #selector(renderVisibility), relatedObject: nil) + preferenceManager?.showOutline.subscribe(self, action: #selector(renderImageAndColor), relatedObject: nil) + preferenceManager?.isQuickSelecting.subscribe(self, action: #selector(renderVisibility), relatedObject: nil) + } + } + + var displayItem: LookinDisplayItem? { + didSet { + // Never set the contents to nil: some scenes then render the + // wrong content. + contentPlane.firstMaterial?.diffuse.contents = displayItem?.backgroundColor ?? NSColor.clear + // Nodes are recycled across renders, so the rendering order has + // to be recomputed whenever the item changes and not only when + // the index does. + updateRenderingOrder() + // Calls displayItem(_:propertyDidChange:) at once with .none. + displayItem?.previewItemDelegate = self + } + } + + var isDarkMode = false { + didSet { renderImageAndColor() } + } + + init(dataSource: LKHierarchyDataSource?) { + self.dataSource = dataSource + isMacTarget = LKHelper.appInfoLooksLikeMacTarget(dataSource?.rawHierarchyInfo?.appInfo) + + contentPlane.firstMaterial?.isDoubleSided = true + contentPlane.firstMaterial?.lightingModel = .constant + contentPlane.firstMaterial?.diffuse.contents = NSColor.clear + contentNode = SCNNode(geometry: contentPlane) + contentNode.position = SCNVector3(0, 0, 0) + contentNode.name = "screenshot" + contentNode.categoryBitMask = Int(LookinPreviewBitMask.noLight.rawValue) + + regionPlane.firstMaterial?.isDoubleSided = true + regionPlane.firstMaterial?.lightingModel = .constant + regionNode = SCNNode(geometry: regionPlane) + regionNode.name = "screenshot-region" + // Just above the content plane, which shows the background colour + // beneath a partial screenshot. + regionNode.position = SCNVector3(0, 0, 0.0005) + regionNode.categoryBitMask = Int(LookinPreviewBitMask.noLight.rawValue) + regionNode.isHidden = true + + maskPlane.firstMaterial?.isDoubleSided = true + maskNode = SCNNode(geometry: maskPlane) + maskNode.name = "mask" + maskNode.position = SCNVector3(0, 0, 0.001) + maskNode.categoryBitMask = Int(LookinPreviewBitMask.hasLight.rawValue) + + borderNode.name = "border" + borderNode.position = SCNVector3(0, 0, 0.002) + borderNode.categoryBitMask = Int(LookinPreviewBitMask.noLight.rawValue) + + super.init() + + addChildNode(contentNode) + addChildNode(regionNode) + addChildNode(borderNode) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + // MARK: - Rendering + + private func updateRenderingOrder() { + contentNode.renderingOrder = index * 10 + maskNode.renderingOrder = index * 10 + 1 + if displayItem?.shouldRenderAsCoplanarPreviewOverlay() == true { + borderNode.renderingOrder = Self.overlayBorderRenderingOrderBase + index * 10 + 2 + } else { + borderNode.renderingOrder = index * 10 + 2 + } + } + + private func renderBorderColor() { + borderGeometry?.firstMaterial?.diffuse.contents = borderColor + } + + private func makeBorderGeometry(around planeNode: SCNNode) -> SCNGeometry { + let (min, max) = planeNode.boundingBox + let width = max.x - min.x + let height = max.y - min.y + let vertices = [ + max, + SCNVector3(max.x, max.y - height, max.z), + SCNVector3(max.x - width, max.y - height, max.z), + SCNVector3(max.x - width, max.y, max.z), + ] + let indexes: [UInt8] = [0, 1, 1, 2, 2, 3, 3, 0] + let vertexSource = SCNGeometrySource(vertices: vertices) + let element = SCNGeometryElement( + data: Data(indexes), + primitiveType: .line, + primitiveCount: 4, + bytesPerIndex: MemoryLayout.size + ) + let geometry = SCNGeometry(sources: [vertexSource], elements: [element]) + geometry.firstMaterial?.isDoubleSided = true + geometry.firstMaterial?.lightingModel = .constant + if displayItem?.shouldRenderAsCoplanarPreviewOverlay() == true { + // The box marks out a region of the owning view, so it has to + // stay readable even when that region is covered by opaque + // subviews. Those sit in front of it on the z axis, so the depth + // test would otherwise discard it. + geometry.firstMaterial?.readsFromDepthBuffer = false + geometry.firstMaterial?.writesToDepthBuffer = false + } + return geometry + } + + @objc private func renderVisibility() { + let displayingInHierarchy = displayItem?.displayingInHierarchy ?? false + let inHiddenHierarchy = displayItem?.inHiddenHierarchy ?? false + let showEvenWhenCollapsed = (preferenceManager?.isQuickSelecting.currentBOOLValue ?? false) + && !(displayItem?.super?.preferToBeCollapsed ?? false) + let showHiddenItems = preferenceManager?.showHiddenItems.currentBOOLValue ?? false + + var canSelect: Bool + if inHiddenHierarchy, !showHiddenItems { + contentNode.opacity = 0 + borderNode.opacity = 0 + maskNode.isHidden = true + canSelect = false + } else if displayingInHierarchy { + contentNode.opacity = 1 + borderNode.opacity = 1 + maskNode.isHidden = false + canSelect = true + } else { + contentNode.opacity = 0 + maskNode.isHidden = true + if showEvenWhenCollapsed { + borderNode.opacity = 1 + canSelect = true + } else { + borderNode.opacity = 0 + canSelect = false + } + } + + if displayItem?.shouldRenderAsCoplanarPreviewOverlay() == true { + // Never hit-testable. A safe-area guide covers its whole view, + // and it sits right on that view's plane, so leaving it + // selectable would mean every click landing on the view selects + // the guide instead. Such a node is selected from the hierarchy + // tree, the way Xcode does it. + canSelect = false + } + + let selectability: LookinPreviewBitMask = canSelect ? .selectable : .unselectable + contentNode.categoryBitMask = Int(selectability.union(.noLight).rawValue) + } + + /// The region `screenshot` covers, or nil when it covers the whole node + /// — which is also the answer for an image that is neither of the node's + /// two screenshots, or a region that equals the bounds anyway. + private func region(of screenshot: NSImage?) -> CGRect? { + guard let screenshot, let displayItem else { return nil } + var region = CGRect.zero + if screenshot === displayItem.soloScreenshot { + region = displayItem.soloScreenshotRegion + } else if screenshot === displayItem.groupScreenshot { + region = displayItem.groupScreenshotRegion + } + if region.isEmpty || !LookinIsUsableRect(displayItem.bounds) || region.equalTo(displayItem.bounds) { + return nil + } + return region + } + + /// Places the region plane over `region`, a rect in the node's bounds + /// space. It maps to root space the way a subview's frame does (see + /// -[LookinDisplayItem calculateFrameToRoot]): offset from the bounds + /// origin, measured from the bottom when the node is flipped, and the + /// y axis negated for iOS targets as the node's own position is. + private func layoutRegionNode(region: CGRect, frameToRoot: CGRect) { + guard let displayItem else { return } + let bounds = displayItem.bounds + let x = region.origin.x - bounds.origin.x + let y: CGFloat + if displayItem.isFlipped { + y = bounds.size.height - region.origin.y - region.size.height + } else { + y = region.origin.y - bounds.origin.y + } + let offsetX = (x + region.size.width / 2) - frameToRoot.size.width / 2 + let offsetY = (y + region.size.height / 2) - frameToRoot.size.height / 2 + regionPlane.width = region.size.width * Self.factor + regionPlane.height = region.size.height * Self.factor + var position = regionNode.position + position.x = offsetX * Self.factor + position.y = (isMacTarget ? offsetY : -offsetY) * Self.factor + regionNode.position = position + } + + @objc private func renderImageAndColor() { + // Like the Objective-C pointer comparison, nil equals nil. + let isSelected = dataSource?.selectedItem === displayItem + let isHovered = dataSource?.hoveredItem === displayItem + + let screenshot = displayItem?.lkOptionalAppropriateScreenshot + if let rep = screenshot?.representations.first { + assert(CGFloat(Swift.max(rep.pixelsWide, rep.pixelsHigh)) <= CGFloat(LookinNodeImageMaxLengthInPx), "image is too large") + } + let backgroundColor = displayItem?.backgroundColor ?? NSColor.clear + if let screenshot, let region = region(of: screenshot) { + // A partial screenshot: the node's own plane shows what the image + // does not cover, the region plane shows the image where it + // belongs. + contentPlane.firstMaterial?.diffuse.contents = backgroundColor + regionPlane.firstMaterial?.diffuse.contents = screenshot + regionNode.isHidden = false + layoutRegionNode(region: region, frameToRoot: displayItem?.calculateFrameToRoot() ?? .zero) + } else { + contentPlane.firstMaterial?.diffuse.contents = screenshot ?? backgroundColor + regionNode.isHidden = true + } + + let tooLargeToFetchScreenshot = screenshot == nil + && displayItem?.doNotFetchScreenshotReason == .doNotFetchScreenshotForTooLarge + + if displayItem?.shouldRenderAsCoplanarPreviewOverlay() == true { + // Draw the box only while the node is the one being looked at. + // These nodes are coplanar with the view that owns them and there + // is one on almost every view (safe area, layout margins), so + // outlining them all the time would bury the preview under + // duplicated rectangles. No mask either: the point is to outline + // the region, not to tint it. + borderColor = (isSelected || isHovered) ? Self.color(100, 146, 199) : NSColor.clear + maskNode.opacity = 0 + return + } + + if isSelected || isHovered { + borderColor = tooLargeToFetchScreenshot ? Self.color(255, 38, 0, 0.8) : Self.color(100, 146, 199) + } else if preferenceManager?.showOutline.currentBOOLValue == true { + if tooLargeToFetchScreenshot { + borderColor = isDarkMode ? Self.color(255, 38, 0, 0.5) : Self.color(255, 38, 0, 0.6) + } else { + borderColor = isDarkMode ? Self.color(160, 168, 189, 0.6) : Self.color(120, 122, 124, 0.6) + } + } else { + borderColor = NSColor.clear + } + + let maskColor: NSColor + let maskOpacity: CGFloat + if tooLargeToFetchScreenshot { + maskColor = Self.color(255, 38, 0) + if isSelected { + maskOpacity = 0.45 + } else if isHovered { + maskOpacity = 0.3 + } else { + maskOpacity = isDarkMode ? 0.17 : 0.2 + } + } else { + maskColor = Self.color(110, 183, 255) + var level = LKPreferenceManager.shared.imageContrastLevel + if level < 0 || level > 2 { + assertionFailure() + level = 0 + } + if isSelected { + maskOpacity = [0.35, 0.6, 0.85][level] + } else if isHovered { + maskOpacity = [0.18, 0.38, 0.6][level] + } else { + maskOpacity = 0 + } + } + if maskOpacity > 0, maskNode.parent == nil { + insertChildNode(maskNode, at: 1) + } + maskNode.opacity = maskOpacity + maskPlane.firstMaterial?.diffuse.contents = maskColor + } + + /// `LookinColorRGBAMake`. + private static func color(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat, _ alpha: CGFloat = 1) -> NSColor { + NSColor(red: red / 255, green: green / 255, blue: blue / 255, alpha: alpha) + } + + // MARK: - LookinDisplayItemDelegate + + func displayItem(_ displayItem: LookinDisplayItem!, propertyDidChange property: LookinDisplayItemProperty) { + render(displayItem, changedProperty: property) + } + + private func render(_ displayItem: LookinDisplayItem, changedProperty property: LookinDisplayItemProperty) { + if property == .none || property == .frameToRoot { + let frameToRoot = displayItem.calculateFrameToRoot() + let width = frameToRoot.size.width + let height = frameToRoot.size.height + let xOffset = -referenceCenter.x + let yOffset = referenceCenter.y + + let transformedX = frameToRoot.origin.x + width / 2 + xOffset + let transformedY: CGFloat + if isMacTarget { + transformedY = frameToRoot.origin.y + height / 2 - yOffset + } else { + transformedY = -(frameToRoot.origin.y + height / 2) + yOffset + } + + contentPlane.width = width * Self.factor + contentPlane.height = height * Self.factor + maskPlane.width = contentPlane.width + maskPlane.height = contentPlane.height + if !regionNode.isHidden { + let region = region(of: self.displayItem?.lkOptionalAppropriateScreenshot) ?? .zero + layoutRegionNode(region: region, frameToRoot: frameToRoot) + } + + var position = self.position + position.x = transformedX * Self.factor + position.y = transformedY * Self.factor + self.position = position + + borderGeometry = makeBorderGeometry(around: contentNode) + borderNode.geometry = borderGeometry + renderBorderColor() + } + + switch property { + case .none, .isExpandable, .isExpanded, .soloScreenshot, .groupScreenshot, .isSelected, .isHovered, .avoidSyncScreenshot: + renderImageAndColor() + default: + break + } + + switch property { + case .none, .displayingInHierarchy, .inHiddenHierarchy: + renderVisibility() + default: + break + } + } +} + +extension LookinDisplayItem { + /// `-appropriateScreenshot`, which returns nil for an item without a + /// screenshot although its header (inside NS_ASSUME_NONNULL) says it + /// never does; read through the runtime so Swift sees the nil. + var lkOptionalAppropriateScreenshot: NSImage? { + value(forKey: "appropriateScreenshot") as? NSImage + } +} diff --git a/LookInside/Static/Preview/LKPreviewController.h b/LookInside/Static/Preview/LKPreviewController.h deleted file mode 100644 index fafaeaa4..00000000 --- a/LookInside/Static/Preview/LKPreviewController.h +++ /dev/null @@ -1,23 +0,0 @@ -// -// LKPreviewViewController.h -// Lookin -// -// Created by Li Kai on 2018/8/6. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LKHierarchyDataSource, LKStaticViewController, LKStaticAsyncUpdateManager; - -@interface LKPreviewController : LKBaseViewController - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource; - -@property(nonatomic, weak) LKStaticViewController *staticViewController; - -/// Phase A 引入:由 owner 注入的 per-instance update manager(weak)。 -/// 仅在 live workspace 路径下用到;archive 路径下保持 nil。 -@property(nonatomic, weak) LKStaticAsyncUpdateManager *asyncUpdateManager; - -@end diff --git a/LookInside/Static/Preview/LKPreviewController.m b/LookInside/Static/Preview/LKPreviewController.m deleted file mode 100644 index 700aae55..00000000 --- a/LookInside/Static/Preview/LKPreviewController.m +++ /dev/null @@ -1,761 +0,0 @@ -// -// LKPreviewViewController.m -// Lookin -// -// Created by Li Kai on 2018/8/6. -// https://lookin.work -// - -#import "LKPreviewController.h" -#import "LKStaticHierarchyDataSource.h" -#import "LKReadHierarchyDataSource.h" -#import "LKPreviewStageView.h" -#import "LKPreviewPanGestureRecognizer.h" -#import "LookinDisplayItem.h" -#import "LKHierarchyView.h" -#import "LKPreferenceManager.h" -#import "LookinAppInfo.h" -#import "LookinHierarchyInfo.h" -#import "LKNavigationManager.h" -#import "LKStaticViewController.h" -#import "LKPreviewView.h" -#import "LKUserActionManager.h" -#import "LKHierarchyDataSource+KeyDown.h" -#import "LKStaticAsyncUpdateManager.h" - -extern NSString *const LKAppShowConsoleNotificationName; - -@interface LKPreviewController () - -@property(nonatomic, strong) NSClickGestureRecognizer *clickRecognizer; - -@property(nonatomic, strong) LKPreviewPanGestureRecognizer *panRecognizer; - -@property(nonatomic, strong) LKHierarchyDataSource *dataSource; - -@property(nonatomic, strong) LKPreviewStageView *stageView; -@property(nonatomic, strong) LKPreviewView *previewView; - -/// 按住 space 时可以通过 pan 来移动图像 -@property(nonatomic, assign) BOOL isKeyingDownSpace; -/// 按住 command 时可以穿透选择,以及通过 scroll 鼠标来放大缩小图像 -@property(nonatomic, assign) BOOL isKeyingDownCommand; -/// 按住 option 时可以测距 -@property(nonatomic, assign) BOOL isKeyingDownOption; - -@property(nonatomic, strong) id keyUpEventMonitor; -@property(nonatomic, strong) id keyMaskFlagsChangedEventMonitor; - -@property(nonatomic, strong) NSMenu *rightClickMenu; -@property(nonatomic, strong) LookinDisplayItem *rightClickingDisplayItem; - -@property(nonatomic, strong) NSClickGestureRecognizer *doubleClickRecognizer; -@property(nonatomic, strong) NSClickGestureRecognizer *rightClickRecognizer; - -@end - -@implementation LKPreviewController - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource { - if (self = [self initWithContainerView:nil]) { - self.dataSource = dataSource; - - LookinAppInfo *appInfo = dataSource.rawHierarchyInfo.appInfo; - - self.previewView = [[LKPreviewView alloc] initWithDataSource:dataSource]; - self.previewView.preferenceManager = self.dataSource.preferenceManager; - self.previewView.alphaValue = 0; - self.previewView.appScreenSize = CGSizeMake(appInfo.screenWidth, appInfo.screenHeight); - self.previewView.showHiddenItems = dataSource.preferenceManager.showHiddenItems.currentBOOLValue; - [dataSource.preferenceManager.showHiddenItems subscribe:self action:@selector(_handleShowHiddenItemsChange:) relatedObject:nil]; - @weakify(self); - self.stageView.didChangeAppearanceBlock = ^(LKBaseView *view, BOOL isDarkMode) { - @strongify(self); - self.previewView.isDarkMode = isDarkMode; - }; - [self.view addSubview:self.previewView]; - - // 这里通过 [RACSignal return:nil] 来立即执行一次渲染 - [[RACSignal merge:@[[RACSignal return:nil], - self.dataSource.didReloadHierarchyInfo, - self.dataSource.itemDidChangeNoPreview]] subscribeNext:^(id _Nullable x) { - @strongify(self); - NSArray *validItems = [dataSource.flatItems lookin_filter:^BOOL(LookinDisplayItem *obj) { - if (obj.inNoPreviewHierarchy) { - return NO; - } - if (obj.customInfo) { - return [obj.customInfo hasValidFrame]; - } - return YES; - }]; - - [self.previewView renderWithDisplayItems:validItems discardCache:YES]; - }]; - - [self.dataSource.didReloadFlatItemsWithSearchOrFocus subscribeNext:^(id _Nullable x) { - @strongify(self); - NSArray *validItems = [dataSource.flatItems lookin_filter:^BOOL(LookinDisplayItem *obj) { - return !obj.inNoPreviewHierarchy; - }]; - [self.previewView renderWithDisplayItems:validItems discardCache:NO]; - }]; - - NSMutableArray *signalsToUpdateZIndex = @[RACObserve(dataSource, displayingFlatItems)].mutableCopy; - if ([dataSource isKindOfClass:[LKStaticHierarchyDataSource class]]) { - [signalsToUpdateZIndex addObject:((LKStaticHierarchyDataSource *)dataSource).itemDidChangeHiddenAlphaValue]; - [signalsToUpdateZIndex addObject:((LKStaticHierarchyDataSource *)dataSource).itemsDidChangeFrame]; - } - [[[RACSignal merge:signalsToUpdateZIndex] skip:1] - subscribeNext:^(id _Nullable x) { - @strongify(self); - [self.previewView updateZPosition]; - }]; - - self.panRecognizer = [[LKPreviewPanGestureRecognizer alloc] initWithTarget:self action:@selector(_handlePanGesture:)]; - self.panRecognizer.delegate = self; - [self.previewView addGestureRecognizer:self.panRecognizer]; - - self.clickRecognizer = [[NSClickGestureRecognizer alloc] initWithTarget:self action:@selector(_handleClickGesture:)]; - self.clickRecognizer.numberOfClicksRequired = 1; - self.clickRecognizer.delegate = self; - [self.previewView addGestureRecognizer:self.clickRecognizer]; - - self.doubleClickRecognizer = [[NSClickGestureRecognizer alloc] initWithTarget:self action:@selector(_handleDoubleClick:)]; - self.doubleClickRecognizer.numberOfClicksRequired = 2; - [self.previewView addGestureRecognizer:self.doubleClickRecognizer]; - - self.rightClickRecognizer = [[NSClickGestureRecognizer alloc] initWithTarget:self action:@selector(_handleRightClick:)]; - self.rightClickRecognizer.buttonMask = 0x2; - self.rightClickRecognizer.numberOfClicksRequired = 1; - [self.previewView addGestureRecognizer:self.rightClickRecognizer]; - - self.rightClickMenu = [self _makeRightClickMenu]; - - self.keyUpEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskKeyUp handler:^NSEvent * _Nullable(NSEvent * _Nonnull event) { - @strongify(self); - // 在 macbook 上,按下 space 然后通过触摸板拖拽然后松开 space,此时 keyUp: 方法不会被调到,不知道为啥,这里做个兜底 - if (event.type == NSEventTypeKeyUp && [event.charactersIgnoringModifiers isEqualToString:@" "]) { - self.isKeyingDownSpace = NO; - [self.view.window invalidateCursorRectsForView:self.view]; - } - return event; - }]; - self.keyMaskFlagsChangedEventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskFlagsChanged handler:^NSEvent * _Nullable(NSEvent * _Nonnull event) { - @strongify(self); - if ([event modifierFlags] & NSEventModifierFlagCommand) { - self.isKeyingDownCommand = YES; - } else { - self.isKeyingDownCommand = NO; - } - - if ([event modifierFlags] & NSEventModifierFlagOption) { - self.isKeyingDownOption = YES; - } else { - self.isKeyingDownOption = NO; - } - - return event; - }]; - - [self.dataSource.preferenceManager.previewScale subscribe:self action:@selector(_handleManagerPreviewScaleDidChange:) relatedObject:nil sendAtOnce:YES]; - [self.dataSource.preferenceManager.previewDimension subscribe:self action:@selector(_handleManagerPreviewDimensionDidChange:) relatedObject:nil sendAtOnce:YES]; - [self.dataSource.preferenceManager.freeRotation subscribe:self action:@selector(_handleManagerFreeRotationDidChange:) relatedObject:nil sendAtOnce:YES]; - [self.dataSource.preferenceManager.zInterspace subscribe:self action:@selector(_handleManagerZInterspaceDidChange:) relatedObject:nil sendAtOnce:YES]; - - // 用于在 reload app 时重置 translation 和 scale - [[RACObserve(dataSource, rawHierarchyInfo) skip:1] subscribeNext:^(id _Nullable x) { - @strongify(self); - [self _hierarchyInfoDidChange]; - }]; - - [RACObserve(dataSource, selectedItem) subscribeNext:^(LookinDisplayItem *item) { - @strongify(self); - [self.previewView didSelectItem:item]; - }]; - - [[NSNotificationCenter defaultCenter] addObserverForName:NSWindowDidResignKeyNotification object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) { - @strongify(self); - if (note.object == self.view.window) { - self.isKeyingDownSpace = NO; - self.isKeyingDownOption = NO; - self.isKeyingDownCommand = NO; - } - }]; - } - return self; -} - -- (NSView *)makeContainerView { - self.stageView = [[LKPreviewStageView alloc] init]; - self.stageView.didChangeAppearanceBlock = ^(LKBaseView *view, BOOL isDarkMode) { - view.backgroundColor = isDarkMode ? LookinColorMake(0, 0, 0) : LookinColorMake(255, 255, 255); - }; - self.stageView.delegate = self; - return self.stageView; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - - /// 向左多延伸一个 dashboard 的视觉宽度(由于一般向右旋转,所以少延伸个 20 似乎更好看一点),向下多延伸一个 titleBar 高度,从而让 preview 在视觉上居中 - CGFloat titleBarHeight = [LKNavigationManager sharedInstance].windowTitleBarHeight; - $(self.previewView).width(self.view.$width + DashboardViewWidth - DashboardHorInset - 20).right(0).height(self.view.$height + titleBarHeight).y(0); - // $(self.previewView).fullFrame; - - BOOL hasLayouted = [self lookin_getBindBOOLForKey:@"hasLayouted"]; - if (!hasLayouted) { - [self lookin_bindBOOL:YES forKey:@"hasLayouted"]; - - // 做一个开场动画 - [self.previewView setRotation:CGPointMake(.8, 0) animated:NO]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.15 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - - [NSAnimationContext runAnimationGroup:^(NSAnimationContext * _Nonnull context) { - context.duration = 1; - self.previewView.animator.alphaValue = 1; - } completionHandler:nil]; - - [self.previewView setRotation:CGPointMake(.6, 0) animated:YES timingFunction:[CAMediaTimingFunction functionWithControlPoints:.3 :.93 :.26 :.88] duration:1.5]; - }); - } -} - -- (void)viewDidAppear { - [super viewDidAppear]; - [self.view.window makeFirstResponder:self]; -} - -- (void)dealloc { - [NSEvent removeMonitor:self.keyUpEventMonitor]; - [NSEvent removeMonitor:self.keyMaskFlagsChangedEventMonitor]; -} - -#pragma mark - - -- (BOOL)gestureRecognizer:(NSGestureRecognizer *)gestureRecognizer shouldAttemptToRecognizeWithEvent:(NSEvent *)event { -#ifdef DEBUG - if (gestureRecognizer == self.clickRecognizer) { - if (event.locationInWindow.y <= 25) { - // 点击底部的 debug 按钮 - return NO; - } - } -#endif - if (gestureRecognizer == self.panRecognizer) { - if (self.previewView.dimension != LookinPreviewDimension3D && !self.isKeyingDownSpace) { - // 没有 isKeyingDownSpace 说明此时手势目的是旋转,而此时视图又不是 3D,因此直接拒绝该手势 - return NO; - } - } - return YES; -} - -#pragma mark - - -- (void)previewStageView:(LKPreviewStageView *)view mouseMoved:(NSEvent *)event { - if (self.isKeyingDownSpace) { - if (self.dataSource.hoveredItem) { - self.dataSource.hoveredItem = nil; - } - return; - } - NSPoint rawPoint = [event locationInWindow]; - rawPoint.y = self.view.window.frame.size.height - rawPoint.y; - NSView *targetView = [self.view.window.contentView hitTest:rawPoint]; - if (targetView != self.previewView) { - return; - } - - NSPoint point = [self.previewView convertPoint:rawPoint fromView:self.view.window.contentView]; - LookinDisplayItem *item = [self.previewView displayItemAtPoint:point]; - if (self.dataSource.hoveredItem != item) { - self.dataSource.hoveredItem = item; - } -} - -#pragma mark - Event - -- (void)_handleShowHiddenItemsChange:(LookinMsgActionParams *)param { - self.previewView.showHiddenItems = param.boolValue; - [self.previewView updateZPosition]; -} - -- (void)_handleManagerFreeRotationDidChange:(LookinMsgActionParams *)params { - BOOL canFreeRotate = params.boolValue; - if (canFreeRotate) { - if (self.previewView.dimension == LookinPreviewDimension3D) { - CGPoint rotation = self.previewView.rotation; - rotation.y = -0.05; - [self.previewView setRotation:rotation animated:YES]; - } - - } else { - CGPoint rotation = self.previewView.rotation; - if (rotation.y == 0) { - return; - } - rotation.y = 0; - [self.previewView setRotation:rotation animated:YES]; - } - -} - -- (void)_handleManagerPreviewScaleDidChange:(LookinMsgActionParams *)params { - double scale = params.doubleValue; - self.previewView.scale = scale; -} - -- (void)_handleManagerPreviewDimensionDidChange:(LookinMsgActionParams *)params { - LookinPreviewDimension newDimension = params.integerValue; - - if (self.previewView.dimension == newDimension) { - return; - } - - if (newDimension == LookinPreviewDimension2D) { - [self lookin_bindPoint:self.previewView.rotation forKey:@"prevRotation"]; - [self.previewView setDimension:newDimension animated:YES]; - } else { - CGPoint rotation = [self lookin_getBindPointForKey:@"prevRotation"]; - [self.previewView setDimension:newDimension animated:YES]; - - // 避免之前的 rotation 太小而感知不到 2D / 3D 的切换 - if (rotation.x >= 0) { - rotation.x = MAX(0.18, rotation.x); - } else if (rotation.x < 0) { - rotation.x = MIN(-0.18, rotation.x); - } - - if (!self.dataSource.preferenceManager.freeRotation.currentBOOLValue) { - rotation.y = 0; - } - - [self.previewView setRotation:rotation animated:YES]; - } -} - -- (void)_handleManagerZInterspaceDidChange:(LookinMsgActionParams *)params { - double doubleValue = params.doubleValue; - self.previewView.zInterspace = doubleValue; -} - -- (void)_handlePanGesture:(LKPreviewPanGestureRecognizer *)recognizer { - if (recognizer.state == NSGestureRecognizerStateBegan) { - [[LKUserActionManager sharedInstance] sendAction:LKUserActionType_PreviewOperation]; - - // 停止可能正在编辑的 card 输入框 - [self.view.window makeFirstResponder:self]; - - if (self.isKeyingDownSpace) { - recognizer.purpose = PreviewPanGesturePurposeTranslate; - } else { - recognizer.purpose = PreviewPanGesturePurposeRotate; - } - recognizer.initialRotation = self.previewView.rotation; - recognizer.initialTranslation = self.previewView.translation; - - } else if (recognizer.state == NSGestureRecognizerStateChanged) { - NSPoint translation = [recognizer translationInView:self.view]; - if (recognizer.purpose == PreviewPanGesturePurposeRotate) { - if (self.previewView.dimension != LookinPreviewDimension3D) { - return; - } - CGFloat newRotationX = recognizer.initialRotation.x + translation.x * 0.01; - CGFloat newRotationY; - if (self.dataSource.preferenceManager.freeRotation.currentBOOLValue) { - newRotationY = recognizer.initialRotation.y + translation.y * 0.004; - } else { - newRotationY = 0; - } - [self.previewView setRotation:CGPointMake(newRotationX, newRotationY) animated:NO]; - - } else if (recognizer.purpose == PreviewPanGesturePurposeTranslate) { - NSPoint initialTranslation = recognizer.initialTranslation; - // 我们希望随着 scale 的变大,translation 的比率逐渐变小,避免图像被放大到很大的时候轻轻一滑就滑走了 - // currentScale 范围是 0 ~ 1,factor 被相应映射为 1 ~ 0.1 - CGFloat currentScale = self.previewView.scale; - CGFloat factor = (1 - currentScale) * 0.92 + 0.08; - NSPoint newTranslation = NSMakePoint(initialTranslation.x + translation.x * 0.01 * factor, initialTranslation.y - translation.y * 0.01 * factor); - self.previewView.translation = newTranslation; - } - } -} - -- (void)_handleClickGesture:(NSClickGestureRecognizer *)recognizer { - if (self.dataSource.shouldAvoidChangingPreviewSelectionDueToDashboardSearch) { - return; - } - - [[LKUserActionManager sharedInstance] sendAction:LKUserActionType_PreviewOperation]; - - // 停止可能正在编辑的 card 输入框 - [self.view.window makeFirstResponder:self]; - - NSPoint point = [recognizer locationInView:self.previewView]; - LookinDisplayItem *item = [self.previewView displayItemAtPoint:point]; - - if (self.dataSource.selectedItem == item) { - return; - } - // 注意这里要先 expand 然后再 select 以使得可以滚动到目标位置 - if (!item.displayingInHierarchy) { - [self.dataSource expandToShowItem:item]; - } - - self.dataSource.selectedItem = item; -} - -- (void)_handleRightClick:(NSClickGestureRecognizer *)recognizer { - [[LKUserActionManager sharedInstance] sendAction:LKUserActionType_PreviewOperation]; - // 停止可能正在编辑的 card 输入框 - [self.view.window makeFirstResponder:self]; - - NSPoint point = [recognizer locationInView:self.previewView]; - LookinDisplayItem *item = [self.previewView displayItemAtPoint:point]; - - if (!item.displayingInHierarchy) { - return; - } - - if (self.dataSource.selectedItem != item) { - self.dataSource.selectedItem = item; - } - - self.rightClickingDisplayItem = item; - [self.rightClickMenu popUpMenuPositioningItem:nil atLocation:[NSEvent mouseLocation] inView:nil]; -} - -- (void)_handleDoubleClick:(NSClickGestureRecognizer *)recognizer { - [[LKUserActionManager sharedInstance] sendAction:LKUserActionType_PreviewOperation]; - // 停止可能正在编辑的 card 输入框 - [self.view.window makeFirstResponder:self]; - - NSPoint point = [recognizer locationInView:self.previewView]; - LookinDisplayItem *item = [self.previewView displayItemAtPoint:point]; - if (!item.displayingInHierarchy) { - return; - } - - LookinDoubleClickBehavior behavior = [[LKPreferenceManager mainManager] doubleClickBehavior]; - if (behavior == LookinDoubleClickBehaviorCollapse) { - if (item.isExpandable) { - if (item.isExpanded) { - [self.dataSource collapseItem:item]; - } else { - [self.dataSource expandItem:item]; - } - } - - } else if (behavior == LookinDoubleClickBehaviorFocus) { - [self.dataSource focusDisplayItem:item]; - - } else { - NSAssert(NO, @""); - } -} - -- (void)magnifyWithEvent:(NSEvent *)event { - [super magnifyWithEvent:event]; - if (event.phase == NSEventPhaseChanged) { - LKPreferenceManager *manager = self.dataSource.preferenceManager; - double targetScale = manager.previewScale.currentDoubleValue + event.magnification * 0.3; - targetScale = MAX(MIN(targetScale, 1), 0); - [manager.previewScale setDoubleValue:targetScale ignoreSubscriber:nil]; - } -} - -- (void)scrollWheel:(NSEvent *)event { - [super scrollWheel:event]; - if (self.isKeyingDownCommand) { - LKPreferenceManager *manager = self.dataSource.preferenceManager; - CGFloat currentScale = manager.previewScale.currentDoubleValue; - CGFloat targetScale = currentScale - event.deltaY * .005; - targetScale = MAX(MIN(targetScale, LookinPreviewMaxScale), LookinPreviewMinScale); - [manager.previewScale setDoubleValue:targetScale ignoreSubscriber:nil]; - - } else { - // 我们希望随着 scale 的变大,translation 的比率逐渐变小,避免图像被放大到很大的时候轻轻一滑就滑走了 - // currentScale 范围是 0 ~ 1,factor 被相应映射为 1 ~ 0.3 - CGFloat currentScale = self.previewView.scale; - CGFloat factor = (1 - currentScale) * 0.7 + 0.3; - - CGPoint translation = self.previewView.translation; - translation.x += event.deltaX * 0.04 * factor; - translation.y -= event.deltaY * 0.04 * factor; - self.previewView.translation = translation; - } -} - -- (BOOL)acceptsFirstResponder { - return YES; -} - -- (void)keyDown:(NSEvent *)event { - if ([event.charactersIgnoringModifiers isEqualToString:@" "]) { - self.isKeyingDownSpace = YES; - return; - } - - if ([self.dataSource keyDown:event]) { - return; - } - - [super keyDown:event]; -} - -- (void)keyUp:(NSEvent *)event { - if ([event.charactersIgnoringModifiers isEqualToString:@" "]) { - self.isKeyingDownSpace = NO; - return; - } - [super keyUp:event]; -} - -- (void)setIsKeyingDownCommand:(BOOL)isKeyingDownCommand { - _isKeyingDownCommand = isKeyingDownCommand; - - if (isKeyingDownCommand && self.view.window.isKeyWindow) { - [self.dataSource.preferenceManager.isQuickSelecting setBOOLValue:YES ignoreSubscriber:nil]; - } else { - [self.dataSource.preferenceManager.isQuickSelecting setBOOLValue:NO ignoreSubscriber:nil]; - } -} - -- (void)setIsKeyingDownSpace:(BOOL)isKeyingDownSpace { - _isKeyingDownSpace = isKeyingDownSpace; - - [self.view.window invalidateCursorRectsForView:self.view]; - if (self.isKeyingDownSpace) { - self.doubleClickRecognizer.enabled = NO; - self.rightClickRecognizer.enabled = NO; - } else { - self.doubleClickRecognizer.enabled = YES; - self.rightClickRecognizer.enabled = YES; - } -} - -- (void)setIsKeyingDownOption:(BOOL)isKeyingDownOption { - if (_isKeyingDownOption == isKeyingDownOption) { - return; - } - _isKeyingDownOption = isKeyingDownOption; - - if (self.dataSource.preferenceManager.measureState.currentIntegerValue == LookinMeasureState_locked) { - return; - } - - LookinMeasureState state; - if (isKeyingDownOption && self.dataSource.selectedItem && [self.view.window isKeyWindow]) { - state = LookinMeasureState_unlocked; - } else { - state = LookinMeasureState_no; - } - [self.dataSource.preferenceManager.measureState setIntegerValue:state ignoreSubscriber:nil]; -} - -- (void)didResetCursorRectsInPreviewStageView:(LKPreviewStageView *)view { - if (self.isKeyingDownSpace) { - [view addCursorRect:self.view.bounds cursor:[NSCursor openHandCursor]]; - } -} - -- (void)_hierarchyInfoDidChange { - LookinHierarchyInfo *currentInfo = self.dataSource.rawHierarchyInfo; - if (!currentInfo) { - NSAssert(NO, @""); - return; - } - - self.previewView.appScreenSize = CGSizeMake(currentInfo.appInfo.screenWidth, currentInfo.appInfo.screenHeight); - - LookinHierarchyInfo *prevInfo = [self lookin_getBindObjectForKey:@"prevRawHierarchyInfo"]; - [self lookin_bindObject:currentInfo forKey:@"prevRawHierarchyInfo"]; - if (!prevInfo) { - return; - } - CGFloat prevWidth = prevInfo.appInfo.screenWidth; - CGFloat prevHeight = prevInfo.appInfo.screenHeight; - CGFloat currentWidth = currentInfo.appInfo.screenWidth; - CGFloat currentHeight = currentInfo.appInfo.screenHeight; - - if (prevWidth == currentWidth && prevHeight == currentHeight) { - // 内容尺寸没有变化 - return; - } - /// iOS App 尺寸变化,重置 Scale - [self.dataSource.preferenceManager.previewScale setDoubleValue:LKInitialPreviewScale ignoreSubscriber:nil]; -} - -#pragma mark - - -- (NSMenu *)_makeRightClickMenu { - NSMenu *menu = [NSMenu new]; - menu.autoenablesItems = NO; - menu.delegate = self; - return menu; -} - -- (void)menuNeedsUpdate:(NSMenu *)menu { - [menu removeAllItems]; - - LookinDisplayItem *displayItem = self.rightClickingDisplayItem; - if (!displayItem) { - return; - } - - if (!displayItem.isUserCustom) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleFocusCurrentItem:); - item.title = NSLocalizedString(@"Focus", nil); - item; - })]; - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handlePrintItem:); - item.title = NSLocalizedString(@"Print", nil); - item; - })]; - [menu addItem:[NSMenuItem separatorItem]]; - - if (!self.dataSource.isReadOnly) { - BOOL isUpdating = self.asyncUpdateManager.isUpdating; - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.title = NSLocalizedString(@"Reload layer", nil); - if (!isUpdating) { - item.target = self; - item.action = @selector(_handleReloadSelfItem:); - } - item; - })]; - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.title = NSLocalizedString(@"Reload layer and its children", nil); - if (!isUpdating) { - item.target = self; - item.action = @selector(_handleReloadSelfAndChildrenItem:); - } - item; - })]; - [menu addItem:[NSMenuItem separatorItem]]; - } - } - - if (displayItem.isExpandable) { - if (displayItem.isExpanded) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleCollapseChildren:); - item.title = NSLocalizedString(@"Collapse children", nil); - item; - })]; - } else { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleExpandRecursively:); - item.title = NSLocalizedString(@"Expand recursively", nil); - item; - })]; - } - [menu addItem:[NSMenuItem separatorItem]]; - } - - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.enabled = YES; - item.target = self; - item.action = @selector(_handleCancelPreview:); - item.title = NSLocalizedString(@"Hide screenshot this time", nil); - item; - })]; - - if (!displayItem.isUserCustom && displayItem.groupScreenshot) { - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.target = self; - item.action = @selector(_handleHideScreenshotForever); - item.title = NSLocalizedString(@"Hide screenshot forever…", nil); - item; - })]; - [menu addItem:[NSMenuItem separatorItem]]; - - [menu addItem:({ - NSMenuItem *item = [NSMenuItem new]; - item.enabled = YES; - item.target = self; - item.action = @selector(_handleExportScreenshot:); - item.title = NSLocalizedString(@"Export screenshot…", nil); - item; - })]; - } -} - -- (void)menuDidClose:(NSMenu *)menu { - // 按住 cmd 键激活右键菜单,然后松开 cmd,然后关闭菜单,这时 eventMonitor 不会捕捉到 cmd 被松开,所以要在这里弥补一下 - if ([NSEvent modifierFlags] & NSEventModifierFlagCommand) { - self.isKeyingDownCommand = YES; - } else { - self.isKeyingDownCommand = NO; - } -} - -- (void)_handlePrintItem:(NSMenuItem *)menuItem { - LookinDisplayItem *item = self.rightClickingDisplayItem; - [[NSNotificationCenter defaultCenter] postNotificationName:LKAppShowConsoleNotificationName object:item]; -} - -- (void)_handleReloadSelfItem:(NSMenuItem *)menuItem { - LookinDisplayItem *item = self.rightClickingDisplayItem; - if (!item) { - return; - } - [self.asyncUpdateManager reloadSingleDisplayItem:item]; -} - -- (void)_handleReloadSelfAndChildrenItem:(NSMenuItem *)menuItem { - LookinDisplayItem *item = self.rightClickingDisplayItem; - if (!item) { - return; - } - [self.asyncUpdateManager reloadDisplayItemAndChildren:item]; -} - -- (void)_handleFocusCurrentItem:(NSMenuItem *)menuItem { - LookinDisplayItem *item = self.rightClickingDisplayItem; - [self.dataSource focusDisplayItem:item]; -} - -- (void)_handleExpandRecursively:(NSMenuItem *)menuItem { - LookinDisplayItem *item = self.rightClickingDisplayItem; - NSAssert(item, @""); - [self.dataSource expandItemsRootedByItem:item]; -} - -- (void)_handleCollapseChildren:(NSMenuItem *)menuItem { - LookinDisplayItem *item = self.rightClickingDisplayItem; - NSAssert(item, @""); - [self.dataSource collapseAllChildrenOfItem:item]; -} - -- (void)_handleCancelPreview:(NSMenuItem *)menuItem { - LookinDisplayItem *item = self.rightClickingDisplayItem; - item.noPreview = YES; - [self.dataSource.itemDidChangeNoPreview sendNext:nil]; -} - -- (void)_handleExportScreenshot:(NSMenuItem *)menuItem { - LookinDisplayItem *item = self.rightClickingDisplayItem; - [LKExportManager exportScreenshotWithDisplayItem:item]; -} - -- (void)_handleHideScreenshotForever { - [LKHelper openCustomConfigWebsite]; -} - -@end diff --git a/LookInside/Static/Preview/LKPreviewController.swift b/LookInside/Static/Preview/LKPreviewController.swift new file mode 100644 index 00000000..9112b78a --- /dev/null +++ b/LookInside/Static/Preview/LKPreviewController.swift @@ -0,0 +1,642 @@ +// +// LKPreviewController.swift +// LookInside +// +// Created by Li Kai on 2018/8/6. +// https://lookin.work +// + +import AppKit + +/// Hosts the 3D preview and turns mouse, trackpad and keyboard input into +/// rotation, translation, zoom, selection and the context menu. +@objc(LKPreviewController) +final class LKPreviewController: LKBaseViewController, NSGestureRecognizerDelegate, NSMenuDelegate, LKPreviewStageViewDelegate { + @objc weak var staticViewController: LKStaticViewController? + + /// Set by the owner in a live window; nil when reading a `.lookin` file. + @objc weak var asyncUpdateManager: LKStaticAsyncUpdateManager? + + private let dataSource: LKHierarchyDataSource? + private let previewView: LKPreviewView + /// Made by makeContainerView() during the superclass initializer. + private var stageView: LKPreviewStageView! + + private var panRecognizer: LKPreviewPanGestureRecognizer! + private var clickRecognizer: NSClickGestureRecognizer! + private var doubleClickRecognizer: NSClickGestureRecognizer! + private var rightClickRecognizer: NSClickGestureRecognizer! + + private let rightClickMenu = NSMenu() + private var rightClickingDisplayItem: LookinDisplayItem? + + private var eventMonitors: [Any] = [] + private var notificationObservers: [NSObjectProtocol] = [] + private var keyValueObservations: [NSKeyValueObservation] = [] + private let dataSourceSignals = LKSyncSubscriptionBag() + + private var hasLaidOut = false + /// The rotation to go back to when switching from 2D to 3D. + private var rotationBeforeFlattening: CGPoint = .zero + /// The hierarchy shown before the current one, to tell whether the + /// app's screen size changed. + private var previousHierarchyInfo: LookinHierarchyInfo? + + /// While space is held, panning moves the preview. + private var isKeyingDownSpace = false { + didSet { + view.window?.invalidateCursorRects(for: view) + doubleClickRecognizer.isEnabled = !isKeyingDownSpace + rightClickRecognizer.isEnabled = !isKeyingDownSpace + } + } + + /// While command is held, clicks select through, and scrolling zooms. + private var isKeyingDownCommand = false { + didSet { + let isQuickSelecting = isKeyingDownCommand && view.window?.isKeyWindow == true + dataSource?.preferenceManager().isQuickSelecting.setBOOLValue(isQuickSelecting, ignoreSubscriber: nil) + } + } + + /// While option is held, the preview measures. + private var isKeyingDownOption = false { + didSet { + guard isKeyingDownOption != oldValue, let preferenceManager = dataSource?.preferenceManager() else { return } + if preferenceManager.measureState.currentIntegerValue == LookinMeasureState.locked.rawValue { + return + } + let measures = isKeyingDownOption && dataSource?.selectedItem != nil && view.window?.isKeyWindow == true + let state: LookinMeasureState = measures ? .unlocked : .no + preferenceManager.measureState.setIntegerValue(state.rawValue, ignoreSubscriber: nil) + } + } + + @objc(initWithDataSource:) + init(dataSource: LKHierarchyDataSource?) { + self.dataSource = dataSource + previewView = LKPreviewView(dataSource: dataSource) + super.init(containerView: nil) + setUp() + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + deinit { + for monitor in eventMonitors { + NSEvent.removeMonitor(monitor) + } + for observer in notificationObservers { + NotificationCenter.default.removeObserver(observer) + } + } + + override func makeContainerView() -> NSView { + let stageView = LKPreviewStageView() + stageView.didChangeAppearanceBlock = { view, isDarkMode in + view?.backgroundColor = isDarkMode + ? NSColor(red: 0, green: 0, blue: 0, alpha: 1) + : NSColor(red: 1, green: 1, blue: 1, alpha: 1) + } + stageView.delegate = self + self.stageView = stageView + return stageView + } + + private func setUp() { + let appInfo = dataSource?.rawHierarchyInfo?.appInfo + let preferenceManager = dataSource?.preferenceManager() + + previewView.preferenceManager = preferenceManager + previewView.alphaValue = 0 + previewView.appScreenSize = CGSize(width: appInfo?.screenWidth ?? 0, height: appInfo?.screenHeight ?? 0) + previewView.showHiddenItems = preferenceManager?.showHiddenItems.currentBOOLValue ?? false + preferenceManager?.showHiddenItems.subscribe(self, action: #selector(handleShowHiddenItemsChange(_:)), relatedObject: nil) + stageView.didChangeAppearanceBlock = { [weak self] _, isDarkMode in + self?.previewView.isDarkMode = isDarkMode + } + view.addSubview(previewView) + + // Render once now, then on every reload and every preview toggle. + renderAllItems() + dataSourceSignals.observe(dataSource?.didReloadHierarchyInfo) { [weak self] in + self?.renderAllItems() + } + dataSourceSignals.observe(dataSource?.itemDidChangeNoPreview) { [weak self] in + self?.renderAllItems() + } + dataSourceSignals.observe(dataSource?.didReloadFlatItemsWithSearchOrFocus) { [weak self] in + guard let self else { return } + let validItems = (dataSource?.flatItems ?? []).filter { !$0.inNoPreviewHierarchy } + previewView.render(displayItems: validItems, discardCache: false) + } + + // The z positions follow the displayed items, hidden / alpha + // changes, and frame changes. + if let dataSource { + keyValueObservations.append(dataSource.observe(\.displayingFlatItems) { [weak self] _, _ in + self?.previewView.updateZPosition() + }) + // Resets translation and scale when the app is reloaded. + keyValueObservations.append(dataSource.observe(\.rawHierarchyInfo) { [weak self] _, _ in + self?.hierarchyInfoDidChange() + }) + keyValueObservations.append(dataSource.observe(\.selectedItem, options: [.initial]) { [weak self] dataSource, _ in + self?.previewView.didSelect(dataSource.selectedItem) + }) + } + if let staticDataSource = dataSource as? LKStaticHierarchyDataSource { + dataSourceSignals.observe(staticDataSource.itemDidChangeHiddenAlphaValue) { [weak self] in + self?.previewView.updateZPosition() + } + dataSourceSignals.observe(staticDataSource.itemsDidChangeFrame) { [weak self] in + self?.previewView.updateZPosition() + } + } + + panRecognizer = LKPreviewPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + panRecognizer.delegate = self + previewView.addGestureRecognizer(panRecognizer) + + clickRecognizer = NSClickGestureRecognizer(target: self, action: #selector(handleClickGesture(_:))) + clickRecognizer.numberOfClicksRequired = 1 + clickRecognizer.delegate = self + previewView.addGestureRecognizer(clickRecognizer) + + doubleClickRecognizer = NSClickGestureRecognizer(target: self, action: #selector(handleDoubleClick(_:))) + doubleClickRecognizer.numberOfClicksRequired = 2 + previewView.addGestureRecognizer(doubleClickRecognizer) + + rightClickRecognizer = NSClickGestureRecognizer(target: self, action: #selector(handleRightClick(_:))) + rightClickRecognizer.buttonMask = 0x2 + rightClickRecognizer.numberOfClicksRequired = 1 + previewView.addGestureRecognizer(rightClickRecognizer) + + rightClickMenu.autoenablesItems = false + rightClickMenu.delegate = self + + if let monitor = NSEvent.addLocalMonitorForEvents(matching: .keyUp, handler: { [weak self] event in + // On a MacBook, holding space, dragging on the trackpad and then + // releasing space never calls keyUp(with:); catch it here. + if let self, event.type == .keyUp, event.charactersIgnoringModifiers == " " { + isKeyingDownSpace = false + view.window?.invalidateCursorRects(for: view) + } + return event + }) { + eventMonitors.append(monitor) + } + if let monitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged, handler: { [weak self] event in + self?.isKeyingDownCommand = event.modifierFlags.contains(.command) + self?.isKeyingDownOption = event.modifierFlags.contains(.option) + return event + }) { + eventMonitors.append(monitor) + } + + preferenceManager?.previewScale.subscribe(self, action: #selector(handlePreviewScaleChange(_:)), relatedObject: nil, sendAtOnce: true) + preferenceManager?.previewDimension.subscribe(self, action: #selector(handlePreviewDimensionChange(_:)), relatedObject: nil, sendAtOnce: true) + preferenceManager?.freeRotation.subscribe(self, action: #selector(handleFreeRotationChange(_:)), relatedObject: nil, sendAtOnce: true) + preferenceManager?.zInterspace.subscribe(self, action: #selector(handleZInterspaceChange(_:)), relatedObject: nil, sendAtOnce: true) + + notificationObservers.append(NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: nil, + queue: .main + ) { [weak self] note in + MainActor.assumeIsolated { + guard let self, (note.object as? NSWindow) === self.view.window else { return } + self.isKeyingDownSpace = false + self.isKeyingDownOption = false + self.isKeyingDownCommand = false + } + }) + } + + private func renderAllItems() { + let validItems = (dataSource?.flatItems ?? []).filter { item in + if item.inNoPreviewHierarchy { + return false + } + if let customInfo = item.customInfo { + return customInfo.hasValidFrame() + } + return true + } + previewView.render(displayItems: validItems, discardCache: true) + } + + override func viewDidLayout() { + super.viewDidLayout() + + // Reach a dashboard's width further left (20 less, since the preview + // usually turns right) and a title bar's height further down, so the + // preview looks centred. + let titleBarHeight = LKNavigationManager.shared.windowTitleBarHeight + let layout = LKStaticLayout(previewView) + layout.width(view.frame.width + DashboardViewWidth - DashboardHorInset - 20) + layout.right(0) + layout.height(view.frame.height + titleBarHeight) + layout.y(0) + + if !hasLaidOut { + hasLaidOut = true + // The opening animation. + previewView.setRotation(CGPoint(x: 0.8, y: 0), animated: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + NSAnimationContext.runAnimationGroup { context in + context.duration = 1 + self.previewView.animator().alphaValue = 1 + } + self.previewView.setRotation( + CGPoint(x: 0.6, y: 0), + animated: true, + timingFunction: CAMediaTimingFunction(controlPoints: 0.3, 0.93, 0.26, 0.88), + duration: 1.5 + ) + } + } + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.makeFirstResponder(self) + } + + // MARK: - NSGestureRecognizerDelegate + + func gestureRecognizer(_ gestureRecognizer: NSGestureRecognizer, shouldAttemptToRecognizeWith event: NSEvent) -> Bool { + #if DEBUG + if gestureRecognizer === clickRecognizer, event.locationInWindow.y <= 25 { + // A click on the debug buttons at the bottom. + return false + } + #endif + if gestureRecognizer === panRecognizer, previewView.dimension != .dimension3D, !isKeyingDownSpace { + // Without space the pan rotates, and a flat preview does not. + return false + } + return true + } + + // MARK: - LKPreviewStageViewDelegate + + func previewStageView(_: LKPreviewStageView, mouseMoved event: NSEvent) { + guard let dataSource else { return } + if isKeyingDownSpace { + if dataSource.hoveredItem != nil { + dataSource.hoveredItem = nil + } + return + } + guard let window = view.window else { return } + var rawPoint = event.locationInWindow + rawPoint.y = window.frame.size.height - rawPoint.y + let targetView = window.contentView?.hitTest(rawPoint) + guard targetView === previewView else { return } + + let point = previewView.convert(rawPoint, from: window.contentView) + let item = previewView.displayItem(at: point) + if dataSource.hoveredItem !== item { + dataSource.hoveredItem = item + } + } + + func didResetCursorRects(in view: LKPreviewStageView) { + if isKeyingDownSpace { + view.addCursorRect(self.view.bounds, cursor: .openHand) + } + } + + // MARK: - Preferences + + @objc private func handleShowHiddenItemsChange(_ param: LookinMsgActionParams) { + previewView.showHiddenItems = param.boolValue + previewView.updateZPosition() + } + + @objc private func handleFreeRotationChange(_ param: LookinMsgActionParams) { + var rotation = previewView.rotation + if param.boolValue { + if previewView.dimension == .dimension3D { + rotation.y = -0.05 + previewView.setRotation(rotation, animated: true) + } + } else { + guard rotation.y != 0 else { return } + rotation.y = 0 + previewView.setRotation(rotation, animated: true) + } + } + + @objc private func handlePreviewScaleChange(_ param: LookinMsgActionParams) { + previewView.scale = param.doubleValue + } + + @objc private func handlePreviewDimensionChange(_ param: LookinMsgActionParams) { + guard let newDimension = LookinPreviewDimension(rawValue: UInt(bitPattern: param.integerValue)), + previewView.dimension != newDimension + else { + return + } + if newDimension == .dimension2D { + rotationBeforeFlattening = previewView.rotation + previewView.setDimension(newDimension, animated: true) + } else { + var rotation = rotationBeforeFlattening + previewView.setDimension(newDimension, animated: true) + + // Too small a rotation would hide the switch between 2D and 3D. + if rotation.x >= 0 { + rotation.x = max(0.18, rotation.x) + } else { + rotation.x = min(-0.18, rotation.x) + } + if dataSource?.preferenceManager().freeRotation.currentBOOLValue != true { + rotation.y = 0 + } + previewView.setRotation(rotation, animated: true) + } + } + + @objc private func handleZInterspaceChange(_ param: LookinMsgActionParams) { + previewView.zInterspace = param.doubleValue + } + + private func hierarchyInfoDidChange() { + guard let currentInfo = dataSource?.rawHierarchyInfo else { + assertionFailure() + return + } + let currentWidth = currentInfo.appInfo?.screenWidth ?? 0 + let currentHeight = currentInfo.appInfo?.screenHeight ?? 0 + previewView.appScreenSize = CGSize(width: currentWidth, height: currentHeight) + + let previousInfo = previousHierarchyInfo + previousHierarchyInfo = currentInfo + guard let previousInfo else { return } + if previousInfo.appInfo?.screenWidth == currentWidth, previousInfo.appInfo?.screenHeight == currentHeight { + return + } + // The app's screen size changed: reset the scale. + dataSource?.preferenceManager().previewScale.setDoubleValue(Double(LKInitialPreviewScale), ignoreSubscriber: nil) + } + + // MARK: - Gestures + + @objc private func handlePanGesture(_ recognizer: LKPreviewPanGestureRecognizer) { + switch recognizer.state { + case .began: + LKUserActionManager.sharedInstance().send(.previewOperation) + // End any editing in a dashboard card. + view.window?.makeFirstResponder(self) + recognizer.purpose = isKeyingDownSpace ? .translate : .rotate + recognizer.initialRotation = previewView.rotation + recognizer.initialTranslation = previewView.translation + + case .changed: + let translation = recognizer.translation(in: view) + switch recognizer.purpose { + case .rotate: + guard previewView.dimension == .dimension3D else { return } + let rotationX = recognizer.initialRotation.x + translation.x * 0.01 + let rotationY: CGFloat + if dataSource?.preferenceManager().freeRotation.currentBOOLValue == true { + rotationY = recognizer.initialRotation.y + translation.y * 0.004 + } else { + rotationY = 0 + } + previewView.setRotation(CGPoint(x: rotationX, y: rotationY), animated: false) + case .translate: + // The bigger the preview, the slower it moves, so a zoomed-in + // image does not fly away at a light touch. Scale 0...1 maps + // to a factor of 1...0.08. + let factor = (1 - previewView.scale) * 0.92 + 0.08 + let initial = recognizer.initialTranslation + previewView.translation = NSPoint( + x: initial.x + translation.x * 0.01 * factor, + y: initial.y - translation.y * 0.01 * factor + ) + } + + default: + break + } + } + + @objc private func handleClickGesture(_ recognizer: NSClickGestureRecognizer) { + guard let dataSource, !dataSource.shouldAvoidChangingPreviewSelectionDueToDashboardSearch else { return } + LKUserActionManager.sharedInstance().send(.previewOperation) + // End any editing in a dashboard card. + view.window?.makeFirstResponder(self) + + let item = previewView.displayItem(at: recognizer.location(in: previewView)) + if dataSource.selectedItem === item { + return + } + // Expand before selecting, so the tree can scroll to the item. + if let item, !item.displayingInHierarchy { + dataSource.expand(toShow: item) + } + dataSource.selectedItem = item + } + + @objc private func handleRightClick(_ recognizer: NSClickGestureRecognizer) { + LKUserActionManager.sharedInstance().send(.previewOperation) + // End any editing in a dashboard card. + view.window?.makeFirstResponder(self) + + guard let item = previewView.displayItem(at: recognizer.location(in: previewView)), + item.displayingInHierarchy + else { + return + } + if dataSource?.selectedItem !== item { + dataSource?.selectedItem = item + } + rightClickingDisplayItem = item + rightClickMenu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil) + } + + @objc private func handleDoubleClick(_ recognizer: NSClickGestureRecognizer) { + LKUserActionManager.sharedInstance().send(.previewOperation) + // End any editing in a dashboard card. + view.window?.makeFirstResponder(self) + + guard let item = previewView.displayItem(at: recognizer.location(in: previewView)), + item.displayingInHierarchy + else { + return + } + switch LKPreferenceManager.shared.doubleClickBehavior { + case .collapse: + if item.isExpandable { + if item.isExpanded { + dataSource?.collapse(item) + } else { + dataSource?.expand(item) + } + } + case .focus: + dataSource?.focus(item) + default: + assertionFailure() + } + } + + // MARK: - Events + + override func magnify(with event: NSEvent) { + super.magnify(with: event) + guard event.phase == .changed, let manager = dataSource?.preferenceManager() else { return } + let targetScale = min(max(manager.previewScale.currentDoubleValue + event.magnification * 0.3, 0), 1) + manager.previewScale.setDoubleValue(targetScale, ignoreSubscriber: nil) + } + + override func scrollWheel(with event: NSEvent) { + super.scrollWheel(with: event) + if isKeyingDownCommand { + guard let manager = dataSource?.preferenceManager() else { return } + let targetScale = manager.previewScale.currentDoubleValue - event.deltaY * 0.005 + manager.previewScale.setDoubleValue( + min(max(targetScale, LookinPreviewMinScale), LookinPreviewMaxScale), + ignoreSubscriber: nil + ) + } else { + // The bigger the preview, the slower it moves. Scale 0...1 maps + // to a factor of 1...0.3. + let factor = (1 - previewView.scale) * 0.7 + 0.3 + var translation = previewView.translation + translation.x += event.deltaX * 0.04 * factor + translation.y -= event.deltaY * 0.04 * factor + previewView.translation = translation + } + } + + override var acceptsFirstResponder: Bool { + true + } + + override func keyDown(with event: NSEvent) { + if event.charactersIgnoringModifiers == " " { + isKeyingDownSpace = true + return + } + if dataSource?.keyDown(event) == true { + return + } + super.keyDown(with: event) + } + + override func keyUp(with event: NSEvent) { + if event.charactersIgnoringModifiers == " " { + isKeyingDownSpace = false + return + } + super.keyUp(with: event) + } + + // MARK: - NSMenuDelegate + + func menuNeedsUpdate(_ menu: NSMenu) { + menu.removeAllItems() + guard let displayItem = rightClickingDisplayItem else { return } + + func addItem(_ title: String, _ action: Selector?) { + let item = NSMenuItem() + item.title = title + if let action { + item.target = self + item.action = action + } + menu.addItem(item) + } + + if !displayItem.isUserCustom() { + addItem(NSLocalizedString("Focus", comment: ""), #selector(handleFocusCurrentItem(_:))) + addItem(NSLocalizedString("Print", comment: ""), #selector(handlePrintItem(_:))) + menu.addItem(.separator()) + + if dataSource?.isReadOnly() == false { + let isUpdating = asyncUpdateManager?.isUpdating() ?? false + addItem(NSLocalizedString("Reload layer", comment: ""), isUpdating ? nil : #selector(handleReloadSelfItem(_:))) + addItem( + NSLocalizedString("Reload layer and its children", comment: ""), + isUpdating ? nil : #selector(handleReloadSelfAndChildrenItem(_:)) + ) + menu.addItem(.separator()) + } + } + + if displayItem.isExpandable { + if displayItem.isExpanded { + addItem(NSLocalizedString("Collapse children", comment: ""), #selector(handleCollapseChildren(_:))) + } else { + addItem(NSLocalizedString("Expand recursively", comment: ""), #selector(handleExpandRecursively(_:))) + } + menu.addItem(.separator()) + } + + addItem(NSLocalizedString("Hide screenshot this time", comment: ""), #selector(handleCancelPreview(_:))) + + if !displayItem.isUserCustom(), displayItem.groupScreenshot != nil { + addItem(NSLocalizedString("Hide screenshot forever…", comment: ""), #selector(handleHideScreenshotForever)) + menu.addItem(.separator()) + addItem(NSLocalizedString("Export screenshot…", comment: ""), #selector(handleExportScreenshot(_:))) + } + } + + func menuDidClose(_: NSMenu) { + // Holding command to open the menu, releasing it and then closing + // the menu escapes the flags monitor; catch up here. + isKeyingDownCommand = NSEvent.modifierFlags.contains(.command) + } + + @objc private func handlePrintItem(_: NSMenuItem) { + NotificationCenter.default.post( + name: NSNotification.Name(LKAppShowConsoleNotificationName), + object: rightClickingDisplayItem + ) + } + + @objc private func handleReloadSelfItem(_: NSMenuItem) { + guard let item = rightClickingDisplayItem else { return } + asyncUpdateManager?.reloadSingleDisplayItem(item) + } + + @objc private func handleReloadSelfAndChildrenItem(_: NSMenuItem) { + guard let item = rightClickingDisplayItem else { return } + asyncUpdateManager?.reloadDisplayItemAndChildren(item) + } + + @objc private func handleFocusCurrentItem(_: NSMenuItem) { + dataSource?.focus(rightClickingDisplayItem) + } + + @objc private func handleExpandRecursively(_: NSMenuItem) { + assert(rightClickingDisplayItem != nil) + dataSource?.expandItemsRooted(by: rightClickingDisplayItem) + } + + @objc private func handleCollapseChildren(_: NSMenuItem) { + assert(rightClickingDisplayItem != nil) + dataSource?.collapseAllChildren(of: rightClickingDisplayItem) + } + + @objc private func handleCancelPreview(_: NSMenuItem) { + rightClickingDisplayItem?.noPreview = true + dataSource?.itemDidChangeNoPreview.send() + } + + @objc private func handleExportScreenshot(_: NSMenuItem) { + guard let item = rightClickingDisplayItem else { return } + LKExportManager.exportScreenshot(with: item) + } + + @objc private func handleHideScreenshotForever() { + LKHelper.openCustomConfigWebsite() + } +} diff --git a/LookInside/Static/Preview/LKPreviewPanGestureRecognizer.h b/LookInside/Static/Preview/LKPreviewPanGestureRecognizer.h deleted file mode 100644 index 0c1d119a..00000000 --- a/LookInside/Static/Preview/LKPreviewPanGestureRecognizer.h +++ /dev/null @@ -1,26 +0,0 @@ -// -// LKPreviewPanGestureRecognizer.h -// Lookin -// -// Created by Li Kai on 2018/8/31. -// https://lookin.work -// - -#import - -typedef NS_ENUM(NSUInteger, PreviewPanGesturePurpose) { - PreviewPanGesturePurposeRotate, - PreviewPanGesturePurposeTranslate -}; - -@interface LKPreviewPanGestureRecognizer : NSPanGestureRecognizer - -/** - 默认为 PreviewPanGesturePurposeRotate - */ -@property(nonatomic, assign) PreviewPanGesturePurpose purpose; - -@property(nonatomic, assign) CGPoint initialRotation; -@property(nonatomic, assign) NSPoint initialTranslation; - -@end diff --git a/LookInside/Static/Preview/LKPreviewPanGestureRecognizer.m b/LookInside/Static/Preview/LKPreviewPanGestureRecognizer.m deleted file mode 100644 index f95b03f8..00000000 --- a/LookInside/Static/Preview/LKPreviewPanGestureRecognizer.m +++ /dev/null @@ -1,13 +0,0 @@ -// -// LKPreviewPanGestureRecognizer.m -// Lookin -// -// Created by Li Kai on 2018/8/31. -// https://lookin.work -// - -#import "LKPreviewPanGestureRecognizer.h" - -@implementation LKPreviewPanGestureRecognizer - -@end diff --git a/LookInside/Static/Preview/LKPreviewStageView.h b/LookInside/Static/Preview/LKPreviewStageView.h deleted file mode 100644 index bd0b7850..00000000 --- a/LookInside/Static/Preview/LKPreviewStageView.h +++ /dev/null @@ -1,24 +0,0 @@ -// -// LKPreviewStageView.h -// Lookin -// -// Created by Li Kai on 2018/8/6. -// https://lookin.work -// - -#import "LKBaseView.h" - -@class LKPreviewStageView; - -@protocol LKPreviewStageViewDelegate - -- (void)previewStageView:(LKPreviewStageView *)view mouseMoved:(NSEvent *)event; -- (void)didResetCursorRectsInPreviewStageView:(LKPreviewStageView *)view; - -@end - -@interface LKPreviewStageView : LKBaseView - -@property(nonatomic, weak) id delegate; - -@end diff --git a/LookInside/Static/Preview/LKPreviewStageView.m b/LookInside/Static/Preview/LKPreviewStageView.m deleted file mode 100644 index 92a836a6..00000000 --- a/LookInside/Static/Preview/LKPreviewStageView.m +++ /dev/null @@ -1,37 +0,0 @@ -// -// LKViewsPreviewStageView.m -// Lookin -// -// Created by Li Kai on 2018/8/6. -// https://lookin.work -// - -#import "LKPreviewStageView.h" - -@implementation LKPreviewStageView - -- (void)mouseMoved:(NSEvent *)event { - [super mouseMoved:event]; - if ([self.delegate respondsToSelector:@selector(previewStageView:mouseMoved:)]) { - [self.delegate previewStageView:self mouseMoved:event]; - } -} - -- (void)updateTrackingAreas { - [super updateTrackingAreas]; - [self.trackingAreas enumerateObjectsUsingBlock:^(NSTrackingArea * _Nonnull oldArea, NSUInteger idx, BOOL * _Nonnull stop) { - [self removeTrackingArea:oldArea]; - }]; - - NSTrackingArea *newArea = [[NSTrackingArea alloc] initWithRect:self.bounds options:NSTrackingMouseMoved|NSTrackingActiveInKeyWindow|NSTrackingInVisibleRect owner:self userInfo:nil]; - [self addTrackingArea:newArea]; -} - -- (void)resetCursorRects { - [super resetCursorRects]; - if ([self.delegate respondsToSelector:@selector(didResetCursorRectsInPreviewStageView:)]) { - [self.delegate didResetCursorRectsInPreviewStageView:self]; - } -} - -@end diff --git a/LookInside/Static/Preview/LKPreviewStageView.swift b/LookInside/Static/Preview/LKPreviewStageView.swift new file mode 100644 index 00000000..d021a115 --- /dev/null +++ b/LookInside/Static/Preview/LKPreviewStageView.swift @@ -0,0 +1,59 @@ +// +// LKPreviewStageView.swift +// LookInside +// +// Created by Li Kai on 2018/8/6. +// https://lookin.work +// + +import AppKit + +@MainActor +protocol LKPreviewStageViewDelegate: AnyObject { + func previewStageView(_ view: LKPreviewStageView, mouseMoved event: NSEvent) + func didResetCursorRects(in view: LKPreviewStageView) +} + +/// The preview controller's root view: reports mouse moves anywhere over it +/// and lets the delegate add cursor rects. +@objc(LKPreviewStageView) +final class LKPreviewStageView: LKBaseView { + weak var delegate: LKPreviewStageViewDelegate? + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + delegate?.previewStageView(self, mouseMoved: event) + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + for area in trackingAreas { + removeTrackingArea(area) + } + addTrackingArea(NSTrackingArea( + rect: bounds, + options: [.mouseMoved, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + override func resetCursorRects() { + super.resetCursorRects() + delegate?.didResetCursorRects(in: self) + } +} + +/// The preview's pan gesture: rotates the preview, or moves it while space +/// is held. +@objc(LKPreviewPanGestureRecognizer) +final class LKPreviewPanGestureRecognizer: NSPanGestureRecognizer { + enum Purpose { + case rotate + case translate + } + + var purpose: Purpose = .rotate + var initialRotation: CGPoint = .zero + var initialTranslation: NSPoint = .zero +} diff --git a/LookInside/Static/Preview/LKPreviewView.h b/LookInside/Static/Preview/LKPreviewView.h deleted file mode 100644 index 0285943f..00000000 --- a/LookInside/Static/Preview/LKPreviewView.h +++ /dev/null @@ -1,69 +0,0 @@ -// -// LKPreviewView.h -// Lookin -// -// Created by Li Kai on 2019/8/17. -// https://lookin.work -// - -#import - -extern const CGFloat LookinPreviewMinScale; -extern const CGFloat LookinPreviewMaxScale; - -extern const CGFloat LookinPreviewMaxZInterspace; -extern const CGFloat LookinPreviewMinZInterspace; - -typedef NS_ENUM (NSUInteger, LookinPreviewDimension) { - LookinPreviewDimension2D, - LookinPreviewDimension3D -}; - -@class LookinDisplayItem, LKPreferenceManager, LKHierarchyDataSource; - -@interface LKPreviewView : SCNView - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource; - -/// rotation.x 是左右旋转的角度,rotation.y 是上下旋转的角度 -@property(nonatomic, assign, readonly) CGPoint rotation; -- (void)setRotation:(CGPoint)rotation animated:(BOOL)animated; -/// duration 为 0 时表示使用系统默认时长(即 0.25s) -- (void)setRotation:(CGPoint)rotation animated:(BOOL)animated timingFunction:(CAMediaTimingFunction *)function duration:(CGFloat)duration; - -/// 上下移动的距离 -@property(nonatomic, assign) CGPoint translation; - -/// 图像大小,最小值是 LookinPreviewMinScale,最大值是 LookinPreviewMaxScale -@property(nonatomic, assign) CGFloat scale; - -/// 图层之间的纵向间距,最小值是 LookinPreviewMinZInterspace,最大值是 LookinPreviewMaxZInterspace -@property(nonatomic, assign) CGFloat zInterspace; - -/** - 设置 2D 和 3D 模式 - 置为 2D 会使得 rotation 变成 0,置为 3D 不会改变 rotation - */ -@property(nonatomic, assign, readonly) LookinPreviewDimension dimension; -- (void)setDimension:(LookinPreviewDimension)dimension animated:(BOOL)animated; - -/// iOS App 的屏幕大小 -@property(nonatomic, assign) CGSize appScreenSize; - -@property(nonatomic, strong) LKPreferenceManager *preferenceManager; - -/// 通过 items 来构建对应的 node,items 里不应该包含 noPreview 为 YES 的 item -/// 如果 discardCache 为 YES,则会丢弃本次渲染未用到的 node 对象。如果 discardCache 为 NO,则本次渲染未用到的 node 对象会被隐藏起来但不会被丢弃,从而供以后使用 -- (void)renderWithDisplayItems:(NSArray *)items discardCache:(BOOL)discardCache; - -- (void)updateZPosition; - -- (LookinDisplayItem *)displayItemAtPoint:(CGPoint)point; - -- (void)didSelectItem:(LookinDisplayItem *)item; - -@property(nonatomic, assign) BOOL isDarkMode; - -@property(nonatomic, assign) BOOL showHiddenItems; - -@end diff --git a/LookInside/Static/Preview/LKPreviewView.m b/LookInside/Static/Preview/LKPreviewView.m deleted file mode 100644 index 3b5ff9bd..00000000 --- a/LookInside/Static/Preview/LKPreviewView.m +++ /dev/null @@ -1,453 +0,0 @@ -// -// LKPreviewView.m -// Lookin -// -// Created by Li Kai on 2019/8/17. -// https://lookin.work -// -// 该类的代码实现,借鉴甚至直接复制了很多 https://github.com/TalkingData/YourView 项目的 SceneKit 相关代码 -// Lookin 项目鸣谢:https://qxh1ndiez2w.feishu.cn/docx/YIFjdE4gIolp3hxn1tGckiBxnWf - -#import "LKPreviewView.h" -#import "LKDisplayItemNode.h" -#import "LookinDisplayItem.h" -#import "LKHierarchyDataSource.h" - -const CGFloat LookinPreviewMinScale = 0; -const CGFloat LookinPreviewMaxScale = 1; - -const CGFloat LookinPreviewMinZInterspace = 0; -const CGFloat LookinPreviewMaxZInterspace = 1; - -@interface LKPreviewView () - -@property(nonatomic, strong) LKHierarchyDataSource *dataSource; - -@property(nonatomic, strong) SCNNode *stageNode; - -@property (nonatomic,strong) SCNNode *cameraNode; - -@property(nonatomic, strong) SCNNode *rightLightNode; -@property(nonatomic, strong) SCNNode *leftLightNode; - -@property(nonatomic, copy) NSArray *flatDisplayItems; -@property(nonatomic, strong) NSMutableArray *displayItemNodes; - -/// 3D 视图坐标原点对应的 frameToRoot 点,等于"面积最大的顶层 window item"的中心。 -/// 每次 render 时根据当前 flatDisplayItems 重新计算并分发到所有 LKDisplayItemNode。 -@property(nonatomic, assign) CGPoint referenceCenter; - -@end - -@implementation LKPreviewView - -- (instancetype)initWithDataSource:(LKHierarchyDataSource *)dataSource { - if (self = [super initWithFrame:CGRectZero options:nil]) { - self.dataSource = dataSource; - self.displayItemNodes = [NSMutableArray array]; - - self.allowsCameraControl = NO; - self.showsStatistics = NO; - - self.scene = [SCNScene new]; - - self.stageNode = [SCNNode node]; - self.stageNode.name = @"stage"; - [self.scene.rootNode addChildNode:self.stageNode]; - - self.cameraNode = [SCNNode node]; - self.cameraNode.name = @"camera"; - self.cameraNode.camera = [SCNCamera camera]; - self.cameraNode.camera.automaticallyAdjustsZRange = YES; - /// 这里的 position.z 决定了畸变的程度,即“近大远小”的程度 - self.cameraNode.position = SCNVector3Make(0, 0, 34); - [self.scene.rootNode addChildNode:self.cameraNode]; - - SCNLight *rightLight = [SCNLight light]; - rightLight.type = SCNLightTypeOmni; - rightLight.categoryBitMask = LookinPreviewBitMask_HasLight; - self.rightLightNode = [SCNNode node]; - self.rightLightNode.name = @"right light"; - self.rightLightNode.light = rightLight; - [self.scene.rootNode addChildNode:self.rightLightNode]; - - SCNLight *leftLight = [SCNLight light]; - leftLight.type = SCNLightTypeSpot; - leftLight.categoryBitMask = LookinPreviewBitMask_HasLight; - self.leftLightNode = [SCNNode node]; - self.leftLightNode.name = @"left light"; - self.leftLightNode.light = rightLight; - [self.scene.rootNode addChildNode:self.leftLightNode]; - } - return self; -} - -- (void)setAppScreenSize:(CGSize)appScreenSize { - if (CGSizeEqualToSize(_appScreenSize, appScreenSize)) { - return; - } - _appScreenSize = appScreenSize; - - SCNVector3 rightPos = self.rightLightNode.position; - rightPos.x = appScreenSize.width * 0.01 * 0.5 + 2; - rightPos.y = appScreenSize.height * 0.01 * 0.5 + 2; - self.rightLightNode.position = rightPos; - - SCNVector3 leftPos = self.leftLightNode.position; - leftPos.x = -appScreenSize.width * 0.01 * 0.5 - 2; - leftPos.y = -appScreenSize.height * 0.01 * 0.5 - 2; - self.leftLightNode.position = leftPos; - - // 兜底:若尚未通过 render 计算出 referenceCenter,先以屏幕中心作为缺省。 - // 真正的 referenceCenter 会在每次 renderWithDisplayItems: 中根据"面积最大的顶层窗口"重算。 - if (CGPointEqualToPoint(self.referenceCenter, CGPointZero)) { - self.referenceCenter = CGPointMake(appScreenSize.width / 2, appScreenSize.height / 2); - } -} - -- (void)setReferenceCenter:(CGPoint)referenceCenter { - if (CGPointEqualToPoint(_referenceCenter, referenceCenter)) { - return; - } - _referenceCenter = referenceCenter; - [self.displayItemNodes enumerateObjectsUsingBlock:^(LKDisplayItemNode * _Nonnull node, NSUInteger idx, BOOL * _Nonnull stop) { - node.referenceCenter = referenceCenter; - }]; -} - -/// 在所有顶层 displayItem 及其直接子 item(覆盖 macOS 直接窗口顶层结构和 iOS/Catalyst 的 sceneItem→windowItem 结构) -/// 中挑出 frameToRoot 面积最大的那个,作为 3D 视图的参考中心。 -/// 若找不到任何具有合法 frame 的候选项,回退到 appScreenSize 的几何中心。 -- (CGPoint)_calculateReferenceCenterForItems:(NSArray *)items { - LookinDisplayItem *largestItem = nil; - CGFloat largestArea = 0; - for (LookinDisplayItem *item in items) { - if (item.superItem != nil) { - continue; - } - NSMutableArray *candidates = [NSMutableArray arrayWithObject:item]; - for (LookinDisplayItem *child in item.subitems) { - [candidates addObject:child]; - } - for (LookinDisplayItem *candidate in candidates) { - CGRect frameToRoot = [candidate calculateFrameToRoot]; - CGFloat area = frameToRoot.size.width * frameToRoot.size.height; - if (area > largestArea) { - largestArea = area; - largestItem = candidate; - } - } - } - if (largestItem) { - CGRect frameToRoot = [largestItem calculateFrameToRoot]; - return CGPointMake(CGRectGetMidX(frameToRoot), CGRectGetMidY(frameToRoot)); - } - return CGPointMake(self.appScreenSize.width / 2, self.appScreenSize.height / 2); -} - -- (void)renderWithDisplayItems:(NSArray *)items discardCache:(BOOL)discardCache { - NSLog(@"LKPreviewView - render %@ items", @(items.count)); - - self.flatDisplayItems = items; - - // 在创建/复用任何 displayItemNode 之前确定 referenceCenter。 - // setter 会自动把新中心分发给已存在的 displayItemNodes(触发其位置重算)。 - self.referenceCenter = [self _calculateReferenceCenterForItems:items]; - - NSMutableArray *nodesToBeDiscarded = nil; - if (discardCache) { - nodesToBeDiscarded = [NSMutableArray array]; - } - - [self.displayItemNodes lookin_dequeueWithCount:items.count add:^LKDisplayItemNode *(NSUInteger idx) { - LKDisplayItemNode *newNode = [[LKDisplayItemNode alloc] initWithDataSource:self.dataSource]; - newNode.referenceCenter = self.referenceCenter; - newNode.preferenceManager = self.preferenceManager; - newNode.isDarkMode = self.isDarkMode; - [self.stageNode addChildNode:newNode]; - return newNode; - - } notDequeued:^(NSUInteger idx, LKDisplayItemNode *node) { - [node removeFromParentNode]; - if (discardCache) { - [nodesToBeDiscarded addObject:node]; - } - - } doNext:^(NSUInteger idx, LKDisplayItemNode *node) { - if (!node.parentNode) { - [self.stageNode addChildNode:node]; - } - - LookinDisplayItem *displayItem = items[idx]; - displayItem.previewNode = node; - node.index = idx; - node.displayItem = displayItem; - }]; - - if (nodesToBeDiscarded.count) { - [self.displayItemNodes removeObjectsInArray:nodesToBeDiscarded]; - } - - [self updateZPosition]; -} - -/** - 重新计算每个 item 的 zIndex,并依 zIndex 设置对应的图层在 z 轴上的 translation。同时根据 fold 等属性来显示或隐藏图层。 - */ -- (void)updateZPosition { - [self.flatDisplayItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [self _updateZIndexForItem:obj]; - }]; - - [self _updateZPositionByZIndex]; -} - -- (void)_updateZPositionByZIndex { - CGFloat interspace; - if (self.dimension == LookinPreviewDimension2D) { - interspace = 0.01; - } else { - interspace = 0.1 + self.zInterspace * 0.7; - } - - // key 是 zIndex,value 是该 zIndex 下有多少 item,作用是避免下文提到的 offsetToAvoidOverlapBug - NSMutableDictionary *zIndexAndCountDict = [NSMutableDictionary dictionary]; - - __block NSUInteger maxZIndex = 0; - [self.displayItemNodes enumerateObjectsUsingBlock:^(LKDisplayItemNode * _Nonnull node, NSUInteger idx, BOOL * _Nonnull stop) { - maxZIndex = MAX(maxZIndex, node.displayItem.previewZIndex); - }]; - NSUInteger zIndexOffset = round(maxZIndex * 0.5); - - // 没这个 SCNTransaction 的话,收起、展开时图像没动画 - [SCNTransaction begin]; - - [self.displayItemNodes enumerateObjectsUsingBlock:^(LKDisplayItemNode * _Nonnull node, NSUInteger idx, BOOL * _Nonnull stop) { - LookinDisplayItem *item = node.displayItem; - // 将 "1, 2, 3, 4, 5 ..." 这样的 zIndex 排序调整为 “-2,-1,0,1,2 ...”,这样旋转时 Y 轴就会位于 zIndex 为中间值的那个 layer 的位置 - NSInteger adjustedZIndex = item.previewZIndex - zIndexOffset; - - NSUInteger countOfCurrentZIndex = [[zIndexAndCountDict objectForKey:@(adjustedZIndex)] unsignedIntegerValue]; - [zIndexAndCountDict setObject:@(countOfCurrentZIndex + 1) forKey:@(adjustedZIndex)]; - - /// 当 zIndex 相同时,把更靠近用户的那个 layer 的 zIndex 增大一丁点,从而避免重叠 - CGFloat offsetToAvoidOverlapBug = countOfCurrentZIndex * 0.0001; - - SCNVector3 position = node.position; - position.z = adjustedZIndex * interspace + offsetToAvoidOverlapBug; - - node.position = position; - }]; - - // 切记:要把 SCNTransaction commit 放到 for 循环外面,不能放到 for 循环里面。否则短时间内大量细碎的 SCNTransaction 会导致渲染很慢 - [SCNTransaction commit]; -// NSLog(@"SCNTransaction commit"); -} - -- (void)_updateZIndexForItem:(LookinDisplayItem *)item { - item.previewZIndex = -1; - // A pixelless overlay node (layout guide, cell) marks out a region of the - // node that owns it, so it belongs on that node's plane rather than on one - // of its own. It always overlaps its super item, so the rule below would - // push every guide a full plane forward and thicken the preview for - // nothing. Inheriting the super item's zIndex keeps them coplanar; - // offsetToAvoidOverlapBug in _updateZPositionByZIndex then nudges them a - // hair in front, which is exactly "drawn on its own view". - // - // flatDisplayItems runs parents before children, so the super item's - // zIndex read here was already computed in this same pass. - if (item.shouldRenderAsCoplanarPreviewOverlay && item.superItem) { - item.previewZIndex = MAX(item.superItem.previewZIndex, 0); - return; - } - if (item.displayingInHierarchy) { - LookinDisplayItem *referenceItem = [self _maxZIndexForOverlappedItemUnderItem:item]; - if (referenceItem) { - // 如果 item 和另一个 itemA 重叠了,则 item.previewZIndex 应该比 itemA.previewZIndex 高一级 - item.previewZIndex = referenceItem.previewZIndex + 1; - } else { - item.previewZIndex = 0; - } - - } else { - if (item.superItem) { - item.previewZIndex = item.superItem.previewZIndex; - } else { - // 没有 superItem 的顶层 item(如折叠的 UIWindowScene) - item.previewZIndex = 0; - } - } - - if (item.previewZIndex < 0) { - NSAssert(NO, @""); - item.previewZIndex = 0; - } -} - -/** - 传入 itemA,返回另一个 itemB,itemB 满足以下条件: - - itemB 在 preview 中可见 - - itemB 的层级比 itemA 要低(即 itemB 在 flatItems 里的 index 要比 itemA 小) - - itemB 和 itemA 的 frameToRoot 有重叠,即视觉上它们是彼此遮挡的 - - itemB 是满足以上两个条件中的所有 items 里的 zIndex 值最高的 - - @note 如果没有找到任何符合条件的 itemB,则返回 nil - */ -- (LookinDisplayItem *)_maxZIndexForOverlappedItemUnderItem:(LookinDisplayItem *)item { - NSUInteger itemIndex = [self.flatDisplayItems indexOfObject:item]; - if (itemIndex == 0) { - return nil; - } - if (itemIndex == NSNotFound) { - NSAssert(NO, @""); - return nil; - } - CGRect itemFrameToRoot = [item calculateFrameToRoot]; - NSIndexSet *indexesBelow = [NSIndexSet indexSetWithIndexesInRange:NSMakeRange(0, itemIndex)]; - __block LookinDisplayItem *targetItem = nil; - [self.flatDisplayItems enumerateObjectsAtIndexes:indexesBelow options:NSEnumerationReverse usingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (!obj.inHiddenHierarchy || self.showHiddenItems) { - if (CGRectIntersectsRect(itemFrameToRoot, [obj calculateFrameToRoot])) { - if (!targetItem) { - targetItem = obj; - } else { - if (obj.previewZIndex > targetItem.previewZIndex) { - targetItem = obj; - } - } - } - } - }]; - return targetItem; -} - -- (void)didSelectItem:(LookinDisplayItem *)item { - if (!item) { - return; - } - - LKDisplayItemNode *displayItemNode = item.previewNode; - - SCNVector3 rightPos = self.rightLightNode.position; - rightPos.z = displayItemNode.position.z + 2; - self.rightLightNode.position = rightPos; - - SCNVector3 leftPos = self.leftLightNode.position; - leftPos.z = displayItemNode.position.z + 2; - self.leftLightNode.position = leftPos; -} - -- (LookinDisplayItem *)displayItemAtPoint:(CGPoint)point { - NSArray *hitResults = [self hitTest:point options:@{SCNHitTestOptionCategoryBitMask:@(LookinPreviewBitMask_Selectable), - SCNHitTestOptionSearchMode: @(SCNHitTestSearchModeClosest), - SCNHitTestIgnoreHiddenNodesKey:@(NO)}]; - if ([hitResults count] > 0) { - SCNHitTestResult *result = [hitResults objectAtIndex:0]; - LKDisplayItemNode *targetNode = (LKDisplayItemNode *)result.node.parentNode; - NSAssert([targetNode isKindOfClass:[LKDisplayItemNode class]], @""); - return targetNode.displayItem; - } else { - return nil; - } -} - -- (void)setRotation:(CGPoint)rotation animated:(BOOL)animated { - [self setRotation:rotation animated:animated timingFunction:nil duration:0]; -} - -- (void)setRotation:(CGPoint)rotation animated:(BOOL)animated timingFunction:(CAMediaTimingFunction *)function duration:(CGFloat)duration { - _rotation = rotation; - - CGPoint equivalentRotation = [self _equivalentRotationFromRotation:rotation]; - - SCNVector3 angles = SCNVector3Make(rotation.y, rotation.x, 0); - - if (animated) { - [SCNTransaction begin]; - if (duration > 0) { - [SCNTransaction setAnimationDuration:duration]; - } - if (function) { - [SCNTransaction setAnimationTimingFunction:function]; - } - [SCNTransaction setCompletionBlock:^{ - self->_rotation = equivalentRotation; - }]; - self.stageNode.eulerAngles = angles; - - [SCNTransaction commit]; - } else { - self.stageNode.eulerAngles = angles; - _rotation = equivalentRotation; - } -} - -- (void)setTranslation:(CGPoint)translation { - _translation = translation; - -// NSLog(@"Translation: %@", @(translation)); - - SCNVector3 position = self.stageNode.position; - position.x = translation.x; - position.y = translation.y; - self.stageNode.position = position; -} - -- (void)setScale:(CGFloat)scale { - _scale = scale; - -// NSLog(@"Scale: %@", @(scale)); - - /** - focalLength 越小则图像越小,focalLength 越大则图像越大 - 传入的 scale 是 0 ~ 1,会把 focalLength 映射为 20 ~ 750 - */ - self.cameraNode.camera.focalLength = 20 + scale * scale * 730; -} - -- (void)setZInterspace:(CGFloat)zInterspace { - _zInterspace = MIN(MAX(zInterspace, LookinPreviewMinZInterspace), LookinPreviewMaxZInterspace); - [self _updateZPositionByZIndex]; -} - -- (void)setDimension:(LookinPreviewDimension)dimension animated:(BOOL)animated { - _dimension = dimension; - - if (dimension == LookinPreviewDimension3D) { - // 3D - } else { - // 2D - [self setRotation:CGPointZero animated:animated]; - } - - [self _updateZPositionByZIndex]; -} - -/// 把 rotation 转换为 -180 ~ 180 以内的角度(不包含 -180 本身),注意这里传入和传出的都是弧度制 -- (CGPoint)_equivalentRotationFromRotation:(CGPoint)rotation { - rotation.x = [self _equivalentRotationValueFromRotationValue:rotation.x]; - rotation.y = [self _equivalentRotationValueFromRotationValue:rotation.y]; - return rotation; -} - -- (CGFloat)_equivalentRotationValueFromRotationValue:(CGFloat)rotation { - while (rotation <= -M_PI) { - rotation += M_PI * 2; - } - while (rotation >= M_PI) { - rotation -= M_PI * 2; - } - return rotation; -} - -- (void)setIsDarkMode:(BOOL)isDarkMode { - _isDarkMode = isDarkMode; - - self.backgroundColor = isDarkMode ? LookinColorMake(19, 20, 21) : LookinColorMake(249, 249, 249); - - [self.displayItemNodes enumerateObjectsUsingBlock:^(LKDisplayItemNode * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.isDarkMode = isDarkMode; - }]; -} - -@end diff --git a/LookInside/Static/Preview/LKPreviewView.swift b/LookInside/Static/Preview/LKPreviewView.swift new file mode 100644 index 00000000..e769782c --- /dev/null +++ b/LookInside/Static/Preview/LKPreviewView.swift @@ -0,0 +1,380 @@ +// +// LKPreviewView.swift +// LookInside +// +// Created by Li Kai on 2019/8/17. +// https://lookin.work +// +// Much of the SceneKit code here was borrowed or copied from +// https://github.com/TalkingData/YourView. +// Lookin acknowledgements: https://qxh1ndiez2w.feishu.cn/docx/YIFjdE4gIolp3hxn1tGckiBxnWf +// + +import AppKit +import LookInsideHostCore +import SceneKit + +/// The 3D (or flat) preview of the hierarchy: one LKDisplayItemNode per +/// item, stacked along the z axis. +@objc(LKPreviewView) +final class LKPreviewView: SCNView { + private let dataSource: LKHierarchyDataSource? + + private let stageNode = SCNNode() + private let cameraNode = SCNNode() + private let rightLightNode = SCNNode() + private let leftLightNode = SCNNode() + + private var flatDisplayItems: [LookinDisplayItem] = [] + private var displayItemNodes: [LKDisplayItemNode] = [] + + /// The frameToRoot point at the 3D origin: the centre of the largest + /// top-level window item. Recomputed on every render and handed to every + /// node. + private var referenceCenter: CGPoint = .zero { + didSet { + guard referenceCenter != oldValue else { return } + for node in displayItemNodes { + node.referenceCenter = referenceCenter + } + } + } + + /// x turns left and right, y turns up and down (radians). + private(set) var rotation: CGPoint = .zero + + /// How far the preview is moved. + var translation: CGPoint = .zero { + didSet { + var position = stageNode.position + position.x = translation.x + position.y = translation.y + stageNode.position = position + } + } + + /// Zoom, from LookinPreviewMinScale to LookinPreviewMaxScale. + var scale: CGFloat = 0 { + didSet { + // A smaller focal length shows a smaller image. + cameraNode.camera?.focalLength = PreviewGeometry.focalLength(forScale: scale) + } + } + + /// The spacing between layers, from LookinPreviewMinZInterspace to + /// LookinPreviewMaxZInterspace. + var zInterspace: CGFloat { + get { storedZInterspace } + set { + storedZInterspace = PreviewGeometry.clampedZInterspace(newValue) + updateZPositionByZIndex() + } + } + + private var storedZInterspace: CGFloat = 0 + + /// 2D or 3D. Going 2D resets the rotation; going 3D keeps it. + private(set) var dimension: LookinPreviewDimension = .dimension2D + + /// The inspected app's screen size. + var appScreenSize: CGSize = .zero { + didSet { + guard appScreenSize != oldValue else { return } + + var rightPosition = rightLightNode.position + rightPosition.x = appScreenSize.width * 0.01 * 0.5 + 2 + rightPosition.y = appScreenSize.height * 0.01 * 0.5 + 2 + rightLightNode.position = rightPosition + + var leftPosition = leftLightNode.position + leftPosition.x = -appScreenSize.width * 0.01 * 0.5 - 2 + leftPosition.y = -appScreenSize.height * 0.01 * 0.5 - 2 + leftLightNode.position = leftPosition + + // Until a render computes the real reference centre (from the + // largest top-level window), use the screen centre. + if referenceCenter == .zero { + referenceCenter = CGPoint(x: appScreenSize.width / 2, y: appScreenSize.height / 2) + } + } + } + + var preferenceManager: LKPreferenceManager? + + var isDarkMode = false { + didSet { + backgroundColor = isDarkMode + ? NSColor(red: 19 / 255, green: 20 / 255, blue: 21 / 255, alpha: 1) + : NSColor(red: 249 / 255, green: 249 / 255, blue: 249 / 255, alpha: 1) + for node in displayItemNodes { + node.isDarkMode = isDarkMode + } + } + } + + var showHiddenItems = false + + init(dataSource: LKHierarchyDataSource?) { + self.dataSource = dataSource + super.init(frame: .zero, options: nil) + + allowsCameraControl = false + showsStatistics = false + + let scene = SCNScene() + self.scene = scene + + stageNode.name = "stage" + scene.rootNode.addChildNode(stageNode) + + let camera = SCNCamera() + camera.automaticallyAdjustsZRange = true + cameraNode.name = "camera" + cameraNode.camera = camera + // position.z sets how strong the perspective is. + cameraNode.position = SCNVector3(0, 0, 34) + scene.rootNode.addChildNode(cameraNode) + + let rightLight = SCNLight() + rightLight.type = .omni + rightLight.categoryBitMask = Int(LookinPreviewBitMask.hasLight.rawValue) + rightLightNode.name = "right light" + rightLightNode.light = rightLight + scene.rootNode.addChildNode(rightLightNode) + + // The left light node has always carried the right light, so the + // spot light made here was never used; kept as it was. + leftLightNode.name = "left light" + leftLightNode.light = rightLight + scene.rootNode.addChildNode(leftLightNode) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + // MARK: - Rendering + + /// Picks, among the top-level items and their direct children (macOS's + /// top-level windows, and iOS / Catalyst's scene → window), the one with + /// the largest frameToRoot and returns its centre. Falls back to the + /// centre of the app's screen. + private func referenceCenter(for items: [LookinDisplayItem]) -> CGPoint { + var largestItem: LookinDisplayItem? + var largestArea: CGFloat = 0 + for item in items where item.super == nil { + for candidate in [item] + (item.subitems ?? []) { + let frameToRoot = candidate.calculateFrameToRoot() + let area = frameToRoot.size.width * frameToRoot.size.height + if area > largestArea { + largestArea = area + largestItem = candidate + } + } + } + if let largestItem { + let frameToRoot = largestItem.calculateFrameToRoot() + return CGPoint(x: frameToRoot.midX, y: frameToRoot.midY) + } + return CGPoint(x: appScreenSize.width / 2, y: appScreenSize.height / 2) + } + + /// Builds a node per item. `items` must not contain items whose + /// `noPreview` is set. With `discardCache`, nodes this render does not + /// use are dropped; otherwise they are only removed from the scene and + /// kept for later renders. + func render(displayItems items: [LookinDisplayItem], discardCache: Bool) { + NSLog("LKPreviewView - render %@ items", NSNumber(value: items.count)) + + flatDisplayItems = items + + // Decide the reference centre before any node is made or reused; + // setting it hands it to the existing nodes. + referenceCenter = referenceCenter(for: items) + + for (index, item) in items.enumerated() { + let node: LKDisplayItemNode + if index < displayItemNodes.count { + node = displayItemNodes[index] + } else { + node = LKDisplayItemNode(dataSource: dataSource) + node.referenceCenter = referenceCenter + node.preferenceManager = preferenceManager + node.isDarkMode = isDarkMode + stageNode.addChildNode(node) + displayItemNodes.append(node) + } + if node.parent == nil { + stageNode.addChildNode(node) + } + item.previewNode = node + node.index = index + node.displayItem = item + } + + if displayItemNodes.count > items.count { + let unused = displayItemNodes[items.count...] + for node in unused { + node.removeFromParentNode() + } + if discardCache { + displayItemNodes.removeLast(unused.count) + } + } + + updateZPosition() + } + + /// Recomputes every item's z index and moves its plane along the z axis + /// to match; folding and other states show or hide the planes. + func updateZPosition() { + for item in flatDisplayItems { + updateZIndex(for: item) + } + updateZPositionByZIndex() + } + + private func updateZPositionByZIndex() { + let interspace = PreviewGeometry.layerInterspace(is3D: dimension == .dimension3D, zInterspace: zInterspace) + let zIndexes = displayItemNodes.map { $0.displayItem?.previewZIndex ?? 0 } + let positions = PreviewGeometry.zPositions(zIndexes: zIndexes, interspace: interspace) + + // Without the transaction, folding and unfolding do not animate. + SCNTransaction.begin() + for (node, z) in zip(displayItemNodes, positions) { + var position = node.position + position.z = z + node.position = position + } + // One commit outside the loop: many small transactions render slowly. + SCNTransaction.commit() + } + + private func updateZIndex(for item: LookinDisplayItem) { + item.previewZIndex = -1 + // A pixelless overlay node (layout guide, cell) marks out a region of + // the node that owns it, so it belongs on that node's plane rather + // than on one of its own. It always overlaps its super item, so the + // rule below would push every guide a full plane forward and thicken + // the preview for nothing. Inheriting the super item's zIndex keeps + // them coplanar; the coplanar offset in PreviewGeometry.zPositions + // then nudges them a hair in front, which is exactly "drawn on its + // own view". + // + // flatDisplayItems runs parents before children, so the super item's + // zIndex read here was already computed in this same pass. + if item.shouldRenderAsCoplanarPreviewOverlay(), let superItem = item.super { + item.previewZIndex = max(superItem.previewZIndex, 0) + return + } + if item.displayingInHierarchy { + if let referenceItem = highestOverlappedItem(below: item) { + // Overlapping another item puts this one a level above it. + item.previewZIndex = referenceItem.previewZIndex + 1 + } else { + item.previewZIndex = 0 + } + } else if let superItem = item.super { + item.previewZIndex = superItem.previewZIndex + } else { + // A top-level item without a super item (a folded UIWindowScene). + item.previewZIndex = 0 + } + + if item.previewZIndex < 0 { + assertionFailure() + item.previewZIndex = 0 + } + } + + /// The item, among those visible in the preview and earlier in + /// flatDisplayItems (lower in the hierarchy) whose frameToRoot overlaps + /// `item`'s, with the highest z index; nil when there is none. + private func highestOverlappedItem(below item: LookinDisplayItem) -> LookinDisplayItem? { + guard let itemIndex = flatDisplayItems.firstIndex(where: { $0 == item }) else { + assertionFailure() + return nil + } + guard itemIndex > 0 else { return nil } + let itemFrameToRoot = item.calculateFrameToRoot() + var target: LookinDisplayItem? + for other in flatDisplayItems[.. LookinDisplayItem? { + let results = hitTest(point, options: [ + .categoryBitMask: Int(LookinPreviewBitMask.selectable.rawValue), + .searchMode: SCNHitTestSearchMode.closest.rawValue, + .ignoreHiddenNodes: false, + ]) + guard let result = results.first else { return nil } + let targetNode = result.node.parent as? LKDisplayItemNode + assert(targetNode != nil) + return targetNode?.displayItem + } + + // MARK: - Rotation and dimension + + func setRotation(_ rotation: CGPoint, animated: Bool) { + setRotation(rotation, animated: animated, timingFunction: nil, duration: 0) + } + + /// A zero `duration` uses the default (0.25 s). + func setRotation(_ rotation: CGPoint, animated: Bool, timingFunction: CAMediaTimingFunction?, duration: CGFloat) { + self.rotation = rotation + let equivalentRotation = CGPoint( + x: PreviewGeometry.equivalentAngle(rotation.x), + y: PreviewGeometry.equivalentAngle(rotation.y) + ) + let angles = SCNVector3(rotation.y, rotation.x, 0) + + if animated { + SCNTransaction.begin() + if duration > 0 { + SCNTransaction.animationDuration = duration + } + if let timingFunction { + SCNTransaction.animationTimingFunction = timingFunction + } + SCNTransaction.completionBlock = { [weak self] in + self?.rotation = equivalentRotation + } + stageNode.eulerAngles = angles + SCNTransaction.commit() + } else { + stageNode.eulerAngles = angles + self.rotation = equivalentRotation + } + } + + func setDimension(_ dimension: LookinPreviewDimension, animated: Bool) { + self.dimension = dimension + if dimension != .dimension3D { + setRotation(.zero, animated: animated) + } + updateZPositionByZIndex() + } +} diff --git a/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.h b/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.h deleted file mode 100644 index 64c14223..00000000 --- a/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.h +++ /dev/null @@ -1,33 +0,0 @@ -#import -#import - -NS_ASSUME_NONNULL_BEGIN - -typedef NS_ENUM(NSInteger, LKSwiftUIHierarchyDisplayMode) { - LKSwiftUIHierarchyDisplayModeVerbose = 0, - LKSwiftUIHierarchyDisplayModeCompact = 1, -}; - -/// Posted on the main thread whenever -/// `+[LKSwiftUIHierarchyDisplayModeStore currentMode]` changes. -/// Notification object: the store class. -FOUNDATION_EXPORT NSNotificationName const LKSwiftUIHierarchyDisplayModeDidChangeNotification; - -@interface LKSwiftUIHierarchyDisplayModeStore : NSObject - -/// Default = compact when no value persisted yet. -+ (LKSwiftUIHierarchyDisplayMode)currentMode; - -/// Persist the new mode to NSUserDefaults and post -/// LKSwiftUIHierarchyDisplayModeDidChangeNotification on the main queue if -/// the value actually changed. -+ (void)setCurrentMode:(LKSwiftUIHierarchyDisplayMode)mode; - -/// IBAction-shape selector intended for an NSSegmentedControl whose -/// segment 0 is Compact and segment 1 is Verbose. Writes the new mode -/// through `+setCurrentMode:`, which posts the change notification. -+ (void)swiftUIModeSegmentChanged:(NSSegmentedControl *)sender; - -@end - -NS_ASSUME_NONNULL_END diff --git a/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.m b/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.m deleted file mode 100644 index 2eb670e3..00000000 --- a/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.m +++ /dev/null @@ -1,40 +0,0 @@ -#import "LKSwiftUIHierarchyDisplayMode.h" - -NSNotificationName const LKSwiftUIHierarchyDisplayModeDidChangeNotification = - @"LKSwiftUIHierarchyDisplayModeDidChangeNotification"; - -static NSString *const kDefaultsKey = @"LookInside.SwiftUIHierarchyDisplayMode"; - -@implementation LKSwiftUIHierarchyDisplayModeStore - -+ (LKSwiftUIHierarchyDisplayMode)currentMode { - NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; - if ([defaults objectForKey:kDefaultsKey] == nil) { - return LKSwiftUIHierarchyDisplayModeCompact; - } - NSInteger raw = [defaults integerForKey:kDefaultsKey]; - if (raw == LKSwiftUIHierarchyDisplayModeVerbose - || raw == LKSwiftUIHierarchyDisplayModeCompact) { - return (LKSwiftUIHierarchyDisplayMode)raw; - } - return LKSwiftUIHierarchyDisplayModeCompact; -} - -+ (void)setCurrentMode:(LKSwiftUIHierarchyDisplayMode)mode { - LKSwiftUIHierarchyDisplayMode old = [self currentMode]; - if (old == mode) { - return; - } - [NSUserDefaults.standardUserDefaults setInteger:mode forKey:kDefaultsKey]; - [NSNotificationCenter.defaultCenter postNotificationName:LKSwiftUIHierarchyDisplayModeDidChangeNotification - object:self.class]; -} - -+ (void)swiftUIModeSegmentChanged:(NSSegmentedControl *)sender { - LKSwiftUIHierarchyDisplayMode mode = (sender.selectedSegment == 0) - ? LKSwiftUIHierarchyDisplayModeCompact - : LKSwiftUIHierarchyDisplayModeVerbose; - [self setCurrentMode:mode]; -} - -@end diff --git a/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.swift b/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.swift new file mode 100644 index 00000000..3c0bea3b --- /dev/null +++ b/LookInside/SwiftUISupport/LKSwiftUIHierarchyDisplayMode.swift @@ -0,0 +1,50 @@ +// +// LKSwiftUIHierarchyDisplayMode.swift +// LookInside +// + +import AppKit + +@objc enum LKSwiftUIHierarchyDisplayMode: Int { + case verbose = 0 + case compact = 1 +} + +extension Notification.Name { + /// Posted on the main thread whenever + /// `LKSwiftUIHierarchyDisplayModeStore.currentMode()` changes. + /// Notification object: the store class. + static let LKSwiftUIHierarchyDisplayModeDidChange = Notification.Name("LKSwiftUIHierarchyDisplayModeDidChangeNotification") +} + +@objc(LKSwiftUIHierarchyDisplayModeStore) +final class LKSwiftUIHierarchyDisplayModeStore: NSObject { + private static let defaultsKey = "LookInside.SwiftUIHierarchyDisplayMode" + + /// Default = compact when no value persisted yet. + static func currentMode() -> LKSwiftUIHierarchyDisplayMode { + let defaults = UserDefaults.standard + guard defaults.object(forKey: defaultsKey) != nil else { + return .compact + } + return LKSwiftUIHierarchyDisplayMode(rawValue: defaults.integer(forKey: defaultsKey)) ?? .compact + } + + /// Persist the new mode to UserDefaults and post + /// LKSwiftUIHierarchyDisplayModeDidChange if the value actually changed. + static func setCurrentMode(_ mode: LKSwiftUIHierarchyDisplayMode) { + guard currentMode() != mode else { + return + } + UserDefaults.standard.set(mode.rawValue, forKey: defaultsKey) + NotificationCenter.default.post(name: .LKSwiftUIHierarchyDisplayModeDidChange, object: self) + } + + /// Action of an NSSegmentedControl whose segment 0 is Compact and + /// segment 1 is Verbose. Writes the new mode through `setCurrentMode(_:)`, + /// which posts the change notification. + @objc(swiftUIModeSegmentChanged:) + static func swiftUIModeSegmentChanged(_ sender: NSSegmentedControl) { + setCurrentMode(sender.selectedSegment == 0 ? .compact : .verbose) + } +} diff --git a/LookInside/SwiftUISupport/LKSwiftUISupportAuthServerPathPolicy.swift b/LookInside/SwiftUISupport/LKSwiftUISupportAuthServerPathPolicy.swift deleted file mode 100644 index ead62631..00000000 --- a/LookInside/SwiftUISupport/LKSwiftUISupportAuthServerPathPolicy.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -enum LKSwiftUISupportAuthServerPathPolicy { - static func executableURL( - environment: [String: String], - overrideKey: String, - defaultURL: URL - ) -> URL { - #if DEBUG - if let explicitPath = environment[overrideKey], explicitPath.isEmpty == false { - return URL(fileURLWithPath: explicitPath) - } - #endif - return defaultURL - } - - static func socketURL( - environment: [String: String], - overrideKey: String, - defaultURL: URL - ) -> URL { - #if DEBUG - if let explicitPath = environment[overrideKey], explicitPath.isEmpty == false { - return URL(fileURLWithPath: explicitPath) - } - #endif - return defaultURL - } - - static func launchEnvironment( - from environment: [String: String], - helperPathKey: String, - helperVersionKey: String - ) -> [String: String] { - var launchEnvironment = environment - #if !DEBUG - launchEnvironment.removeValue(forKey: helperPathKey) - launchEnvironment.removeValue(forKey: helperVersionKey) - #endif - return launchEnvironment - } -} - -enum LKSwiftUISupportActivationStateRefreshStartupAction: Equatable { - case installAndLaunch - case launchInstalledHelper -} - -enum LKSwiftUISupportActivationStateRefreshPolicy { - static var startupAction: LKSwiftUISupportActivationStateRefreshStartupAction { - return .installAndLaunch - } -} diff --git a/LookInside/SwiftUISupport/LKSwiftUISupportGatekeeper.swift b/LookInside/SwiftUISupport/LKSwiftUISupportGatekeeper.swift index 08c6bc61..d8c033e4 100644 --- a/LookInside/SwiftUISupport/LKSwiftUISupportGatekeeper.swift +++ b/LookInside/SwiftUISupport/LKSwiftUISupportGatekeeper.swift @@ -1,38 +1,21 @@ import AppKit -import Darwin import Foundation - -private enum LKSwiftUISupportAuthServerConstants { - static let supportedProtocolVersion = 1 - - static let helperPathEnvironmentKey = "LOOKINSIDE_AUTH_SERVER_PATH" - static let helperSocketPathEnvironmentKey = "LOOKINSIDE_AUTH_SERVER_SOCKET_PATH" - static let helperClientProcessIDEnvironmentKey = "LOOKINSIDE_AUTH_SERVER_CLIENT_PID" - - static let firstLaunchHealthTimeout: TimeInterval = 1.0 - static let relaunchHealthTimeout: TimeInterval = 3.0 - static let helperHealthPollInterval: useconds_t = 100_000 - static let helperShutdownTimeout: TimeInterval = 3 - - static let activationStatePollingInterval: DispatchTimeInterval = .seconds(5) - static let activationStatePollingLeeway: DispatchTimeInterval = .milliseconds(500) -} - -private enum LKSwiftUISupportHelperPresence { - case notDetermined - case spawned -} - -private enum LKSwiftUISupportAuthServerInstallMode { - case userInitiated - case backgroundRefresh -} +import LookInsideActivation +import LookInsideActivationUI @objc public enum LKSwiftUISupportActivationState: Int { case unknown case notActivated case activated + init(_ decision: ActivationAccessDecision?) { + guard let decision else { + self = .unknown + return + } + self = decision.grantsAccess ? .activated : .notActivated + } + var lkDebugDescription: String { switch self { case .unknown: return "unknown" @@ -42,1145 +25,437 @@ private enum LKSwiftUISupportAuthServerInstallMode { } } -private struct LKSwiftUISupportAuthServerInstallation { - let executableURL: URL - let socketURL: URL +private enum LKSwiftUISupportGatekeeperConstants { + /// How often the stored license state is re-evaluated, so a lease that runs + /// out while LookInside is open closes access without a user action. + static let monitoringInterval: Duration = .seconds(60) } -private struct LKSwiftUISupportEmptyPayload: Codable {} - -private struct LKSwiftUISupportClientProcessPayload: Encodable { - let lookInsideProcessID: Int32 - - private enum CodingKeys: String, CodingKey { - case lookInsideProcessID = "lookinside_pid" - } -} - -private struct LKSwiftUISupportSignChallengeRequestPayload: Encodable { - let nonce: String - let serverInstanceID: String - - private enum CodingKeys: String, CodingKey { - case nonce - case serverInstanceID = "server_instance_id" - } -} - -private struct LKSwiftUISupportSignChallengeResponsePayload: Decodable { - let signature: String - let intermediateCertDER: String - let udid: String - - private enum CodingKeys: String, CodingKey { - case signature - case intermediateCertDER = "intermediate_cert_der" - case udid - } -} - -private struct LKSwiftUISupportUserAlertPayload: Encodable { - let title: String - let message: String - let style: String -} - -private struct LKSwiftUISupportAuthServerRequestEnvelope: Encodable { - let protocolVersion: Int - let requestID: String - let method: String - let payload: Payload - - private enum CodingKeys: String, CodingKey { - case protocolVersion = "protocol_version" - case requestID = "request_id" - case method - case payload - } -} - -private struct LKSwiftUISupportAuthServerResponseEnvelope: Decodable { - let protocolVersion: Int - let requestID: String - let ok: Bool - let payload: Payload? - let error: LKSwiftUISupportAuthServerErrorPayload? - - private enum CodingKeys: String, CodingKey { - case protocolVersion = "protocol_version" - case requestID = "request_id" - case ok - case payload - case error - } -} - -private struct LKSwiftUISupportAuthServerErrorPayload: Decodable, Error { - let code: String - let message: String -} - -private struct LKSwiftUISupportAuthServerHealthPayload: Decodable { - let serverVersion: String - let protocolVersion: Int - let statusSummary: String - - private enum CodingKeys: String, CodingKey { - case serverVersion = "server_version" - case protocolVersion = "protocol_version" - case statusSummary = "status_summary" - } -} - -private struct LKSwiftUISupportAuthServerAccessDecisionPayload: Decodable { - enum Decision: String, Decodable { - case allow - case allowWithWarning = "allow_with_warning" - case block - } - - let decision: Decision - let title: String - let message: String - let statusSummary: String? +/// Gate in front of LookInside Pro features, backed by the in-process +/// activation runtime (`LookInsideActivation`). +/// +/// The Objective-C surface is the one the Auth helper bridge had, so the +/// connection, menu, launch and static-update code call it unchanged. The +/// runtime reads and writes the helper's `state.json` and keychain key; this +/// class never launches, updates or removes the helper. +@objcMembers +public final class LKSwiftUISupportGatekeeper: NSObject { + private static let shared = LKSwiftUISupportGatekeeper(runtime: ActivationRuntime()) - private enum CodingKeys: String, CodingKey { - case decision - case title - case message - case statusSummary = "status_summary" - } + public static let activationStateDidChangeNotification = Notification.Name( + "LKSwiftUISupportActivationStateDidChangeNotification" + ) - static var activationRequired: LKSwiftUISupportAuthServerAccessDecisionPayload { - LKSwiftUISupportAuthServerAccessDecisionPayload( - decision: .block, - title: NSLocalizedString("Activation Required", comment: ""), - message: NSLocalizedString("Activate LookInside Pro from the LookInside Pro menu before using this feature.", comment: ""), - statusSummary: NSLocalizedString("No local LookInside Pro license is stored on this Mac.", comment: "") - ) + public static var activationStateDidChangeNotificationName: NSString { + activationStateDidChangeNotification.rawValue as NSString } -} -private enum LKSwiftUISupportAuthServerError: LocalizedError { - case helperMissing(String) - case incompatibleProtocol(expected: Int, found: Int) - case helperVersionMismatch(expected: String, found: String) - case socketPathInvalid(String) - case launchFailed(String) - case launchTimedOut(String) - case rpcTransport(String) - case rpcServer(code: String, message: String) - case invalidResponse(String) + /// Posted on the main thread when license handshakes that were held back + /// may start: the first window appeared, the keychain explainer's + /// Continue let the held-back handshakes run, Try Again cleared the wait + /// after a refused keychain prompt, or that wait ended. Connected channels that are not licensed should + /// handshake again. + public static let licenseHandshakeAvailabilityDidChangeNotification = Notification.Name( + "LKSwiftUISupportLicenseHandshakeAvailabilityDidChangeNotification" + ) - var errorDescription: String? { - switch self { - case let .helperMissing(path): - return String(format: NSLocalizedString("LookInside Auth Server is not installed.\nExpected executable:\n%@", comment: ""), path) - case let .incompatibleProtocol(expected, found): - return String(format: NSLocalizedString("LookInside Auth Server protocol is incompatible.\nApp expects v%1$ld, helper provides v%2$ld.", comment: ""), expected, found) - case let .helperVersionMismatch(expected, found): - return String(format: NSLocalizedString("LookInside Auth Server version mismatch.\nExpected: %1$@\nFound: %2$@", comment: ""), expected, found) - case let .socketPathInvalid(path): - return String(format: NSLocalizedString("LookInside Auth Server socket path is too long for a Unix domain socket.\n%@", comment: ""), path) - case let .launchFailed(message): - return String(format: NSLocalizedString("LookInside Auth Server could not be launched.\n%@", comment: ""), message) - case let .launchTimedOut(path): - return String(format: NSLocalizedString("LookInside Auth Server did not respond after launch.\nSocket path:\n%@", comment: ""), path) - case let .rpcTransport(message): - return String(format: NSLocalizedString("LookInside Auth Server connection failed.\n%@", comment: ""), message) - case let .rpcServer(code, message): - return String(format: NSLocalizedString("LookInside Auth Server returned %1$@.\n%2$@", comment: ""), code, message) - case let .invalidResponse(message): - return String(format: NSLocalizedString("LookInside Auth Server returned an unreadable response.\n%@", comment: ""), message) - } + public static var licenseHandshakeAvailabilityDidChangeNotificationName: NSString { + licenseHandshakeAvailabilityDidChangeNotification.rawValue as NSString } -} -private final class LKSwiftUISupportAuthServerBridge { - private let lock = NSLock() - private var launchedProcess: Process? - private var lastPresentedErrorDescription: String? - private var helperPresence: LKSwiftUISupportHelperPresence = .notDetermined - private var activationState: LKSwiftUISupportActivationState = .unknown - private var activationStatusSummary: String? - private var activationStateRefreshInFlight = false + private let runtime: ActivationRuntime + private let stateLock = NSLock() + private var publishedState: LKSwiftUISupportActivationState + private var publishedStatusSummary: String? + private var stateRefreshInFlight = false private var licenseStatusRefreshInFlight = false - private var activationStatePollingStarted = false - private var activationStatePollingTimer: DispatchSourceTimer? - - var currentActivationState: LKSwiftUISupportActivationState { - lock.withLock { activationState } - } + private var lastPresentedWarning: String? + private var decisionObservation: ActivationObservation? + private var signingObservation: ActivationObservation? + private var handshakeGeneration: Int? + /// `false` without an app around the gatekeeper (tests, the end-to-end + /// harness): no keychain explainer is shown. + private let presentsKeychainExplainer: Bool - private func recordAccessDecision(_ payload: LKSwiftUISupportAuthServerAccessDecisionPayload) { - let newState: LKSwiftUISupportActivationState - switch payload.decision { - case .allow, .allowWithWarning: - newState = .activated - case .block: - newState = .notActivated - } - var previousState: LKSwiftUISupportActivationState = .unknown - let shouldNotify: Bool = lock.withLock { - let previousStatusSummary = activationStatusSummary - let stateChanged = activationState != newState - let licenseMaterialMayHaveChanged = newState == .activated && previousStatusSummary != payload.statusSummary - guard stateChanged || licenseMaterialMayHaveChanged else { return false } - previousState = activationState - activationState = newState - activationStatusSummary = payload.statusSummary - return true - } - if shouldNotify { - LKSwiftUISupportLogger.authServer.info( - "activation state changed: \(previousState.lkDebugDescription, privacy: .public) -> \(newState.lkDebugDescription, privacy: .public) (decision=\(payload.decision.rawValue, privacy: .public))" - ) - DispatchQueue.main.async { - NotificationCenter.default.post( - name: LKSwiftUISupportGatekeeper.activationStateDidChangeNotification, - object: LKSwiftUISupportGatekeeper.sharedInstance(), - userInfo: ["activationState": NSNumber(value: newState.rawValue)] - ) - } - } - } - - private func recordLocalActivationRequired(_ probeResult: LKSwiftUISupportLocalLicenseProbeResult) { - LKSwiftUISupportLogger.authServer.info( - "local license probe result=\(probeResult.debugDescription, privacy: .public) action=notActivated" + private let activationPromptLock = NSLock() + private var hasPendingDetectedSwiftUISupportPrompt = false + private var hasPromptedForDetectedSwiftUISupport = false + private var hasPendingDetectionPrompt = false + private var hasInstalledKeyWindowObserver = false + private var firstWindowObserver: NSObjectProtocol? + + @MainActor private var cachedWindowCoordinator: ActivationWindowCoordinator? + + /// Creates a gatekeeper over `runtime`. The app uses `sharedInstance()`, + /// which runs on the standard (helper-compatible) configuration; tests + /// pass a runtime with an isolated state directory and keychain. + init(runtime: ActivationRuntime, monitorsState: Bool = true) { + self.runtime = runtime + let initialDecision = runtime.lastDecision + publishedState = LKSwiftUISupportActivationState(initialDecision) + publishedStatusSummary = initialDecision?.statusSummary + presentsKeychainExplainer = monitorsState + super.init() + LKSwiftUISupportLogger.activation.info( + // Logger interpolations are escaping autoclosures. + // swiftformat:disable:next redundantSelf + "activation runtime ready, state=\(self.publishedState.lkDebugDescription, privacy: .public)" ) - recordAccessDecision(.activationRequired) - } - - private func hasPersistedLocalLicenseMaterial() -> Bool { - let probeResult = LKSwiftUISupportLocalLicenseProbe.probe() - guard probeResult.hasPersistedLicenseMaterial else { - recordLocalActivationRequired(probeResult) - return false + decisionObservation = runtime.addDecisionObserver { [weak self] decision in + self?.recordDecision(decision) } - return true - } - - func startActivationStatePolling() { - let shouldStart: Bool = lock.withLock { - guard !activationStatePollingStarted else { return false } - activationStatePollingStarted = true - return true + signingObservation = runtime.addSigningStatusObserver { [weak self] status in + self?.recordSigningStatus(status) } - guard shouldStart else { return } - - let interval = LKSwiftUISupportAuthServerConstants.activationStatePollingInterval - LKSwiftUISupportLogger.authServer.info( - "activation state polling started (interval=\(String(describing: interval), privacy: .public))" - ) - - let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility)) - timer.schedule( - deadline: .now(), - repeating: interval, - leeway: LKSwiftUISupportAuthServerConstants.activationStatePollingLeeway - ) - timer.setEventHandler { [weak self] in - self?.refreshActivationStateInBackground() + if monitorsState { + runtime.startMonitoring(interval: LKSwiftUISupportGatekeeperConstants.monitoringInterval) + allowSigningAfterFirstWindow() + } else { + // Without an app around it (tests, the end-to-end harness) there + // is no window to wait for. + runtime.noteFirstWindowShown() } - lock.withLock { activationStatePollingTimer = timer } - timer.resume() } - func refreshActivationStateInBackground() { - let shouldStart: Bool = lock.withLock { - if activationStateRefreshInFlight { - return false - } - activationStateRefreshInFlight = true - return true - } - guard shouldStart else { - return - } - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - defer { - self?.lock.withLock { self?.activationStateRefreshInFlight = false } - } + /// License handshakes and silent renewal sign with the license key. For + /// a key the 2.3.x helper created, the first use raises a login-keychain + /// prompt, which must not appear while LookInside is still launching. + /// Both are held back until a window has been shown (renewal also waits + /// for the runtime's launch delay). + private func allowSigningAfterFirstWindow() { + DispatchQueue.main.async { [weak self] in guard let self else { return } - guard self.hasPersistedLocalLicenseMaterial() else { - return - } - let installation: LKSwiftUISupportAuthServerInstallation - do { - switch LKSwiftUISupportActivationStateRefreshPolicy.startupAction { - case .installAndLaunch: - installation = try self.ensureInstalledAndRunning( - window: nil, - installMode: .backgroundRefresh - ) - case .launchInstalledHelper: - installation = try self.ensureServerAvailable( - using: self.resolveInstallation() - ) - } - } catch { - LKSwiftUISupportLogger.authServer.info( - "activation state refresh setup failed: \(error.localizedDescription, privacy: .public)" - ) + if NSApp?.windows.contains(where: { $0.isVisible }) == true { + self.runtime.noteFirstWindowShown() return } - do { - let response = try self.sendRequest( - method: "license.check_access", - payload: LKSwiftUISupportEmptyPayload(), - installation: installation, - responseType: LKSwiftUISupportAuthServerAccessDecisionPayload.self - ) - if let payload = response.payload { - self.recordAccessDecision(payload) - } - } catch { - LKSwiftUISupportLogger.authServer.info( - "activation state refresh failed: \(error.localizedDescription, privacy: .public)" - ) + self.firstWindowObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] _ in + guard let self, let observer = self.firstWindowObserver else { return } + NotificationCenter.default.removeObserver(observer) + self.firstWindowObserver = nil + self.runtime.noteFirstWindowShown() } } } - func shutdownRuntime() { - guard let installation = try? resolveInstallation() else { - return + /// Posts the handshake-availability notification when held-back + /// handshakes may start, and shows the keychain explainer when an + /// automatic use of the license key waits for it. + private func recordSigningStatus(_ status: ActivationSigningStatus) { + if status.needsKeychainAccessExplainer, presentsKeychainExplainer { + presentKeychainAccessExplainer() } - - do { - _ = try sendRequest( - method: "server.shutdown", - payload: LKSwiftUISupportEmptyPayload(), - installation: installation, - responseType: LKSwiftUISupportEmptyPayload.self - ) - } catch { - LKSwiftUISupportLogger.authServer.error( - "shutdown request failed: \(error.localizedDescription, privacy: .public)" - ) + let shouldNotify: Bool = stateLock.withLock { + defer { handshakeGeneration = status.handshakeGeneration } + guard let handshakeGeneration else { return false } + return handshakeGeneration != status.handshakeGeneration } - - unlink(installation.socketURL.path + ".lock") - unlink(installation.socketURL.path) - - let deadline = Date().addingTimeInterval(LKSwiftUISupportAuthServerConstants.helperShutdownTimeout) - while Date() < deadline { - let isRunning = lock.withLock { - launchedProcess?.isRunning == true - } - if isRunning == false { - break - } - usleep(50000) - } - - lock.withLock { - if let launchedProcess, launchedProcess.isRunning { - launchedProcess.terminate() - } - self.launchedProcess = nil - self.helperPresence = .notDetermined + guard shouldNotify else { return } + LKSwiftUISupportLogger.activation.info( + "license handshakes may start again (generation \(status.handshakeGeneration, privacy: .public))" + ) + DispatchQueue.main.async { + NotificationCenter.default.post(name: Self.licenseHandshakeAvailabilityDidChangeNotification, object: self) } } - private func terminateHelperProcess() { - let process = lock.withLock { launchedProcess } - if let process, process.isRunning { - process.terminate() - let deadline = Date().addingTimeInterval(1) - while Date() < deadline, process.isRunning { - usleep(50000) - } - if process.isRunning { - kill(process.processIdentifier, SIGKILL) - } - } - if let installation = try? resolveInstallation() { - unlink(installation.socketURL.path + ".lock") - unlink(installation.socketURL.path) - } - lock.withLock { - self.launchedProcess = nil - self.helperPresence = .notDetermined + /// For a license key the 2.3.x helper created, macOS asks before + /// LookInside may use it. The runtime asks for this explainer on the + /// first automatic use (a handshake or silent renewal), which only + /// happens after the first window appeared. It is a panel over the main + /// window that does not take keyboard focus. + private func presentKeychainAccessExplainer() { + let runtime = runtime + onMain { coordinator in + guard runtime.signingStatus.needsKeychainAccessExplainer else { return } + let window = NSApp.mainWindow ?? NSApp.keyWindow ?? NSApp.windows.first { $0.isVisible } + LKSwiftUISupportLogger.activation.info("showing the keychain access explainer") + coordinator.showKeychainAccessExplainer(over: window) } } - private func shouldTriggerRefresh(for error: Error) -> Bool { - switch error { - case LKSwiftUISupportAuthServerError.helperVersionMismatch, - LKSwiftUISupportAuthServerError.launchTimedOut, - LKSwiftUISupportAuthServerError.incompatibleProtocol, - LKSwiftUISupportAuthServerError.helperMissing: - return true - case let LKSwiftUISupportAuthServerError.rpcServer(code, _): - // Back-compat: the pre-timestamped stale helper on disk keeps - // emitting api_configuration_missing. A single refresh clears it. - return code == "api_configuration_missing" - default: - return false - } + @objc public class func sharedInstance() -> LKSwiftUISupportGatekeeper { + shared } - func showActivationWindow(from window: NSWindow?) { - performVoidRequest( - method: "ui.show_activation", - payload: LKSwiftUISupportClientProcessPayload( - lookInsideProcessID: ProcessInfo.processInfo.processIdentifier - ), - from: window - ) + // MARK: - State + + public var activationState: LKSwiftUISupportActivationState { + stateLock.withLock { publishedState } } - func showActivationPrompt(from window: NSWindow?) { - let probeResult = LKSwiftUISupportLocalLicenseProbe.probe() - guard probeResult.hasPersistedLicenseMaterial else { - recordLocalActivationRequired(probeResult) - return + /// Re-evaluates the stored license state off the main thread. Posts the + /// state-change notification when the result differs. + public func refreshActivationStateInBackground() { + let shouldStart: Bool = stateLock.withLock { + guard !stateRefreshInFlight else { return false } + stateRefreshInFlight = true + return true } - if currentActivationState == .unknown { - LKSwiftUISupportLogger.authServer.info( - "activation prompt skipped while local license material is present and state is unknown; refreshing in background" - ) - refreshActivationStateInBackground() - return + guard shouldStart else { return } + let runtime = runtime + Task.detached(priority: .utility) { [weak self] in + await runtime.currentDecision() + self?.stateLock.withLock { self?.stateRefreshInFlight = false } } - performVoidRequest( - method: "ui.show_activation_prompt", - payload: LKSwiftUISupportClientProcessPayload( - lookInsideProcessID: ProcessInfo.processInfo.processIdentifier - ), - from: window - ) } - func showLicenseWindow(from window: NSWindow?) { - performVoidRequest(method: "ui.show_license", from: window) + /// Stops the periodic re-evaluation when LookInside quits. The activation + /// state on disk is left as it is. + @objc(shutdownRuntime) + public func shutdownRuntime() { + runtime.stopMonitoring() } - func refreshLicenseStatus(from window: NSWindow?) { - guard hasPersistedLocalLicenseMaterial() else { - presentAccessAlert( - title: LKSwiftUISupportAuthServerAccessDecisionPayload.activationRequired.title, - detail: LKSwiftUISupportAuthServerAccessDecisionPayload.activationRequired.message, - window: window - ) - return - } - let shouldStart: Bool = lock.withLock { - guard !licenseStatusRefreshInFlight else { - return false - } - licenseStatusRefreshInFlight = true + private func recordDecision(_ decision: ActivationAccessDecision) { + let newState = LKSwiftUISupportActivationState(decision) + var previousState = LKSwiftUISupportActivationState.unknown + let shouldNotify: Bool = stateLock.withLock { + let stateChanged = publishedState != newState + let licenseMaterialMayHaveChanged = newState == .activated + && publishedStatusSummary != decision.statusSummary + guard stateChanged || licenseMaterialMayHaveChanged else { return false } + previousState = publishedState + publishedState = newState + publishedStatusSummary = decision.statusSummary return true } - guard shouldStart else { - return - } - - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - defer { - self?.lock.withLock { - self?.licenseStatusRefreshInFlight = false - } - } - guard let self else { return } - - do { - let payload: LKSwiftUISupportAuthServerAccessDecisionPayload = try self.runWithAutoRefresh(window: window) { installation in - let response = try self.sendRequest( - method: "license.refresh_status", - payload: LKSwiftUISupportEmptyPayload(), - installation: installation, - responseType: LKSwiftUISupportAuthServerAccessDecisionPayload.self - ) - guard let payload = response.payload else { - throw LKSwiftUISupportAuthServerError.invalidResponse("Missing access decision payload.") - } - return payload - } - self.recordAccessDecision(payload) - self.presentAccessAlert(title: payload.title, detail: payload.message, window: nil) - } catch { - if self.handleInstallerCancellation(error) { - return - } - self.presentRuntimeAlert( - title: NSLocalizedString("LookInside Auth Server Required", comment: ""), - detail: error.localizedDescription, - window: nil - ) - } - } - } - - func signChallenge( - nonce: Data, - serverInstanceID: String - ) throws -> (signature: Data, intermediateCertDER: Data, udid: String) { - let installation = try ensureInstalledAndRunning(window: nil) - let nonceHex = nonce.map { String(format: "%02x", $0) }.joined() - let response = try sendRequest( - method: "license.sign_challenge", - payload: LKSwiftUISupportSignChallengeRequestPayload( - nonce: nonceHex, - serverInstanceID: serverInstanceID - ), - installation: installation, - responseType: LKSwiftUISupportSignChallengeResponsePayload.self + guard shouldNotify else { return } + LKSwiftUISupportLogger.activation.info( + "activation state changed: \(previousState.lkDebugDescription, privacy: .public) -> \(newState.lkDebugDescription, privacy: .public) (decision=\(decision.decision.rawValue, privacy: .public))" ) - guard let payload = response.payload else { - throw LKSwiftUISupportAuthServerError.invalidResponse("Missing sign-challenge payload.") - } - guard let signature = Data(base64Encoded: payload.signature), - let intermediateDER = Data(base64Encoded: payload.intermediateCertDER) - else { - throw LKSwiftUISupportAuthServerError.invalidResponse("Sign-challenge payload base64 decode failed.") + DispatchQueue.main.async { + NotificationCenter.default.post( + name: Self.activationStateDidChangeNotification, + object: self, + userInfo: ["activationState": NSNumber(value: newState.rawValue)] + ) } - return (signature, intermediateDER, payload.udid) } - func allowProtectedFeatureAccess(for window: NSWindow?) -> Bool { - if Thread.isMainThread, - let mainThreadDecision = mainThreadProtectedFeatureAccessDecision(window: window, presentsAlerts: true) - { - return mainThreadDecision - } + // MARK: - Protected features - guard hasPersistedLocalLicenseMaterial() else { - let payload = LKSwiftUISupportAuthServerAccessDecisionPayload.activationRequired - presentAccessAlert(title: payload.title, detail: payload.message, window: window) - return false - } - do { - let payload = try checkProtectedFeatureAccess(window: window) - - recordAccessDecision(payload) - - switch payload.decision { - case .allow: - return true - case .allowWithWarning: - presentAccessAlert(title: payload.title, detail: payload.message, window: window, deduplicate: true) - return true - case .block: - presentAccessAlert(title: payload.title, detail: payload.message, window: window) - return false - } - } catch { - if handleInstallerCancellation(error) { - return false - } - if Thread.isMainThread, shouldTriggerRefresh(for: error) { - LKSwiftUISupportLogger.authServer.notice( - "protected feature access scheduled background auth server refresh after error=\(error.localizedDescription, privacy: .public)" - ) - refreshActivationStateInBackground() - presentLocalAlert( - title: NSLocalizedString("LookInside Auth Server Updating", comment: ""), - detail: NSLocalizedString("LookInside is preparing the local Auth Server. Try the action again after the update finishes.", comment: ""), - window: window - ) - return false - } - presentRuntimeAlert(title: NSLocalizedString("LookInside Auth Server Required", comment: ""), detail: error.localizedDescription, window: window) + /// Callers include reloads and connection flows, so the alert for a + /// warning or a block is a sheet on `window` and never a modal loop. + @objc(allowProtectedFeatureAccessForWindow:) + public func allowProtectedFeatureAccess(for window: NSWindow?) -> Bool { + let decision = accessDecisionForSynchronousCaller() + switch decision.decision { + case .allow: + return true + case .allowWithWarning: + presentWarningOnce(title: decision.title, message: decision.message, window: window) + return true + case .block: + presentAlert( + title: decision.title, + message: decision.message, + style: .warning, + presentation: .attached(to: window) + ) return false } } - func canUseProtectedFeatureWithoutPrompt() -> Bool { - if Thread.isMainThread, - let mainThreadDecision = mainThreadProtectedFeatureAccessDecision(window: nil, presentsAlerts: false) - { - return mainThreadDecision - } - - switch currentActivationState { - case .activated: - return true - case .notActivated: - if hasPersistedLocalLicenseMaterial() { - refreshActivationStateInBackground() - } - return false - case .unknown: - guard hasPersistedLocalLicenseMaterial() else { - return false - } - refreshActivationStateInBackground() - return true - } + @objc(canUseProtectedFeatureWithoutPrompt) + public func canUseProtectedFeatureWithoutPrompt() -> Bool { + accessDecisionForSynchronousCaller().grantsAccess } - private func mainThreadProtectedFeatureAccessDecision(window: NSWindow?, presentsAlerts: Bool) -> Bool? { - switch currentActivationState { - case .activated: - return true - case .notActivated: - guard hasPersistedLocalLicenseMaterial() else { - if presentsAlerts { - let payload = LKSwiftUISupportAuthServerAccessDecisionPayload.activationRequired - presentAccessAlert(title: payload.title, detail: payload.message, window: window) - } - return false - } - LKSwiftUISupportLogger.authServer.info( - "protected feature denied from cached notActivated state on main thread; refreshing auth state in background" - ) - refreshActivationStateInBackground() - return false - case .unknown: - guard hasPersistedLocalLicenseMaterial() else { - if presentsAlerts { - let payload = LKSwiftUISupportAuthServerAccessDecisionPayload.activationRequired - presentAccessAlert(title: payload.title, detail: payload.message, window: window) - } - return false - } - LKSwiftUISupportLogger.authServer.info( - "protected feature allowed from main thread using persisted local license material; refreshing auth state in background" - ) + /// The main thread answers from the last published decision and refreshes + /// it in the background; other threads wait for a fresh evaluation, which + /// is local and does not touch the network. + private func accessDecisionForSynchronousCaller() -> ActivationAccessDecision { + if Thread.isMainThread, let decision = runtime.lastDecision { refreshActivationStateInBackground() - return true + return decision } + return evaluateDecisionBlocking() } - private func checkProtectedFeatureAccess(window: NSWindow?) throws -> LKSwiftUISupportAuthServerAccessDecisionPayload { - let operation: (LKSwiftUISupportAuthServerInstallation) throws -> LKSwiftUISupportAuthServerAccessDecisionPayload = { installation in - let response = try self.sendRequest( - method: "license.check_access", - payload: LKSwiftUISupportEmptyPayload(), - installation: installation, - responseType: LKSwiftUISupportAuthServerAccessDecisionPayload.self - ) - guard let payload = response.payload else { - throw LKSwiftUISupportAuthServerError.invalidResponse("Missing access decision payload.") - } - return payload - } - - if Thread.isMainThread { - let installation = try ensureServerAvailable(using: resolveInstallation()) - return try operation(installation) + private func evaluateDecisionBlocking() -> ActivationAccessDecision { + let runtime = runtime + let box = LKSwiftUISupportDecisionBox() + let semaphore = DispatchSemaphore(value: 0) + Task.detached(priority: .userInitiated) { + box.decision = await runtime.currentDecision() + semaphore.signal() } - - return try runWithAutoRefresh(window: window, operation) + semaphore.wait() + return box.decision ?? runtime.lastDecision ?? Self.activationRequiredDecision } - private func performVoidRequest(method: String, from window: NSWindow?) { - performVoidRequest( - method: method, - payload: LKSwiftUISupportEmptyPayload(), - from: window + private static var activationRequiredDecision: ActivationAccessDecision { + ActivationAccessDecision( + decision: .block, + title: NSLocalizedString("Activation Required", comment: ""), + message: NSLocalizedString( + "Activate LookInside Pro from the LookInside Pro menu before using this feature.", + comment: "" + ), + statusSummary: nil ) } - private func performVoidRequest( - method: String, - payload: RequestPayload, - from window: NSWindow? - ) { - do { - try runWithAutoRefresh(window: window) { installation in - _ = try self.sendRequest( - method: method, - payload: payload, - installation: installation, - responseType: LKSwiftUISupportEmptyPayload.self - ) - } - } catch { - if handleInstallerCancellation(error) { - return - } - presentRuntimeAlert(title: NSLocalizedString("LookInside Auth Server Required", comment: ""), detail: error.localizedDescription, window: window) - } - } - - private func handleInstallerCancellation(_ error: Error) -> Bool { - guard let installerError = error as? LKSwiftUISupportInstallerError, - case .cancelled = installerError - else { - return false - } - terminateHelperProcess() - recordAccessDecision(.activationRequired) - LKSwiftUISupportLogger.authServer.info("auth server install cancelled action=notActivated") - return true - } + // MARK: - License handshake (220 / 221) - private func runWithAutoRefresh( - window: NSWindow?, - _ body: (LKSwiftUISupportAuthServerInstallation) throws -> T - ) throws -> T { - do { - let installation = try ensureInstalledAndRunning(window: window) - return try body(installation) - } catch { - guard shouldTriggerRefresh(for: error) else { - throw error - } - LKSwiftUISupportLogger.authServer.notice( - "auto-refresh triggered by error=\(error.localizedDescription, privacy: .public)" - ) - terminateHelperProcess() - LKSwiftUISupportInstaller.shared.invalidate() - let installation = try ensureInstalledAndRunning(window: window) - return try body(installation) - } + /// `YES` when a license handshake may start on `channelID` now. It may + /// not before LookInside shows its first window, before the user + /// confirmed the keychain explainer for a key LookInside did not create + /// (this call then asks for the explainer), while automatic signing + /// waits after a refused keychain prompt (an hour, then a day, until Try + /// Again in the license window), or after a handshake failed on this + /// channel until it reconnects, the activation changes or the denial + /// wait ends. Every handshake attempt, including a retry with a fresh + /// challenge, asks first. A request on a channel without a handshake + /// still runs; the Server then withholds the Pro features. + @objc(shouldStartLicenseHandshakeOnChannel:) + public func shouldStartLicenseHandshake(onChannel channelID: String) -> Bool { + runtime.allowsLicenseHandshake(onChannel: channelID) } - private func ensureInstalledAndRunning( - window: NSWindow?, - installMode: LKSwiftUISupportAuthServerInstallMode = .userInitiated - ) throws -> LKSwiftUISupportAuthServerInstallation { - switch installMode { - case .userInitiated: - try LKSwiftUISupportInstaller.shared.ensureInstalled(presentingWindow: window) - case .backgroundRefresh: - try LKSwiftUISupportInstaller.shared.ensureInstalledWithoutUserInteraction() - } - let installation = try resolveInstallation() - return try ensureServerAvailable(using: installation) + /// Records that the Server on `channelID` rejected the 221 or sent a + /// malformed 220, so the handshake is not repeated on that channel. + @objc(noteLicenseHandshakeFailedOnChannel:) + public func noteLicenseHandshakeFailed(onChannel channelID: String) { + runtime.noteLicenseHandshakeFailed(onChannel: channelID) } - private func ensureServerAvailable( - using installation: LKSwiftUISupportAuthServerInstallation - ) throws -> LKSwiftUISupportAuthServerInstallation { - let presence = lock.withLock { helperPresence } - - if presence == .notDetermined { - try launchHelperIfNeeded(for: installation) - try waitForHealthyServer( - using: installation, - timeout: LKSwiftUISupportAuthServerConstants.firstLaunchHealthTimeout - ) - lock.withLock { helperPresence = .spawned } - return installation - } - - for attempt in 0 ..< 2 { - do { - try performHealthPing(using: installation) - return installation - } catch let error as LKSwiftUISupportAuthServerError { - if case .helperVersionMismatch = error { - throw error - } - LKSwiftUISupportLogger.authServer.info( - "health probe attempt \(attempt + 1) failed: \(error.localizedDescription, privacy: .public)" - ) - } catch { - LKSwiftUISupportLogger.authServer.info( - "health probe attempt \(attempt + 1) failed: \(error.localizedDescription, privacy: .public)" - ) - } - } - - try launchHelperIfNeeded(for: installation) - try waitForHealthyServer( - using: installation, - timeout: LKSwiftUISupportAuthServerConstants.relaunchHealthTimeout - ) - return installation + /// Forgets `channelID` after it disconnected. + @objc(licenseHandshakeChannelDidEnd:) + public func licenseHandshakeChannelDidEnd(_ channelID: String) { + runtime.noteLicenseHandshakeChannelEnded(channelID) } - private func performHealthPing( - using installation: LKSwiftUISupportAuthServerInstallation + /// Signs `nonce || server_instance_id.utf8` with this Mac's intermediate + /// key. On success writes the RSA-PKCS1v15-SHA256 signature to + /// `signatureOut`, the DER-encoded intermediate certificate to + /// `intermediateCertDEROut`, and the device UDID to `udidOut`. Returns + /// `NO` and populates `error` when the license is not activated, the + /// lease has expired, the request is malformed, the signing policy holds + /// the key back or signing fails. + /// + /// Blocks until the signature is ready; call it off the main thread. + @objc(signChallengeWithNonce:serverInstanceID:signature:intermediateCertDER:udid:error:) + public func signChallenge( + nonce: Data, + serverInstanceID: String, + signature signatureOut: AutoreleasingUnsafeMutablePointer, + intermediateCertDER intermediateCertDEROut: AutoreleasingUnsafeMutablePointer, + udid udidOut: AutoreleasingUnsafeMutablePointer ) throws { - let response = try sendRequest( - method: "health.ping", - payload: LKSwiftUISupportEmptyPayload(), - installation: installation, - responseType: LKSwiftUISupportAuthServerHealthPayload.self + try signChallenge( + nonce: nonce, + serverInstanceID: serverInstanceID, + channelID: nil, + signature: signatureOut, + intermediateCertDER: intermediateCertDEROut, + udid: udidOut, + keyUseDuration: nil ) - guard let payload = response.payload else { - throw LKSwiftUISupportAuthServerError.invalidResponse("Missing health payload.") - } - guard payload.protocolVersion == LKSwiftUISupportAuthServerConstants.supportedProtocolVersion else { - throw LKSwiftUISupportAuthServerError.incompatibleProtocol( - expected: LKSwiftUISupportAuthServerConstants.supportedProtocolVersion, - found: payload.protocolVersion - ) - } - try enforceVersionMatch(payload: payload) } - private func waitForHealthyServer( - using installation: LKSwiftUISupportAuthServerInstallation, - timeout: TimeInterval + /// `signChallengeWithNonce:serverInstanceID:signature:...` for the + /// handshake on `channelID`: a signing that fails there is not repeated + /// on that channel. `keyUseDuration`, when given, receives how long the + /// key took in its turn (the keychain's time, including a prompt), + /// without the time this call waited behind other uses of the key. + @objc(signChallengeWithNonce:serverInstanceID:channelID:signature:intermediateCertDER:udid:keyUseDuration:error:) + public func signChallenge( + nonce: Data, + serverInstanceID: String, + channelID: String?, + signature signatureOut: AutoreleasingUnsafeMutablePointer, + intermediateCertDER intermediateCertDEROut: AutoreleasingUnsafeMutablePointer, + udid udidOut: AutoreleasingUnsafeMutablePointer, + keyUseDuration keyUseDurationOut: UnsafeMutablePointer? ) throws { - let deadline = Date().addingTimeInterval(timeout) - var lastError: Error? - - while Date() < deadline { - do { - try performHealthPing(using: installation) - return - } catch let error as LKSwiftUISupportAuthServerError { - if case .helperVersionMismatch = error { - throw error - } - lastError = error - usleep(LKSwiftUISupportAuthServerConstants.helperHealthPollInterval) - } catch { - lastError = error - usleep(LKSwiftUISupportAuthServerConstants.helperHealthPollInterval) - } - } - - if let lastError { - LKSwiftUISupportLogger.authServer.error( - "launch health check failed: \(lastError.localizedDescription, privacy: .public)" - ) - } - throw LKSwiftUISupportAuthServerError.launchTimedOut(installation.socketURL.path) - } - - private func enforceVersionMatch(payload: LKSwiftUISupportAuthServerHealthPayload) throws { - #if DEBUG - if LKSwiftUISupportInstallerLayout.debugLocalAuthRepositoryURL != nil { - return - } - #endif - - guard Thread.isMainThread == false else { - return - } - - let published: String? - published = LKSwiftUISupportInstaller.shared.fetchPublishedVersion() - guard let published else { - return - } - guard published == payload.serverVersion else { - LKSwiftUISupportLogger.authServer.notice( - "helper version mismatch expected=\(published, privacy: .public) found=\(payload.serverVersion, privacy: .public)" - ) - throw LKSwiftUISupportAuthServerError.helperVersionMismatch( - expected: published, - found: payload.serverVersion - ) - } - } - - private func resolveInstallation() throws -> LKSwiftUISupportAuthServerInstallation { - let environment = ProcessInfo.processInfo.environment - let fileManager = FileManager.default - - let executableURL = LKSwiftUISupportAuthServerPathPolicy.executableURL( - environment: environment, - overrideKey: LKSwiftUISupportAuthServerConstants.helperPathEnvironmentKey, - defaultURL: LKSwiftUISupportInstallerLayout.installedExecutableURL - ) - - guard fileManager.fileExists(atPath: executableURL.path) else { - throw LKSwiftUISupportAuthServerError.helperMissing(executableURL.path) - } - - let socketURL = LKSwiftUISupportAuthServerPathPolicy.socketURL( - environment: environment, - overrideKey: LKSwiftUISupportAuthServerConstants.helperSocketPathEnvironmentKey, - defaultURL: LKSwiftUISupportInstallerLayout.installedSocketURL - ) - - return LKSwiftUISupportAuthServerInstallation( - executableURL: executableURL, - socketURL: socketURL - ) - } - - private func launchHelperIfNeeded(for installation: LKSwiftUISupportAuthServerInstallation) throws { - let shouldLaunch = lock.withLock { - if let launchedProcess, launchedProcess.isRunning { - return false - } - - let process = Process() - process.executableURL = installation.executableURL - let clientProcessID = ProcessInfo.processInfo.processIdentifier - process.arguments = [ - "--socket-path", installation.socketURL.path, - "--lookinside-pid", "\(clientProcessID)", - ] - - var environment = LKSwiftUISupportAuthServerPathPolicy.launchEnvironment( - from: ProcessInfo.processInfo.environment, - helperPathKey: LKSwiftUISupportAuthServerConstants.helperPathEnvironmentKey, - helperVersionKey: "LOOKINSIDE_AUTH_SERVER_VERSION" - ) - environment[LKSwiftUISupportAuthServerConstants.helperSocketPathEnvironmentKey] = installation.socketURL.path - environment[LKSwiftUISupportAuthServerConstants.helperClientProcessIDEnvironmentKey] = "\(clientProcessID)" - process.environment = environment - process.terminationHandler = { [weak self] _ in - self?.lock.withLock { - self?.launchedProcess = nil - self?.helperPresence = .notDetermined - } - } - self.launchedProcess = process - return true - } - - guard shouldLaunch else { - return - } - + let result: ActivationChallengeSignature do { - let process = lock.withLock { launchedProcess } - try process?.run() - } catch { - lock.withLock { - self.launchedProcess = nil - } - throw LKSwiftUISupportAuthServerError.launchFailed(error.localizedDescription) - } - } - - private func sendRequest( - method: String, - payload: RequestPayload, - installation: LKSwiftUISupportAuthServerInstallation, - responseType _: ResponsePayload.Type - ) throws -> LKSwiftUISupportAuthServerResponseEnvelope { - let request = LKSwiftUISupportAuthServerRequestEnvelope( - protocolVersion: LKSwiftUISupportAuthServerConstants.supportedProtocolVersion, - requestID: UUID().uuidString.lowercased(), - method: method, - payload: payload - ) - let start = Date() - LKSwiftUISupportLogger.authServer.info( - "rpc-start method=\(method, privacy: .public) request_id=\(request.requestID, privacy: .public) socket=\(installation.socketURL.path, privacy: .public)" - ) - do { - let requestData = try Self.jsonEncoder.encode(request) - let responseData = try Self.sendSocketRequest( - requestData, - to: installation.socketURL.path - ) - - let response = try Self.jsonDecoder.decode( - LKSwiftUISupportAuthServerResponseEnvelope.self, - from: responseData - ) - - guard response.protocolVersion == LKSwiftUISupportAuthServerConstants.supportedProtocolVersion else { - throw LKSwiftUISupportAuthServerError.incompatibleProtocol( - expected: LKSwiftUISupportAuthServerConstants.supportedProtocolVersion, - found: response.protocolVersion - ) - } - - if response.ok == false { - let payload = response.error ?? .init(code: "unknown_error", message: "The helper did not provide an error payload.") - throw LKSwiftUISupportAuthServerError.rpcServer(code: payload.code, message: payload.message) - } - - let durationMs = Int(Date().timeIntervalSince(start) * 1000) - LKSwiftUISupportLogger.authServer.info( - "rpc-ok method=\(method, privacy: .public) request_id=\(request.requestID, privacy: .public) duration_ms=\(durationMs, privacy: .public)" + let measured = try runtime.signChallengeMeasuredBlocking( + nonce: nonce, + serverInstanceID: serverInstanceID, + channel: channelID ) - return response - } catch { - let durationMs = Int(Date().timeIntervalSince(start) * 1000) - let code: String - if case let LKSwiftUISupportAuthServerError.rpcServer(errorCode, _) = error { - code = errorCode - } else { - code = "client_error" - } - LKSwiftUISupportLogger.authServer.error( - "rpc-fail method=\(method, privacy: .public) request_id=\(request.requestID, privacy: .public) code=\(code, privacy: .public) duration_ms=\(durationMs, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + result = measured.signature + keyUseDurationOut?.pointee = measured.keyUseDuration + } catch let error as ActivationError { + LKSwiftUISupportLogger.activation.error( + "sign_challenge failed code=\(error.errorCode, privacy: .public)" ) throw error } + signatureOut.pointee = result.signature as NSData + intermediateCertDEROut.pointee = result.intermediateCertificateDER as NSData + udidOut.pointee = result.udid as NSString } - private func presentRuntimeAlert(title: String, detail: String, window: NSWindow?) { - presentAlert(title: title, detail: detail, window: window, deduplicate: true) - } - - private func presentAccessAlert(title: String, detail: String, window: NSWindow?, deduplicate: Bool = false) { - presentAlert(title: title, detail: detail, window: window, deduplicate: deduplicate) - } - - private func presentLocalAlert(title: String, detail: String, window: NSWindow?) { - DispatchQueue.main.async { - let alert = NSAlert() - alert.messageText = title - alert.informativeText = detail - alert.alertStyle = .informational - alert.addButton(withTitle: NSLocalizedString("OK", comment: "")) - if let window { - alert.beginSheetModal(for: window, completionHandler: nil) - } else { - alert.runModal() - } - } - } - - private func presentAlert(title: String, detail: String, window _: NSWindow?, deduplicate: Bool) { - if deduplicate { - let shouldPresent = lock.withLock { - if lastPresentedErrorDescription == detail { - return false - } - lastPresentedErrorDescription = detail - return true - } + // MARK: - Windows - guard shouldPresent else { - return - } + @objc(showActivationWindow) + public func showActivationWindow() { + onMain { coordinator in + Task { await coordinator.showActivationWindow() } } - - presentAuthenticatorAlert(title: title, detail: detail) } - private func presentAuthenticatorAlert(title: String, detail: String) { - DispatchQueue.global(qos: .userInitiated).async { [weak self] in - guard let self else { return } - do { - let installation = try self.ensureServerAvailable( - using: self.resolveInstallation() - ) - _ = try self.sendRequest( - method: "ui.show_alert", - payload: LKSwiftUISupportUserAlertPayload( - title: title, - message: detail, - style: "warning" - ), - installation: installation, - responseType: LKSwiftUISupportEmptyPayload.self - ) - } catch { - LKSwiftUISupportLogger.authServer.error( - "authenticator alert delivery failed: \(error.localizedDescription, privacy: .public)" - ) - } + @objc(showLicenseWindow) + public func showLicenseWindow() { + onMain { coordinator in + Task { await coordinator.showLicenseWindow() } } } - private static let jsonEncoder: JSONEncoder = { - let encoder = JSONEncoder() - encoder.dateEncodingStrategy = .iso8601 - encoder.outputFormatting = [.sortedKeys] - return encoder - }() - - private static let jsonDecoder: JSONDecoder = { - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - return decoder - }() - - private static func sendSocketRequest(_ data: Data, to socketPath: String) throws -> Data { - let fileManager = FileManager.default - let socketDirectory = URL(fileURLWithPath: socketPath).deletingLastPathComponent() - try fileManager.createDirectory(at: socketDirectory, withIntermediateDirectories: true) - - let fd = socket(AF_UNIX, SOCK_STREAM, 0) - guard fd >= 0 else { - throw LKSwiftUISupportAuthServerError.rpcTransport(String(cString: strerror(errno))) - } - - defer { - close(fd) - } - - var address = sockaddr_un() - #if os(macOS) - address.sun_len = UInt8(MemoryLayout.stride) - #endif - address.sun_family = sa_family_t(AF_UNIX) - - let pathBytes = socketPath.utf8CString - let maxPathLength = MemoryLayout.size(ofValue: address.sun_path) - guard pathBytes.count <= maxPathLength else { - throw LKSwiftUISupportAuthServerError.socketPathInvalid(socketPath) + /// Fetches the license status from the activation service, shows it in + /// the license window and reports the result in an alert. + @objc(refreshLicenseStatus) + public func refreshLicenseStatus() { + let shouldStart: Bool = stateLock.withLock { + guard !licenseStatusRefreshInFlight else { return false } + licenseStatusRefreshInFlight = true + return true } + guard shouldStart else { return } - withUnsafeMutablePointer(to: &address.sun_path) { pointer in - let destination = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CChar.self) - destination.initialize(repeating: 0, count: maxPathLength) - pathBytes.withUnsafeBufferPointer { buffer in - guard let baseAddress = buffer.baseAddress else { + let runtime = runtime + onMain { [weak self] coordinator in + Task { @MainActor in + defer { + self?.stateLock.withLock { self?.licenseStatusRefreshInFlight = false } + } + guard await runtime.hasLicenseMaterial() else { + let decision = Self.activationRequiredDecision + coordinator.showAlert( + ActivationAlert(title: decision.title, message: decision.message, style: .warning) + ) return } - _ = strncpy(destination, baseAddress, maxPathLength - 1) - } - } - - let connectResult = withUnsafePointer(to: &address) { pointer in - pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { - connect(fd, $0, socklen_t(MemoryLayout.stride)) - } - } - - guard connectResult == 0 else { - throw LKSwiftUISupportAuthServerError.rpcTransport(String(cString: strerror(errno))) - } - - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: false) - do { - try handle.write(contentsOf: data) - Darwin.shutdown(fd, SHUT_WR) - guard let responseData = try handle.readToEnd(), responseData.isEmpty == false else { - throw LKSwiftUISupportAuthServerError.invalidResponse("The helper closed the connection without a payload.") + do { + let decision = try await coordinator.refreshLicenseStatus() + coordinator.showAlert( + ActivationAlert(title: decision.title, message: decision.message, style: .warning) + ) + } catch { + LKSwiftUISupportLogger.activation.error( + "license status refresh failed: \(error.localizedDescription, privacy: .public)" + ) + coordinator.showAlert( + ActivationAlert( + title: NSLocalizedString("Unable to Refresh License Status", comment: ""), + message: error.localizedDescription, + style: .warning + ) + ) + } } - return responseData - } catch { - throw LKSwiftUISupportAuthServerError.rpcTransport(error.localizedDescription) } } -} - -private extension NSLock { - func withLock(_ work: () throws -> T) rethrows -> T { - lock() - defer { unlock() } - return try work() - } -} - -@objcMembers -public final class LKSwiftUISupportGatekeeper: NSObject { - private static let shared = LKSwiftUISupportGatekeeper() - private let runtimeBridge = LKSwiftUISupportAuthServerBridge() - private let activationPromptLock = NSLock() - private var hasPendingDetectedSwiftUISupportPrompt = false - private var hasPromptedForDetectedSwiftUISupport = false - private var hasPendingDetectionPrompt = false - private var hasInstalledKeyWindowObserver = false - public static let activationStateDidChangeNotification = Notification.Name( - "LKSwiftUISupportActivationStateDidChangeNotification" - ) - - public static var activationStateDidChangeNotificationName: NSString { - activationStateDidChangeNotification.rawValue as NSString - } - - override private init() { - super.init() - runtimeBridge.startActivationStatePolling() - } - - @objc public class func sharedInstance() -> LKSwiftUISupportGatekeeper { - shared - } - - @objc(shutdownRuntime) - public func shutdownRuntime() { - runtimeBridge.shutdownRuntime() - } - - @objc(showActivationWindow) - public func showActivationWindow() { - runtimeBridge.showActivationWindow(from: NSApp.keyWindow) - } + // MARK: - SwiftUI detection prompt @objc(promptForDetectedSwiftUISupportIfNeededForWindow:) public func promptForDetectedSwiftUISupportIfNeeded(window: NSWindow?) { @@ -1257,58 +532,110 @@ public final class LKSwiftUISupportGatekeeper: NSObject { return NSStringFromClass(type(of: wc)) == "LKStaticWindowController" } + /// Shown as a sheet on the inspector window that triggered it. As in + /// 2.3.x, a Mac without stored license material (no activation, lease + /// or trial) gets no automatic prompt; the LookInside Pro menu still + /// opens the activation window. private func presentSwiftUISupportActivationPrompt(window: NSWindow?) { - runtimeBridge.showActivationPrompt(from: window) + let runtime = runtime + Task { [weak self, weak window] in + guard await runtime.hasLicenseMaterial() else { + LKSwiftUISupportLogger.activation.info( + "activation prompt skipped: no local license material" + ) + return + } + self?.onMain { [weak self] coordinator in + guard self?.activationState != .activated else { return } + coordinator.showActivationPrompt(presentation: .attached(to: window)) + } + } } - @objc(showLicenseWindow) - public func showLicenseWindow() { - runtimeBridge.showLicenseWindow(from: NSApp.keyWindow) - } + // MARK: - Alerts - @objc(refreshLicenseStatus) - public func refreshLicenseStatus() { - runtimeBridge.refreshLicenseStatus(from: NSApp.keyWindow) + /// A warning that still grants access is shown once per message. + private func presentWarningOnce(title: String, message: String, window: NSWindow?) { + let shouldPresent: Bool = stateLock.withLock { + guard lastPresentedWarning != message else { return false } + lastPresentedWarning = message + return true + } + guard shouldPresent else { return } + presentAlert(title: title, message: message, style: .warning, presentation: .attached(to: window)) } - @objc(allowProtectedFeatureAccessForWindow:) - public func allowProtectedFeatureAccess(for window: NSWindow?) -> Bool { - runtimeBridge.allowProtectedFeatureAccess(for: window) + private func presentAlert( + title: String, + message: String, + style: ActivationAlert.Style, + presentation: ActivationAlertPresentation + ) { + onMain { coordinator in + coordinator.showAlert( + ActivationAlert(title: title, message: message, style: style), + presentation: presentation + ) + } } - @objc(canUseProtectedFeatureWithoutPrompt) - public func canUseProtectedFeatureWithoutPrompt() -> Bool { - runtimeBridge.canUseProtectedFeatureWithoutPrompt() - } + // MARK: - Main thread - public var activationState: LKSwiftUISupportActivationState { - runtimeBridge.currentActivationState + /// Runs `work` on the main thread with the activation window coordinator. + /// Always asynchronous, so callers on any thread return before an alert or + /// window appears. + private func onMain(_ work: @escaping @MainActor (ActivationWindowCoordinator) -> Void) { + DispatchQueue.main.async { [weak self] in + MainActor.assumeIsolated { + guard let self else { return } + work(self.windowCoordinator()) + } + } } - public func refreshActivationStateInBackground() { - runtimeBridge.refreshActivationStateInBackground() + @MainActor + private func windowCoordinator() -> ActivationWindowCoordinator { + if let cachedWindowCoordinator { + return cachedWindowCoordinator + } + let coordinator = ActivationWindowCoordinator(runtime: runtime) + cachedWindowCoordinator = coordinator + return coordinator } +} - /// Requests a signature over `nonce || server_instance_id.utf8` from the - /// local Auth helper. On success writes the RSA-PKCS1v15-SHA256 signature - /// to `signatureOut`, the DER-encoded intermediate certificate to - /// `intermediateCertDEROut`, and the device UDID to `udidOut`. Returns - /// `NO` and populates `error` on any failure (no helper, socket error, - /// license not activated, signing failed, etc.). - @objc(signChallengeWithNonce:serverInstanceID:signature:intermediateCertDER:udid:error:) - public func signChallenge( - nonce: Data, - serverInstanceID: String, - signature signatureOut: AutoreleasingUnsafeMutablePointer, - intermediateCertDER intermediateCertDEROut: AutoreleasingUnsafeMutablePointer, - udid udidOut: AutoreleasingUnsafeMutablePointer - ) throws { - let result = try runtimeBridge.signChallenge( - nonce: nonce, - serverInstanceID: serverInstanceID - ) - signatureOut.pointee = result.signature as NSData - intermediateCertDEROut.pointee = result.intermediateCertDER as NSData - udidOut.pointee = result.udid as NSString - } +private final class LKSwiftUISupportDecisionBox: @unchecked Sendable { + var decision: ActivationAccessDecision? } + +#if DEBUG + public extension LKSwiftUISupportGatekeeper { + /// DEBUG end-to-end hook (LKDebugE2EDump.swift): creates the license key + /// in the test file keychain and returns a certificate request for + /// it. See `ActivationRuntime.debugMakeTestKeyCertificateSigningRequestPEM`. + @objc(debugMakeTestKeyCertificateSigningRequestPEMWithCommonName:error:) + func debugMakeTestKeyCertificateSigningRequestPEM(commonName: String) throws -> String { + try runtime.debugMakeTestKeyCertificateSigningRequestPEM(commonName: commonName) + } + + /// DEBUG end-to-end hook (LKDebugE2EDump.swift): the Host runs behind + /// other apps there, so its window never becomes key. The dump calls + /// this once a Host window is visible, the condition this gatekeeper + /// itself accepts when it is created after the first window. + @objc(debugNoteFirstWindowShown) + func debugNoteFirstWindowShown() { + runtime.noteFirstWindowShown() + } + + /// DEBUG UI snapshot hook (LKDebugUISnapshots.swift): the activation + /// window with its model restored from the activation state, as + /// `showActivationWindow()` prepares it, but not presented: the + /// snapshot shows it without activating the app or floating it. + @MainActor + internal func debugPreparedActivationWindow() async -> NSWindow { + let coordinator = windowCoordinator() + await runtime.restoreActivationModel() + return coordinator.activationWindow + } + } +#endif diff --git a/LookInside/SwiftUISupport/LKSwiftUISupportInstaller.swift b/LookInside/SwiftUISupport/LKSwiftUISupportInstaller.swift deleted file mode 100644 index b941b9e0..00000000 --- a/LookInside/SwiftUISupport/LKSwiftUISupportInstaller.swift +++ /dev/null @@ -1,657 +0,0 @@ -import AppKit -import CryptoKit -import Foundation -import Security - -enum LKSwiftUISupportInstallerError: LocalizedError { - case downloadFailed(String) - case unzipFailed(String) - case checksumFailed(String) - case appBundleNotFound - case teamIdentifierUnavailable(String) - case teamIdentifierMismatch(expected: String, found: String) - case installFailed(String) - case cancelled - - var errorDescription: String? { - switch self { - case let .downloadFailed(message): - return String(format: NSLocalizedString("Failed to download LookInside Auth Server.\n%@", comment: ""), message) - case let .unzipFailed(message): - return String(format: NSLocalizedString("Failed to extract LookInside Auth Server.\n%@", comment: ""), message) - case let .checksumFailed(message): - return String(format: NSLocalizedString("Failed to verify LookInside Auth Server download.\n%@", comment: ""), message) - case .appBundleNotFound: - return NSLocalizedString("The downloaded archive did not contain a LookInside Auth Server bundle.", comment: "") - case let .teamIdentifierUnavailable(message): - return String(format: NSLocalizedString("Unable to read the code signature of LookInside Auth Server.\n%@", comment: ""), message) - case let .teamIdentifierMismatch(expected, found): - return String(format: NSLocalizedString("LookInside Auth Server is signed by a different team.\nExpected: %1$@\nFound: %2$@", comment: ""), expected, found) - case let .installFailed(message): - return String(format: NSLocalizedString("Failed to install LookInside Auth Server.\n%@", comment: ""), message) - case .cancelled: - return NSLocalizedString("Installation was cancelled.", comment: "") - } - } -} - -enum LKSwiftUISupportInstallerStage: String { - case preparing = "Preparing…" - case checkingForUpdates = "Checking for updates…" - case downloading = "Downloading…" - case extracting = "Extracting…" - case verifying = "Verifying code signature…" - case installing = "Installing…" - case finishing = "Finalizing…" - - var localizedDescription: String { - switch self { - case .preparing: - return NSLocalizedString("Preparing…", comment: "") - case .checkingForUpdates: - return NSLocalizedString("Checking for updates…", comment: "") - case .downloading: - return NSLocalizedString("Downloading…", comment: "") - case .extracting: - return NSLocalizedString("Extracting…", comment: "") - case .verifying: - return NSLocalizedString("Verifying code signature…", comment: "") - case .installing: - return NSLocalizedString("Installing…", comment: "") - case .finishing: - return NSLocalizedString("Finalizing…", comment: "") - } - } -} - -enum LKSwiftUISupportInstallerLayout { - static let appBundleName = "lookinside-auth-server.app" - static let executableLeafName = "lookinside-auth-server" - static let installParentRelativePath = "Library/Application Support/LookInside/AuthServer/current" - static let socketRelativePath = "Library/Application Support/LookInside/AuthServer/run/lookinside-auth-server.sock" - - static let downloadURL = URL(string: "https://lookinside-app.com/downloads/auth-server/lookinside-auth-server.app.zip")! - static let checksumURL = URL(string: "https://lookinside-app.com/downloads/auth-server/lookinside-auth-server.app.zip.sha256")! - static let versionURL = URL(string: "https://lookinside-app.com/downloads/auth-server/lookinside-auth-server.app.zip.version")! - - static var installedAppURL: URL { - #if DEBUG - if debugLocalAuthRepositoryURL != nil { - return debugInstalledAppURL - } - #endif - - return FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(installParentRelativePath, isDirectory: true) - .appendingPathComponent(appBundleName, isDirectory: true) - } - - static var installedExecutableURL: URL { - installedAppURL - .appendingPathComponent("Contents", isDirectory: true) - .appendingPathComponent("MacOS", isDirectory: true) - .appendingPathComponent(executableLeafName, isDirectory: false) - } - - static var installedSocketURL: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(socketRelativePath, isDirectory: false) - } - - static var isInstalled: Bool { - FileManager.default.isExecutableFile(atPath: installedExecutableURL.path) - } - - static var installedInfoPlistURL: URL { - installedAppURL - .appendingPathComponent("Contents", isDirectory: true) - .appendingPathComponent("Info.plist", isDirectory: false) - } - - #if DEBUG - static var debugInstalledAppURL: URL { - URL(fileURLWithPath: "/tmp", isDirectory: true) - .appendingPathComponent("lookinside-auth-server-debug", isDirectory: true) - .appendingPathComponent("current", isDirectory: true) - .appendingPathComponent(appBundleName, isDirectory: true) - } - - static var debugLocalAuthRepositoryURL: URL? { - let fileManager = FileManager.default - let searchRoots = [ - URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true), - Bundle.main.bundleURL, - URL(fileURLWithPath: #filePath, isDirectory: false).deletingLastPathComponent(), - ] - - for root in searchRoots { - if let monorepoRoot = findMonorepoRoot(startingAt: root) { - let authRepositoryURL = monorepoRoot.appendingPathComponent("LookInside-Auth", isDirectory: true) - let projectURL = authRepositoryURL.appendingPathComponent("Project.swift", isDirectory: false) - if fileManager.fileExists(atPath: projectURL.path) { - return authRepositoryURL - } - } - } - - return nil - } - - private static func findMonorepoRoot(startingAt startURL: URL) -> URL? { - let fileManager = FileManager.default - var cursor = startURL.standardizedFileURL - if cursor.hasDirectoryPath == false { - cursor.deleteLastPathComponent() - } - - for _ in 0 ..< 12 { - let gitmodulesURL = cursor.appendingPathComponent(".gitmodules", isDirectory: false) - let authRepositoryURL = cursor.appendingPathComponent("LookInside-Auth", isDirectory: true) - if fileManager.fileExists(atPath: gitmodulesURL.path), - fileManager.fileExists(atPath: authRepositoryURL.path) - { - return cursor - } - - let parent = cursor.deletingLastPathComponent() - guard parent.path != cursor.path else { break } - cursor = parent - } - - return nil - } - #endif -} - -final class LKSwiftUISupportInstaller { - static let shared = LKSwiftUISupportInstaller() - - private let installLock = NSLock() - private let cacheLock = NSLock() - private var cachedPublishedVersion: String? - private var lastFailedFetchAt: Date? - private var lastFailedBackgroundInstallAt: Date? - private static let failedFetchCooldown: TimeInterval = 30 - private static let failedBackgroundInstallCooldown: TimeInterval = 300 - #if DEBUG - private var debugLocalInstallPrepared = false - #endif - - func ensureInstalled(presentingWindow: NSWindow?) throws { - #if DEBUG - if LKSwiftUISupportInstallerLayout.debugLocalAuthRepositoryURL != nil { - try ensureDebugLocalInstall() - return - } - #endif - - if LKSwiftUISupportInstallerLayout.isInstalled { - let installed = installedHelperVersion() - let published = publishedVersionForInstalledHelperCheck() - if let published, let installed, published != installed { - LKSwiftUISupportLogger.installer.notice( - "installed helper is stale installed=\(installed, privacy: .public) published=\(published, privacy: .public) action=refresh" - ) - invalidate() - try runInstallWithModal(presentingWindow: presentingWindow) - return - } - if published == nil { - LKSwiftUISupportLogger.installer.warning( - "published version unavailable; keeping cached helper installed=\(installed ?? "unknown", privacy: .public)" - ) - } - try verifyTeamIdentifier(of: LKSwiftUISupportInstallerLayout.installedAppURL) - return - } - try runInstallWithModal(presentingWindow: presentingWindow) - } - - func ensureInstalledWithoutUserInteraction() throws { - #if DEBUG - if LKSwiftUISupportInstallerLayout.debugLocalAuthRepositoryURL != nil { - try ensureDebugLocalInstall() - return - } - #endif - - try enforceBackgroundInstallCooldown() - do { - try ensureInstalledForBackgroundRefresh() - cacheLock.lkLock { - lastFailedBackgroundInstallAt = nil - } - } catch { - cacheLock.lkLock { - lastFailedBackgroundInstallAt = Date() - } - throw error - } - } - - #if DEBUG - private func ensureDebugLocalInstall() throws { - installLock.lock() - defer { installLock.unlock() } - - if debugLocalInstallPrepared, LKSwiftUISupportInstallerLayout.isInstalled { - return - } - - let destination = LKSwiftUISupportInstallerLayout.debugInstalledAppURL - guard FileManager.default.isExecutableFile(atPath: LKSwiftUISupportInstallerLayout.installedExecutableURL.path) else { - throw LKSwiftUISupportInstallerError.installFailed( - "Debug auth server app is missing. Build the LookInside Debug target again to prepare \(destination.path)." - ) - } - try LKInstallerFilesystem.ensureExecutableBit( - at: LKSwiftUISupportInstallerLayout.installedExecutableURL, - errorBuilder: { LKSwiftUISupportInstallerError.installFailed($0) } - ) - debugLocalInstallPrepared = true - invalidatePublishedVersionCache() - LKSwiftUISupportLogger.installer.notice( - "debug auth server app ready destination=\(destination.path, privacy: .public)" - ) - } - #endif - - func installedHelperVersion() -> String? { - let plistURL = LKSwiftUISupportInstallerLayout.installedInfoPlistURL - guard FileManager.default.fileExists(atPath: plistURL.path), - let data = try? Data(contentsOf: plistURL), - let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], - let value = plist["CFBundleShortVersionString"] as? String, - value.isEmpty == false - else { - return nil - } - return value - } - - func fetchPublishedVersion() -> String? { - let cachedState: (version: String?, cooldownActive: Bool) = cacheLock.lkLock { - let cooldown: Bool - if let lastFailedFetchAt { - cooldown = Date().timeIntervalSince(lastFailedFetchAt) < Self.failedFetchCooldown - } else { - cooldown = false - } - return (cachedPublishedVersion, cooldown) - } - if let cached = cachedState.version { - return cached - } - if cachedState.cooldownActive { - return nil - } - if Thread.isMainThread { - return fetchPublishedVersionPresentingProgress() - } - return fetchPublishedVersionFromNetwork() - } - - func publishedVersionFromCache() -> String? { - cacheLock.lkLock { cachedPublishedVersion } - } - - func publishedVersionForInstalledHelperCheck() -> String? { - if Thread.isMainThread { - LKSwiftUISupportLogger.installer.info( - "foreground install check is keeping installed helper; update check will run in background" - ) - return nil - } - - return fetchPublishedVersion() - } - - private func fetchPublishedVersionPresentingProgress() -> String? { - let controller = LKInstallerProgressWindowController( - title: NSLocalizedString("Checking LookInside Auth Server", comment: ""), - initialStatus: LKSwiftUISupportInstallerStage.checkingForUpdates.localizedDescription - ) - controller.showWindow(self) - - var capturedVersion: String? - let semaphore = DispatchSemaphore(value: 0) - Thread.detachNewThread { - capturedVersion = self.fetchPublishedVersionFromNetwork() - RunLoop.main.perform(inModes: [.default, .modalPanel, .common]) { - NSApp.stopModal() - semaphore.signal() - } - } - NSApp.runModal(for: controller.window!) - _ = semaphore.wait(timeout: .now() + 1) - controller.close() - return capturedVersion - } - - private func fetchPublishedVersionFromNetwork() -> String? { - var capturedVersion: String? - let semaphore = DispatchSemaphore(value: 0) - var request = URLRequest(url: LKSwiftUISupportInstallerLayout.versionURL) - request.timeoutInterval = 5 - request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - let task = URLSession.shared.dataTask(with: request) { data, response, error in - defer { semaphore.signal() } - if let error { - LKSwiftUISupportLogger.installer.warning( - "version fetch failed: \(error.localizedDescription, privacy: .public)" - ) - return - } - guard let httpResponse = response as? HTTPURLResponse, - (200 ... 299).contains(httpResponse.statusCode), - let data - else { - let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - LKSwiftUISupportLogger.installer.warning( - "version fetch returned status=\(status)" - ) - return - } - let trimmed = String(data: data, encoding: .utf8)? - .trimmingCharacters(in: .whitespacesAndNewlines) - capturedVersion = (trimmed?.isEmpty == false) ? trimmed : nil - } - task.resume() - _ = semaphore.wait(timeout: .now() + 6) - cacheLock.lkLock { - if let capturedVersion { - cachedPublishedVersion = capturedVersion - lastFailedFetchAt = nil - } else { - lastFailedFetchAt = Date() - } - } - return capturedVersion - } - - func invalidatePublishedVersionCache() { - cacheLock.lkLock { - cachedPublishedVersion = nil - lastFailedFetchAt = nil - } - } - - func invalidate() { - invalidatePublishedVersionCache() - let url = LKSwiftUISupportInstallerLayout.installedAppURL - do { - if FileManager.default.fileExists(atPath: url.path) { - try FileManager.default.removeItem(at: url) - LKSwiftUISupportLogger.installer.info( - "invalidated cached helper at \(url.path, privacy: .public)" - ) - } - } catch { - LKSwiftUISupportLogger.installer.error( - "invalidate failed: \(error.localizedDescription, privacy: .public)" - ) - } - } - - func verifyTeamIdentifier(of appURL: URL) throws { - try LKInstallerCodeSignature.verifyTeamIdentifierMatchesHost( - of: appURL, - unavailableErrorBuilder: { LKSwiftUISupportInstallerError.teamIdentifierUnavailable($0) }, - mismatchErrorBuilder: { expected, found in - LKSwiftUISupportInstallerError.teamIdentifierMismatch(expected: expected, found: found) - } - ) - } - - private func runInstallWithModal(presentingWindow: NSWindow?) throws { - if !Thread.isMainThread { - var capturedError: Error? - DispatchQueue.main.sync { - do { - try self.runInstallWithModal(presentingWindow: presentingWindow) - } catch { - capturedError = error - } - } - if let capturedError { throw capturedError } - return - } - installLock.lock() - defer { installLock.unlock() } - - if LKSwiftUISupportInstallerLayout.isInstalled { - try verifyTeamIdentifier(of: LKSwiftUISupportInstallerLayout.installedAppURL) - return - } - - let cancellation = LKInstallerCancellation() - let controller = LKInstallerProgressWindowController( - title: NSLocalizedString("Installing LookInside Auth Server", comment: ""), - initialStatus: LKSwiftUISupportInstallerStage.preparing.localizedDescription - ) - var cancelledByUser = false - controller.onCancel = { - cancelledByUser = true - cancellation.cancel() - NSApp.stopModal() - } - controller.showWindow(self) - - var capturedError: Error? - let semaphore = DispatchSemaphore(value: 0) - - Thread.detachNewThread { - do { - try self.performInstall(cancellation: cancellation) { stage in - RunLoop.main.perform(inModes: [.default, .modalPanel, .common]) { - controller.updateStatus(stage.localizedDescription) - } - } - } catch { - capturedError = error - } - RunLoop.main.perform(inModes: [.default, .modalPanel, .common]) { - NSApp.stopModal() - semaphore.signal() - } - } - - NSApp.runModal(for: controller.window!) - let waitResult = semaphore.wait(timeout: .now() + 5) - controller.close() - - if cancelledByUser { - throw LKSwiftUISupportInstallerError.cancelled - } - if waitResult == .timedOut { - cancellation.cancel() - throw LKSwiftUISupportInstallerError.cancelled - } - if let capturedError { - throw capturedError - } - } - - private func enforceBackgroundInstallCooldown() throws { - let cooldownActive: Bool = cacheLock.lkLock { - guard let lastFailedBackgroundInstallAt else { - return false - } - return Date().timeIntervalSince(lastFailedBackgroundInstallAt) < Self.failedBackgroundInstallCooldown - } - guard cooldownActive == false else { - throw LKSwiftUISupportInstallerError.installFailed( - NSLocalizedString("Background Auth Server install is waiting after a recent failure.", comment: "") - ) - } - } - - private func ensureInstalledForBackgroundRefresh() throws { - precondition(Thread.isMainThread == false, "Background Auth Server install must run off the main thread.") - - installLock.lock() - defer { installLock.unlock() } - - if LKSwiftUISupportInstallerLayout.isInstalled { - let installed = installedHelperVersion() - let published = fetchPublishedVersion() - if let published, let installed, published != installed { - LKSwiftUISupportLogger.installer.notice( - "installed helper is stale installed=\(installed, privacy: .public) published=\(published, privacy: .public) action=background-refresh" - ) - invalidate() - } else { - if published == nil { - LKSwiftUISupportLogger.installer.warning( - "published version unavailable; keeping cached helper installed=\(installed ?? "unknown", privacy: .public)" - ) - } - try verifyTeamIdentifier(of: LKSwiftUISupportInstallerLayout.installedAppURL) - return - } - } - - let cancellation = LKInstallerCancellation() - try performInstall(cancellation: cancellation) { stage in - LKSwiftUISupportLogger.installer.info( - "background install stage=\(stage.rawValue, privacy: .public)" - ) - } - } - - private func performInstall( - cancellation: LKInstallerCancellation, - onStage: @escaping (LKSwiftUISupportInstallerStage) -> Void - ) throws { - do { - try cancellation.checkCancellation() - onStage(.preparing) - let fileManager = FileManager.default - let stagingRoot = fileManager.temporaryDirectory - .appendingPathComponent("LookInsideAuthServer-\(UUID().uuidString)", isDirectory: true) - try fileManager.createDirectory(at: stagingRoot, withIntermediateDirectories: true) - defer { - try? fileManager.removeItem(at: stagingRoot) - } - - try cancellation.checkCancellation() - onStage(.downloading) - let zipURL = stagingRoot.appendingPathComponent("helper.zip", isDirectory: false) - let checksumURL = stagingRoot.appendingPathComponent("helper.zip.sha256", isDirectory: false) - try LKInstallerDownloader.download( - from: LKSwiftUISupportInstallerLayout.downloadURL, - to: zipURL, - cancellation: cancellation, - errorBuilder: { LKSwiftUISupportInstallerError.downloadFailed($0) } - ) - try LKInstallerDownloader.download( - from: LKSwiftUISupportInstallerLayout.checksumURL, - to: checksumURL, - cancellation: cancellation, - errorBuilder: { LKSwiftUISupportInstallerError.downloadFailed($0) } - ) - - try cancellation.checkCancellation() - onStage(.verifying) - try Self.verifyChecksum(of: zipURL, using: checksumURL) - - try cancellation.checkCancellation() - onStage(.extracting) - let extractDir = stagingRoot.appendingPathComponent("extracted", isDirectory: true) - try fileManager.createDirectory(at: extractDir, withIntermediateDirectories: true) - try LKInstallerArchiveExtractor.unzip( - zipURL, - into: extractDir, - cancellation: cancellation, - errorBuilder: { LKSwiftUISupportInstallerError.unzipFailed($0) } - ) - - try cancellation.checkCancellation() - let stagedApp = try Self.findAppBundle(in: extractDir) - - onStage(.verifying) - try verifyTeamIdentifier(of: stagedApp) - - try cancellation.checkCancellation() - onStage(.installing) - let destination = LKSwiftUISupportInstallerLayout.installedAppURL - try fileManager.createDirectory( - at: destination.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) - } - do { - try fileManager.moveItem(at: stagedApp, to: destination) - } catch { - throw LKSwiftUISupportInstallerError.installFailed(error.localizedDescription) - } - - try cancellation.checkCancellation() - onStage(.finishing) - try LKInstallerFilesystem.ensureExecutableBit( - at: LKSwiftUISupportInstallerLayout.installedExecutableURL, - errorBuilder: { LKSwiftUISupportInstallerError.installFailed($0) } - ) - try verifyTeamIdentifier(of: destination) - } catch is LKInstallerCancelled { - throw LKSwiftUISupportInstallerError.cancelled - } - } - - private static func findAppBundle(in directory: URL) throws -> URL { - let fileManager = FileManager.default - let entries = (try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] - for entry in entries where entry.pathExtension == "app" { - return entry - } - for entry in entries { - var isDir: ObjCBool = false - fileManager.fileExists(atPath: entry.path, isDirectory: &isDir) - if isDir.boolValue { - if let match = try? findAppBundle(in: entry) { - return match - } - } - } - throw LKSwiftUISupportInstallerError.appBundleNotFound - } - - private static func verifyChecksum(of zipURL: URL, using checksumURL: URL) throws { - let checksumText: String - do { - checksumText = try String(contentsOf: checksumURL, encoding: .utf8) - } catch { - throw LKSwiftUISupportInstallerError.checksumFailed(error.localizedDescription) - } - - guard let expected = checksumText - .split(whereSeparator: { $0 == " " || $0 == "\n" || $0 == "\t" }) - .first - .map({ String($0).lowercased() }), - expected.count == 64 - else { - throw LKSwiftUISupportInstallerError.checksumFailed( - NSLocalizedString("The release checksum file is malformed.", comment: "") - ) - } - - let zipData: Data - do { - zipData = try Data(contentsOf: zipURL) - } catch { - throw LKSwiftUISupportInstallerError.checksumFailed(error.localizedDescription) - } - - let actual = SHA256.hash(data: zipData) - .map { String(format: "%02x", $0) } - .joined() - - guard actual == expected else { - throw LKSwiftUISupportInstallerError.checksumFailed( - String(format: NSLocalizedString("Expected %1$@, got %2$@.", comment: ""), expected, actual) - ) - } - } -} diff --git a/LookInside/SwiftUISupport/LKSwiftUISupportLocalLicenseProbe.swift b/LookInside/SwiftUISupport/LKSwiftUISupportLocalLicenseProbe.swift deleted file mode 100644 index 9eb027fc..00000000 --- a/LookInside/SwiftUISupport/LKSwiftUISupportLocalLicenseProbe.swift +++ /dev/null @@ -1,89 +0,0 @@ -import Foundation - -enum LKSwiftUISupportLocalLicenseProbeResult: Equatable { - case present - case missing - case unreadable(String) - - var hasPersistedLicenseMaterial: Bool { - switch self { - case .present: - return true - case .missing, .unreadable: - return false - } - } - - var debugDescription: String { - switch self { - case .present: - return "present" - case .missing: - return "missing" - case let .unreadable(message): - return "unreadable: \(message)" - } - } -} - -enum LKSwiftUISupportLocalLicenseProbe { - static var defaultStateURL: URL { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent( - "Library/Application Support/LookInside/AuthServer/state/state.json", - isDirectory: false - ) - } - - static func probe() -> LKSwiftUISupportLocalLicenseProbeResult { - probe(stateURL: defaultStateURL) - } - - static func probe(stateURL: URL) -> LKSwiftUISupportLocalLicenseProbeResult { - let data: Data - do { - data = try Data(contentsOf: stateURL) - } catch CocoaError.fileReadNoSuchFile { - return .missing - } catch { - return .unreadable(error.localizedDescription) - } - - let object: Any - do { - object = try JSONSerialization.jsonObject(with: data) - } catch { - return .unreadable(error.localizedDescription) - } - - guard let state = object as? [String: Any] else { - return .unreadable( - NSLocalizedString("Auth Server state file is not a JSON object.", comment: "") - ) - } - - if hasDictionaryValue(forKey: "activationResponse", in: state) { - return .present - } - - guard let entitlementStatus = state["entitlementStatus"] as? [String: Any] else { - return .missing - } - - if hasDictionaryValue(forKey: "currentLease", in: entitlementStatus) { - return .present - } - - return .missing - } - - private static func hasDictionaryValue(forKey key: String, in dictionary: [String: Any]) -> Bool { - guard let value = dictionary[key], !(value is NSNull) else { - return false - } - guard let nested = value as? [String: Any] else { - return false - } - return nested.isEmpty == false - } -} diff --git a/LookInside/SwiftUISupport/LKSwiftUISupportLogger.swift b/LookInside/SwiftUISupport/LKSwiftUISupportLogger.swift index feb63a35..29de4451 100644 --- a/LookInside/SwiftUISupport/LKSwiftUISupportLogger.swift +++ b/LookInside/SwiftUISupport/LKSwiftUISupportLogger.swift @@ -2,8 +2,7 @@ import Foundation import os enum LKSwiftUISupportLogger { - static let authServer = Logger(subsystem: subsystem, category: "AuthServer") - static let installer = Logger(subsystem: subsystem, category: "Installer") + static let activation = Logger(subsystem: subsystem, category: "Activation") private static let subsystem = "com.lookinside.app" } diff --git a/LookInside/Toolbar/LKMenuPopoverAppsListController.h b/LookInside/Toolbar/LKMenuPopoverAppsListController.h deleted file mode 100644 index 5f462a5e..00000000 --- a/LookInside/Toolbar/LKMenuPopoverAppsListController.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// LKMenuPopoverAppsListController.h -// Lookin -// -// Created by Li Kai on 2018/11/5. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LKInspectableApp; - -typedef NS_ENUM(NSInteger, MenuPopoverAppsListControllerEventSource) { - MenuPopoverAppsListControllerEventSourceReloadButton, - MenuPopoverAppsListControllerEventSourceNoConnectionTips, - MenuPopoverAppsListControllerEventSourceAppButton -}; - -@interface LKMenuPopoverAppsListController : LKBaseViewController - -- (instancetype)initWithApps:(NSArray *)apps source:(MenuPopoverAppsListControllerEventSource)source; - -@property(nonatomic, copy) void (^didSelectApp)(LKInspectableApp *app); - -- (NSSize)bestSize; - -@end diff --git a/LookInside/Toolbar/LKMenuPopoverAppsListController.m b/LookInside/Toolbar/LKMenuPopoverAppsListController.m deleted file mode 100644 index f843aa4f..00000000 --- a/LookInside/Toolbar/LKMenuPopoverAppsListController.m +++ /dev/null @@ -1,160 +0,0 @@ -// -// LKMenuPopoverAppsListController.m -// Lookin -// -// Created by Li Kai on 2018/11/5. -// https://lookin.work -// - -#import "LKMenuPopoverAppsListController.h" -#import "LKAppsManager.h" -#import "LKHelper.h" -#import "LKLaunchAppView.h" -#import "LookinHierarchyInfo.h" -#import "LKStaticHierarchyDataSource.h" - -@interface LKMenuPopoverAppsListController () - -@property(nonatomic, strong) NSArray *appViews; - -@property(nonatomic, strong) LKLabel *titleLabel; -@property(nonatomic, strong) LKLabel *subtitleLabel; - -@end - -@implementation LKMenuPopoverAppsListController { - CGFloat _appViewInterSpace; - NSEdgeInsets _insets; - CGFloat _titleMarginBottom; - CGFloat _subtitleMarginBottom; -} - -- (instancetype)initWithApps:(NSArray *)apps source:(MenuPopoverAppsListControllerEventSource)source { - if (self = [self init]) { - _insets = NSEdgeInsetsMake(9, 18, 35, 14); - _titleMarginBottom = 3; - _subtitleMarginBottom = 5; - _appViewInterSpace = 1; - - NSString *title = nil; - NSString *subtitle = nil; - if (source == MenuPopoverAppsListControllerEventSourceReloadButton || source == MenuPopoverAppsListControllerEventSourceNoConnectionTips) { - title = NSLocalizedString(@"Connection lost", nil); - if (apps.count == 0) { - subtitle = NSLocalizedString(@"And no inspectable app was found", nil); - } else if (apps.count == 1) { - subtitle = NSLocalizedString(@"Click the screenshot below to Change App", nil); - } else { - subtitle = [NSString stringWithFormat:NSLocalizedString(@"Other %@ apps were found", nil), @(apps.count)]; - } - - } else if (source == MenuPopoverAppsListControllerEventSourceAppButton) { - if (apps.count) { - if (apps.count == 1) { - title = NSLocalizedString(@"1 active app was found", nil); - } else { - title = [NSString stringWithFormat:NSLocalizedString(@"%@ active apps were found", nil), @(apps.count)]; - } - subtitle = NSLocalizedString(@"Click the screenshot below to inspect", nil); - } else { - title = NSLocalizedString(@"No inspectable app was found", nil); - } - } else { - NSAssert(NO, @""); - } - - if (apps.count) { - self.appViews = [apps.rac_sequence map:^id _Nullable(LKInspectableApp *app) { - LKLaunchAppView *view = [LKLaunchAppView new]; - view.compactLayout = YES; - view.app = app; - [view addTarget:self clickAction:@selector(handleClickAppView:)]; - [self.view addSubview:view]; - return view; - }].array; - } - - if (title.length) { - self.titleLabel = [LKLabel new]; - self.titleLabel.alignment = NSTextAlignmentCenter; - self.titleLabel.font = NSFontMake(14); - self.titleLabel.textColor = [NSColor labelColor]; - self.titleLabel.stringValue = title; - [self.view addSubview:self.titleLabel]; - } - - if (subtitle.length) { - self.subtitleLabel = [LKLabel new]; - self.subtitleLabel.alignment = NSTextAlignmentCenter; - self.subtitleLabel.font = NSFontMake(12); - self.subtitleLabel.textColor = [NSColor labelColor]; - self.subtitleLabel.stringValue = subtitle; - [self.view addSubview:self.subtitleLabel]; - } - - } - return self; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - - CGFloat y = _insets.top; - if (self.titleLabel) { - $(self.titleLabel).fullWidth.heightToFit.y(y); - y = self.titleLabel.$maxY + _titleMarginBottom; - } - if (self.subtitleLabel) { - $(self.subtitleLabel).fullWidth.heightToFit.y(y); - y = self.subtitleLabel.$maxY + _subtitleMarginBottom; - } - - if (self.appViews.count) { - __block CGFloat posX = 0; - [self.appViews enumerateObjectsUsingBlock:^(LKLaunchAppView * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - $(obj).sizeToFit.x(posX).y(y); - posX = obj.$maxX + self->_appViewInterSpace; - }]; - $(self.appViews).groupHorAlign; - - } else { - $(self.titleLabel, self.subtitleLabel).visibles.groupVerAlign; - } - -} - -- (void)handleClickAppView:(LKLaunchAppView *)view { - LKInspectableApp *app = view.app; - if (self.didSelectApp) { - self.didSelectApp(app); - } -} - -- (NSSize)bestSize { - if (self.appViews.count <= 0) { - return NSMakeSize(245, 80); - } - __block CGFloat width = _insets.left + _insets.right + (self.appViews.count - 1) * _appViewInterSpace; - __block CGFloat appViewMaxHeight = 0; - [self.appViews enumerateObjectsUsingBlock:^(LKLaunchAppView * _Nonnull view, NSUInteger idx, BOOL * _Nonnull stop) { - NSSize size = [view sizeThatFits:NSSizeMax]; - width += size.width; - appViewMaxHeight = MAX(appViewMaxHeight, size.height); - }]; - - CGFloat height = _insets.top + _insets.bottom + appViewMaxHeight; - if (self.titleLabel) { - NSSize titleSize = [self.titleLabel sizeThatFits:NSSizeMax]; - height += titleSize.height + _titleMarginBottom; - width = MAX(width, titleSize.width + _insets.left + _insets.right); - } - if (self.subtitleLabel) { - NSSize subtitleSize = [self.subtitleLabel sizeThatFits:NSSizeMax]; - height += subtitleSize.height + _subtitleMarginBottom; - width = MAX(width, subtitleSize.width + _insets.left + _insets.right); - } - - return NSMakeSize(width, height); -} - -@end diff --git a/LookInside/Toolbar/LKMenuPopoverAppsListController.swift b/LookInside/Toolbar/LKMenuPopoverAppsListController.swift new file mode 100644 index 00000000..1dd2b494 --- /dev/null +++ b/LookInside/Toolbar/LKMenuPopoverAppsListController.swift @@ -0,0 +1,123 @@ +// +// LKMenuPopoverAppsListController.swift +// Lookin +// +// Created by Li Kai on 2018/11/5. +// https://lookin.work +// + +import AppKit + +/// The popover that lists the inspectable apps, opened from the app button, +/// the reload button or the connection-lost tips. +@objc(LKMenuPopoverAppsListController) +final class LKMenuPopoverAppsListController: LKBaseViewController { + private let insets = NSEdgeInsets(top: 9, left: 18, bottom: 35, right: 14) + private let titleMarginBottom: CGFloat = 3 + private let subtitleMarginBottom: CGFloat = 5 + private let appViewInterSpace: CGFloat = 1 + + private var appViews: [LKLaunchAppView] = [] + private var titleLabel: LKLabel? + private var subtitleLabel: LKLabel? + + @objc var didSelectApp: ((LKInspectableApp) -> Void)? + + @objc(initWithApps:source:) + init(apps: [LKInspectableApp]?, source: MenuPopoverAppsListControllerEventSource) { + // The apps scan sends nil when no port is connected. + let apps = apps ?? [] + super.init(containerView: nil) + + let copy = LKToolbarRules.appsPopoverCopy(source: source, appCount: apps.count) + + appViews = apps.map { app in + let appView = LKLaunchAppView() + appView.compactLayout = true + appView.app = app + appView.addTarget(self, clickAction: #selector(handleClickAppView(_:))) + view.addSubview(appView) + return appView + } + + if let title = copy.title, !title.isEmpty { + titleLabel = makeLabel(title, fontSize: 14) + } + if let subtitle = copy.subtitle, !subtitle.isEmpty { + subtitleLabel = makeLabel(subtitle, fontSize: 12) + } + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + private func makeLabel(_ text: String, fontSize: CGFloat) -> LKLabel { + let label = LKLabel() + label.alignment = .center + label.font = NSFont.systemFont(ofSize: fontSize) + label.textColor = .labelColor + label.stringValue = text + view.addSubview(label) + return label + } + + override func viewDidLayout() { + super.viewDidLayout() + + var y = insets.top + if let titleLabel { + LKViewFrameLayout(titleLabel).fullWidth().heightToFit().y(y) + y = titleLabel.frame.maxY + titleMarginBottom + } + if let subtitleLabel { + LKViewFrameLayout(subtitleLabel).fullWidth().heightToFit().y(y) + y = subtitleLabel.frame.maxY + subtitleMarginBottom + } + + if appViews.isEmpty { + let labels = [titleLabel, subtitleLabel].compactMap { $0 }.filter { !$0.isHidden } + LKViewFrameLayout.groupVerAlign(labels) + } else { + var posX: CGFloat = 0 + for appView in appViews { + LKViewFrameLayout(appView).sizeToFit().x(posX).y(y) + posX = appView.frame.maxX + appViewInterSpace + } + LKViewFrameLayout.groupHorAlign(appViews) + } + } + + @objc private func handleClickAppView(_ appView: LKLaunchAppView) { + guard let app = appView.app else { return } + didSelectApp?(app) + } + + @objc func bestSize() -> NSSize { + guard !appViews.isEmpty else { + return NSSize(width: 245, height: 80) + } + let unlimited = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + var width = insets.left + insets.right + CGFloat(appViews.count - 1) * appViewInterSpace + var appViewMaxHeight: CGFloat = 0 + for appView in appViews { + let size = appView.sizeThatFits(unlimited) + width += size.width + appViewMaxHeight = max(appViewMaxHeight, size.height) + } + + var height = insets.top + insets.bottom + appViewMaxHeight + if let titleLabel { + let titleSize = titleLabel.sizeThatFits(unlimited) + height += titleSize.height + titleMarginBottom + width = max(width, titleSize.width + insets.left + insets.right) + } + if let subtitleLabel { + let subtitleSize = subtitleLabel.sizeThatFits(unlimited) + height += subtitleSize.height + subtitleMarginBottom + width = max(width, subtitleSize.width + insets.left + insets.right) + } + return NSSize(width: width, height: height) + } +} diff --git a/LookInside/Toolbar/LKMenuPopoverSettingController.h b/LookInside/Toolbar/LKMenuPopoverSettingController.h deleted file mode 100644 index 21c2d2a1..00000000 --- a/LookInside/Toolbar/LKMenuPopoverSettingController.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// LKMenuPopoverSettingController.h -// Lookin -// -// Created by Li Kai on 2019/1/9. -// https://lookin.work -// - -#import "LKBaseViewController.h" - -@class LKPreferenceManager; - -@interface LKMenuPopoverSettingController : LKBaseViewController - -/// `isMacTarget` describes the app being inspected, not the host. It decides whether the -/// copy talks about `NSView` or `UIView`. -- (instancetype)initWithPreferenceManager:(LKPreferenceManager *)manager isMacTarget:(BOOL)isMacTarget; - -@end diff --git a/LookInside/Toolbar/LKMenuPopoverSettingController.m b/LookInside/Toolbar/LKMenuPopoverSettingController.m deleted file mode 100644 index 89950744..00000000 --- a/LookInside/Toolbar/LKMenuPopoverSettingController.m +++ /dev/null @@ -1,170 +0,0 @@ -// -// LKMenuPopoverSettingController.m -// Lookin -// -// Created by Li Kai on 2019/1/9. -// https://lookin.work -// - -#import "LKMenuPopoverSettingController.h" -#import "LKPreferenceManager.h" -#import "LKNavigationManager.h" -#import "LKPreviewView.h" -#import "LKHelper.h" - -@interface LKMenuPopoverSettingController () - -@property(nonatomic, strong) LKPreferenceManager *manager; - -@property(nonatomic, strong) NSButton *enableOutlineButton; -@property(nonatomic, strong) NSButton *showInvisiblesButton; -@property(nonatomic, strong) NSButton *showSystemLayoutGuidesButton; -@property(nonatomic, strong) NSButton *showBackingLayersButton; - -@property(nonatomic, strong) NSSlider *spaceSlider; -@property(nonatomic, strong) LKLabel *spaceSliderLabel; - -@property(nonatomic, strong) NSButton *preferenceButton; - -@end - -@implementation LKMenuPopoverSettingController - -- (instancetype)initWithPreferenceManager:(LKPreferenceManager *)manager isMacTarget:(BOOL)isMacTarget { - if (self = [self initWithContainerView:nil]) { - self.manager = manager; - - self.enableOutlineButton = [NSButton new]; - [self.enableOutlineButton setButtonType:NSButtonTypeSwitch]; - self.enableOutlineButton.font = NSFontMake(14); - self.enableOutlineButton.title = NSLocalizedString(@"Show layer outline", nil); - self.enableOutlineButton.target = self; - self.enableOutlineButton.action = @selector(_handleOutlineControl); - [self.view addSubview:self.enableOutlineButton]; - if (manager.showOutline.currentBOOLValue) { - self.enableOutlineButton.state = NSControlStateValueOn; - } else { - self.enableOutlineButton.state = NSControlStateValueOff; - } - - self.showInvisiblesButton = [NSButton new]; - self.showInvisiblesButton.font = NSFontMake(14); - self.showInvisiblesButton.title = [NSString stringWithFormat:NSLocalizedString(@"Show hidden %@ and CALayer", nil), - [LKHelper viewClassNameForMacTarget:isMacTarget]]; - [self.showInvisiblesButton setButtonType:NSButtonTypeSwitch]; - self.showInvisiblesButton.target = self; - self.showInvisiblesButton.action = @selector(_handleShowInvisiblesControl); - [self.view addSubview:self.showInvisiblesButton]; - if (manager.showHiddenItems.currentBOOLValue) { - self.showInvisiblesButton.state = NSControlStateValueOn; - } else { - self.showInvisiblesButton.state = NSControlStateValueOff; - } - - self.showSystemLayoutGuidesButton = [NSButton new]; - self.showSystemLayoutGuidesButton.font = NSFontMake(14); - self.showSystemLayoutGuidesButton.title = NSLocalizedString(@"Show system layout guides", nil); - [self.showSystemLayoutGuidesButton setButtonType:NSButtonTypeSwitch]; - self.showSystemLayoutGuidesButton.target = self; - self.showSystemLayoutGuidesButton.action = @selector(_handleShowSystemLayoutGuidesControl); - [self.view addSubview:self.showSystemLayoutGuidesButton]; - if (manager.showSystemLayoutGuides.currentBOOLValue) { - self.showSystemLayoutGuidesButton.state = NSControlStateValueOn; - } else { - self.showSystemLayoutGuidesButton.state = NSControlStateValueOff; - } - - self.showBackingLayersButton = [NSButton new]; - self.showBackingLayersButton.font = NSFontMake(14); - self.showBackingLayersButton.title = NSLocalizedString(@"Show backing layers", nil); - [self.showBackingLayersButton setButtonType:NSButtonTypeSwitch]; - self.showBackingLayersButton.target = self; - self.showBackingLayersButton.action = @selector(_handleShowBackingLayersControl); - [self.view addSubview:self.showBackingLayersButton]; - if (manager.showBackingLayers.currentBOOLValue) { - self.showBackingLayersButton.state = NSControlStateValueOn; - } else { - self.showBackingLayersButton.state = NSControlStateValueOff; - } - - self.spaceSlider = [NSSlider new]; - self.spaceSlider.minValue = LookinPreviewMinZInterspace; - self.spaceSlider.maxValue = LookinPreviewMaxZInterspace; - self.spaceSlider.target = self; - self.spaceSlider.action = @selector(_handleSpaceSlider:); - [self.view addSubview:self.spaceSlider]; - - self.spaceSliderLabel = [LKLabel new]; - self.spaceSliderLabel.font = NSFontMake(14); - self.spaceSliderLabel.stringValue = NSLocalizedString(@"Item separation", nil); - [self.view addSubview:self.spaceSliderLabel]; - - self.preferenceButton = [NSButton lk_normalButtonWithTitle:NSLocalizedString(@"More…", nil) target:self action:@selector(_handlePreferenceButton)]; - [self.view addSubview:self.preferenceButton]; - - [manager.zInterspace subscribe:self action:@selector(_handleZInterspaceDidChange:) relatedObject:nil sendAtOnce:YES]; - } - return self; -} - -- (void)viewDidLayout { - [super viewDidLayout]; - - $(self.enableOutlineButton).x(15).toRight(0).height(24).y(15); - $(self.showInvisiblesButton).x(15).toRight(0).height(24).y(self.enableOutlineButton.$maxY + 6); - $(self.showSystemLayoutGuidesButton).x(15).toRight(0).height(24).y(self.showInvisiblesButton.$maxY + 6); - $(self.showBackingLayersButton).x(15).toRight(0).height(24).y(self.showSystemLayoutGuidesButton.$maxY + 6); - - $(self.spaceSlider).x(15).toRight(15).height(26).y(self.showBackingLayersButton.$maxY + 22); - $(self.spaceSliderLabel).sizeToFit.x(self.spaceSlider.$x + 3).y(self.spaceSlider.$maxY); - - $(self.preferenceButton).width(130).horAlign.bottom(4); -} - -- (void)_handleOutlineControl { - if (self.enableOutlineButton.state == NSControlStateValueOn) { - [self.manager.showOutline setBOOLValue:YES ignoreSubscriber:nil]; - } else { - [self.manager.showOutline setBOOLValue:NO ignoreSubscriber:nil]; - } -} - -- (void)_handleShowInvisiblesControl { - if (self.showInvisiblesButton.state == NSControlStateValueOn) { - [self.manager.showHiddenItems setBOOLValue:YES ignoreSubscriber:nil]; - } else { - [self.manager.showHiddenItems setBOOLValue:NO ignoreSubscriber:nil]; - } -} - -- (void)_handleShowSystemLayoutGuidesControl { - if (self.showSystemLayoutGuidesButton.state == NSControlStateValueOn) { - [self.manager.showSystemLayoutGuides setBOOLValue:YES ignoreSubscriber:nil]; - } else { - [self.manager.showSystemLayoutGuides setBOOLValue:NO ignoreSubscriber:nil]; - } -} - -- (void)_handleShowBackingLayersControl { - if (self.showBackingLayersButton.state == NSControlStateValueOn) { - [self.manager.showBackingLayers setBOOLValue:YES ignoreSubscriber:nil]; - } else { - [self.manager.showBackingLayers setBOOLValue:NO ignoreSubscriber:nil]; - } -} - -- (void)_handleSpaceSlider:(NSSlider *)slider { - double doubleValue = slider.doubleValue; - [self.manager.zInterspace setDoubleValue:doubleValue ignoreSubscriber:self]; -} - -- (void)_handlePreferenceButton { - [[LKNavigationManager sharedInstance] showPreference]; -} - -- (void)_handleZInterspaceDidChange:(LookinMsgActionParams *)param { - double doubleValue = param.doubleValue; - self.spaceSlider.doubleValue = doubleValue; -} - -@end diff --git a/LookInside/Toolbar/LKMenuPopoverSettingController.swift b/LookInside/Toolbar/LKMenuPopoverSettingController.swift new file mode 100644 index 00000000..9a10d42a --- /dev/null +++ b/LookInside/Toolbar/LKMenuPopoverSettingController.swift @@ -0,0 +1,143 @@ +// +// LKMenuPopoverSettingController.swift +// Lookin +// +// Created by Li Kai on 2019/1/9. +// https://lookin.work +// + +import AppKit + +/// The View popover of a window toolbar: outline, hidden items, system +/// layout guides, backing layers, item separation, and a way to the +/// preferences. +@objc(LKMenuPopoverSettingController) +final class LKMenuPopoverSettingController: LKBaseViewController { + private let manager: LKPreferenceManager + + private let enableOutlineButton = NSButton() + private let showInvisiblesButton = NSButton() + private let showSystemLayoutGuidesButton = NSButton() + private let showBackingLayersButton = NSButton() + private let spaceSlider = NSSlider() + private let spaceSliderLabel = LKLabel() + private let preferenceButton = LKMenuPopoverSettingController.makePreferenceButton() + + /// `isMacTarget` describes the app being inspected, not the host. It decides whether the + /// copy talks about `NSView` or `UIView`. + @objc(initWithPreferenceManager:isMacTarget:) + init(preferenceManager manager: LKPreferenceManager, isMacTarget: Bool) { + self.manager = manager + super.init(containerView: nil) + + configureSwitch( + enableOutlineButton, + title: NSLocalizedString("Show layer outline", comment: ""), + action: #selector(handleOutlineControl), + isOn: manager.showOutline.currentBOOLValue + ) + configureSwitch( + showInvisiblesButton, + title: String( + format: NSLocalizedString("Show hidden %@ and CALayer", comment: ""), + LKHelper.viewClassName(forMacTarget: isMacTarget) + ), + action: #selector(handleShowInvisiblesControl), + isOn: manager.showHiddenItems.currentBOOLValue + ) + configureSwitch( + showSystemLayoutGuidesButton, + title: NSLocalizedString("Show system layout guides", comment: ""), + action: #selector(handleShowSystemLayoutGuidesControl), + isOn: manager.showSystemLayoutGuides.currentBOOLValue + ) + configureSwitch( + showBackingLayersButton, + title: NSLocalizedString("Show backing layers", comment: ""), + action: #selector(handleShowBackingLayersControl), + isOn: manager.showBackingLayers.currentBOOLValue + ) + + spaceSlider.minValue = Double(LookinPreviewMinZInterspace) + spaceSlider.maxValue = Double(LookinPreviewMaxZInterspace) + spaceSlider.target = self + spaceSlider.action = #selector(handleSpaceSlider(_:)) + view.addSubview(spaceSlider) + + spaceSliderLabel.font = NSFont.systemFont(ofSize: 14) + spaceSliderLabel.stringValue = NSLocalizedString("Item separation", comment: "") + view.addSubview(spaceSliderLabel) + + preferenceButton.target = self + preferenceButton.action = #selector(handlePreferenceButton) + view.addSubview(preferenceButton) + + manager.zInterspace.subscribe(self, action: #selector(handleZInterspaceDidChange(_:)), relatedObject: nil, sendAtOnce: true) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + /// `+[NSButton lk_normalButtonWithTitle:target:action:]`. + private static func makePreferenceButton() -> NSButton { + let button = NSButton() + button.bezelStyle = .rounded + button.title = NSLocalizedString("More…", comment: "") + button.font = NSFont.systemFont(ofSize: 13) + button.frame = NSRect(x: 0, y: 0, width: 84, height: 40) + return button + } + + private func configureSwitch(_ button: NSButton, title: String, action: Selector, isOn: Bool) { + button.setButtonType(.switch) + button.font = NSFont.systemFont(ofSize: 14) + button.title = title + button.target = self + button.action = action + view.addSubview(button) + button.state = isOn ? .on : .off + } + + override func viewDidLayout() { + super.viewDidLayout() + LKViewFrameLayout(enableOutlineButton).x(15).toRight(0).height(24).y(15) + LKViewFrameLayout(showInvisiblesButton).x(15).toRight(0).height(24).y(enableOutlineButton.frame.maxY + 6) + LKViewFrameLayout(showSystemLayoutGuidesButton).x(15).toRight(0).height(24).y(showInvisiblesButton.frame.maxY + 6) + LKViewFrameLayout(showBackingLayersButton).x(15).toRight(0).height(24).y(showSystemLayoutGuidesButton.frame.maxY + 6) + + LKViewFrameLayout(spaceSlider).x(15).toRight(15).height(26).y(showBackingLayersButton.frame.maxY + 22) + LKViewFrameLayout(spaceSliderLabel).sizeToFit().x(spaceSlider.frame.minX + 3).y(spaceSlider.frame.maxY) + + LKViewFrameLayout(preferenceButton).width(130).horAlign().bottom(4) + } + + @objc private func handleOutlineControl() { + manager.showOutline.setBOOLValue(enableOutlineButton.state == .on, ignoreSubscriber: nil) + } + + @objc private func handleShowInvisiblesControl() { + manager.showHiddenItems.setBOOLValue(showInvisiblesButton.state == .on, ignoreSubscriber: nil) + } + + @objc private func handleShowSystemLayoutGuidesControl() { + manager.showSystemLayoutGuides.setBOOLValue(showSystemLayoutGuidesButton.state == .on, ignoreSubscriber: nil) + } + + @objc private func handleShowBackingLayersControl() { + manager.showBackingLayers.setBOOLValue(showBackingLayersButton.state == .on, ignoreSubscriber: nil) + } + + @objc private func handleSpaceSlider(_ slider: NSSlider) { + manager.zInterspace.setDoubleValue(slider.doubleValue, ignoreSubscriber: self) + } + + @objc private func handlePreferenceButton() { + LKNavigationManager.shared.showPreference() + } + + @objc private func handleZInterspaceDidChange(_ param: LookinMsgActionParams) { + spaceSlider.doubleValue = param.doubleValue + } +} diff --git a/LookInside/Toolbar/LKToolbarRules.swift b/LookInside/Toolbar/LKToolbarRules.swift new file mode 100644 index 00000000..389d104b --- /dev/null +++ b/LookInside/Toolbar/LKToolbarRules.swift @@ -0,0 +1,82 @@ +// +// LKToolbarRules.swift +// LookInside +// +// The decisions behind the window toolbars and their popovers, kept free of +// AppKit views so they can be tested on their own. +// + +import Foundation + +// Toolbar item identifiers. The values are stored in the toolbar +// configuration; never change them. +let LKToolBarIdentifier_Dimension = "0" +let LKToolBarIdentifier_Scale = "1" +let LKToolBarIdentifier_Setting = "2" +let LKToolBarIdentifier_Reload = "3" +let LKToolBarIdentifier_App = "5" +let LKToolBarIdentifier_AppInReadMode = "12" +let LKToolBarIdentifier_Add = "13" +let LKToolBarIdentifier_Remove = "14" +let LKToolBarIdentifier_Console = "15" +let LKToolBarIdentifier_Rotation = "16" +let LKToolBarIdentifier_Measure = "17" +let LKToolBarIdentifier_Message = "18" +let LKToolBarIdentifier_FastMode = "19" +let LKToolBarIdentifier_SwiftUIMode = "20" +let LKToolBarIdentifier_GestureDebug = "21" + +/// What opened the apps popover. +@objc enum MenuPopoverAppsListControllerEventSource: Int { + case reloadButton + case noConnectionTips + case appButton +} + +enum LKToolbarRules { + /// The step the zoom buttons, the zoom menu items and the separation + /// menu items move by. + static let step = 0.1 + + /// `value` moved by `delta`, then clamped to `lower...upper`. + static func stepped(_ value: Double, by delta: Double, lower: Double, upper: Double) -> Double { + min(max(value + delta, lower), upper) + } + + /// The title and subtitle of the apps popover. + /// + /// The reload button and the connection-lost tips say the connection is + /// lost; the app button counts the apps found. + static func appsPopoverCopy( + source: MenuPopoverAppsListControllerEventSource, + appCount: Int + ) -> (title: String?, subtitle: String?) { + switch source { + case .reloadButton, .noConnectionTips: + let title = NSLocalizedString("Connection lost", comment: "") + let subtitle: String + if appCount == 0 { + subtitle = NSLocalizedString("And no inspectable app was found", comment: "") + } else if appCount == 1 { + subtitle = NSLocalizedString("Click the screenshot below to Change App", comment: "") + } else { + subtitle = String(format: NSLocalizedString("Other %@ apps were found", comment: ""), NSNumber(value: appCount)) + } + return (title, subtitle) + case .appButton: + guard appCount > 0 else { + return (NSLocalizedString("No inspectable app was found", comment: ""), nil) + } + let title: String + if appCount == 1 { + title = NSLocalizedString("1 active app was found", comment: "") + } else { + title = String(format: NSLocalizedString("%@ active apps were found", comment: ""), NSNumber(value: appCount)) + } + return (title, NSLocalizedString("Click the screenshot below to inspect", comment: "")) + @unknown default: + assertionFailure("Unknown apps popover source \(source.rawValue)") + return (nil, nil) + } + } +} diff --git a/LookInside/Toolbar/LKViewFrameLayout.swift b/LookInside/Toolbar/LKViewFrameLayout.swift new file mode 100644 index 00000000..f3ebfdf8 --- /dev/null +++ b/LookInside/Toolbar/LKViewFrameLayout.swift @@ -0,0 +1,208 @@ +// +// LKViewFrameLayout.swift +// LookInside +// +// Manual frame layout for the Launch, Toolbar and Read views, with the +// rules ShortCocoa's `$(view)` chains used there: positions and fixed sizes +// are rounded up to the screen's pixel grid, sizes measured by +// `sizeThatFits` are taken as they are, and edges measure against the +// superview's bounds. +// + +import AppKit + +@MainActor +struct LKViewFrameLayout { + let view: NSView + + init(_ view: NSView) { + self.view = view + } + + /// Rounds `value` up to the main screen's pixel grid. + static func snap(_ value: CGFloat) -> CGFloat { + if value == .leastNormalMagnitude || value == .greatestFiniteMagnitude { + return value + } + let scale = NSScreen.main?.backingScaleFactor ?? 1 + return (value * scale).rounded(.up) / scale + } + + private var superSize: NSSize { + view.superview?.bounds.size ?? .zero + } + + private func edit(_ change: (inout NSRect) -> Void) -> Self { + var frame = view.frame + change(&frame) + view.frame = frame + return self + } + + // MARK: Position + + @discardableResult func x(_ value: CGFloat) -> Self { + edit { $0.origin.x = Self.snap(value) } + } + + @discardableResult func y(_ value: CGFloat) -> Self { + edit { $0.origin.y = Self.snap(value) } + } + + @discardableResult func midX(_ value: CGFloat) -> Self { + x(value - view.bounds.width / 2) + } + + @discardableResult func midY(_ value: CGFloat) -> Self { + y(value - view.bounds.height / 2) + } + + @discardableResult func maxX(_ value: CGFloat) -> Self { + x(value - view.bounds.width) + } + + @discardableResult func maxY(_ value: CGFloat) -> Self { + y(value - view.bounds.height) + } + + /// The right edge `inset` points from the superview's right edge. + @discardableResult func right(_ inset: CGFloat) -> Self { + guard view.superview != nil else { return self } + return maxX(superSize.width - inset) + } + + /// The bottom edge `inset` points from the superview's bottom edge. + @discardableResult func bottom(_ inset: CGFloat) -> Self { + guard let superview = view.superview else { return self } + return superview.isFlipped ? maxY(superview.bounds.height - inset) : y(inset) + } + + /// Centered horizontally in the superview. + @discardableResult func horAlign() -> Self { + guard view.superview != nil else { return self } + return midX(superSize.width / 2) + } + + /// Centered vertically in the superview. + @discardableResult func verAlign() -> Self { + guard view.superview != nil else { return self } + return midY(superSize.height / 2) + } + + @discardableResult func offsetX(_ delta: CGFloat) -> Self { + x(view.frame.minX + delta) + } + + @discardableResult func offsetY(_ delta: CGFloat) -> Self { + y(view.frame.minY + delta) + } + + // MARK: Size + + @discardableResult func width(_ value: CGFloat) -> Self { + edit { $0.size.width = Self.snap(value) } + } + + @discardableResult func height(_ value: CGFloat) -> Self { + edit { $0.size.height = Self.snap(value) } + } + + @discardableResult func size(_ value: NSSize) -> Self { + width(value.width).height(value.height) + } + + /// Stretches the right edge to `inset` points from the superview's right edge. + @discardableResult func toRight(_ inset: CGFloat) -> Self { + let width = max(Self.snap(superSize.width - view.frame.minX - inset), 0) + return edit { $0.size.width = width } + } + + /// Stretches the right edge to `value`, never past the left edge. + @discardableResult func toMaxX(_ value: CGFloat) -> Self { + let width = Self.snap(max(value, view.frame.minX) - view.frame.minX) + return edit { $0.size.width = width } + } + + @discardableResult func fullWidth() -> Self { + x(0).toRight(0) + } + + @discardableResult func fullHeight() -> Self { + guard view.superview != nil else { return self } + return height(superSize.height).y(0) + } + + @discardableResult func fullFrame() -> Self { + fullWidth().fullHeight() + } + + /// The size `sizeThatFits` asks for with no limit. + @discardableResult func sizeToFit() -> Self { + var size = Self.fittingSize(of: view, limit: NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude)) + if size.width.isNaN { + size.width = 0 + } + if size.height.isNaN { + size.height = 0 + } + return edit { $0.size = size } + } + + /// The height `sizeThatFits` asks for at the current width. + @discardableResult func heightToFit() -> Self { + let height = Self.fittingSize(of: view, limit: NSSize(width: view.bounds.width, height: .greatestFiniteMagnitude)).height + return edit { $0.size.height = height } + } + + /// `sizeThatFits` of a control or an `LKBaseView`; other views keep their size. + static func fittingSize(of view: NSView, limit: NSSize) -> NSSize { + if let control = view as? NSControl { + return control.sizeThatFits(limit) + } + if let baseView = view as? LKBaseView { + return baseView.sizeThatFits(limit) + } + return view.frame.size + } + + // MARK: Groups + + /// Moves `views` together so their union is centered horizontally in + /// their common superview. + static func groupHorAlign(_ views: [NSView]) { + guard let superview = commonSuperview(of: views) else { return } + groupMidX(views, superview.bounds.width / 2) + } + + /// Moves `views` together so their union is centered vertically in + /// their common superview. + static func groupVerAlign(_ views: [NSView]) { + guard let superview = commonSuperview(of: views) else { return } + groupMidY(views, superview.bounds.height / 2) + } + + static func groupMidX(_ views: [NSView], _ value: CGFloat) { + guard let union = union(of: views) else { return } + views.forEach { LKViewFrameLayout($0).offsetX(value - union.midX) } + } + + static func groupMidY(_ views: [NSView], _ value: CGFloat) { + guard let union = union(of: views) else { return } + views.forEach { LKViewFrameLayout($0).offsetY(value - union.midY) } + } + + private static func union(of views: [NSView]) -> NSRect? { + guard let first = views.first else { return nil } + if views.count > 1, commonSuperview(of: views) == nil { + return nil + } + return views.dropFirst().reduce(first.frame) { $0.union($1.frame) } + } + + private static func commonSuperview(of views: [NSView]) -> NSView? { + guard let superview = views.first?.superview, + views.allSatisfy({ $0.superview === superview }) + else { return nil } + return superview + } +} diff --git a/LookInside/Toolbar/LKWindowToolbarAppButton.h b/LookInside/Toolbar/LKWindowToolbarAppButton.h deleted file mode 100644 index 53d2deb0..00000000 --- a/LookInside/Toolbar/LKWindowToolbarAppButton.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKWindowToolbarAppView.h -// LookinClient -// -// Created by 李凯 on 2020/6/14. -// Copyright © 2020 hughkli. All rights reserved. -// - -#import - -@class LookinAppInfo; - -@interface LKWindowToolbarAppButton : NSButton - -@property(nonatomic, strong) LookinAppInfo *appInfo; - -@end diff --git a/LookInside/Toolbar/LKWindowToolbarAppButton.m b/LookInside/Toolbar/LKWindowToolbarAppButton.m deleted file mode 100644 index 85073f89..00000000 --- a/LookInside/Toolbar/LKWindowToolbarAppButton.m +++ /dev/null @@ -1,150 +0,0 @@ -// -// LKWindowToolbarAppView.m -// LookinClient -// -// Created by 李凯 on 2020/6/14. -// Copyright © 2020 hughkli. All rights reserved. -// - -#import "LKWindowToolbarAppButton.h" -#import "LookinAppInfo.h" -#import "LookInside-Swift.h" - -@interface LKWindowToolbarAppButton () - -@property(nonatomic, strong) NSImageView *appImageView; -@property(nonatomic, strong) LKLabel *appNameLabel; -@property(nonatomic, strong) NSImageView *sepImageView; -@property(nonatomic, strong) NSImageView *deviceImageView; -@property(nonatomic, strong) LKLabel *deviceLabel; - -@property(nonatomic, assign) CGFloat appImageWidth; -@property(nonatomic, copy) NSArray *spaces; - -@end - -@implementation LKWindowToolbarAppButton - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - self.title = @""; - self.appImageWidth = 14; - self.spaces = @[@7, @3, @3, @4, @1]; - - self.appImageView = [NSImageView new]; - self.appImageView.wantsLayer = YES; - self.appImageView.layer.cornerRadius = 2; - self.appImageView.layer.masksToBounds = YES; - [self addSubview:self.appImageView]; - - self.appNameLabel = [LKLabel new]; - self.appNameLabel.textColors = LKColorsCombine(LookinColorMake(65, 65, 65), [NSColor labelColor]); - [self addSubview:self.appNameLabel]; - - self.sepImageView = [NSImageView new]; - self.sepImageView.image = NSImageMake(@"icon_go_forward"); - self.sepImageView.image.template = YES; - [self addSubview:self.sepImageView]; - - self.deviceImageView = [NSImageView new]; - [self addSubview:self.deviceImageView]; - - self.deviceLabel = [LKLabel new]; - self.deviceLabel.textColors = LKColorsCombine(LookinColorMake(65, 65, 65), [NSColor labelColor]); - self.deviceLabel.lineBreakMode = NSLineBreakByTruncatingMiddle; - [self addSubview:self.deviceLabel]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.appImageView).width(self.appImageWidth).height(self.appImageWidth).x([self.spaces[0] doubleValue]).verAlign.offsetY(-.5); - $(self.appNameLabel).sizeToFit.verAlign.x(self.appImageView.$maxX + [self.spaces[1] doubleValue]).offsetY(-1); - $(self.sepImageView).sizeToFit.verAlign.x(self.appNameLabel.$maxX + [self.spaces[2] doubleValue]).offsetY(-.5); - $(self.deviceImageView).sizeToFit.x(self.sepImageView.$maxX + [self.spaces[3] doubleValue]).verAlign; - $(self.deviceLabel).sizeToFit.verAlign.x(self.deviceImageView.$maxX + [self.spaces[4] doubleValue]).toMaxX(self.$width).offsetY(-1); -} - -- (void)setAppInfo:(LookinAppInfo *)appInfo { - _appInfo = appInfo; - - if (appInfo) { - $(self.appImageView, self.appNameLabel, self.sepImageView, self.deviceImageView, self.deviceLabel).show; - self.image = nil; - - NSImage *appIcon = appInfo.appIcon; - if (!appIcon) { - appIcon = NSImageMake(@"Icon_EmptyProject"); - } - self.appImageView.image = appIcon; - - self.appNameLabel.stringValue = appInfo.appName; - - self.deviceLabel.stringValue = [NSString stringWithFormat:@"%@ (%@)", appInfo.deviceDescription, appInfo.osDescription]; - - // Prefer the icon of the actual hardware model the app runs on. Falls back to the - // per-family asset when the peer's LookinServer does not report a model identifier. - NSImage *deviceIcon = [LKDeviceIconProvider deviceIconForAppInfo:appInfo - pointSize:LKDeviceIconProvider.toolbarPointSize]; - if (!deviceIcon) { - if ([LKHelper appInfoLooksLikeMacTarget:appInfo]) { - deviceIcon = NSImageMake(@"icon_mac_small"); - } else { - switch (appInfo.deviceType) { - case LookinAppInfoDeviceSimulator: - deviceIcon = NSImageMake(@"icon_simulator_small"); - break; - case LookinAppInfoDeviceIPad: - deviceIcon = NSImageMake(@"icon_ipad_small"); - break; - case LookinAppInfoDeviceOthers: - deviceIcon = NSImageMake(@"icon_iphone_small"); - break; - case LookinAppInfoDeviceMacCatalyst: - // A Catalyst app runs on Mac hardware, so it gets the Mac icon even - // though its views are UIKit ones. It reaches this switch at all - // because +appInfoLooksLikeMacTarget: above deliberately answers NO - // for Catalyst — that predicate is about the UI framework, not the - // hardware. - deviceIcon = NSImageMake(@"icon_mac_small"); - break; - default: - deviceIcon = NSImageMake(@"icon_simulator_small"); - break; - } - } - } - self.deviceImageView.image = deviceIcon; - - } else { - $(self.appImageView, self.appNameLabel, self.sepImageView, self.deviceImageView, self.deviceLabel).hide; - self.image = NSImageMake(@"icon_app"); - } - [self invalidateIntrinsicContentSize]; - [self setNeedsLayout:YES]; -} - -- (NSSize)sizeThatFits:(NSSize)size { - if (!self.appInfo) { - return NSMakeSize(42, 34); - } - - __block CGFloat width = 0; - - [self.spaces enumerateObjectsUsingBlock:^(NSNumber * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - width += [obj doubleValue]; - }]; - - width += self.appImageWidth + self.appNameLabel.bestWidth + self.sepImageView.bestWidth + self.deviceImageView.bestWidth + self.deviceLabel.bestWidth; - - size.width = width; - return size; -} - -- (NSSize)intrinsicContentSize { - NSSize size = [self sizeThatFits:NSSizeMax]; - return NSMakeSize(size.width + 6, 34); -} - -@end diff --git a/LookInside/Toolbar/LKWindowToolbarAppButton.swift b/LookInside/Toolbar/LKWindowToolbarAppButton.swift new file mode 100644 index 00000000..c9609a6c --- /dev/null +++ b/LookInside/Toolbar/LKWindowToolbarAppButton.swift @@ -0,0 +1,129 @@ +// +// LKWindowToolbarAppButton.swift +// LookinClient +// +// Created by 李凯 on 2020/6/14. +// Copyright © 2020 hughkli. All rights reserved. +// + +import AppKit + +/// The toolbar button that names the inspected app and its device: app +/// icon, app name, a chevron, device icon, device and OS. Without an app it +/// shows a plain app icon. +@objc(LKWindowToolbarAppButton) +final class LKWindowToolbarAppButton: NSButton { + private let appImageView = NSImageView() + private let appNameLabel = LKLabel() + private let sepImageView = NSImageView() + private let deviceImageView = NSImageView() + private let deviceLabel = LKLabel() + + private let appImageWidth: CGFloat = 14 + /// Gaps before the app icon, the name, the chevron, the device icon and + /// the device label. + private let spaces: [CGFloat] = [7, 3, 3, 4, 1] + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + title = "" + + appImageView.wantsLayer = true + appImageView.layer?.cornerRadius = 2 + appImageView.layer?.masksToBounds = true + addSubview(appImageView) + + appNameLabel.textColors = Self.labelColors + addSubview(appNameLabel) + + sepImageView.image = NSImage(named: "icon_go_forward") + sepImageView.image?.isTemplate = true + addSubview(sepImageView) + + addSubview(deviceImageView) + + deviceLabel.textColors = Self.labelColors + deviceLabel.lineBreakMode = .byTruncatingMiddle + addSubview(deviceLabel) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + private static var labelColors: LKTwoColors { + LKTwoColors(colorInLightMode: NSColor(red: 65 / 255.0, green: 65 / 255.0, blue: 65 / 255.0, alpha: 1), colorInDarkMode: .labelColor) + } + + @objc var appInfo: LookinAppInfo? { + didSet { appInfoDidChange() } + } + + override func layout() { + super.layout() + LKViewFrameLayout(appImageView).width(appImageWidth).height(appImageWidth).x(spaces[0]).verAlign().offsetY(-0.5) + LKViewFrameLayout(appNameLabel).sizeToFit().verAlign().x(appImageView.frame.maxX + spaces[1]).offsetY(-1) + LKViewFrameLayout(sepImageView).sizeToFit().verAlign().x(appNameLabel.frame.maxX + spaces[2]).offsetY(-0.5) + LKViewFrameLayout(deviceImageView).sizeToFit().x(sepImageView.frame.maxX + spaces[3]).verAlign() + LKViewFrameLayout(deviceLabel).sizeToFit().verAlign().x(deviceImageView.frame.maxX + spaces[4]).toMaxX(bounds.width).offsetY(-1) + } + + private func appInfoDidChange() { + let parts: [NSView] = [appImageView, appNameLabel, sepImageView, deviceImageView, deviceLabel] + if let appInfo { + parts.forEach { $0.isHidden = false } + image = nil + appImageView.image = appInfo.appIcon ?? NSImage(named: "Icon_EmptyProject") + appNameLabel.stringValue = appInfo.appName ?? "" + deviceLabel.stringValue = "\(appInfo.deviceDescription ?? "(null)") (\(appInfo.osDescription ?? "(null)"))" + // Prefer the icon of the actual hardware model the app runs on. Falls back to the + // per-family asset when the peer's LookinServer does not report a model identifier. + deviceImageView.image = LKDeviceIconProvider.deviceIcon(forAppInfo: appInfo, pointSize: LKDeviceIconProvider.toolbarPointSize) + ?? Self.familyIcon(for: appInfo) + } else { + parts.forEach { $0.isHidden = true } + image = NSImage(named: "icon_app") + } + invalidateIntrinsicContentSize() + needsLayout = true + } + + private static func familyIcon(for appInfo: LookinAppInfo) -> NSImage? { + if LKHelper.appInfoLooksLikeMacTarget(appInfo) { + return NSImage(named: "icon_mac_small") + } + switch appInfo.deviceType { + case .simulator: + return NSImage(named: "icon_simulator_small") + case .iPad: + return NSImage(named: "icon_ipad_small") + case .others: + return NSImage(named: "icon_iphone_small") + case .macCatalyst: + // A Catalyst app runs on Mac hardware, so it gets the Mac icon even + // though its views are UIKit ones. It reaches this switch at all + // because +appInfoLooksLikeMacTarget: above deliberately answers NO + // for Catalyst — that predicate is about the UI framework, not the + // hardware. + return NSImage(named: "icon_mac_small") + default: + return NSImage(named: "icon_simulator_small") + } + } + + override func sizeThatFits(_ size: NSSize) -> NSSize { + guard appInfo != nil else { + return NSSize(width: 42, height: 34) + } + let unlimited = NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + let contentWidth = [appNameLabel, sepImageView, deviceImageView, deviceLabel] + .reduce(appImageWidth) { $0 + $1.sizeThatFits(unlimited).width } + return NSSize(width: spaces.reduce(0, +) + contentWidth, height: size.height) + } + + override var intrinsicContentSize: NSSize { + let size = sizeThatFits(NSSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude)) + return NSSize(width: size.width + 6, height: 34) + } +} diff --git a/LookInside/Toolbar/LKWindowToolbarHelper.h b/LookInside/Toolbar/LKWindowToolbarHelper.h deleted file mode 100644 index 136c6549..00000000 --- a/LookInside/Toolbar/LKWindowToolbarHelper.h +++ /dev/null @@ -1,51 +0,0 @@ -// -// LKWindowToolbarHelper.h -// Lookin -// -// Created by Li Kai on 2019/5/8. -// https://lookin.work -// - -#import - -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Dimension; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Scale; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Rotation; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Setting; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Reload; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_App; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_AppInReadMode; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Console; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Add; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Remove; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Measure; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_Message; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_FastMode; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_SwiftUIMode; -extern NSToolbarItemIdentifier const LKToolBarIdentifier_GestureDebug; - -@class LKPreferenceManager, LookinAppInfo; - -@interface LKWindowToolbarHelper : NSObject - -+ (instancetype)sharedInstance; - -/** - 通过以下 identifier 创建的 toolBarItem 需要业务自己设置点击 action: - - Reload - - App - - Expansion - - DynamicMode - - StaticMode - - RemoteSelect - - Add - - Remove - - Setting - - Change - */ -- (NSToolbarItem *)makeToolBarItemWithIdentifier:(NSToolbarItemIdentifier)identifier preferenceManager:(LKPreferenceManager *)manager; - -/// 请使用该方法初始化 AppInReadMode 这个 item -- (NSToolbarItem *)makeAppInReadModeItemWithAppInfo:(LookinAppInfo *)appInfo; - -@end diff --git a/LookInside/Toolbar/LKWindowToolbarHelper.m b/LookInside/Toolbar/LKWindowToolbarHelper.m deleted file mode 100644 index 8b2fa98e..00000000 --- a/LookInside/Toolbar/LKWindowToolbarHelper.m +++ /dev/null @@ -1,361 +0,0 @@ -// -// LKWindowToolbarHelper.m -// Lookin -// -// Created by Li Kai on 2019/5/8. -// https://lookin.work -// - -#import "LKWindowToolbarHelper.h" -#import "LKPreferenceManager.h" -#import "LKMenuPopoverSettingController.h" -#import "LKNavigationManager.h" -#import "LKPreviewView.h" -#import "LKWindowToolbarScaleView.h" -#import "LKWindowToolbarAppButton.h" -#import "LKSwiftUIHierarchyDisplayMode.h" - -NSToolbarItemIdentifier const LKToolBarIdentifier_Dimension = @"0"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Scale = @"1"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Setting = @"2"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Reload = @"3"; -NSToolbarItemIdentifier const LKToolBarIdentifier_App = @"5"; -NSToolbarItemIdentifier const LKToolBarIdentifier_AppInReadMode = @"12"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Add = @"13"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Remove = @"14"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Console = @"15"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Rotation = @"16"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Measure = @"17"; -NSToolbarItemIdentifier const LKToolBarIdentifier_Message = @"18"; -NSToolbarItemIdentifier const LKToolBarIdentifier_FastMode = @"19"; -NSToolbarItemIdentifier const LKToolBarIdentifier_SwiftUIMode = @"20"; -NSToolbarItemIdentifier const LKToolBarIdentifier_GestureDebug = @"21"; - - -static NSString * const Key_BindingPreferenceManager = @"PreferenceManager"; -static NSString * const Key_BindingAppInfo = @"AppInfo"; - -@interface LKWindowToolbarHelper () - -@end - -@implementation LKWindowToolbarHelper - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKWindowToolbarHelper *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -- (NSToolbarItem *)makeToolBarItemWithIdentifier:(NSToolbarItemIdentifier)identifier preferenceManager:(LKPreferenceManager *)manager { - NSAssert(![identifier isEqualToString:LKToolBarIdentifier_AppInReadMode], @"请使用 makeAppInReadModeItemWithAppInfo: 方法"); - - if ([identifier isEqualToString:LKToolBarIdentifier_GestureDebug]) { - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:identifier]; - item.label = NSLocalizedString(@"Gestures", nil); - item.toolTip = NSLocalizedString(@"Inspect SwiftUI responders and gesture events", nil); - item.image = [NSImage imageWithSystemSymbolName:@"hand.point.up.left" accessibilityDescription:item.label]; - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Measure]) { - NSImage *image = NSImageMake(@"icon_measure"); - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - [button setButtonType:NSButtonTypePushOnPushOff]; - button.target = self; - button.action = @selector(_handleToggleMeasureButton:); - [button lookin_bindObject:manager forKey:@"manager"]; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Measure]; - item.label = NSLocalizedString(@"Measure", nil); - item.view = button; - - [manager.measureState subscribe:self action:@selector(_handleMeasureStateDidChange:) relatedObject:button sendAtOnce:YES]; - - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Rotation]) { - NSImage *image = NSImageMake(@"icon_rotation"); - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - [button setButtonType:NSButtonTypePushOnPushOff]; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Rotation]; - item.label = NSLocalizedString(@"Free Rotation", nil); - item.view = button; - - [manager.freeRotation subscribe:self action:@selector(_handleFreeRotationDidChange:) relatedObject:button sendAtOnce:YES]; - - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Dimension]) { - NSImage *image_2d = NSImageMake(@"icon_2d"); - image_2d.template = YES; - NSImage *image_3d = NSImageMake(@"icon_3d"); - image_3d.template = YES; - - NSSegmentedControl *control = [NSSegmentedControl segmentedControlWithImages:@[image_2d, image_3d] trackingMode:NSSegmentSwitchTrackingSelectOne target:self action:@selector(_handleDimension:)]; - [control lookin_bindObjectWeakly:manager forKey:Key_BindingPreferenceManager]; - control.segmentDistribution = NSSegmentDistributionFillEqually; - [control setWidth:45 forSegment:0]; - [control setWidth:45 forSegment:1]; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Dimension]; - item.label = @"2D / 3D"; - item.view = control; - - [manager.previewDimension subscribe:self action:@selector(_handleDimensionDidChange:) relatedObject:control sendAtOnce:YES]; - - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Scale]) { - double scale = manager.previewScale.currentDoubleValue; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Scale]; - LKWindowToolbarScaleView *scaleView = [LKWindowToolbarScaleView new]; - scaleView.slider.minValue = LookinPreviewMinScale; - scaleView.slider.maxValue = LookinPreviewMaxScale; - scaleView.slider.doubleValue = scale; - scaleView.slider.target = self; - scaleView.slider.action = @selector(_handleScaleSlider:); - scaleView.increaseButton.target = self; - scaleView.increaseButton.action = @selector(_handleScaleIncreaseButton:); - scaleView.decreaseButton.target = self; - scaleView.decreaseButton.action = @selector(_handleScaleDecreaseButton:); - [scaleView.slider lookin_bindObjectWeakly:manager forKey:Key_BindingPreferenceManager]; - [scaleView.increaseButton lookin_bindObjectWeakly:manager forKey:Key_BindingPreferenceManager]; - [scaleView.decreaseButton lookin_bindObjectWeakly:manager forKey:Key_BindingPreferenceManager]; - - item.label = NSLocalizedString(@"Zoom", nil); - item.view = scaleView; - - [manager.previewScale subscribe:self action:@selector(_handlePreviewScaleDidChange:) relatedObject:scaleView.slider sendAtOnce:YES]; - - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Setting]) { - NSImage *image = NSImageMake(@"icon_setting"); - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - [button lookin_bindObjectWeakly:manager forKey:Key_BindingPreferenceManager]; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Setting]; - item.view = button; - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Reload]) { - NSImage *image = NSImageMake(@"icon_reload"); - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Reload]; - item.label = NSLocalizedString(@"Reload", nil); - item.view = button; - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_App]) { - LKWindowToolbarAppButton *button = [LKWindowToolbarAppButton new]; - button.bezelStyle = NSBezelStyleTexturedRounded; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_App]; - item.label = NSLocalizedString(@"Select App", nil); - item.view = button; - // Phase F: the App icon is bound to the owning Live Doc's - // `inspectableApp` by `LKStaticWindowController` (which has - // direct access to its own per-instance state). - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_SwiftUIMode]) { - NSSegmentedControl *segmented = [NSSegmentedControl segmentedControlWithLabels:@[ - NSLocalizedString(@"Compact", nil), - NSLocalizedString(@"Verbose", nil), - ] - trackingMode:NSSegmentSwitchTrackingSelectOne - target:LKSwiftUIHierarchyDisplayModeStore.class - action:@selector(swiftUIModeSegmentChanged:)]; - - LKSwiftUIHierarchyDisplayMode mode = [LKSwiftUIHierarchyDisplayModeStore currentMode]; - segmented.selectedSegment = (mode == LKSwiftUIHierarchyDisplayModeCompact) ? 0 : 1; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_SwiftUIMode]; - item.label = NSLocalizedString(@"SwiftUI", nil); - item.view = segmented; - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Console]) { - NSImage *image = NSImageMake(@"icon_console"); - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - [button setButtonType:NSButtonTypePushOnPushOff]; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Console]; - item.label = NSLocalizedString(@"Console", nil); - item.view = button; - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_FastMode]) { - NSImage *image = NSImageMake(@"icon_turbo"); - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - [button setButtonType:NSButtonTypePushOnPushOff]; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_FastMode]; - item.label = NSLocalizedString(@"Fast Mode", nil); - item.view = button; - - [manager.fastMode subscribe:self action:@selector(_handleFastModeDidChange:) relatedObject:button sendAtOnce:YES]; - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Add]) { - NSImage *image = [NSImage imageNamed:NSImageNameAddTemplate]; - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Add]; - item.view = button; - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Remove]) { - NSImage *image = NSImageMake(@"icon_delete"); - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Remove]; - item.view = button; - return item; - } - - if ([identifier isEqualToString:LKToolBarIdentifier_Message]) { - NSImage *image = NSImageMake(@"icon_notification"); - image.template = YES; - - NSButton *button = [NSButton new]; - [button setImage:image]; - button.bezelStyle = NSBezelStyleTexturedRounded; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_Message]; - item.view = button; - return item; - } - - NSAssert(NO, @""); - return nil; -} - -- (NSToolbarItem *)makeAppInReadModeItemWithAppInfo:(LookinAppInfo *)appInfo { - LKWindowToolbarAppButton *button = [LKWindowToolbarAppButton new]; - button.bezelStyle = NSBezelStyleTexturedRounded; - [button lookin_bindObject:appInfo forKey:Key_BindingAppInfo]; - button.appInfo = appInfo; - - NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_AppInReadMode]; - item.label = @"iOS App"; - item.view = button; - return item; -} - -- (void)_handleDimension:(NSSegmentedControl *)control { - LKPreferenceManager *manager = [control lookin_getBindObjectForKey:Key_BindingPreferenceManager]; - NSUInteger index = control.selectedSegment; - [manager.previewDimension setIntegerValue:index ignoreSubscriber:self]; -} - -- (void)_handleScaleSlider:(NSSlider *)slider { - LKPreferenceManager *manager = [slider lookin_getBindObjectForKey:Key_BindingPreferenceManager]; - [manager.previewScale setDoubleValue:slider.doubleValue ignoreSubscriber:self]; -} - -- (void)_handleScaleIncreaseButton:(NSButton *)button { - LKPreferenceManager *manager = [button lookin_getBindObjectForKey:Key_BindingPreferenceManager]; - double currentScale = manager.previewScale.currentDoubleValue; - double targetScale = MIN(MAX(currentScale + 0.1, LookinPreviewMinScale), LookinPreviewMaxScale); - [manager.previewScale setDoubleValue:targetScale ignoreSubscriber:nil]; -} - -- (void)_handleScaleDecreaseButton:(NSButton *)button { - LKPreferenceManager *manager = [button lookin_getBindObjectForKey:Key_BindingPreferenceManager]; - double currentScale = manager.previewScale.currentDoubleValue; - double targetScale = MIN(MAX(currentScale - 0.1, LookinPreviewMinScale), LookinPreviewMaxScale); - [manager.previewScale setDoubleValue:targetScale ignoreSubscriber:nil]; -} - -- (void)_handlePreviewScaleDidChange:(LookinMsgActionParams *)param { - NSSlider *slider = param.relatedObject; - CGFloat scale = param.doubleValue; - slider.doubleValue = scale; -} - -- (void)_handleFastModeDidChange:(LookinMsgActionParams *)param { - NSButton *button = param.relatedObject; - BOOL boolValue = param.boolValue; - button.state = boolValue ? NSControlStateValueOn : NSControlStateValueOff; -} - -- (void)_handleDimensionDidChange:(LookinMsgActionParams *)param { - LookinPreviewDimension newDimension = param.integerValue; - NSSegmentedControl *control = param.relatedObject; - control.selectedSegment = newDimension; -} - -- (void)_handleFreeRotationDidChange:(LookinMsgActionParams *)param { - NSButton *button = param.relatedObject; - BOOL boolValue = param.boolValue; - button.state = boolValue ? NSControlStateValueOn : NSControlStateValueOff; -} - -- (void)_handleToggleMeasureButton:(NSButton *)button { - LKPreferenceManager *manager = [button lookin_getBindObjectForKey:@"manager"]; - LookinMeasureState state = ((button.state == NSControlStateValueOn) ? LookinMeasureState_locked : LookinMeasureState_no); - [manager.measureState setIntegerValue:state ignoreSubscriber:self]; -} - -- (void)_handleMeasureStateDidChange:(LookinMsgActionParams *)param { - NSButton *button = param.relatedObject; - LookinMeasureState measureState = param.integerValue; - button.state = (measureState != LookinMeasureState_no); -} - -@end diff --git a/LookInside/Toolbar/LKWindowToolbarHelper.swift b/LookInside/Toolbar/LKWindowToolbarHelper.swift new file mode 100644 index 00000000..f054ad50 --- /dev/null +++ b/LookInside/Toolbar/LKWindowToolbarHelper.swift @@ -0,0 +1,243 @@ +// +// LKWindowToolbarHelper.swift +// Lookin +// +// Created by Li Kai on 2019/5/8. +// https://lookin.work +// + +import AppKit + +/// Makes the toolbar items the inspector and reader windows share. +/// +/// The items for dimension, rotation, scale, measure and fast mode follow a +/// preference manager both ways. The window sets the click action of the +/// others itself: Reload, App, Setting, Console, Add, Remove and Message. +@objc(LKWindowToolbarHelper) +@MainActor +final class LKWindowToolbarHelper: NSObject { + @objc(sharedInstance) + static let shared = LKWindowToolbarHelper() + + override private init() { + super.init() + } + + /// The item for `identifier`. Use `makeAppInReadModeItem(with:)` for the + /// reader's app item. + @objc(makeToolBarItemWithIdentifier:preferenceManager:) + func makeToolBarItem(identifier: String, preferenceManager manager: LKPreferenceManager) -> NSToolbarItem? { + assert(identifier != LKToolBarIdentifier_AppInReadMode, "Use makeAppInReadModeItemWithAppInfo:") + + switch identifier { + case LKToolBarIdentifier_GestureDebug: + let item = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier(identifier)) + item.label = NSLocalizedString("Gestures", comment: "") + item.toolTip = NSLocalizedString("Inspect SwiftUI responders and gesture events", comment: "") + item.image = NSImage(systemSymbolName: "hand.point.up.left", accessibilityDescription: item.label) + return item + + case LKToolBarIdentifier_Measure: + let button = LKToolbarPreferenceButton() + button.preferenceManager = manager + button.image = Self.templateImage("icon_measure") + button.bezelStyle = .texturedRounded + button.setButtonType(.pushOnPushOff) + button.target = self + button.action = #selector(handleToggleMeasureButton(_:)) + manager.measureState.subscribe(self, action: #selector(handleMeasureStateDidChange(_:)), relatedObject: button, sendAtOnce: true) + return Self.item(LKToolBarIdentifier_Measure, label: NSLocalizedString("Measure", comment: ""), view: button) + + case LKToolBarIdentifier_Rotation: + let button = Self.imageButton("icon_rotation", buttonType: .pushOnPushOff) + manager.freeRotation.subscribe(self, action: #selector(handleOnOffDidChange(_:)), relatedObject: button, sendAtOnce: true) + return Self.item(LKToolBarIdentifier_Rotation, label: NSLocalizedString("Free Rotation", comment: ""), view: button) + + case LKToolBarIdentifier_Dimension: + let images = [Self.templateImage("icon_2d"), Self.templateImage("icon_3d")].compactMap { $0 } + let control = LKToolbarPreferenceSegmentedControl( + images: images, + trackingMode: .selectOne, + target: self, + action: #selector(handleDimension(_:)) + ) + control.preferenceManager = manager + control.segmentDistribution = .fillEqually + control.setWidth(45, forSegment: 0) + control.setWidth(45, forSegment: 1) + manager.previewDimension.subscribe(self, action: #selector(handleDimensionDidChange(_:)), relatedObject: control, sendAtOnce: true) + return Self.item(LKToolBarIdentifier_Dimension, label: "2D / 3D", view: control) + + case LKToolBarIdentifier_Scale: + let scaleView = LKWindowToolbarScaleView() + let slider = scaleView.slider + slider.preferenceManager = manager + slider.minValue = Double(LookinPreviewMinScale) + slider.maxValue = Double(LookinPreviewMaxScale) + slider.doubleValue = manager.previewScale.currentDoubleValue + slider.target = self + slider.action = #selector(handleScaleSlider(_:)) + scaleView.increaseButton.target = self + scaleView.increaseButton.action = #selector(handleScaleIncreaseButton(_:)) + scaleView.increaseButton.preferenceManager = manager + scaleView.decreaseButton.target = self + scaleView.decreaseButton.action = #selector(handleScaleDecreaseButton(_:)) + scaleView.decreaseButton.preferenceManager = manager + manager.previewScale.subscribe(self, action: #selector(handlePreviewScaleDidChange(_:)), relatedObject: slider, sendAtOnce: true) + return Self.item(LKToolBarIdentifier_Scale, label: NSLocalizedString("Zoom", comment: ""), view: scaleView) + + case LKToolBarIdentifier_Setting: + return Self.item(LKToolBarIdentifier_Setting, label: nil, view: Self.imageButton("icon_setting")) + + case LKToolBarIdentifier_Reload: + return Self.item(LKToolBarIdentifier_Reload, label: NSLocalizedString("Reload", comment: ""), view: Self.imageButton("icon_reload")) + + case LKToolBarIdentifier_App: + let button = LKWindowToolbarAppButton() + button.bezelStyle = .texturedRounded + // The owning live document's window controller binds the button + // to its inspected app. + return Self.item(LKToolBarIdentifier_App, label: NSLocalizedString("Select App", comment: ""), view: button) + + case LKToolBarIdentifier_SwiftUIMode: + let segmented = NSSegmentedControl( + labels: [NSLocalizedString("Compact", comment: ""), NSLocalizedString("Verbose", comment: "")], + trackingMode: .selectOne, + target: LKSwiftUIHierarchyDisplayModeStore.self as AnyObject, + action: #selector(LKSwiftUIHierarchyDisplayModeStore.swiftUIModeSegmentChanged(_:)) + ) + segmented.selectedSegment = LKSwiftUIHierarchyDisplayModeStore.currentMode() == .compact ? 0 : 1 + return Self.item(LKToolBarIdentifier_SwiftUIMode, label: NSLocalizedString("SwiftUI", comment: ""), view: segmented) + + case LKToolBarIdentifier_Console: + let button = Self.imageButton("icon_console", buttonType: .pushOnPushOff) + return Self.item(LKToolBarIdentifier_Console, label: NSLocalizedString("Console", comment: ""), view: button) + + case LKToolBarIdentifier_FastMode: + let button = Self.imageButton("icon_turbo", buttonType: .pushOnPushOff) + manager.fastMode.subscribe(self, action: #selector(handleOnOffDidChange(_:)), relatedObject: button, sendAtOnce: true) + return Self.item(LKToolBarIdentifier_FastMode, label: NSLocalizedString("Fast Mode", comment: ""), view: button) + + case LKToolBarIdentifier_Add: + let button = NSButton() + let image = NSImage(named: NSImage.addTemplateName) + image?.isTemplate = true + button.image = image + button.bezelStyle = .texturedRounded + return Self.item(LKToolBarIdentifier_Add, label: nil, view: button) + + case LKToolBarIdentifier_Remove: + return Self.item(LKToolBarIdentifier_Remove, label: nil, view: Self.imageButton("icon_delete")) + + case LKToolBarIdentifier_Message: + return Self.item(LKToolBarIdentifier_Message, label: nil, view: Self.imageButton("icon_notification")) + + default: + assertionFailure("Unknown toolbar item identifier \(identifier)") + return nil + } + } + + /// The reader's app item, showing `appInfo`. + @objc(makeAppInReadModeItemWithAppInfo:) + func makeAppInReadModeItem(with appInfo: LookinAppInfo?) -> NSToolbarItem { + let button = LKWindowToolbarAppButton() + button.bezelStyle = .texturedRounded + button.appInfo = appInfo + return Self.item(LKToolBarIdentifier_AppInReadMode, label: "iOS App", view: button) + } + + // MARK: - Building + + private static func item(_ identifier: String, label: String?, view: NSView) -> NSToolbarItem { + let item = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier(identifier)) + if let label { + item.label = label + } + item.view = view + return item + } + + private static func templateImage(_ name: String) -> NSImage? { + let image = NSImage(named: name) + image?.isTemplate = true + return image + } + + private static func imageButton(_ imageName: String, buttonType: NSButton.ButtonType? = nil) -> NSButton { + let button = NSButton() + button.image = templateImage(imageName) + button.bezelStyle = .texturedRounded + if let buttonType { + button.setButtonType(buttonType) + } + return button + } + + // MARK: - Control → preference + + @objc private func handleDimension(_ control: LKToolbarPreferenceSegmentedControl) { + control.preferenceManager?.previewDimension.setIntegerValue(control.selectedSegment, ignoreSubscriber: self) + } + + @objc private func handleScaleSlider(_ slider: LKToolbarPreferenceSlider) { + slider.preferenceManager?.previewScale.setDoubleValue(slider.doubleValue, ignoreSubscriber: self) + } + + @objc private func handleScaleIncreaseButton(_ button: LKToolbarPreferenceButton) { + stepScale(of: button.preferenceManager, by: LKToolbarRules.step) + } + + @objc private func handleScaleDecreaseButton(_ button: LKToolbarPreferenceButton) { + stepScale(of: button.preferenceManager, by: -LKToolbarRules.step) + } + + private func stepScale(of manager: LKPreferenceManager?, by delta: Double) { + guard let manager else { return } + let scale = LKToolbarRules.stepped( + manager.previewScale.currentDoubleValue, + by: delta, + lower: Double(LookinPreviewMinScale), + upper: Double(LookinPreviewMaxScale) + ) + manager.previewScale.setDoubleValue(scale, ignoreSubscriber: nil) + } + + @objc private func handleToggleMeasureButton(_ button: LKToolbarPreferenceButton) { + let state: LookinMeasureState = button.state == .on ? .locked : .no + button.preferenceManager?.measureState.setIntegerValue(state.rawValue, ignoreSubscriber: self) + } + + // MARK: - Preference → control + + @objc private func handlePreviewScaleDidChange(_ param: LookinMsgActionParams) { + (param.relatedObject as? NSSlider)?.doubleValue = param.doubleValue + } + + /// Fast mode and free rotation: the button is on while the preference is. + @objc private func handleOnOffDidChange(_ param: LookinMsgActionParams) { + (param.relatedObject as? NSButton)?.state = param.boolValue ? .on : .off + } + + @objc private func handleDimensionDidChange(_ param: LookinMsgActionParams) { + (param.relatedObject as? NSSegmentedControl)?.selectedSegment = param.integerValue + } + + @objc private func handleMeasureStateDidChange(_ param: LookinMsgActionParams) { + (param.relatedObject as? NSButton)?.state = param.integerValue != LookinMeasureState.no.rawValue ? .on : .off + } +} + +/// Toolbar controls that remember the preference manager they drive; the +/// shared helper is their target. +final class LKToolbarPreferenceButton: NSButton { + weak var preferenceManager: LKPreferenceManager? +} + +final class LKToolbarPreferenceSlider: NSSlider { + weak var preferenceManager: LKPreferenceManager? +} + +final class LKToolbarPreferenceSegmentedControl: NSSegmentedControl { + weak var preferenceManager: LKPreferenceManager? +} diff --git a/LookInside/Toolbar/LKWindowToolbarScaleView.h b/LookInside/Toolbar/LKWindowToolbarScaleView.h deleted file mode 100644 index 5cce29df..00000000 --- a/LookInside/Toolbar/LKWindowToolbarScaleView.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// LKWindowToolbarScaleView.h -// Lookin -// -// Created by Li Kai on 2019/10/14. -// https://lookin.work -// - -#import "LKBaseView.h" - -@interface LKWindowToolbarScaleView : LKBaseView - -@property(nonatomic, strong, readonly) NSSlider *slider; -@property(nonatomic, strong, readonly) NSButton *decreaseButton; -@property(nonatomic, strong, readonly) NSButton *increaseButton; - -@end diff --git a/LookInside/Toolbar/LKWindowToolbarScaleView.m b/LookInside/Toolbar/LKWindowToolbarScaleView.m deleted file mode 100644 index 04b2659f..00000000 --- a/LookInside/Toolbar/LKWindowToolbarScaleView.m +++ /dev/null @@ -1,38 +0,0 @@ -// -// LKWindowToolbarScaleView.m -// Lookin -// -// Created by Li Kai on 2019/10/14. -// https://lookin.work -// - -#import "LKWindowToolbarScaleView.h" - -@implementation LKWindowToolbarScaleView - -- (instancetype)initWithFrame:(NSRect)frameRect { - if (self = [super initWithFrame:frameRect]) { - _slider = [NSSlider new]; - [self addSubview:self.slider]; - - _decreaseButton = [NSButton lk_buttonWithImage:NSImageMake(@"icon_decrease") target:nil action:nil]; - [self addSubview:self.decreaseButton]; - - _increaseButton = [NSButton lk_buttonWithImage:NSImageMake(@"icon_increase") target:nil action:nil]; - [self addSubview:self.increaseButton]; - } - return self; -} - -- (void)layout { - [super layout]; - $(self.decreaseButton).width(20).fullHeight.x(0); - $(self.increaseButton).width(20).fullHeight.right(0); - $(self.slider).fullHeight.x(self.decreaseButton.$maxX).toMaxX(self.increaseButton.$x); -} - -- (NSSize)intrinsicContentSize { - return NSMakeSize(160, 34); -} - -@end diff --git a/LookInside/Toolbar/LKWindowToolbarScaleView.swift b/LookInside/Toolbar/LKWindowToolbarScaleView.swift new file mode 100644 index 00000000..fdd1d50b --- /dev/null +++ b/LookInside/Toolbar/LKWindowToolbarScaleView.swift @@ -0,0 +1,49 @@ +// +// LKWindowToolbarScaleView.swift +// Lookin +// +// Created by Li Kai on 2019/10/14. +// https://lookin.work +// + +import AppKit + +/// The toolbar's zoom control: a slider between a decrease and an increase +/// button. +final class LKWindowToolbarScaleView: LKBaseView { + let slider = LKToolbarPreferenceSlider() + let decreaseButton = LKWindowToolbarScaleView.makeButton(imageNamed: "icon_decrease") + let increaseButton = LKWindowToolbarScaleView.makeButton(imageNamed: "icon_increase") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + addSubview(slider) + addSubview(decreaseButton) + addSubview(increaseButton) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func layout() { + super.layout() + LKViewFrameLayout(decreaseButton).width(20).fullHeight().x(0) + LKViewFrameLayout(increaseButton).width(20).fullHeight().right(0) + LKViewFrameLayout(slider).fullHeight().x(decreaseButton.frame.maxX).toMaxX(increaseButton.frame.minX) + } + + override var intrinsicContentSize: NSSize { + NSSize(width: 160, height: 34) + } + + /// `+[NSButton lk_buttonWithImage:target:action:]`: borderless, image only. + private static func makeButton(imageNamed name: String) -> LKToolbarPreferenceButton { + let button = LKToolbarPreferenceButton() + button.image = NSImage(named: name) + button.bezelStyle = .roundRect + button.isBordered = false + return button + } +} diff --git a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyAttributes.swift b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyAttributes.swift index fe27bdcf..d3a509b2 100644 --- a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyAttributes.swift +++ b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyAttributes.swift @@ -93,7 +93,7 @@ final class LKXcodeViewHierarchyConstraintIndex { else { continue } var itemIdentifiers: Set = [] for endpointPropertyName in Self.endpointPropertyNames { - guard case .objectReference(let reference)? = constraintNode.property(named: endpointPropertyName)?.value + guard case let .objectReference(reference)? = constraintNode.property(named: endpointPropertyName)?.value else { continue } itemIdentifiers.insert(reference.objectIdentifier) } @@ -109,7 +109,9 @@ final class LKXcodeViewHierarchyConstraintIndex { } private static func isConstraint(_ node: LKXcodeViewHierarchyNode, graph: LKXcodeViewHierarchyObjectGraph) -> Bool { - if node.groupingIdentifier?.hasSuffix("." + constraintClassName) == true { return true } + if node.groupingIdentifier?.hasSuffix("." + constraintClassName) == true { + return true + } guard let className = node.className else { return false } return graph.classChain(forClassName: className).contains(constraintClassName) } @@ -165,7 +167,8 @@ enum LKXcodeViewHierarchyAttributes { // The server drops an Auto Layout card that holds only sizing // priorities; a view with no constraints gets no card. if catalogGroup.identifier == LookinAttrGroup_AutoLayout, - !sections.contains(where: { $0.identifier == LookinAttrSec_AutoLayout_Constraints }) { + !sections.contains(where: { $0.identifier == LookinAttrSec_AutoLayout_Constraints }) + { continue } if !sections.isEmpty { @@ -209,7 +212,7 @@ enum LKXcodeViewHierarchyAttributes { guard context.role == .windowScene, let keyWindowIdentifier = context.environment.keyWindowIdentifier, let keyWindowClassName = context.sceneWindowNodes - .first(where: { $0.objectIdentifier == keyWindowIdentifier })?.className + .first(where: { $0.objectIdentifier == keyWindowIdentifier })?.className else { return nil } return makingAttribute(identifier, .nsString, keyWindowClassName as NSString) @@ -281,7 +284,9 @@ enum LKXcodeViewHierarchyAttributes { for sourceNode in sourceNodes { guard let sourceNode else { continue } for propertyName in specification.properties { - if let property = sourceNode.property(named: propertyName) { return property } + if let property = sourceNode.property(named: propertyName) { + return property + } } } return nil @@ -300,7 +305,7 @@ enum LKXcodeViewHierarchyAttributes { propertyName: String, graph: LKXcodeViewHierarchyObjectGraph ) -> LKXcodeViewHierarchyNode? { - guard case .objectReference(let reference)? = node.property(named: propertyName)?.value else { return nil } + guard case let .objectReference(reference)? = node.property(named: propertyName)?.value else { return nil } return graph.node(reference.objectIdentifier) } @@ -331,7 +336,7 @@ enum LKXcodeViewHierarchyAttributes { return (.nsString, text as NSString) case .color: - guard case .color(let color) = value, let components = rgbaComponents(of: color) else { return nil } + guard case let .color(color) = value, let components = rgbaComponents(of: color) else { return nil } return (.uiColor, components as NSArray) case .rect: @@ -362,23 +367,23 @@ enum LKXcodeViewHierarchyAttributes { return (.double, NSNumber(value: components[1])) case .fontName: - guard case .font(let font) = value else { return nil } + guard case let .font(font) = value else { return nil } return (.nsString, font.fontName as NSString) case .fontSize: - guard case .font(let font) = value else { return nil } + guard case let .font(font) = value else { return nil } return (.double, NSNumber(value: font.pointSize)) case .imageName: - guard case .image(let image) = value, let imageName = image.metadata?.imageName, !imageName.isEmpty + guard case let .image(image) = value, let imageName = image.metadata?.imageName, !imageName.isEmpty else { return nil } return (.nsString, imageName as NSString) case .imageData: - guard case .image(let image) = value else { return nil } + guard case let .image(image) = value else { return nil } return (.customObj, image.encodedData as NSData) - case .maskBit(let bit): + case let .maskBit(bit): guard let integerValue = integerValue(of: value) else { return nil } return (.BOOL, NSNumber(value: (UInt64(bitPattern: integerValue) & bit) != 0)) } @@ -395,10 +400,10 @@ enum LKXcodeViewHierarchyAttributes { private static func integerValue(of value: LKXcodeViewHierarchyValue) -> Int64? { switch value { - case .integer(let integer): return integer - case .unsignedInteger(let unsigned): return Int64(bitPattern: unsigned) - case .number(let number): return Int64(exactly: number.rounded()) - case .boolean(let flag): return flag ? 1 : 0 + case let .integer(integer): return integer + case let .unsignedInteger(unsigned): return Int64(bitPattern: unsigned) + case let .number(number): return Int64(exactly: number.rounded()) + case let .boolean(flag): return flag ? 1 : 0 default: return nil } } @@ -511,7 +516,7 @@ enum LKXcodeViewHierarchyAttributes { } private static func delegateDescription(of node: LKXcodeViewHierarchyNode) -> String? { - guard case .objectReference(let reference)? = node.property(named: "delegate")?.value else { return nil } + guard case let .objectReference(reference)? = node.property(named: "delegate")?.value else { return nil } return "(\(reference.className) *) delegate" } @@ -546,7 +551,9 @@ enum LKXcodeViewHierarchyAttributes { return constraintIdentifiers.compactMap { constraintIdentifier in guard let constraintNode = graph.node(constraintIdentifier) else { return nil } // The whole product ignores inactive constraints, as the server does. - if constraintNode.property(named: ConstraintProperty.active)?.value.boolValue == false { return nil } + if constraintNode.property(named: ConstraintProperty.active)?.value.boolValue == false { + return nil + } let firstAttribute = integerProperty(constraintNode, ConstraintProperty.firstAttribute) ?? 0 let secondAttribute = integerProperty(constraintNode, ConstraintProperty.secondAttribute) ?? 0 @@ -582,7 +589,7 @@ enum LKXcodeViewHierarchyAttributes { /// 37 — private ones it has no name for. Captures so far use 32, 33, 36 /// and 37 among the private values, all of which it accepts. private static func isRepresentableAttribute(_ attribute: Int) -> Bool { - !((21...31).contains(attribute) || attribute > 37) + !((21 ... 31).contains(attribute) || attribute > 37) } private static func affectingConstraintIdentifiers(of node: LKXcodeViewHierarchyNode) -> Set { @@ -591,7 +598,9 @@ enum LKXcodeViewHierarchyAttributes { guard let text = node.property(named: propertyName)?.value.textValue else { continue } for identifier in text.split(separator: ",") { let trimmed = identifier.trimmingCharacters(in: .whitespaces) - if !trimmed.isEmpty { identifiers.insert(trimmed) } + if !trimmed.isEmpty { + identifiers.insert(trimmed) + } } } return identifiers @@ -611,7 +620,7 @@ enum LKXcodeViewHierarchyAttributes { graph: LKXcodeViewHierarchyObjectGraph, context: LKXcodeViewHierarchyAttributeContext ) -> (object: LookinObject?, type: LookinConstraintItemType) { - guard case .objectReference(let reference)? = constraintNode.property(named: propertyName)?.value else { + guard case let .objectReference(reference)? = constraintNode.property(named: propertyName)?.value else { return (nil, .`nil`) } let object = LookinObject() @@ -625,7 +634,8 @@ enum LKXcodeViewHierarchyAttributes { } else if reference.objectIdentifier == context.superviewNode?.objectIdentifier { type = .super } else if object.classChainList?.contains(where: ChainEnd.layoutGuide.contains) == true - || reference.className.hasSuffix("LayoutGuide") { + || reference.className.hasSuffix("LayoutGuide") + { type = .layoutGuide } else { type = .view diff --git a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyBundleReader.swift b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyBundleReader.swift index 94a4dcb3..f729bd05 100644 --- a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyBundleReader.swift +++ b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyBundleReader.swift @@ -44,11 +44,11 @@ enum LKXcodeViewHierarchyBundleReadingError: Error, CustomStringConvertible { var description: String { switch self { - case .notADirectory(let url): + case let .notADirectory(url): return "\(url.lastPathComponent) is not a view hierarchy package" - case .missingResponses(let url): + case let .missingResponses(url): return "\(url.lastPathComponent) has no RequestResponses directory" - case .noUsableResponses(let url): + case let .noUsableResponses(url): return "\(url.lastPathComponent) contains no readable capture responses" } } @@ -121,7 +121,9 @@ enum LKXcodeViewHierarchyBundleReader { .sorted { leadingName, trailingName in let leadingIndex = responseIndex(fromFileName: leadingName) let trailingIndex = responseIndex(fromFileName: trailingName) - if leadingIndex != trailingIndex { return leadingIndex < trailingIndex } + if leadingIndex != trailingIndex { + return leadingIndex < trailingIndex + } return leadingName < trailingName } .map { responsesURL.appendingPathComponent($0) } @@ -170,8 +172,12 @@ enum LKXcodeViewHierarchyBundleReader { /// `DocumentVersion` ships as a string, but a plist rewrite could make it a /// number; accept either rather than lose the field. private static func stringValue(_ rawValue: Any?) -> String? { - if let text = rawValue as? String { return text } - if let number = rawValue as? NSNumber { return number.stringValue } + if let text = rawValue as? String { + return text + } + if let number = rawValue as? NSNumber { + return number.stringValue + } return nil } } @@ -179,5 +185,7 @@ enum LKXcodeViewHierarchyBundleReader { /// Shown by the document when an import fails, so the wording must be the /// user-facing one rather than "The operation couldn't be completed". extension LKXcodeViewHierarchyBundleReadingError: LocalizedError { - var errorDescription: String? { description } + var errorDescription: String? { + description + } } diff --git a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyConverter.swift b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyConverter.swift index 6922629c..7f9b63fb 100644 --- a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyConverter.swift +++ b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyConverter.swift @@ -286,7 +286,8 @@ enum LKXcodeViewHierarchyConverter { // Xcode only credits owners that appear in the owner root group; // a window whose owner the capture never listed stays unowned. if let ownerIdentifier = windowNode.associatedIdentifiers(inGroup: vocabulary.windowOwnerGroup).first, - listedOwnerIdentifiers.contains(ownerIdentifier) { + listedOwnerIdentifiers.contains(ownerIdentifier) + { windowIdentifiersByOwner[ownerIdentifier, default: []].append(windowIdentifier) } else { unownedWindowIdentifiers.append(windowIdentifier) @@ -349,7 +350,8 @@ enum LKXcodeViewHierarchyConverter { } for viewIdentifier in graph.rootIdentifiers(inGroup: vocabulary.viewGroup) - where !convertedViewIdentifiers.contains(viewIdentifier) { + where !convertedViewIdentifiers.contains(viewIdentifier) + { guard let viewNode = graph.node(viewIdentifier), !viewNode.childIdentifiers.isEmpty, isNode(viewNode, kindOfClassNamed: ClassName.touchBarView, graph: graph), @@ -403,7 +405,8 @@ enum LKXcodeViewHierarchyConverter { ) { guard let windowNode = graph.node(windowIdentifier) else { return } for childIdentifier in windowNode.childIdentifiers - where graph.node(childIdentifier)?.groupingIdentifier == vocabulary.windowGroup { + where graph.node(childIdentifier)?.groupingIdentifier == vocabulary.windowGroup + { childWindowIdentifiers.append(childIdentifier) appendingChildWindowIdentifiers( of: childIdentifier, graph: graph, vocabulary: vocabulary, into: &childWindowIdentifiers @@ -419,7 +422,9 @@ enum LKXcodeViewHierarchyConverter { graph: LKXcodeViewHierarchyObjectGraph, vocabulary: Vocabulary ) -> Bool { - if isNode(windowNode, kindOfClassNamed: ClassName.systemOverlayWindow, graph: graph) { return true } + if isNode(windowNode, kindOfClassNamed: ClassName.systemOverlayWindow, graph: graph) { + return true + } guard let isInternal = windowNode.property(named: "internal")?.value.boolValue, let isVisible = windowNode.property(named: "visible")?.value.boolValue, isInternal || !isVisible @@ -435,7 +440,9 @@ enum LKXcodeViewHierarchyConverter { graph: LKXcodeViewHierarchyObjectGraph, vocabulary: Vocabulary ) -> Bool { - if isNode(windowNode, kindOfClassNamed: ClassName.remoteKeyboardWindow, graph: graph) { return false } + if isNode(windowNode, kindOfClassNamed: ClassName.remoteKeyboardWindow, graph: graph) { + return false + } var pendingControllerIdentifiers = windowNode.associatedIdentifiers(inGroup: vocabulary.viewControllerGroup) var visited: Set = [] while !pendingControllerIdentifiers.isEmpty { @@ -548,7 +555,8 @@ enum LKXcodeViewHierarchyConverter { if !vocabulary.windowOwnerIsSceneNode, let controllerIdentifier = windowNode.associatedIdentifiers(inGroup: vocabulary.windowOwnerGroup).first, - let controllerNode = graph.node(controllerIdentifier) { + let controllerNode = graph.node(controllerIdentifier) + { item.hostWindowControllerObject = makingLookinObject(for: controllerNode, graph: graph) context.windowControllerNode = controllerNode } @@ -556,7 +564,8 @@ enum LKXcodeViewHierarchyConverter { // cards leave it out, since a window is not that controller's view // and a live session's Class and Relation cards do not list it. if let controllerIdentifier = windowNode.associatedIdentifiers(inGroup: vocabulary.viewControllerGroup).first, - let controllerNode = graph.node(controllerIdentifier) { + let controllerNode = graph.node(controllerIdentifier) + { item.hostViewControllerObject = makingLookinObject(for: controllerNode, graph: graph) } @@ -646,7 +655,8 @@ enum LKXcodeViewHierarchyConverter { var context = LKXcodeViewHierarchyAttributeContext(role: .view, environment: environment) context.superviewNode = superviewNode if let controllerIdentifier = viewNode.associatedIdentifiers(inGroup: vocabulary.viewControllerGroup).first, - let controllerNode = graph.node(controllerIdentifier) { + let controllerNode = graph.node(controllerIdentifier) + { item.hostViewControllerObject = makingLookinObject(for: controllerNode, graph: graph) context.viewControllerNode = controllerNode } @@ -718,7 +728,7 @@ enum LKXcodeViewHierarchyConverter { } let wrappedLayerNodes = associatedNode.childIdentifiers.compactMap(graph.node) let backingNode = wrappedLayerNodes.first { wrappedLayerNode in - guard case .objectReference(let delegate)? = wrappedLayerNode.property(named: "delegate")?.value else { + guard case let .objectReference(delegate)? = wrappedLayerNode.property(named: "delegate")?.value else { return false } return delegate.objectIdentifier == node.objectIdentifier @@ -941,7 +951,9 @@ enum LKXcodeViewHierarchyConverter { session: ConversionSession ) -> Bool { guard session.vocabulary.isAppKit else { return false } - if case .objectReference? = layerNode.property(named: "delegate")?.value { return false } + if case .objectReference? = layerNode.property(named: "delegate")?.value { + return false + } guard let parentIdentifier = session.topology.parentByLayerIdentifier[layerNode.objectIdentifier], session.topology.isHosted(parentIdentifier) else { return false } @@ -1020,9 +1032,13 @@ enum LKXcodeViewHierarchyConverter { /// width) drive the inspector's toggle for hiding them. private static func looksSystemManaged(_ guideNode: LKXcodeViewHierarchyNode) -> Bool { let identifier = guideNode.property(named: "identifier")?.value.textValue ?? "" - if identifier.hasPrefix("UIView") || identifier.hasPrefix("NSView") { return true } + if identifier.hasPrefix("UIView") || identifier.hasPrefix("NSView") { + return true + } let systemIdentifiers = ["UIViewSafeAreaLayoutGuide", "UIViewLayoutMarginsGuide", "UIViewReadableContentGuide"] - if systemIdentifiers.contains(identifier) { return true } + if systemIdentifiers.contains(identifier) { + return true + } let className = guideNode.className ?? "" return className.hasPrefix("_") || className == "NSSafeAreaLayoutGuide" } @@ -1082,13 +1098,15 @@ enum LKXcodeViewHierarchyConverter { if let frameComponents = node.property(named: "frame")?.value.numericComponents(expectedCount: 4) { item.frame = rect(from: frameComponents) } else if let fallbackComponents = fallbackNode? - .property(named: "frame")?.value.numericComponents(expectedCount: 4) { + .property(named: "frame")?.value.numericComponents(expectedCount: 4) + { item.frame = rect(from: fallbackComponents) } if let boundsComponents = node.property(named: "bounds")?.value.numericComponents(expectedCount: 4) { item.bounds = rect(from: boundsComponents) } else if let fallbackComponents = fallbackNode? - .property(named: "bounds")?.value.numericComponents(expectedCount: 4) { + .property(named: "bounds")?.value.numericComponents(expectedCount: 4) + { item.bounds = rect(from: fallbackComponents) } else { item.bounds = CGRect(origin: .zero, size: item.frame.size) @@ -1109,7 +1127,7 @@ enum LKXcodeViewHierarchyConverter { /// The inspector paints this before a screenshot is available, so a node /// with no recovered pixels still reads as something rather than a hole. private static func applyingBackgroundColor(from layerNode: LKXcodeViewHierarchyNode, to item: LookinDisplayItem) { - guard case .color(let color)? = layerNode.property(named: "backgroundColor")?.value, + guard case let .color(color)? = layerNode.property(named: "backgroundColor")?.value, color.components.count >= 3 else { return } let alpha = color.components.count >= 4 ? color.components[3] : 1 @@ -1130,7 +1148,9 @@ enum LKXcodeViewHierarchyConverter { /// The pointer an `objectID` spells, which is what Lookin's `oid` holds. static func objectIdentifierValue(_ objectIdentifier: String) -> UInt { var text = objectIdentifier - if text.hasPrefix("0x") || text.hasPrefix("0X") { text = String(text.dropFirst(2)) } + if text.hasPrefix("0x") || text.hasPrefix("0X") { + text = String(text.dropFirst(2)) + } return UInt(text, radix: 16) ?? 0 } @@ -1152,7 +1172,9 @@ enum LKXcodeViewHierarchyConverter { } extension LKXcodeViewHierarchyConversionError: LocalizedError { - var errorDescription: String? { description } + var errorDescription: String? { + description + } } private extension Array where Element: Equatable { diff --git a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyDocument.swift b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyDocument.swift index 9bc5370c..1867e7da 100644 --- a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyDocument.swift +++ b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyDocument.swift @@ -45,9 +45,13 @@ enum LKXcodeViewHierarchyImporter { isCancelled: () -> Bool = { false } ) throws -> LKXcodeViewHierarchyImportedCapture { let bundle = try LKXcodeViewHierarchyBundleReader.reading(contentsOf: url) - if isCancelled() { throw CancellationError() } + if isCancelled() { + throw CancellationError() + } let screenshots = LKXcodeViewHierarchyPixelRecovery.recovering(from: bundle.graph, isCancelled: isCancelled) - if isCancelled() { throw CancellationError() } + if isCancelled() { + throw CancellationError() + } return LKXcodeViewHierarchyImportedCapture(bundle: bundle, screenshots: screenshots) } @@ -69,7 +73,9 @@ enum LKXcodeViewHierarchyImporter { isCancelled: () -> Bool = { false } ) throws -> LookinHierarchyFile { let capture = try importingCapture(at: url, isCancelled: isCancelled) - if isCancelled() { throw CancellationError() } + if isCancelled() { + throw CancellationError() + } return try makingHierarchyFile(from: capture, showingBackingLayers: showingBackingLayers) } } @@ -85,13 +91,13 @@ final class LKXcodeViewHierarchyDocument: LookinArchiveDocument { /// Validates the package and starts the import, returning before the /// import finishes so the window can open on its placeholder. - override func read(from url: URL, ofType typeName: String) throws { + override func read(from url: URL, ofType _: String) throws { try LKXcodeViewHierarchyBundleReader.validatingBundle(at: url) importTask?.cancel() importTask = Task.detached(priority: .userInitiated) { [weak self] in let outcome: Result do { - outcome = .success(try LKXcodeViewHierarchyImporter.importingCapture(at: url) { Task.isCancelled }) + outcome = try .success(LKXcodeViewHierarchyImporter.importingCapture(at: url) { Task.isCancelled }) } catch { outcome = .failure(error) } @@ -122,7 +128,7 @@ final class LKXcodeViewHierarchyDocument: LookinArchiveDocument { rebuildTask = Task.detached(priority: .userInitiated) { [weak self] in let outcome: Result do { - outcome = .success(try LKXcodeViewHierarchyImporter.makingHierarchyFile( + outcome = try .success(LKXcodeViewHierarchyImporter.makingHierarchyFile( from: importedCapture, showingBackingLayers: showingBackingLayers )) } catch { @@ -140,12 +146,12 @@ final class LKXcodeViewHierarchyDocument: LookinArchiveDocument { if let windowController = windowControllers.first as? LKReadWindowController { return windowController.preferenceManager.showBackingLayers.currentBOOLValue } - return LKPreferenceManager.main().showBackingLayers.currentBOOLValue + return LKPreferenceManager.shared.showBackingLayers.currentBOOLValue } /// The task has to be created where `close()` can cancel it, on the /// main thread; the superclass's concurrent path would call `read` elsewhere. - override class func canConcurrentlyReadDocuments(ofType typeName: String) -> Bool { + override class func canConcurrentlyReadDocuments(ofType _: String) -> Bool { false } @@ -153,10 +159,10 @@ final class LKXcodeViewHierarchyDocument: LookinArchiveDocument { private func finishingImport(with outcome: Result) { importTask = nil switch outcome { - case .success(let capture): + case let .success(capture): importedCapture = capture rebuildHierarchyFile(showingBackingLayers: showsBackingLayersPreference) - case .failure(let error): + case let .failure(error): presentingFailure(error) } } @@ -165,10 +171,10 @@ final class LKXcodeViewHierarchyDocument: LookinArchiveDocument { private func finishingRebuild(with outcome: Result) { rebuildTask = nil switch outcome { - case .success(let file): + case let .success(file): // The window controller observes this and builds the reader in place. hierarchyFile = file - case .failure(let error): + case let .failure(error): presentingFailure(error) } } diff --git a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyGzip.swift b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyGzip.swift index c24e296e..1895114f 100644 --- a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyGzip.swift +++ b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyGzip.swift @@ -29,7 +29,7 @@ enum LKXcodeViewHierarchyGzipError: Error, CustomStringConvertible { switch self { case .truncatedHeader: return "gzip header is truncated" - case .unsupportedCompressionMethod(let method): + case let .unsupportedCompressionMethod(method): return "gzip compression method \(method) is not DEFLATE" case .truncatedPayload: return "gzip payload ends before its trailer" @@ -70,7 +70,7 @@ enum LKXcodeViewHierarchyGzip { let bytes = [UInt8](payload) let deflateStart = try deflateStreamOffset(in: bytes) guard bytes.count >= deflateStart + 8 else { throw LKXcodeViewHierarchyGzipError.truncatedPayload } - let deflateBytes = bytes[deflateStart..<(bytes.count - 8)] + let deflateBytes = bytes[deflateStart ..< (bytes.count - 8)] return try inflatingRawDeflate(Array(deflateBytes)) } @@ -104,7 +104,9 @@ enum LKXcodeViewHierarchyGzip { private static func offsetPastNulTerminatedField(in bytes: [UInt8], from start: Int) throws -> Int { var cursor = start - while cursor < bytes.count, bytes[cursor] != 0 { cursor += 1 } + while cursor < bytes.count, bytes[cursor] != 0 { + cursor += 1 + } guard cursor < bytes.count else { throw LKXcodeViewHierarchyGzipError.truncatedHeader } return cursor + 1 } diff --git a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyLayerArchive.swift b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyLayerArchive.swift index cf7e271c..b9d6b613 100644 --- a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyLayerArchive.swift +++ b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyLayerArchive.swift @@ -56,7 +56,7 @@ enum LKXcodeViewHierarchyLayerArchive { for objectIdentifier in graph.nodesByIdentifier.keys.sorted() { guard let node = graph.node(objectIdentifier), let property = node.property(named: propertyName), - case .binaryData(let archiveData) = property.value + case let .binaryData(archiveData) = property.value else { continue } if let tree = decodingLayerTree(from: archiveData, rootObjectIdentifier: objectIdentifier) { @@ -168,7 +168,7 @@ enum LKXcodeViewHierarchyLayerAlignment { for capturedChild in capturedChildren { let capturedGeometry = geometry(ofCapturedLayer: capturedChild, graph: graph) var matchIndex: Int? - for index in searchStart.. = [current] while let parent = parentByLayerIdentifier[current] { - if parent == ancestorIdentifier { return current } + if parent == ancestorIdentifier { + return current + } guard visited.insert(parent).inserted else { return nil } current = parent } diff --git a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyObjectGraph.swift b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyObjectGraph.swift index 4568a095..62f79426 100644 --- a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyObjectGraph.swift +++ b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyObjectGraph.swift @@ -234,7 +234,9 @@ final class LKXcodeViewHierarchyObjectGraphBuilder { // Rule 3: a reference names an object described elsewhere. Recording its // identifier (done by the caller) is all it may contribute. - if !describesObjectInFull(object) { return } + if !describesObjectInFull(object) { + return + } if let className = object["className"] as? String { node.className = className @@ -270,7 +272,9 @@ final class LKXcodeViewHierarchyObjectGraphBuilder { /// that points at it (`propertyLogicalType`, `propertyVisibility`) and never /// a `className`. private func describesObjectInFull(_ object: [String: Any]) -> Bool { - if object["className"] != nil { return true } + if object["className"] != nil { + return true + } if object["childGroup"] != nil || object["additionalGroups"] != nil || object["properties"] != nil { return true } @@ -283,7 +287,7 @@ final class LKXcodeViewHierarchyObjectGraphBuilder { responseVersion: Int ) { guard let separatorIndex = keyPath.firstIndex(of: ".") else { return } - let objectIdentifier = String(keyPath[keyPath.startIndex.. LKXcodeViewHierarchyNode { - if let existing = nodesByIdentifier[objectIdentifier] { return existing } + if let existing = nodesByIdentifier[objectIdentifier] { + return existing + } let created = LKXcodeViewHierarchyNode(objectIdentifier: objectIdentifier) nodesByIdentifier[objectIdentifier] = created return created diff --git a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyPixelRecovery.swift b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyPixelRecovery.swift index bf9bd7cd..5cb0072a 100644 --- a/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyPixelRecovery.swift +++ b/LookInside/XcodeViewHierarchy/LKXcodeViewHierarchyPixelRecovery.swift @@ -135,7 +135,8 @@ enum LKXcodeViewHierarchyPixelRecovery { let region = drawingRegion(of: layer, hiding: []), let groupImage = renderingPNG( of: layer, includingSublayers: true, region: region.rect, isFlipped: isFlipped - ) { + ) + { groupByObjectIdentifier[objectIdentifier] = groupImage if region.isPartial { groupRegionByObjectIdentifier[objectIdentifier] = region.relativeRect @@ -149,7 +150,8 @@ enum LKXcodeViewHierarchyPixelRecovery { let region = drawingRegion(of: layer, hiding: hostedDescendants), let excludingImage = renderingPNG( of: layer, includingSublayers: true, hiding: hostedDescendants, region: region.rect, isFlipped: isFlipped - ) { + ) + { groupExcludingHostedViewsByObjectIdentifier[objectIdentifier] = excludingImage if region.isPartial { groupExcludingHostedViewsRegionByObjectIdentifier[objectIdentifier] = region.relativeRect @@ -284,10 +286,16 @@ enum LKXcodeViewHierarchyPixelRecovery { layer.sublayers = nil } let layersHiddenForRender = hiddenLayers.filter { !$0.isHidden } - for hiddenLayer in layersHiddenForRender { hiddenLayer.isHidden = true } + for hiddenLayer in layersHiddenForRender { + hiddenLayer.isHidden = true + } defer { - if let detachedSublayers { layer.sublayers = detachedSublayers } - for hiddenLayer in layersHiddenForRender { hiddenLayer.isHidden = false } + if let detachedSublayers { + layer.sublayers = detachedSublayers + } + for hiddenLayer in layersHiddenForRender { + hiddenLayer.isHidden = false + } } context.scaleBy(x: scale, y: scale) @@ -342,7 +350,8 @@ enum LKXcodeViewHierarchyPixelRecovery { else { return false } if let backgroundColor = layer.backgroundColor, let colorSpace = backgroundColor.colorSpace, - colorSpace.model == .pattern { + colorSpace.model == .pattern + { return false } return true @@ -361,9 +370,9 @@ enum LKXcodeViewHierarchyPixelRecovery { let bytesPerRow = context.bytesPerRow let buffer = pixels.bindMemory(to: UInt8.self, capacity: bytesPerRow * height) - for row in 0.. [Double]? { - guard case .list(let elements) = self, elements.count == expectedCount else { return nil } + guard case let .list(elements) = self, elements.count == expectedCount else { return nil } var components: [Double] = [] components.reserveCapacity(expectedCount) for element in elements { @@ -175,15 +177,15 @@ enum LKXcodeViewHierarchyValueDecodingError: Error, CustomStringConvertible { var description: String { switch self { - case .specifierCountMismatch(let format, let valueCount, let specifierCount): + case let .specifierCountMismatch(format, valueCount, specifierCount): return "value has \(valueCount) elements but format '\(format)' has \(specifierCount) specifiers" - case .multipleSpecifiersOnNonArray(let format): + case let .multipleSpecifiersOnNonArray(format): return "format '\(format)' has multiple specifiers but the value is not an array" - case .unsupportedSpecifier(let specifier): + case let .unsupportedSpecifier(specifier): return "unsupported format specifier '\(specifier)'" - case .malformedScalar(let specifier, let text): + case let .malformedScalar(specifier, text): return "value '\(text)' is not readable as '\(specifier)'" - case .malformedStructure(let specifier): + case let .malformedStructure(specifier): return "structured value for '\(specifier)' is missing required fields" } } @@ -235,7 +237,7 @@ enum LKXcodeViewHierarchyValueDecoder { } if let elements = jsonValue as? [Any] { // A single specifier over an array: decode each element the same way. - return .list(try elements.map { try decoding(jsonValue: $0, format: specifier) }) + return try .list(elements.map { try decoding(jsonValue: $0, format: specifier) }) } if let number = jsonValue as? NSNumber { // Defensive: a capture that inlined a JSON number where a hex-float @@ -294,7 +296,9 @@ enum LKXcodeViewHierarchyValueDecoder { /// Parses a C99 hexadecimal float, which is how every real number travels. static func parsingFloatingPoint(_ text: String) -> Double? { - if let value = Double(text) { return value } + if let value = Double(text) { + return value + } return text.withCString { pointer -> Double? in var endPointer: UnsafeMutablePointer? let value = strtod(pointer, &endPointer) @@ -320,10 +324,14 @@ enum LKXcodeViewHierarchyValueDecoder { if radix == 16, trimmed.hasPrefix("0x") || trimmed.hasPrefix("0X") { trimmed = String(trimmed.dropFirst(2)) } - if let value = UInt64(trimmed, radix: radix) { return value } + if let value = UInt64(trimmed, radix: radix) { + return value + } // A negative decimal where an unsigned value was expected is a signed // representation of the same bits; keep the bits rather than reject. - if let signedValue = Int64(trimmed, radix: radix) { return UInt64(bitPattern: signedValue) } + if let signedValue = Int64(trimmed, radix: radix) { + return UInt64(bitPattern: signedValue) + } return nil } @@ -419,10 +427,13 @@ enum LKXcodeViewHierarchyValueDecoder { } private static func customTextRepresentation(of jsonValue: Any) -> String { - if let text = jsonValue as? String { return text } + if let text = jsonValue as? String { + return text + } if JSONSerialization.isValidJSONObject(jsonValue), let data = try? JSONSerialization.data(withJSONObject: jsonValue), - let text = String(data: data, encoding: .utf8) { + let text = String(data: data, encoding: .utf8) + { return text } return String(describing: jsonValue) diff --git a/LookInside/main.m b/LookInside/main.m deleted file mode 100644 index c9ea15c3..00000000 --- a/LookInside/main.m +++ /dev/null @@ -1,20 +0,0 @@ -// -// main.m -// Lookin -// -// Created by Li Kai on 2018/12/21. -// https://lookin.work -// - -#import -#import "AppDelegate.h" - -int main(int argc, const char * argv[]) { - @autoreleasepool { - NSApplication *application = [NSApplication sharedApplication]; - AppDelegate *delegate = [[AppDelegate alloc] init]; - application.delegate = delegate; - [application run]; - } - return EXIT_SUCCESS; -} diff --git a/Package.swift b/Package.swift index 507c7fe4..b190a9d3 100644 --- a/Package.swift +++ b/Package.swift @@ -15,32 +15,18 @@ let sharedCXXDefines: [CXXSetting] = [ let package = Package( name: "LookInside", platforms: [ - .iOS(.v12), + .iOS(.v15), .tvOS(.v12), .macOS(.v11), ], products: [ .library( name: "LookinCore", - targets: ["LookinCore"] + targets: ["LookinCore", "LookinCoreImpl"] ), .library( name: "LookinShared", - targets: ["LookinCore", "LookinServerBase"] - ), - .library( - name: "LookinServer", - targets: ["LookinServer"] - ), - .library( - name: "LookinServerDynamic", - type: .dynamic, - targets: ["LookinServer"] - ), - .library( - name: "LookinServerInjected", - type: .dynamic, - targets: ["LookinServerInjected"] + targets: ["LookinCore", "LookinCoreImpl", "LookinServerBase"] ), ], dependencies: [], @@ -61,53 +47,19 @@ let package = Package( dependencies: ["LookinServerBase"], path: "Sources/LookinCore", publicHeadersPath: "include", - cSettings: sharedCDefines + [ - .headerSearchPath("."), - .headerSearchPath("Category"), - .headerSearchPath("Peertalk"), - ], + cSettings: sharedCDefines, cxxSettings: sharedCXXDefines ), + // The LookinCore model classes (plain Swift, `@objc(OriginalName)`), mirrored + // from LookInside-Server (Scripts/sync-lookin-core.sh). .target( - name: "LookinServerSwift", - dependencies: ["LookinServerBase"], - path: "Sources/LookinServerSwift", - cxxSettings: sharedCXXDefines, + name: "LookinCoreImpl", + dependencies: ["LookinCore", "LookinServerBase"], + path: "Sources/LookinCoreImpl", swiftSettings: [ .define("SHOULD_COMPILE_LOOKIN_SERVER"), .define("SPM_LOOKIN_SERVER_ENABLED"), ] ), - .target( - name: "LookinServer", - dependencies: ["LookinCore", "LookinServerBase", "LookinServerSwift"], - path: "Sources/LookinServer", - exclude: ["Shared"], - publicHeadersPath: "include", - cSettings: sharedCDefines + [ - .headerSearchPath("Server"), - .headerSearchPath("Server/Category"), - .headerSearchPath("Server/Connection"), - .headerSearchPath("Server/Connection/RequestHandler"), - .headerSearchPath("Server/Others"), - .headerSearchPath("../LookinCore"), - .headerSearchPath("../LookinCore/include"), - .headerSearchPath("../LookinCore/Category"), - .headerSearchPath("../LookinCore/Peertalk"), - ], - cxxSettings: sharedCXXDefines - ), - .target( - name: "LookinServerInjected", - dependencies: ["LookinServer"], - path: "Sources/LookinServerInjected", - publicHeadersPath: "", - cSettings: sharedCDefines, - cxxSettings: sharedCXXDefines, - linkerSettings: [ - .linkedFramework("AppKit", .when(platforms: [.macOS])), - .linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS])), - ] - ), ] ) diff --git a/Packages/LookInsideActivation/.swift-format b/Packages/LookInsideActivation/.swift-format new file mode 100644 index 00000000..12a03bf7 --- /dev/null +++ b/Packages/LookInsideActivation/.swift-format @@ -0,0 +1,17 @@ +{ + "indentation" : { + "spaces" : 4 + }, + "lineLength" : 120, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "respectsExistingLineBreaks" : true, + "rules" : { + "DoNotUseSemicolons" : true, + "OrderedImports" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseExplicitNilCheckInConditions" : true, + "UseShorthandTypeNames" : true + }, + "version" : 1 +} diff --git a/Packages/LookInsideActivation/Package.swift b/Packages/LookInsideActivation/Package.swift new file mode 100644 index 00000000..35050b96 --- /dev/null +++ b/Packages/LookInsideActivation/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "LookInsideActivation", + defaultLocalization: "en", + platforms: [.macOS(.v14)], + products: [ + .library(name: "LookInsideActivation", targets: ["LookInsideActivation"]), + .library(name: "LookInsideActivationUI", targets: ["LookInsideActivationUI"]), + ], + dependencies: [ + .package(url: "https://github.com/simibac/ConfettiSwiftUI.git", from: "2.0.0"), + .package(url: "https://github.com/Lakr233/WindowAnimation.git", from: "1.0.0"), + ], + targets: [ + .target( + name: "LookInsideActivation", + resources: [.process("Resources")] + ), + .target( + name: "LookInsideActivationUI", + dependencies: [ + "LookInsideActivation", + .product(name: "ConfettiSwiftUI", package: "ConfettiSwiftUI"), + .product(name: "WindowAnimation", package: "WindowAnimation"), + ] + ), + .testTarget( + name: "LookInsideActivationTests", + dependencies: ["LookInsideActivation", "LookInsideActivationUI"], + resources: [.copy("Fixtures")] + ), + ], + swiftLanguageModes: [.v6] +) diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationArtifact.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationArtifact.swift new file mode 100644 index 00000000..fbc770a8 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationArtifact.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct ActivationArtifact: Codable, Sendable, Equatable { + public let name: String + public let payload: Data + public let digest: String + + public init(name: String, payload: Data, digest: String) { + self.name = name + self.payload = payload + self.digest = digest + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationPath.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationPath.swift new file mode 100644 index 00000000..1d0978b4 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationPath.swift @@ -0,0 +1,4 @@ +public enum ActivationPath: String, Codable, Sendable, Equatable { + case direct + case timeAnchored +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationSession.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationSession.swift new file mode 100644 index 00000000..35efc5b5 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ActivationSession.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct ActivationSession: Codable, Sendable, Equatable { + public let sessionID: String + public let licenseID: String + public let orderID: String? + public let email: String? + public let deviceIdentifier: String + public let token: String + public let expiresAt: Date + public let createdAt: Date + + public init( + sessionID: String, + licenseID: String, + orderID: String?, + email: String?, + deviceIdentifier: String, + token: String, + expiresAt: Date, + createdAt: Date + ) { + self.sessionID = sessionID + self.licenseID = licenseID + self.orderID = orderID + self.email = email + self.deviceIdentifier = deviceIdentifier + self.token = token + self.expiresAt = expiresAt + self.createdAt = createdAt + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ClientActivationChallenge.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ClientActivationChallenge.swift new file mode 100644 index 00000000..3024d002 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/ClientActivationChallenge.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct ClientActivationChallenge: Codable, Sendable, Equatable { + public let nonce: String + public let issuedAt: Date + public let requestedFeature: String + public let frameworkVersion: String + public let device: DeviceFingerprint + + public init( + nonce: String, + issuedAt: Date, + requestedFeature: String, + frameworkVersion: String, + device: DeviceFingerprint + ) { + self.nonce = nonce + self.issuedAt = issuedAt + self.requestedFeature = requestedFeature + self.frameworkVersion = frameworkVersion + self.device = device + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceBinding.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceBinding.swift new file mode 100644 index 00000000..bc479a9a --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceBinding.swift @@ -0,0 +1,38 @@ +import Foundation + +public struct DeviceBinding: Codable, Sendable, Equatable { + public let licenseID: String + public let udid: String + public let machineName: String? + public let hardwareModel: String + public let operatingSystemVersion: String + public let appBundleID: String + public let status: DeviceBindingStatus + public let firstSeenAt: Date + public let lastSeenAt: Date + public let reviewNote: String? + + public init( + licenseID: String, + udid: String, + machineName: String? = nil, + hardwareModel: String, + operatingSystemVersion: String, + appBundleID: String, + status: DeviceBindingStatus = .active, + firstSeenAt: Date, + lastSeenAt: Date, + reviewNote: String? = nil + ) { + self.licenseID = licenseID + self.udid = udid + self.machineName = machineName + self.hardwareModel = hardwareModel + self.operatingSystemVersion = operatingSystemVersion + self.appBundleID = appBundleID + self.status = status + self.firstSeenAt = firstSeenAt + self.lastSeenAt = lastSeenAt + self.reviewNote = reviewNote + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceBindingStatus.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceBindingStatus.swift new file mode 100644 index 00000000..f9a53abd --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceBindingStatus.swift @@ -0,0 +1,5 @@ +public enum DeviceBindingStatus: String, Codable, Sendable, Equatable, CaseIterable { + case active + case reviewRequired + case blocked +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceFingerprint.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceFingerprint.swift new file mode 100644 index 00000000..6078d238 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/DeviceFingerprint.swift @@ -0,0 +1,18 @@ +public struct DeviceFingerprint: Codable, Sendable, Equatable { + public let deviceID: String + public let hardwareModel: String + public let operatingSystemVersion: String + public let appBundleID: String + + public init( + deviceID: String, + hardwareModel: String, + operatingSystemVersion: String, + appBundleID: String + ) { + self.deviceID = deviceID + self.hardwareModel = hardwareModel + self.operatingSystemVersion = operatingSystemVersion + self.appBundleID = appBundleID + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/EntitlementStatus.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/EntitlementStatus.swift new file mode 100644 index 00000000..49571133 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/EntitlementStatus.swift @@ -0,0 +1,113 @@ +import Foundation + +public struct EntitlementStatus: Codable, Sendable, Equatable { + public let license: LicenseEnvelope + public let deviceBinding: DeviceBinding? + public let currentLease: IntermediateCertificateLease? + public let activationSession: ActivationSession? + public let isEligibleForActivation: Bool + public let isEligibleForRenewal: Bool + public let requiresLiveRefresh: Bool + public let lastFetchedAt: Date + public let informationalMessage: String? + public let warningMessage: String? + + public init( + license: LicenseEnvelope, + deviceBinding: DeviceBinding? = nil, + currentLease: IntermediateCertificateLease? = nil, + activationSession: ActivationSession? = nil, + isEligibleForActivation: Bool, + isEligibleForRenewal: Bool, + requiresLiveRefresh: Bool = false, + lastFetchedAt: Date, + informationalMessage: String? = nil, + warningMessage: String? = nil + ) { + self.license = license + self.deviceBinding = deviceBinding + self.currentLease = currentLease + self.activationSession = activationSession + self.isEligibleForActivation = isEligibleForActivation + self.isEligibleForRenewal = isEligibleForRenewal + self.requiresLiveRefresh = requiresLiveRefresh + self.lastFetchedAt = lastFetchedAt + self.informationalMessage = informationalMessage + self.warningMessage = warningMessage + } + + public var entitlementEndsAt: Date? { + license.serviceEndsAt ?? license.expiresAt + } + + /// The cert and the entitlement are independent expiries; access ends at + /// whichever lapses first. Either side may be missing (perpetual licenses + /// have no entitlement end; pre-issuance state has no lease). + public var effectiveAccessEndsAt: Date? { + switch (currentLease?.expiresAt, entitlementEndsAt) { + case let (lease?, ent?): + return min(lease, ent) + case let (lease?, nil): + return lease + case let (nil, ent?): + return ent + case (nil, nil): + return nil + } + } + + public var certificateRenewalAt: Date? { + currentLease?.renewAfter ?? license.nextCertificateRenewalAt + } + + public var isAccessCurrentlyAvailable: Bool { + guard let effectiveAccessEndsAt else { + return isEligibleForActivation + } + return effectiveAccessEndsAt >= lastFetchedAt + } + + public var displayState: SummaryDisplayState { + switch (license.licenseClass, license.lifecycleState) { + case (.trial, _): + return .trial(daysRemaining: trialDaysRemaining) + case (.full, .active): + return .subscriptionActive + case (.full, .canceled): + return .subscriptionCanceled + case (.full, .expired): + return .subscriptionExpired + case (.full, .refunded): + return .subscriptionRefunded + case (.full, .pendingReview): + return .subscriptionInReview + } + } + + /// Ceiling-rounded days from the server's last verification timestamp to + /// the entitlement end. Reference time is `lastFetchedAt` so the rendered + /// count does not drift with client clock skew or screen-on time. + private var trialDaysRemaining: Int { + guard let end = entitlementEndsAt else { return 0 } + let components = Calendar.current.dateComponents( + [.day, .hour, .minute, .second], + from: lastFetchedAt, + to: end + ) + let days = components.day ?? 0 + let hasRemainder = + (components.hour ?? 0) > 0 + || (components.minute ?? 0) > 0 + || (components.second ?? 0) > 0 + return days + (hasRemainder ? 1 : 0) + } +} + +public enum SummaryDisplayState: Sendable, Equatable { + case trial(daysRemaining: Int) + case subscriptionActive + case subscriptionCanceled + case subscriptionExpired + case subscriptionRefunded + case subscriptionInReview +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateIssuanceReason.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateIssuanceReason.swift new file mode 100644 index 00000000..62a778cc --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateIssuanceReason.swift @@ -0,0 +1,5 @@ +public enum IntermediateCertificateIssuanceReason: String, Codable, Sendable, Equatable, CaseIterable { + case initialActivation + case renewal + case replacement +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateLease.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateLease.swift new file mode 100644 index 00000000..724af52b --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateLease.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct IntermediateCertificateLease: Codable, Sendable, Equatable { + public let certificateID: String + public let licenseID: String + public let udid: String + public let issuedAt: Date + public let expiresAt: Date + public let renewAfter: Date + public let issuanceReason: IntermediateCertificateIssuanceReason + public let renewalBlockedReason: String? + public let certificatePEM: String + public let publicKeyPEM: String + public let fullChainPEM: String + public let status: IntermediateCertificateLeaseStatus + + public init( + certificateID: String, + licenseID: String, + udid: String, + issuedAt: Date, + expiresAt: Date, + renewAfter: Date? = nil, + issuanceReason: IntermediateCertificateIssuanceReason, + renewalBlockedReason: String? = nil, + certificatePEM: String, + publicKeyPEM: String, + fullChainPEM: String, + status: IntermediateCertificateLeaseStatus = .active + ) { + self.certificateID = certificateID + self.licenseID = licenseID + self.udid = udid + self.issuedAt = issuedAt + self.expiresAt = expiresAt + self.renewAfter = + renewAfter + ?? min(issuedAt.addingTimeInterval(24 * 60 * 60), expiresAt) + self.issuanceReason = issuanceReason + self.renewalBlockedReason = renewalBlockedReason + self.certificatePEM = certificatePEM + self.publicKeyPEM = publicKeyPEM + self.fullChainPEM = fullChainPEM + self.status = status + } + + public var isInsideRenewalWindow: Bool { + renewAfter <= expiresAt + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateLeaseStatus.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateLeaseStatus.swift new file mode 100644 index 00000000..75dbb855 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/IntermediateCertificateLeaseStatus.swift @@ -0,0 +1,6 @@ +public enum IntermediateCertificateLeaseStatus: String, Codable, Sendable, Equatable, CaseIterable { + case active + case expired + case renewalBlocked + case superseded +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/SecureTimestampToken.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/SecureTimestampToken.swift new file mode 100644 index 00000000..702019dd --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/SecureTimestampToken.swift @@ -0,0 +1,24 @@ +import Foundation + +public struct SecureTimestampToken: Codable, Sendable, Equatable { + public let challengeNonce: String + public let signedAt: Date + public let rootCertificateID: String + public let signature: Data + + public init( + challengeNonce: String, + signedAt: Date, + rootCertificateID: String, + signature: Data = Data() + ) { + self.challengeNonce = challengeNonce + self.signedAt = signedAt + self.rootCertificateID = rootCertificateID + self.signature = signature + } + + public var hasSignature: Bool { + !signature.isEmpty + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/SignedActivationEnvelope.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/SignedActivationEnvelope.swift new file mode 100644 index 00000000..0b11807f --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Activation/SignedActivationEnvelope.swift @@ -0,0 +1,39 @@ +import Foundation + +public struct SignedActivationEnvelope: Codable, Sendable, Equatable { + public let activationID: String + public let challengeNonce: String + public let licenseID: String + public let issuedAt: Date + public let expiresAt: Date + public let intermediateCertificateID: String + public let boundUDID: String + public let artifacts: [ActivationArtifact] + public let signature: Data + + public init( + activationID: String, + challengeNonce: String, + licenseID: String, + issuedAt: Date, + expiresAt: Date, + intermediateCertificateID: String, + boundUDID: String = "", + artifacts: [ActivationArtifact], + signature: Data = Data() + ) { + self.activationID = activationID + self.challengeNonce = challengeNonce + self.licenseID = licenseID + self.issuedAt = issuedAt + self.expiresAt = expiresAt + self.intermediateCertificateID = intermediateCertificateID + self.boundUDID = boundUDID + self.artifacts = artifacts + self.signature = signature + } + + public var hasSignature: Bool { + !signature.isEmpty + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/CertificateSigningRequestBuilder.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/CertificateSigningRequestBuilder.swift new file mode 100644 index 00000000..43129519 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/CertificateSigningRequestBuilder.swift @@ -0,0 +1,180 @@ +import Foundation +import Security + +public enum CertificateSigningRequestError: Error, Equatable { + case unsupportedKeyType + case publicKeyExportFailed(String) + case signingFailed(String) + /// `SecKeyCreateSignature` failed with this keychain status. + case signingFailedWithStatus(Int32, String) +} + +public enum CertificateSigningRequestBuilder { + public static func makePKCS10PEM( + privateKey: SecKey, + commonName: String + ) throws -> String { + guard let publicKey = SecKeyCopyPublicKey(privateKey) else { + throw CertificateSigningRequestError.publicKeyExportFailed("Public key lookup failed.") + } + var exportError: Unmanaged? + guard let publicKeyBytes = SecKeyCopyExternalRepresentation(publicKey, &exportError) as Data? else { + let message = + (exportError?.takeRetainedValue() as Error?)?.localizedDescription + ?? "Public key export failed." + throw CertificateSigningRequestError.publicKeyExportFailed(message) + } + + let subject = DER.encodeSequence([ + DER.encodeSet([ + DER.encodeSequence([ + DER.encodeOID(ASN1OID.commonName), + DER.encodeUTF8String(commonName), + ]), + ]), + ]) + + let spki = DER.encodeSequence([ + DER.encodeSequence([ + DER.encodeOID(ASN1OID.rsaEncryption), + Data([0x05, 0x00]), + ]), + DER.encodeBitString(publicKeyBytes), + ]) + + let attributes = Data([0xA0, 0x00]) + + let certificationRequestInfo = DER.encodeSequence([ + DER.encodeInteger(0), + subject, + spki, + attributes, + ]) + + var signError: Unmanaged? + guard + let signature = SecKeyCreateSignature( + privateKey, + .rsaSignatureMessagePKCS1v15SHA256, + certificationRequestInfo as CFData, + &signError + ) as Data? + else { + let error = signError?.takeRetainedValue() as Error? + let message = error?.localizedDescription ?? "Signature generation failed." + if let error, (error as NSError).domain == NSOSStatusErrorDomain { + throw CertificateSigningRequestError.signingFailedWithStatus(Int32((error as NSError).code), message) + } + throw CertificateSigningRequestError.signingFailed(message) + } + + let signatureAlgorithm = DER.encodeSequence([ + DER.encodeOID(ASN1OID.sha256WithRSAEncryption), + Data([0x05, 0x00]), + ]) + + let csrDER = DER.encodeSequence([ + certificationRequestInfo, + signatureAlgorithm, + DER.encodeBitString(signature), + ]) + + return PEM.encode(csrDER, label: "CERTIFICATE REQUEST") + } +} + +enum ASN1OID { + static let commonName: [UInt64] = [2, 5, 4, 3] + static let rsaEncryption: [UInt64] = [1, 2, 840, 113_549, 1, 1, 1] + static let sha256WithRSAEncryption: [UInt64] = [1, 2, 840, 113_549, 1, 1, 11] +} + +enum DER { + static func encodeSequence(_ elements: [Data]) -> Data { + encodeTLV(tag: 0x30, value: elements.reduce(Data(), +)) + } + + static func encodeSet(_ elements: [Data]) -> Data { + encodeTLV(tag: 0x31, value: elements.reduce(Data(), +)) + } + + static func encodeInteger(_ value: Int) -> Data { + var bytes: [UInt8] = [] + var remaining = value + if remaining == 0 { + bytes = [0] + } else { + while remaining > 0 { + bytes.insert(UInt8(remaining & 0xFF), at: 0) + remaining >>= 8 + } + if bytes[0] & 0x80 != 0 { + bytes.insert(0, at: 0) + } + } + return encodeTLV(tag: 0x02, value: Data(bytes)) + } + + static func encodeUTF8String(_ value: String) -> Data { + encodeTLV(tag: 0x0C, value: Data(value.utf8)) + } + + static func encodeBitString(_ data: Data) -> Data { + var value = Data([0x00]) + value.append(data) + return encodeTLV(tag: 0x03, value: value) + } + + static func encodeOID(_ components: [UInt64]) -> Data { + precondition(components.count >= 2) + var bytes: [UInt8] = [] + bytes.append(UInt8(components[0] * 40 + components[1])) + for component in components.dropFirst(2) { + bytes.append(contentsOf: base128(component)) + } + return encodeTLV(tag: 0x06, value: Data(bytes)) + } + + private static func base128(_ value: UInt64) -> [UInt8] { + if value == 0 { + return [0x00] + } + var buffer: [UInt8] = [] + var remaining = value + while remaining > 0 { + buffer.insert(UInt8(remaining & 0x7F), at: 0) + remaining >>= 7 + } + for index in 0 ..< (buffer.count - 1) { + buffer[index] |= 0x80 + } + return buffer + } + + private static func encodeTLV(tag: UInt8, value: Data) -> Data { + var result = Data([tag]) + result.append(encodeLength(value.count)) + result.append(value) + return result + } + + private static func encodeLength(_ length: Int) -> Data { + if length < 0x80 { + return Data([UInt8(length)]) + } + var bytes: [UInt8] = [] + var remaining = length + while remaining > 0 { + bytes.insert(UInt8(remaining & 0xFF), at: 0) + remaining >>= 8 + } + return Data([0x80 | UInt8(bytes.count)] + bytes) + } +} + +enum PEM { + static func encode(_ data: Data, label: String) -> String { + let base64 = data.base64EncodedString(options: [.lineLength64Characters, .endLineWithLineFeed]) + return "-----BEGIN \(label)-----\n\(base64)\n-----END \(label)-----\n" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/EmbeddedTrustedRoots.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/EmbeddedTrustedRoots.swift new file mode 100644 index 00000000..844983bf --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/EmbeddedTrustedRoots.swift @@ -0,0 +1,59 @@ +import Foundation + +public struct TrustedRootPublicKey: Sendable, Equatable { + public let certificateID: String + public let publicKeyPEM: String + public let publicKeySHA256: String + + public init( + certificateID: String, + publicKeyPEM: String, + publicKeySHA256: String + ) { + self.certificateID = certificateID + self.publicKeyPEM = publicKeyPEM + self.publicKeySHA256 = publicKeySHA256 + } +} + +public enum EmbeddedTrustedRoots { + public static let rootProd2026 = TrustedRootPublicKey( + certificateID: "root_prod_2026", + publicKeyPEM: """ + -----BEGIN PUBLIC KEY----- + MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEApc0UUibWzZv3pgAulN2G + kihWoLCl0KnSBLdzh3ku+IhFGxV9Vj/YcO36KeLQGiNptpNUWsM5W9N5AcH7Waa8 + fXzEuaMSo/Cjo0sQdthiJ9TH7WH9nZ9AoXrDFszrdjbdGHDdAkLAwEClR45Q2d7s + NqY1p5E2At0193si/I9wWumKPgmbE2XnFpT7kqdNw5geiAo5lDZX3CK0nxE9n9Vq + DmViglQEoWFdPhQa/HD1ry/1vocBoHmvZAE0hDPHxa+Hf6XTZoW62RmalcT2A50H + TuAY3xCcDPuHl93fWgNT1rG4DIUzaJEuaxX9/UvBX7jw+DJ+xH63z/nGuxmoLObq + Dve57W0To5mtaYSNgolXJuZSd+Ju79g0Y+je+/B+Ave+TQiHqwWlVghan2qz+vN7 + CShMgIcBkqAy7XI2i/wzotfagT10OYTfXsIuT0stsScAdd5nyXN8sUu11trJkBIJ + wF/nOMDi8b08bKbMhkUlclrItJQYXl76nOw3umw2oCYuo164bd94uJkwsaN1m7GD + zsArlPlfIqPvzvlAyUPJrHvD5ug1hid0bxu7Vk7eR0MO68H2MEolRZDKAcfYvkl4 + zXo8b6nThTHEF25o/pn3wb3+Dp7rxCQHrMxWKTwNP6zrYxaT4agbSs4cj39OwHxl + nBuxlRY5RfQT0XJm756vnOsCAwEAAQ== + -----END PUBLIC KEY----- + """, + publicKeySHA256: "e67c0e0635447377fa53a894e6547104a33747eab74c25fde4cd2a2742882631" + ) + + /// Roots the client trusts. Release builds trust only `rootProd2026`; + /// DEBUG builds add `ActivationDebugOverrides.additionalTrustedRoot`. + public static var trusted: [TrustedRootPublicKey] { + #if DEBUG + if let additional = ActivationDebugOverrides.additionalTrustedRoot { + return [rootProd2026, additional] + } + #endif + return [rootProd2026] + } + + public static var all: [String: TrustedRootPublicKey] { + Dictionary(trusted.map { ($0.certificateID, $0) }, uniquingKeysWith: { first, _ in first }) + } + + public static func publicKey(for certificateID: String) -> TrustedRootPublicKey? { + all[certificateID] + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Errors.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Errors.swift new file mode 100644 index 00000000..29bcd47f --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Errors.swift @@ -0,0 +1,39 @@ +import Foundation + +public enum AuthenticatorError: Error, Equatable, LocalizedError, Sendable { + case challengeExpired + case challengeIssuedInFuture + case replayDetected + case licenseExpired + case intermediateCertificateExpired + case intermediateCertificateNotYetValid + case secureTimestampProviderUnavailable + case secureTimestampExpired + case secureTimestampRootMismatch + case secureTimestampNonceMismatch + + public var errorDescription: String? { + switch self { + case .challengeExpired: + return "The client challenge is outside the accepted time window." + case .challengeIssuedInFuture: + return "The client challenge time is ahead of the accepted clock skew." + case .replayDetected: + return "The challenge nonce has already been used." + case .licenseExpired: + return "The license is outside its validity window." + case .intermediateCertificateExpired: + return "The intermediate certificate has expired." + case .intermediateCertificateNotYetValid: + return "The intermediate certificate is not yet valid." + case .secureTimestampProviderUnavailable: + return "A secure timestamp provider is required for this activation path." + case .secureTimestampExpired: + return "The secure timestamp is outside the accepted time window." + case .secureTimestampRootMismatch: + return "The secure timestamp root certificate does not match the trusted root." + case .secureTimestampNonceMismatch: + return "The secure timestamp does not match the challenge nonce." + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/CertificateChain.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/CertificateChain.swift new file mode 100644 index 00000000..f1f9cc9c --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/CertificateChain.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct CertificateChain: Codable, Sendable, Equatable { + public let rootCertificateID: String + public let intermediateCertificateID: String + public let intermediateIssuedAt: Date + public let intermediateExpiresAt: Date + public let renewAfter: Date + public let boundUDID: String? + + public init( + rootCertificateID: String, + intermediateCertificateID: String, + intermediateIssuedAt: Date, + intermediateExpiresAt: Date, + renewAfter: Date? = nil, + boundUDID: String? = nil + ) { + self.rootCertificateID = rootCertificateID + self.intermediateCertificateID = intermediateCertificateID + self.intermediateIssuedAt = intermediateIssuedAt + self.intermediateExpiresAt = intermediateExpiresAt + self.renewAfter = + renewAfter + ?? min(intermediateIssuedAt.addingTimeInterval(24 * 60 * 60), intermediateExpiresAt) + self.boundUDID = boundUDID + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseAudience.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseAudience.swift new file mode 100644 index 00000000..87626938 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseAudience.swift @@ -0,0 +1,5 @@ +public enum LicenseAudience: String, Codable, Sendable, Equatable, CaseIterable { + case personal + case team + case organization +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseClass.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseClass.swift new file mode 100644 index 00000000..37fe6b0a --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseClass.swift @@ -0,0 +1,4 @@ +public enum LicenseClass: String, Codable, Sendable, Equatable { + case trial + case full +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseEnvelope+IntermediateCertificateLease.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseEnvelope+IntermediateCertificateLease.swift new file mode 100644 index 00000000..7a611ccc --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseEnvelope+IntermediateCertificateLease.swift @@ -0,0 +1,24 @@ +public extension LicenseEnvelope { + func applying(lease: IntermediateCertificateLease) -> LicenseEnvelope { + LicenseEnvelope( + licenseID: licenseID, + audience: audience, + licenseClass: licenseClass, + lifecycleState: lifecycleState, + issuedTo: issuedTo, + issuedAt: issuedAt, + expiresAt: expiresAt, + serviceEndsAt: serviceEndsAt, + nextCertificateRenewalAt: lease.renewAfter, + deviceWarning: deviceWarning, + certificateChain: CertificateChain( + rootCertificateID: certificateChain.rootCertificateID, + intermediateCertificateID: lease.certificateID, + intermediateIssuedAt: lease.issuedAt, + intermediateExpiresAt: lease.expiresAt, + renewAfter: lease.renewAfter, + boundUDID: lease.udid + ) + ) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseEnvelope.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseEnvelope.swift new file mode 100644 index 00000000..927678a8 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseEnvelope.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct LicenseEnvelope: Codable, Sendable, Equatable { + public let licenseID: String + public let audience: LicenseAudience + public let licenseClass: LicenseClass + public let lifecycleState: LicenseLifecycleState + public let issuedTo: String? + public let issuedAt: Date + public let expiresAt: Date? + public let serviceEndsAt: Date? + public let nextCertificateRenewalAt: Date? + public let deviceWarning: String? + public let certificateChain: CertificateChain + + public init( + licenseID: String, + audience: LicenseAudience = .personal, + licenseClass: LicenseClass, + lifecycleState: LicenseLifecycleState = .active, + issuedTo: String?, + issuedAt: Date, + expiresAt: Date?, + serviceEndsAt: Date? = nil, + nextCertificateRenewalAt: Date? = nil, + deviceWarning: String? = nil, + certificateChain: CertificateChain + ) { + self.licenseID = licenseID + self.audience = audience + self.licenseClass = licenseClass + self.lifecycleState = lifecycleState + self.issuedTo = issuedTo + self.issuedAt = issuedAt + self.expiresAt = expiresAt + self.serviceEndsAt = serviceEndsAt + self.nextCertificateRenewalAt = nextCertificateRenewalAt + self.deviceWarning = deviceWarning + self.certificateChain = certificateChain + } + + public var trustedRootCertificateID: String { + certificateChain.rootCertificateID + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseLifecycleState.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseLifecycleState.swift new file mode 100644 index 00000000..ae0d1bb7 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/License/LicenseLifecycleState.swift @@ -0,0 +1,16 @@ +public enum LicenseLifecycleState: String, Codable, Sendable, Equatable, CaseIterable { + case active + case canceled + case refunded + case expired + case pendingReview + + public var blocksNewCertificateIssuance: Bool { + switch self { + case .active, .pendingReview: + return false + case .canceled, .refunded, .expired: + return true + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Policy.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Policy.swift new file mode 100644 index 00000000..45295d66 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Policy.swift @@ -0,0 +1,32 @@ +import Foundation + +public struct ActivationPolicy: Sendable, Equatable { + public let acceptedClockSkew: TimeInterval + public let challengeTimeToLive: TimeInterval + public let secureTimestampTimeToLive: TimeInterval + public let activationLifetime: TimeInterval + public let requiresSecureTimestampForTrial: Bool + + public init( + acceptedClockSkew: TimeInterval = 30, + challengeTimeToLive: TimeInterval = 180, + secureTimestampTimeToLive: TimeInterval = 120, + activationLifetime: TimeInterval = 3600, + requiresSecureTimestampForTrial: Bool = true + ) { + self.acceptedClockSkew = acceptedClockSkew + self.challengeTimeToLive = challengeTimeToLive + self.secureTimestampTimeToLive = secureTimestampTimeToLive + self.activationLifetime = activationLifetime + self.requiresSecureTimestampForTrial = requiresSecureTimestampForTrial + } + + public func activationPath(for licenseClass: LicenseClass) -> ActivationPath { + switch licenseClass { + case .trial where requiresSecureTimestampForTrial: + return .timeAnchored + case .trial, .full: + return .direct + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Protocols.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Protocols.swift new file mode 100644 index 00000000..dd2ea8f4 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Protocols.swift @@ -0,0 +1,68 @@ +import Foundation + +public protocol Clock: Sendable { + func now() -> Date +} + +public protocol TrustChainValidating: Sendable { + func validate(_ certificateChain: CertificateChain, at now: Date) throws +} + +public protocol ChallengeValidating: Sendable { + func validate(_ challenge: ClientActivationChallenge, now: Date, policy: ActivationPolicy) throws +} + +public protocol ReplayProtecting: Sendable { + func reserve(_ nonce: String, until expiresAt: Date) throws +} + +public protocol SecureTimestampFetching: Sendable { + func fetchTimestamp(for request: SecureTimestampRequest) async throws -> SecureTimestampToken +} + +public protocol SecureTimestampValidating: Sendable { + func validate( + _ token: SecureTimestampToken, + against request: SecureTimestampRequest, + trustedRootCertificateID: String, + now: Date, + policy: ActivationPolicy + ) throws +} + +public protocol PurchaseClaimResolving: Sendable { + func resolvePurchaseClaim(_ request: PurchaseClaimRequest) async throws -> ActivationSession +} + +public protocol TrialIssuing: Sendable { + func issueTrial(_ request: TrialIssueRequest) async throws -> ActivationSession +} + +public protocol EntitlementStatusFetching: Sendable { + func fetchEntitlementStatus(_ request: EntitlementStatusRequest) async throws -> EntitlementStatus +} + +public protocol ActivationSessionRefreshing: Sendable { + func refreshActivationSession( + _ request: ActivationSessionRefreshRequest + ) async throws -> ActivationSession +} + +public protocol IntermediateCertificateIssuing: Sendable { + func issueIntermediateCertificate( + _ request: IntermediateCertificateIssueRequest + ) async throws -> IntermediateCertificateLease +} + +public protocol ActivationMaterialIssuing: Sendable { + func issueActivation( + for challenge: ClientActivationChallenge, + license: LicenseEnvelope, + policy: ActivationPolicy, + secureTimestamp: SecureTimestampToken? + ) async throws -> SignedActivationEnvelope +} + +public protocol RootTimestampSigning: Sendable { + func sign(request: SecureTimestampRequest, at now: Date) async throws -> SecureTimestampToken +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/ActivationSessionRefreshRequest.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/ActivationSessionRefreshRequest.swift new file mode 100644 index 00000000..5cc8db66 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/ActivationSessionRefreshRequest.swift @@ -0,0 +1,7 @@ +public struct ActivationSessionRefreshRequest: Codable, Sendable, Equatable { + public let activationSessionToken: String + + public init(activationSessionToken: String) { + self.activationSessionToken = activationSessionToken + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/EntitlementStatusRequest.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/EntitlementStatusRequest.swift new file mode 100644 index 00000000..e386a0a7 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/EntitlementStatusRequest.swift @@ -0,0 +1,18 @@ +public struct EntitlementStatusRequest: Codable, Sendable, Equatable { + public let licenseID: String + public let deviceID: String + public let forceRefresh: Bool + public let activationSessionToken: String? + + public init( + licenseID: String, + deviceID: String, + forceRefresh: Bool = false, + activationSessionToken: String? = nil + ) { + self.licenseID = licenseID + self.deviceID = deviceID + self.forceRefresh = forceRefresh + self.activationSessionToken = activationSessionToken + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/HostActivationRequest.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/HostActivationRequest.swift new file mode 100644 index 00000000..7747ef41 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/HostActivationRequest.swift @@ -0,0 +1,21 @@ +import Foundation + +public struct HostActivationRequest: Codable, Sendable, Equatable { + public let challenge: ClientActivationChallenge + public let license: LicenseEnvelope + + public init(challenge: ClientActivationChallenge, license: LicenseEnvelope) { + self.challenge = challenge + self.license = license + } + + public var secureTimestampRequest: SecureTimestampRequest { + SecureTimestampRequest( + licenseID: license.licenseID, + challengeNonce: challenge.nonce, + clientIssuedAt: challenge.issuedAt, + deviceIdentifier: challenge.device.deviceID, + requestedFeature: challenge.requestedFeature + ) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/HostActivationResponse.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/HostActivationResponse.swift new file mode 100644 index 00000000..a7c03be1 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/HostActivationResponse.swift @@ -0,0 +1,15 @@ +public struct HostActivationResponse: Codable, Sendable, Equatable { + public let activation: SignedActivationEnvelope + public let path: ActivationPath + public let secureTimestamp: SecureTimestampToken? + + public init( + activation: SignedActivationEnvelope, + path: ActivationPath, + secureTimestamp: SecureTimestampToken? + ) { + self.activation = activation + self.path = path + self.secureTimestamp = secureTimestamp + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/IntermediateCertificateIssueRequest.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/IntermediateCertificateIssueRequest.swift new file mode 100644 index 00000000..5b138397 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/IntermediateCertificateIssueRequest.swift @@ -0,0 +1,15 @@ +public struct IntermediateCertificateIssueRequest: Codable, Sendable, Equatable { + public let activationSessionToken: String + public let deviceIdentifier: String + public let certificateSigningRequestPEM: String + + public init( + activationSessionToken: String, + deviceIdentifier: String, + certificateSigningRequestPEM: String + ) { + self.activationSessionToken = activationSessionToken + self.deviceIdentifier = deviceIdentifier + self.certificateSigningRequestPEM = certificateSigningRequestPEM + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/PurchaseClaimRequest.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/PurchaseClaimRequest.swift new file mode 100644 index 00000000..556dfcd1 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/PurchaseClaimRequest.swift @@ -0,0 +1,11 @@ +public struct PurchaseClaimRequest: Codable, Sendable, Equatable { + public let licenseKey: String + public let email: String + public let device: DeviceFingerprint + + public init(licenseKey: String, email: String, device: DeviceFingerprint) { + self.licenseKey = licenseKey + self.email = email + self.device = device + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/SecureTimestampRequest.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/SecureTimestampRequest.swift new file mode 100644 index 00000000..d9f4ec10 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/SecureTimestampRequest.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct SecureTimestampRequest: Codable, Sendable, Equatable { + public let licenseID: String + public let challengeNonce: String + public let clientIssuedAt: Date + public let deviceIdentifier: String + public let requestedFeature: String + + public init( + licenseID: String, + challengeNonce: String, + clientIssuedAt: Date, + deviceIdentifier: String, + requestedFeature: String + ) { + self.licenseID = licenseID + self.challengeNonce = challengeNonce + self.clientIssuedAt = clientIssuedAt + self.deviceIdentifier = deviceIdentifier + self.requestedFeature = requestedFeature + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/TrialIssueRequest.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/TrialIssueRequest.swift new file mode 100644 index 00000000..f254ccb2 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Requests/TrialIssueRequest.swift @@ -0,0 +1,7 @@ +public struct TrialIssueRequest: Codable, Sendable, Equatable { + public let device: DeviceFingerprint + + public init(device: DeviceFingerprint) { + self.device = device + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Validators.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Validators.swift new file mode 100644 index 00000000..747bd112 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Core/Validators.swift @@ -0,0 +1,83 @@ +import Foundation + +public struct SystemClock: Clock, Sendable { + public init() {} + + public func now() -> Date { + Date() + } +} + +public struct DateBoundTrustChainValidator: TrustChainValidating, Sendable { + public let acceptedClockSkew: TimeInterval + + public init() { + acceptedClockSkew = 30 + } + + public init(acceptedClockSkew: TimeInterval) { + self.acceptedClockSkew = acceptedClockSkew + } + + public func validate(_ certificateChain: CertificateChain, at now: Date) throws { + if now.addingTimeInterval(acceptedClockSkew) < certificateChain.intermediateIssuedAt { + throw AuthenticatorError.intermediateCertificateNotYetValid + } + + if now > certificateChain.intermediateExpiresAt { + throw AuthenticatorError.intermediateCertificateExpired + } + } +} + +public struct DefaultChallengeValidator: ChallengeValidating, Sendable { + public init() {} + + public func validate(_ challenge: ClientActivationChallenge, now: Date, policy: ActivationPolicy) throws { + let futureThreshold = now.addingTimeInterval(policy.acceptedClockSkew) + if challenge.issuedAt > futureThreshold { + throw AuthenticatorError.challengeIssuedInFuture + } + + let expirationThreshold = now.addingTimeInterval(-policy.challengeTimeToLive) + if challenge.issuedAt < expirationThreshold { + throw AuthenticatorError.challengeExpired + } + } +} + +public struct DefaultSecureTimestampValidator: SecureTimestampValidating, Sendable { + private let trustedRootsByID: [String: TrustedRootPublicKey] + + public init(trustedRoots: [TrustedRootPublicKey] = EmbeddedTrustedRoots.trusted) { + trustedRootsByID = Dictionary( + trustedRoots.map { ($0.certificateID, $0) }, + uniquingKeysWith: { first, _ in first } + ) + } + + public func validate( + _ token: SecureTimestampToken, + against request: SecureTimestampRequest, + trustedRootCertificateID: String, + now: Date, + policy: ActivationPolicy + ) throws { + guard trustedRootsByID[trustedRootCertificateID] != nil else { + throw AuthenticatorError.secureTimestampRootMismatch + } + + if token.rootCertificateID != trustedRootCertificateID { + throw AuthenticatorError.secureTimestampRootMismatch + } + + if token.challengeNonce != request.challengeNonce { + throw AuthenticatorError.secureTimestampNonceMismatch + } + + let expirationThreshold = now.addingTimeInterval(-policy.secureTimestampTimeToLive) + if token.signedAt < expirationThreshold { + throw AuthenticatorError.secureTimestampExpired + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Host/HostActivationCoordinator.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Host/HostActivationCoordinator.swift new file mode 100644 index 00000000..a84f7293 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Host/HostActivationCoordinator.swift @@ -0,0 +1,100 @@ +import Foundation + +public struct HostActivationCoordinator: Sendable { + private let policy: ActivationPolicy + private let clock: any Clock + private let trustValidator: any TrustChainValidating + private let challengeValidator: any ChallengeValidating + private let replayProtector: any ReplayProtecting + private let secureTimestampFetcher: (any SecureTimestampFetching)? + private let secureTimestampValidator: any SecureTimestampValidating + private let activationIssuer: any ActivationMaterialIssuing + + public init( + policy: ActivationPolicy = .init(), + clock: any Clock = SystemClock(), + trustValidator: any TrustChainValidating = DateBoundTrustChainValidator(), + challengeValidator: any ChallengeValidating = DefaultChallengeValidator(), + replayProtector: any ReplayProtecting, + secureTimestampFetcher: (any SecureTimestampFetching)? = nil, + secureTimestampValidator: any SecureTimestampValidating = DefaultSecureTimestampValidator(), + activationIssuer: any ActivationMaterialIssuing + ) { + self.policy = policy + self.clock = clock + self.trustValidator = trustValidator + self.challengeValidator = challengeValidator + self.replayProtector = replayProtector + self.secureTimestampFetcher = secureTimestampFetcher + self.secureTimestampValidator = secureTimestampValidator + self.activationIssuer = activationIssuer + } + + public func activate(_ request: HostActivationRequest) async throws -> HostActivationResponse { + let now = clock.now() + + try validateLicenseWindow(request.license, now: now) + try trustValidator.validate(request.license.certificateChain, at: now) + try challengeValidator.validate(request.challenge, now: now, policy: policy) + try replayProtector.reserve( + request.challenge.nonce, + until: now.addingTimeInterval(policy.challengeTimeToLive) + ) + + let activationPath = policy.activationPath(for: request.license.licenseClass) + let secureTimestamp = try await loadSecureTimestampIfNeeded( + activationPath: activationPath, + request: request, + now: now + ) + + let activation = try await activationIssuer.issueActivation( + for: request.challenge, + license: request.license, + policy: policy, + secureTimestamp: secureTimestamp + ) + + return HostActivationResponse( + activation: activation, + path: activationPath, + secureTimestamp: secureTimestamp + ) + } + + private func validateLicenseWindow(_ license: LicenseEnvelope, now: Date) throws { + guard let expiresAt = license.expiresAt else { + return + } + + guard now <= expiresAt else { + throw AuthenticatorError.licenseExpired + } + } + + private func loadSecureTimestampIfNeeded( + activationPath: ActivationPath, + request: HostActivationRequest, + now: Date + ) async throws -> SecureTimestampToken? { + guard activationPath == .timeAnchored else { + return nil + } + + guard let secureTimestampFetcher else { + throw AuthenticatorError.secureTimestampProviderUnavailable + } + + let timestampRequest = request.secureTimestampRequest + + let token = try await secureTimestampFetcher.fetchTimestamp(for: timestampRequest) + try secureTimestampValidator.validate( + token, + against: timestampRequest, + trustedRootCertificateID: request.license.trustedRootCertificateID, + now: now, + policy: policy + ) + return token + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/ActivationModel.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/ActivationModel.swift new file mode 100644 index 00000000..06bc016e --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/ActivationModel.swift @@ -0,0 +1,500 @@ +import Foundation +import Observation + +public typealias ActivationDeviceFingerprintProvider = @Sendable () async throws -> DeviceFingerprint +public typealias ActivationCertificateSigningRequestProvider = @Sendable () async throws -> String +public typealias ActivationHandler = + @Sendable (HostActivationRequest) async throws -> HostActivationResponse +public typealias ActivationFailureRecoveryMessageProvider = @Sendable () async -> String? + +public enum ActivationModelError: Error, Equatable, LocalizedError, Sendable { + case missingClaimInput + case missingActivationSession + case missingEntitlementStatus + case missingDeviceFingerprint + + public var errorDescription: String? { + switch self { + case .missingClaimInput: + return "Enter the license key and purchase email to continue." + case .missingActivationSession: + return "A verified activation session is required before requesting a certificate." + case .missingEntitlementStatus: + return "Refresh the entitlement status before completing activation." + case .missingDeviceFingerprint: + return "A device fingerprint is required to complete activation." + } + } +} + +@MainActor +@Observable +public final class ActivationModel { + public enum Step: Int, CaseIterable, Identifiable, Sendable { + case claimForm + case verification + case eligibilityReview + case certificateIssue + case finalActivation + case licenseStatus + + public var id: Int { + rawValue + } + + public var title: String { + switch self { + case .claimForm: + return "Claim Form" + case .verification: + return "Verification" + case .eligibilityReview: + return "Eligibility Review" + case .certificateIssue: + return "CSR & Certificate Issue" + case .finalActivation: + return "Final Activation" + case .licenseStatus: + return "License Status" + } + } + } + + public var licenseKey = "" { + didSet { + let sanitizedLicenseKey = Self.sanitizedLicenseKey(licenseKey) + if licenseKey != sanitizedLicenseKey { + licenseKey = sanitizedLicenseKey + } + } + } + + public var email = "" { + didSet { + let sanitizedEmail = Self.sanitizedEmail(email) + if email != sanitizedEmail { + email = sanitizedEmail + } + } + } + + public private(set) var currentStep: Step = .claimForm + public private(set) var isBusy = false + public private(set) var errorMessage: String? + public private(set) var activationSession: ActivationSession? + public private(set) var entitlementStatus: EntitlementStatus? + public private(set) var issuedLease: IntermediateCertificateLease? + public private(set) var activationResponse: HostActivationResponse? + public private(set) var lastDeviceFingerprint: DeviceFingerprint? + public private(set) var isShowingActivationForm: Bool = false + public private(set) var licenseStatusRefreshMessage: String? + /// Hint shown in the license window about the license key's keychain + /// access: paused after a refused prompt or after Not Now, or a + /// suggestion to choose Always Allow after a one-time Allow. + public private(set) var keychainAccessNotice: String? + /// `true` when the notice offers Try Again (the key's use is paused). + public private(set) var offersKeychainAccessRetry = false + /// `true` while Try Again waits for the keychain. + public private(set) var isRetryingKeychainAccess = false + /// Runs Try Again: one explicit use of the license key. Set by + /// `ActivationRuntime`. + package var keychainRetryAction: (@MainActor () async throws -> Void)? + + private let configuration: ActivationUIConfiguration + private let purchaseClaimResolver: any PurchaseClaimResolving + private let trialIssuer: any TrialIssuing + private let entitlementStatusFetcher: any EntitlementStatusFetching + private let intermediateCertificateIssuer: any IntermediateCertificateIssuing + private let deviceFingerprintProvider: ActivationDeviceFingerprintProvider + private let certificateSigningRequestProvider: ActivationCertificateSigningRequestProvider + private let activationHandler: ActivationHandler + private let activationFailureRecoveryMessageProvider: ActivationFailureRecoveryMessageProvider + private let clock: any Clock + + public init( + configuration: ActivationUIConfiguration = .init(), + purchaseClaimResolver: any PurchaseClaimResolving, + trialIssuer: any TrialIssuing, + entitlementStatusFetcher: any EntitlementStatusFetching, + intermediateCertificateIssuer: any IntermediateCertificateIssuing, + deviceFingerprintProvider: @escaping ActivationDeviceFingerprintProvider, + certificateSigningRequestProvider: @escaping ActivationCertificateSigningRequestProvider, + activationHandler: @escaping ActivationHandler, + activationFailureRecoveryMessageProvider: + @escaping ActivationFailureRecoveryMessageProvider = { nil }, + clock: any Clock = SystemClock() + ) { + self.configuration = configuration + self.purchaseClaimResolver = purchaseClaimResolver + self.trialIssuer = trialIssuer + self.entitlementStatusFetcher = entitlementStatusFetcher + self.intermediateCertificateIssuer = intermediateCertificateIssuer + self.deviceFingerprintProvider = deviceFingerprintProvider + self.certificateSigningRequestProvider = certificateSigningRequestProvider + self.activationHandler = activationHandler + self.activationFailureRecoveryMessageProvider = activationFailureRecoveryMessageProvider + self.clock = clock + } + + public var highlightedMessage: String? { + if let errorMessage { + return errorMessage + } + + return entitlementStatus?.warningMessage ?? entitlementStatus?.license.deviceWarning + } + + public var isRefreshingLicenseStatus: Bool { + licenseStatusRefreshMessage != nil + } + + public var canIssueCertificate: Bool { + activationSession != nil && entitlementStatus?.isEligibleForActivation == true + } + + public var canCompleteActivation: Bool { + canIssueCertificate && (issuedLease != nil || entitlementStatus?.currentLease != nil) + } + + public var isTrialActive: Bool { + activationResponse != nil && entitlementStatus?.license.licenseClass == .trial + } + + public func reset() { + licenseKey = "" + email = "" + currentStep = .claimForm + isBusy = false + errorMessage = nil + activationSession = nil + entitlementStatus = nil + issuedLease = nil + activationResponse = nil + lastDeviceFingerprint = nil + isShowingActivationForm = false + licenseStatusRefreshMessage = nil + } + + public func beginFullActivationFromTrial() { + licenseKey = "" + email = "" + errorMessage = nil + currentStep = .claimForm + isShowingActivationForm = true + } + + public func returnToTrialSummary() { + errorMessage = nil + isShowingActivationForm = false + } + + package func setKeychainAccessNotice(_ notice: KeychainAccessNotice?) { + keychainAccessNotice = notice?.message + offersKeychainAccessRetry = notice?.offersRetry ?? false + } + + /// The license window's Try Again: uses the license key once, which may + /// show the keychain prompt. The notice updates from the runtime. + public func retryKeychainAccess() async { + guard let keychainRetryAction, isRetryingKeychainAccess == false else { return } + isRetryingKeychainAccess = true + defer { isRetryingKeychainAccess = false } + do { + try await keychainRetryAction() + } catch { + ActivationLogger.runtime.error( + "keychain retry failed: \(String(describing: error), privacy: .public)" + ) + } + } + + package func restorePersistedActivationState( + activationSession: ActivationSession?, + entitlementStatus: EntitlementStatus?, + activationResponse: HostActivationResponse?, + deviceFingerprint: DeviceFingerprint? + ) { + let restoredSession = activationSession ?? entitlementStatus?.activationSession + let hasActivatedLicense = entitlementStatus?.currentLease != nil || activationResponse != nil + + errorMessage = nil + isBusy = false + self.activationSession = restoredSession + self.entitlementStatus = entitlementStatus + issuedLease = entitlementStatus?.currentLease + self.activationResponse = activationResponse + lastDeviceFingerprint = + deviceFingerprint + ?? entitlementStatus?.deviceBinding.map { + DeviceFingerprint( + deviceID: $0.udid, + hardwareModel: $0.hardwareModel, + operatingSystemVersion: $0.operatingSystemVersion, + appBundleID: $0.appBundleID + ) + } + currentStep = hasActivatedLicense ? .licenseStatus : .claimForm + isShowingActivationForm = false + } + + public func beginLicenseStatusRefresh( + message: String? = nil + ) { + errorMessage = nil + licenseStatusRefreshMessage = message ?? String(localized: "Refreshing license status…", bundle: .module) + if entitlementStatus != nil { + currentStep = .licenseStatus + isShowingActivationForm = false + } + } + + public func finishLicenseStatusRefresh() { + licenseStatusRefreshMessage = nil + } + + public func resolvePurchaseClaim() async { + await runOperation(startingAt: .verification) { + guard licenseKey.isEmpty == false, + email.isEmpty == false + else { + throw ActivationModelError.missingClaimInput + } + + let device = try await deviceFingerprintProvider() + lastDeviceFingerprint = device + + let session = try await purchaseClaimResolver.resolvePurchaseClaim( + PurchaseClaimRequest( + licenseKey: licenseKey, + email: email, + device: device + ) + ) + + activationSession = session + currentStep = .eligibilityReview + try await fetchEntitlementStatus(forceRefresh: true) + } + } + + public func issueTrial() async { + await runOperation(startingAt: .verification) { + let device = try await deviceFingerprintProvider() + lastDeviceFingerprint = device + + let session = try await trialIssuer.issueTrial( + TrialIssueRequest(device: device) + ) + + activationSession = session + currentStep = .eligibilityReview + try await fetchEntitlementStatus(forceRefresh: true) + } + } + + public func refreshEntitlementStatus(forceRefresh: Bool = true) async { + await runOperation(startingAt: .eligibilityReview) { + try await fetchEntitlementStatus(forceRefresh: forceRefresh) + } + } + + public func issueIntermediateCertificate() async { + await runOperation(startingAt: .certificateIssue) { + guard let activationSession else { + throw ActivationModelError.missingActivationSession + } + + let csr = try await certificateSigningRequestProvider() + let lease = try await intermediateCertificateIssuer.issueIntermediateCertificate( + IntermediateCertificateIssueRequest( + activationSessionToken: activationSession.token, + deviceIdentifier: activationSession.deviceIdentifier, + certificateSigningRequestPEM: csr + ) + ) + + issuedLease = lease + mergeEntitlementStatus(with: lease) + currentStep = .finalActivation + } + } + + public func completeActivation() async { + await runOperation(startingAt: .finalActivation) { + guard let entitlementStatus else { + throw ActivationModelError.missingEntitlementStatus + } + + let device = try await resolveDeviceFingerprint(for: entitlementStatus) + let challenge = ClientActivationChallenge( + nonce: UUID().uuidString.lowercased(), + issuedAt: clock.now(), + requestedFeature: configuration.requestedFeature, + frameworkVersion: configuration.frameworkVersion, + device: device + ) + + let effectiveLease = issuedLease ?? entitlementStatus.currentLease + let effectiveLicense = + if let effectiveLease { + entitlementStatus.license.applying(lease: effectiveLease) + } else { + entitlementStatus.license + } + + activationResponse = try await activationHandler( + HostActivationRequest( + challenge: challenge, + license: effectiveLicense + ) + ) + currentStep = .licenseStatus + } + } + + public func runActivationFlow() async { + await resolvePurchaseClaim() + guard errorMessage == nil else { + return + } + + await issueIntermediateCertificate() + guard errorMessage == nil else { + return + } + + await completeActivation() + if errorMessage == nil { + isShowingActivationForm = false + } + } + + public func runTrialFlow() async { + await issueTrial() + guard errorMessage == nil else { + return + } + + await issueIntermediateCertificate() + guard errorMessage == nil else { + return + } + + await completeActivation() + } + + private func runOperation( + startingAt step: Step, + _ operation: @MainActor () async throws -> Void + ) async { + isBusy = true + errorMessage = nil + currentStep = step + + do { + try await operation() + } catch { + let baseMessage = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + if shouldCheckForRecoveryMessage(after: error), + let recoveryMessage = await activationFailureRecoveryMessageProvider(), + recoveryMessage.isEmpty == false + { + errorMessage = "\(baseMessage)\n\n\(recoveryMessage)" + } else { + errorMessage = baseMessage + } + } + + isBusy = false + } + + private func shouldCheckForRecoveryMessage(after error: Error) -> Bool { + if error is ActivationModelError { + return false + } + return true + } + + private func fetchEntitlementStatus(forceRefresh: Bool) async throws { + guard let activationSession else { + throw ActivationModelError.missingActivationSession + } + + let status = try await entitlementStatusFetcher.fetchEntitlementStatus( + EntitlementStatusRequest( + licenseID: activationSession.licenseID, + deviceID: activationSession.deviceIdentifier, + forceRefresh: forceRefresh, + activationSessionToken: activationSession.token + ) + ) + + entitlementStatus = EntitlementStatus( + license: status.license, + deviceBinding: status.deviceBinding, + currentLease: status.currentLease, + activationSession: activationSession, + isEligibleForActivation: status.isEligibleForActivation, + isEligibleForRenewal: status.isEligibleForRenewal, + requiresLiveRefresh: status.requiresLiveRefresh, + lastFetchedAt: status.lastFetchedAt, + informationalMessage: status.informationalMessage, + warningMessage: status.warningMessage + ) + } + + private func mergeEntitlementStatus(with lease: IntermediateCertificateLease) { + guard let entitlementStatus else { + return + } + + self.entitlementStatus = EntitlementStatus( + license: entitlementStatus.license.applying(lease: lease), + deviceBinding: entitlementStatus.deviceBinding, + currentLease: lease, + activationSession: activationSession ?? entitlementStatus.activationSession, + isEligibleForActivation: entitlementStatus.isEligibleForActivation, + isEligibleForRenewal: entitlementStatus.isEligibleForRenewal, + requiresLiveRefresh: entitlementStatus.requiresLiveRefresh, + lastFetchedAt: entitlementStatus.lastFetchedAt, + informationalMessage: entitlementStatus.informationalMessage, + warningMessage: entitlementStatus.warningMessage + ) + } + + private func resolveDeviceFingerprint(for status: EntitlementStatus) async throws -> DeviceFingerprint { + if let lastDeviceFingerprint { + return lastDeviceFingerprint + } + + if let deviceBinding = status.deviceBinding { + let fingerprint = DeviceFingerprint( + deviceID: deviceBinding.udid, + hardwareModel: deviceBinding.hardwareModel, + operatingSystemVersion: deviceBinding.operatingSystemVersion, + appBundleID: deviceBinding.appBundleID + ) + lastDeviceFingerprint = fingerprint + return fingerprint + } + + let device = try await deviceFingerprintProvider() + lastDeviceFingerprint = device + return device + } + + private static func sanitizedEmail(_ email: String) -> String { + email.components(separatedBy: .whitespacesAndNewlines).joined() + } + + private static func sanitizedLicenseKey(_ licenseKey: String) -> String { + licenseKey + .replacingOccurrences(of: "—", with: "-") + .replacingOccurrences(of: "–", with: "-") + .replacingOccurrences(of: "−", with: "-") + .replacingOccurrences(of: "-", with: "-") + .components(separatedBy: .whitespacesAndNewlines) + .joined() + .uppercased() + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/ActivationUIConfiguration.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/ActivationUIConfiguration.swift new file mode 100644 index 00000000..815bdbbb --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/ActivationUIConfiguration.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct ActivationUIConfiguration: Sendable, Equatable { + public let windowTitle: String + public let requestedFeature: String + public let frameworkVersion: String + public let currentAccessCopy: String + public let canceledSubscriptionCopy: String + public let refundedPurchaseCopy: String + public let multipleDevicesCopy: String + + public init( + windowTitle: String? = nil, + requestedFeature: String = "swiftui.support", + frameworkVersion: String = "1.0.0", + currentAccessCopy: String? = nil, + canceledSubscriptionCopy: String? = nil, + refundedPurchaseCopy: String? = nil, + multipleDevicesCopy: String? = nil + ) { + self.windowTitle = windowTitle ?? String(localized: "LookInside Activation", bundle: .module) + self.requestedFeature = requestedFeature + self.frameworkVersion = frameworkVersion + self.currentAccessCopy = + currentAccessCopy ?? String(localized: "Current access remains available until", bundle: .module) + self.canceledSubscriptionCopy = + canceledSubscriptionCopy + ?? String( + localized: "A new certificate will not be issued after expiry because this subscription is canceled.", + bundle: .module + ) + self.refundedPurchaseCopy = + refundedPurchaseCopy + ?? String( + localized: "A new certificate will not be issued after expiry because this purchase was refunded.", + bundle: .module + ) + self.multipleDevicesCopy = + multipleDevicesCopy + ?? String( + localized: "This license has been activated on multiple devices. Usage may be reviewed manually.", + bundle: .module + ) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/KeychainAccessNotice.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/KeychainAccessNotice.swift new file mode 100644 index 00000000..a8aaca6e --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Model/KeychainAccessNotice.swift @@ -0,0 +1,52 @@ +import Foundation + +/// What the license window says about the license key's keychain access. +package enum KeychainAccessNotice: Sendable, Equatable { + /// A keychain prompt was refused: automatic uses wait. + case denied + /// Not Now in the keychain explainer: no system prompt was shown, the + /// key's use waits until the user continues. + case postponed + /// An automatic use succeeded after a one-time Allow: every newly + /// connected app will ask again until the user picks Always Allow. + case allowedOnce + + /// The notice for `status` at `date`, or `nil` when there is nothing to + /// say. + package init?(_ status: ActivationSigningStatus, at date: Date) { + if status.isKeychainAccessDenied(at: date) { + self = status.isKeychainAccessPostponed ? .postponed : .denied + } else if status.isKeychainAccessAllowedOnce { + self = .allowedOnce + } else { + return nil + } + } + + /// `true` when the notice offers Try Again. + package var offersRetry: Bool { + self != .allowedOnce + } + + package var message: String { + switch self { + case .denied: + return String( + localized: "Keychain access to the license key was denied, so Pro features are paused.", + bundle: .module + ) + case .postponed: + return String( + localized: + "LookInside won’t use the license key until you continue, so Pro features are paused. Click Try Again when you’re ready.", + bundle: .module + ) + case .allowedOnce: + return String( + localized: + "macOS asks for keychain access each time LookInside connects to an app. Choose “Always Allow” to stop these requests.", + bundle: .module + ) + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIEnvelope.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIEnvelope.swift new file mode 100644 index 00000000..db547517 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIEnvelope.swift @@ -0,0 +1,3 @@ +struct APIEnvelope: Decodable { + let data: Payload +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIErrorEnvelope.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIErrorEnvelope.swift new file mode 100644 index 00000000..c6b08e59 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIErrorEnvelope.swift @@ -0,0 +1,3 @@ +struct APIErrorEnvelope: Decodable { + let error: APIErrorPayload +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIErrorPayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIErrorPayload.swift new file mode 100644 index 00000000..a4e6af17 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/APIErrorPayload.swift @@ -0,0 +1,4 @@ +struct APIErrorPayload: Decodable { + let code: String + let message: String +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/ActivationSessionRefreshPayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/ActivationSessionRefreshPayload.swift new file mode 100644 index 00000000..e9815f3a --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/ActivationSessionRefreshPayload.swift @@ -0,0 +1,9 @@ +struct ActivationSessionRefreshPayload: Encodable { + let activationSessionToken: String + let requestNonce: String + + private enum CodingKeys: String, CodingKey { + case activationSessionToken = "activation_session_token" + case requestNonce = "request_nonce" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/ActivationSessionTokenClaims.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/ActivationSessionTokenClaims.swift new file mode 100644 index 00000000..62a5abeb --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/ActivationSessionTokenClaims.swift @@ -0,0 +1,29 @@ +struct ActivationSessionTokenClaims: Decodable { + let sessionID: String + let licenseID: String + let licenseClass: String + let orderID: String? + let licenseKey: String? + let creemCustomerID: String? + let creemLicenseID: String? + let email: String? + let deviceIdentifier: String + let servicePeriodEndAt: String? + let issuedAt: String + let expiresAt: String + + private enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case licenseID = "license_id" + case licenseClass = "license_class" + case orderID = "order_id" + case licenseKey = "license_key" + case creemCustomerID = "creem_customer_id" + case creemLicenseID = "creem_license_id" + case email + case deviceIdentifier = "device_identifier" + case servicePeriodEndAt = "service_period_end_at" + case issuedAt = "issued_at" + case expiresAt = "expires_at" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/IntermediateCertificateIssuePayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/IntermediateCertificateIssuePayload.swift new file mode 100644 index 00000000..3614ead9 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/IntermediateCertificateIssuePayload.swift @@ -0,0 +1,13 @@ +struct IntermediateCertificateIssuePayload: Encodable { + let activationSessionToken: String + let deviceIdentifier: String + let certificateSigningRequestPEM: String + let requestNonce: String + + private enum CodingKeys: String, CodingKey { + case activationSessionToken = "activation_session_token" + case deviceIdentifier = "device_identifier" + case certificateSigningRequestPEM = "csr_pem" + case requestNonce = "request_nonce" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/IntermediateCertificatePayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/IntermediateCertificatePayload.swift new file mode 100644 index 00000000..98164d96 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/IntermediateCertificatePayload.swift @@ -0,0 +1,27 @@ +struct IntermediateCertificatePayload: Decodable { + let certificateID: String + let status: String + let issuedAt: String + let expiresAt: String + let renewAfter: String + let issuanceReason: String + let renewalBlockedReason: String? + let rootCertificateID: String + let certificatePEM: String + let publicKeyPEM: String + let fullChainPEM: String + + private enum CodingKeys: String, CodingKey { + case certificateID = "certificate_id" + case status + case issuedAt = "issued_at" + case expiresAt = "expires_at" + case renewAfter = "renew_after" + case issuanceReason = "issuance_reason" + case renewalBlockedReason = "renewal_blocked_reason" + case rootCertificateID = "root_certificate_id" + case certificatePEM = "certificate_pem" + case publicKeyPEM = "public_key_pem" + case fullChainPEM = "full_chain_pem" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/LicenseSummaryPayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/LicenseSummaryPayload.swift new file mode 100644 index 00000000..390c766e --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/LicenseSummaryPayload.swift @@ -0,0 +1,35 @@ +struct LicenseSummaryPayload: Decodable { + let licenseID: String + let licenseClass: String + let lifecycleState: String + let issuedTo: String? + let issuedAt: String + let email: String? + let productID: String + let servicePeriodEndAt: String? + let currentCertificateID: String? + let renewAfter: String? + let lastVerifiedAt: String + let notices: [String] + let deviceWarning: String? + let canIssueCertificate: Bool + let blockedReason: String? + + private enum CodingKeys: String, CodingKey { + case licenseID = "license_id" + case licenseClass = "license_class" + case lifecycleState = "lifecycle_state" + case issuedTo = "issued_to" + case issuedAt = "issued_at" + case email + case productID = "product_id" + case servicePeriodEndAt = "service_period_end_at" + case currentCertificateID = "current_certificate_id" + case renewAfter = "renew_after" + case lastVerifiedAt = "last_verified_at" + case notices + case deviceWarning = "device_warning" + case canIssueCertificate = "can_issue_certificate" + case blockedReason = "blocked_reason" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/LicenseSummaryRequestPayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/LicenseSummaryRequestPayload.swift new file mode 100644 index 00000000..a8ebb8d3 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/LicenseSummaryRequestPayload.swift @@ -0,0 +1,7 @@ +struct LicenseSummaryRequestPayload: Encodable { + let activationSessionToken: String + + private enum CodingKeys: String, CodingKey { + case activationSessionToken = "activation_session_token" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/PurchaseClaimPayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/PurchaseClaimPayload.swift new file mode 100644 index 00000000..1cebddde --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/PurchaseClaimPayload.swift @@ -0,0 +1,26 @@ +struct PurchaseClaimPayload: Encodable { + let licenseKey: String + let email: String + let deviceIdentifier: String + let hardwareModel: String + let appBundleID: String + let requestNonce: String + + init(licenseKey: String, email: String, device: DeviceFingerprint, requestNonce: String) { + self.licenseKey = licenseKey + self.email = email + deviceIdentifier = device.deviceID + hardwareModel = device.hardwareModel + appBundleID = device.appBundleID + self.requestNonce = requestNonce + } + + private enum CodingKeys: String, CodingKey { + case licenseKey = "license_key" + case email + case deviceIdentifier = "device_identifier" + case hardwareModel = "hardware_model" + case appBundleID = "app_bundle_id" + case requestNonce = "request_nonce" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/PurchaseClaimResponse.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/PurchaseClaimResponse.swift new file mode 100644 index 00000000..adea4deb --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/PurchaseClaimResponse.swift @@ -0,0 +1,11 @@ +struct PurchaseClaimResponse: Decodable { + let license: LicenseSummaryPayload + let activationSessionToken: String + let activationSessionExpiresAt: String + + private enum CodingKeys: String, CodingKey { + case license + case activationSessionToken = "activation_session_token" + case activationSessionExpiresAt = "activation_session_expires_at" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/SecureTimestampPayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/SecureTimestampPayload.swift new file mode 100644 index 00000000..b372927c --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/SecureTimestampPayload.swift @@ -0,0 +1,13 @@ +struct SecureTimestampPayload: Decodable { + let challengeNonce: String + let signedAt: String + let rootCertificateID: String + let signature: String + + private enum CodingKeys: String, CodingKey { + case challengeNonce = "challenge_nonce" + case signedAt = "signed_at" + case rootCertificateID = "root_certificate_id" + case signature + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/SecureTimestampRequestPayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/SecureTimestampRequestPayload.swift new file mode 100644 index 00000000..d6cc155b --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/SecureTimestampRequestPayload.swift @@ -0,0 +1,35 @@ +import Foundation + +struct SecureTimestampRequestPayload: Encodable { + let licenseID: String + let challengeNonce: String + let clientIssuedAt: String + let deviceIdentifier: String + let requestedFeature: String + + init( + licenseID: String, + challengeNonce: String, + clientIssuedAt: Date, + deviceIdentifier: String, + requestedFeature: String + ) { + self.licenseID = licenseID + self.challengeNonce = challengeNonce + self.clientIssuedAt = ISO8601DateFormatter.string( + from: clientIssuedAt, + timeZone: .gmt, + formatOptions: [.withInternetDateTime, .withFractionalSeconds] + ) + self.deviceIdentifier = deviceIdentifier + self.requestedFeature = requestedFeature + } + + private enum CodingKeys: String, CodingKey { + case licenseID = "license_id" + case challengeNonce = "challenge_nonce" + case clientIssuedAt = "client_issued_at" + case deviceIdentifier = "device_identifier" + case requestedFeature = "requested_feature" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/TrialIssuePayload.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/TrialIssuePayload.swift new file mode 100644 index 00000000..d31c9d21 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/APIModels/TrialIssuePayload.swift @@ -0,0 +1,20 @@ +struct TrialIssuePayload: Encodable { + let deviceIdentifier: String + let hardwareModel: String + let appBundleID: String + let requestNonce: String + + init(device: DeviceFingerprint, requestNonce: String) { + deviceIdentifier = device.deviceID + hardwareModel = device.hardwareModel + appBundleID = device.appBundleID + self.requestNonce = requestNonce + } + + private enum CodingKeys: String, CodingKey { + case deviceIdentifier = "device_identifier" + case hardwareModel = "hardware_model" + case appBundleID = "app_bundle_id" + case requestNonce = "request_nonce" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPIClient.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPIClient.swift new file mode 100644 index 00000000..35f68e45 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPIClient.swift @@ -0,0 +1,195 @@ +import Foundation + +public enum LookInsideAuthenticatorAPIClientError: Error, Sendable, LocalizedError { + case invalidURL(String) + case invalidResponse + case invalidTimestamp(field: String, value: String) + case invalidSessionToken + case invalidTimestampSignature + case api(statusCode: Int, code: String, message: String) + + public var errorDescription: String? { + switch self { + case let .invalidURL(path): + return "The authenticator service URL is invalid for path \(path)." + case .invalidResponse: + return "The authenticator service returned an unreadable response." + case let .invalidTimestamp(field, value): + return "The authenticator service returned an invalid timestamp for \(field): \(value)" + case .invalidSessionToken: + return "The activation session token payload is invalid." + case .invalidTimestampSignature: + return "The secure timestamp signature could not be decoded." + case let .api(statusCode, code, message): + return "The authenticator service returned \(statusCode) \(code): \(message)" + } + } +} + +public struct LookInsideAuthenticatorAPIClient: + Sendable, + PurchaseClaimResolving, + TrialIssuing, + EntitlementStatusFetching, + ActivationSessionRefreshing, + IntermediateCertificateIssuing, + SecureTimestampFetching +{ + public static let serviceBaseURL = URL(string: "https://lookinside-app.com")! + + let urlSession: URLSession + let baseURL: URL + + public init(urlSession: URLSession = .shared, baseURL: URL = LookInsideAuthenticatorAPIClient.serviceBaseURL) { + self.urlSession = urlSession + self.baseURL = baseURL + } + + public func resolvePurchaseClaim(_ request: PurchaseClaimRequest) async throws -> ActivationSession { + let payload = PurchaseClaimPayload( + licenseKey: request.licenseKey, + email: request.email, + device: request.device, + requestNonce: Self.makeRequestNonce(prefix: "purchase-claim") + ) + let response: PurchaseClaimResponse = try await send( + path: "/v1/purchase-claims/resolve", + method: "POST", + body: payload + ) + return try makeActivationSession(from: response) + } + + public func issueTrial(_ request: TrialIssueRequest) async throws -> ActivationSession { + let payload = TrialIssuePayload( + device: request.device, + requestNonce: Self.makeRequestNonce(prefix: "trial-issue") + ) + let response: PurchaseClaimResponse = try await send( + path: "/v1/trials", + method: "POST", + body: payload + ) + return try makeActivationSession(from: response) + } + + public func fetchEntitlementStatus(_ request: EntitlementStatusRequest) async throws -> EntitlementStatus { + guard let sessionToken = request.activationSessionToken, !sessionToken.isEmpty else { + throw LookInsideAuthenticatorAPIClientError.invalidSessionToken + } + let payload = LicenseSummaryRequestPayload(activationSessionToken: sessionToken) + let summary: LicenseSummaryPayload = try await send( + path: "/v1/licenses/\(request.licenseID)", + method: "POST", + body: payload + ) + let currentLease = try await fetchCurrentLeaseIfPresent( + certificateID: summary.currentCertificateID, + licenseID: summary.licenseID, + deviceIdentifier: request.deviceID + ) + let lastFetchedAt = Date() + + return try EntitlementStatus( + license: makeLicenseEnvelope( + from: summary, + currentLease: currentLease, + deviceID: request.deviceID + ), + deviceBinding: makeDeviceBinding( + from: summary, + deviceID: request.deviceID, + lastFetchedAt: lastFetchedAt + ), + currentLease: currentLease, + activationSession: request.activationSessionToken.flatMap(Self.activationSessionStub), + isEligibleForActivation: summary.canIssueCertificate || currentLease != nil, + isEligibleForRenewal: currentLease != nil && summary.canIssueCertificate, + requiresLiveRefresh: false, + lastFetchedAt: Self.parseDate(summary.lastVerifiedAt, field: "last_verified_at"), + informationalMessage: summary.notices.isEmpty ? nil : summary.notices.joined(separator: "\n"), + warningMessage: summary.deviceWarning ?? summary.blockedReason + ) + } + + public func refreshActivationSession( + _ request: ActivationSessionRefreshRequest + ) async throws -> ActivationSession { + let payload = ActivationSessionRefreshPayload( + activationSessionToken: request.activationSessionToken, + requestNonce: Self.makeRequestNonce(prefix: "session-refresh") + ) + let response: PurchaseClaimResponse = try await send( + path: "/v1/activation-sessions/refresh", + method: "POST", + body: payload + ) + return try makeActivationSession(from: response) + } + + public func issueIntermediateCertificate( + _ request: IntermediateCertificateIssueRequest + ) async throws -> IntermediateCertificateLease { + let payload = IntermediateCertificateIssuePayload( + activationSessionToken: request.activationSessionToken, + deviceIdentifier: request.deviceIdentifier, + certificateSigningRequestPEM: request.certificateSigningRequestPEM, + requestNonce: Self.makeRequestNonce(prefix: "certificate-issue") + ) + let response: IntermediateCertificatePayload = try await send( + path: "/v1/intermediate-certificates", + method: "POST", + body: payload + ) + let claims = try Self.decodeSessionClaims(from: request.activationSessionToken) + + return try makeIntermediateCertificateLease( + from: response, + licenseID: claims.licenseID, + deviceIdentifier: request.deviceIdentifier + ) + } + + public func fetchTimestamp(for request: SecureTimestampRequest) async throws -> SecureTimestampToken { + let payload = SecureTimestampRequestPayload( + licenseID: request.licenseID, + challengeNonce: request.challengeNonce, + clientIssuedAt: request.clientIssuedAt, + deviceIdentifier: request.deviceIdentifier, + requestedFeature: request.requestedFeature + ) + let response: SecureTimestampPayload = try await send( + path: "/v1/secure-timestamps", + method: "POST", + body: payload + ) + + guard let signature = Data(base64Encoded: response.signature) else { + throw LookInsideAuthenticatorAPIClientError.invalidTimestampSignature + } + + return try SecureTimestampToken( + challengeNonce: response.challengeNonce, + signedAt: Self.parseDate(response.signedAt, field: "signed_at"), + rootCertificateID: response.rootCertificateID, + signature: signature + ) + } + + private func makeActivationSession(from response: PurchaseClaimResponse) throws -> ActivationSession { + let claims = try Self.decodeSessionClaims(from: response.activationSessionToken) + return try ActivationSession( + sessionID: claims.sessionID, + licenseID: claims.licenseID, + orderID: claims.orderID, + email: claims.email, + deviceIdentifier: claims.deviceIdentifier, + token: response.activationSessionToken, + expiresAt: Self.parseDate( + response.activationSessionExpiresAt, + field: "activation_session_expires_at" + ), + createdAt: Self.parseDate(claims.issuedAt, field: "issued_at") + ) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPIMapping.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPIMapping.swift new file mode 100644 index 00000000..74038ec7 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPIMapping.swift @@ -0,0 +1,234 @@ +import Foundation + +extension LookInsideAuthenticatorAPIClient { + func fetchCurrentLeaseIfPresent( + certificateID: String?, + licenseID: String, + deviceIdentifier: String + ) async throws -> IntermediateCertificateLease? { + guard let certificateID else { + return nil + } + + let response: IntermediateCertificatePayload = try await send( + path: "/v1/intermediate-certificates/\(certificateID)", + method: "GET" + ) + + return try makeIntermediateCertificateLease( + from: response, + licenseID: licenseID, + deviceIdentifier: deviceIdentifier + ) + } + + func makeLicenseEnvelope( + from summary: LicenseSummaryPayload, + currentLease: IntermediateCertificateLease?, + deviceID: String + ) throws -> LicenseEnvelope { + let serviceEndsAt = try summary.servicePeriodEndAt.map { + try Self.parseDate($0, field: "service_period_end_at") + } + let issuedAt = try Self.parseDate(summary.issuedAt, field: "issued_at") + let fallbackExpiry = serviceEndsAt ?? issuedAt.addingTimeInterval(30 * 24 * 60 * 60) + let fallbackRenewAfter = min( + issuedAt.addingTimeInterval(24 * 60 * 60), + fallbackExpiry + ) + let certificateChain = + currentLease.map(Self.makeCertificateChain(from:)) + ?? CertificateChain( + rootCertificateID: EmbeddedTrustedRoots.rootProd2026.certificateID, + intermediateCertificateID: summary.currentCertificateID ?? "pending-\(summary.licenseID)", + intermediateIssuedAt: issuedAt, + intermediateExpiresAt: fallbackExpiry, + renewAfter: fallbackRenewAfter, + boundUDID: deviceID + ) + let nextCertificateRenewalAt = + try currentLease?.renewAfter + ?? summary.renewAfter.map { + try Self.parseDate($0, field: "renew_after") + } + + return try LicenseEnvelope( + licenseID: summary.licenseID, + audience: .personal, + licenseClass: makeLicenseClass(from: summary.licenseClass), + lifecycleState: makeLifecycleState(from: summary.lifecycleState), + issuedTo: summary.issuedTo, + issuedAt: issuedAt, + expiresAt: serviceEndsAt, + serviceEndsAt: serviceEndsAt, + nextCertificateRenewalAt: nextCertificateRenewalAt, + deviceWarning: summary.deviceWarning, + certificateChain: certificateChain + ) + } + + func makeDeviceBinding( + from summary: LicenseSummaryPayload, + deviceID: String, + lastFetchedAt: Date + ) -> DeviceBinding { + DeviceBinding( + licenseID: summary.licenseID, + udid: deviceID, + machineName: nil, + hardwareModel: "unknown", + operatingSystemVersion: "unknown", + appBundleID: "unknown", + status: summary.deviceWarning == nil ? .active : .reviewRequired, + firstSeenAt: lastFetchedAt, + lastSeenAt: lastFetchedAt, + reviewNote: summary.deviceWarning ?? summary.blockedReason + ) + } + + func makeIntermediateCertificateLease( + from payload: IntermediateCertificatePayload, + licenseID: String, + deviceIdentifier: String + ) throws -> IntermediateCertificateLease { + try IntermediateCertificateLease( + certificateID: payload.certificateID, + licenseID: licenseID, + udid: deviceIdentifier, + issuedAt: Self.parseDate(payload.issuedAt, field: "issued_at"), + expiresAt: Self.parseDate(payload.expiresAt, field: "expires_at"), + renewAfter: Self.parseDate(payload.renewAfter, field: "renew_after"), + issuanceReason: makeIssuanceReason(from: payload.issuanceReason), + renewalBlockedReason: payload.renewalBlockedReason, + certificatePEM: payload.certificatePEM, + publicKeyPEM: payload.publicKeyPEM, + fullChainPEM: payload.fullChainPEM, + status: makeLeaseStatus(from: payload.status) + ) + } + + func makeLicenseClass(from rawValue: String) throws -> LicenseClass { + guard let licenseClass = LicenseClass(rawValue: rawValue) else { + throw LookInsideAuthenticatorAPIClientError.invalidResponse + } + + return licenseClass + } + + func makeLifecycleState(from rawValue: String) -> LicenseLifecycleState { + switch rawValue { + case "active": + return .active + case "canceled": + return .canceled + case "refunded": + return .refunded + case "expired": + return .expired + case "review": + return .pendingReview + default: + return .pendingReview + } + } + + func makeIssuanceReason(from rawValue: String) -> IntermediateCertificateIssuanceReason { + switch rawValue { + case "renewal": + return .renewal + default: + return .initialActivation + } + } + + func makeLeaseStatus(from rawValue: String) -> IntermediateCertificateLeaseStatus { + switch rawValue { + case "active": + return .active + case "expired": + return .expired + case "superseded": + return .superseded + case "blocked", "revoked": + return .renewalBlocked + default: + return .renewalBlocked + } + } + + static func makeCertificateChain(from lease: IntermediateCertificateLease) -> CertificateChain { + CertificateChain( + rootCertificateID: EmbeddedTrustedRoots.rootProd2026.certificateID, + intermediateCertificateID: lease.certificateID, + intermediateIssuedAt: lease.issuedAt, + intermediateExpiresAt: lease.expiresAt, + renewAfter: lease.renewAfter, + boundUDID: lease.udid + ) + } + + static func makeRequestNonce(prefix: String) -> String { + "\(prefix)-\(UUID().uuidString.lowercased())" + } + + static func parseDate(_ rawValue: String, field: String) throws -> Date { + let fractionalFormatter = ISO8601DateFormatter() + fractionalFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractionalFormatter.date(from: rawValue) { + return date + } + + let internetDateFormatter = ISO8601DateFormatter() + internetDateFormatter.formatOptions = [.withInternetDateTime] + if let date = internetDateFormatter.date(from: rawValue) { + return date + } + + throw LookInsideAuthenticatorAPIClientError.invalidTimestamp(field: field, value: rawValue) + } + + static func decodeSessionClaims(from token: String) throws -> ActivationSessionTokenClaims { + let segments = token.split(separator: ".", omittingEmptySubsequences: false) + guard let payload = segments.first, + let data = decodeBase64URL(String(payload)) + else { + throw LookInsideAuthenticatorAPIClientError.invalidSessionToken + } + + do { + return try JSONDecoder().decode(ActivationSessionTokenClaims.self, from: data) + } catch { + throw LookInsideAuthenticatorAPIClientError.invalidSessionToken + } + } + + static func activationSessionStub(from token: String) -> ActivationSession? { + guard let claims = try? decodeSessionClaims(from: token), + let issuedAt = try? parseDate(claims.issuedAt, field: "issued_at"), + let expiresAt = try? parseDate(claims.expiresAt, field: "expires_at") + else { + return nil + } + + return ActivationSession( + sessionID: claims.sessionID, + licenseID: claims.licenseID, + orderID: claims.orderID, + email: claims.email, + deviceIdentifier: claims.deviceIdentifier, + token: token, + expiresAt: expiresAt, + createdAt: issuedAt + ) + } + + static func decodeBase64URL(_ rawValue: String) -> Data? { + var normalized = + rawValue + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let paddingLength = (4 - normalized.count % 4) % 4 + normalized.append(String(repeating: "=", count: paddingLength)) + return Data(base64Encoded: normalized) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPITransport.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPITransport.swift new file mode 100644 index 00000000..f4df7667 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Remote/LookInsideAuthenticatorAPITransport.swift @@ -0,0 +1,86 @@ +import Foundation + +extension LookInsideAuthenticatorAPIClient { + func send( + path: String, + method: String, + queryItems: [URLQueryItem] = [] + ) async throws -> Response { + let request = try makeRequest( + path: path, + method: method, + queryItems: queryItems + ) + return try await perform(request) + } + + func send( + path: String, + method: String, + body: Body, + queryItems: [URLQueryItem] = [] + ) async throws -> Response { + var request = try makeRequest( + path: path, + method: method, + queryItems: queryItems + ) + request.httpBody = try JSONEncoder().encode(body) + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + return try await perform(request) + } + + private func makeRequest( + path: String, + method: String, + queryItems: [URLQueryItem] + ) throws -> URLRequest { + let url = try makeURL(path: path, queryItems: queryItems) + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Accept") + return request + } + + private func makeURL(path: String, queryItems: [URLQueryItem]) throws -> URL { + guard + var components = URLComponents( + url: baseURL, + resolvingAgainstBaseURL: false + ) + else { + throw LookInsideAuthenticatorAPIClientError.invalidURL(path) + } + + components.path = (components.path as NSString).appendingPathComponent(path) + components.queryItems = queryItems.isEmpty ? nil : queryItems + + guard let url = components.url else { + throw LookInsideAuthenticatorAPIClientError.invalidURL(path) + } + + return url + } + + private func perform(_ request: URLRequest) async throws -> Response { + let (data, response) = try await urlSession.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw LookInsideAuthenticatorAPIClientError.invalidResponse + } + + if (200 ..< 300).contains(httpResponse.statusCode) { + return try JSONDecoder().decode(APIEnvelope.self, from: data).data + } + + if let failure = try? JSONDecoder().decode(APIErrorEnvelope.self, from: data) { + throw LookInsideAuthenticatorAPIClientError.api( + statusCode: httpResponse.statusCode, + code: failure.error.code, + message: failure.error.message + ) + } + + throw LookInsideAuthenticatorAPIClientError.invalidResponse + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Resources/Localizable.xcstrings b/Packages/LookInsideActivation/Sources/LookInsideActivation/Resources/Localizable.xcstrings new file mode 100644 index 00000000..e300a33c --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Resources/Localizable.xcstrings @@ -0,0 +1,384 @@ +{ + "sourceLanguage" : "en", + "version" : "1.0", + "strings" : { + "%@ is ready on this Mac." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%@ is ready on this Mac." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "%@ 已在本机激活。" } } + } + }, + "Access ends on" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Access ends on" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "访问期限至" } } + } + }, + "Access Granted" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Access Granted" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "授权成功" } } + } + }, + "Access until" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Access until" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "可使用至" } } + } + }, + "A new certificate will not be issued after expiry because this purchase was refunded." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "A new certificate will not be issued after expiry because this purchase was refunded." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "购买已退款,到期后将无法签发新的证书。" } } + } + }, + "A new certificate will not be issued after expiry because this subscription is canceled." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "A new certificate will not be issued after expiry because this subscription is canceled." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "订阅已取消,到期后将无法签发新的证书。" } } + } + }, + "Activate" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Activate" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "激活" } } + } + }, + "Activate Full Version" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Activate Full Version" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "激活正式版" } } + } + }, + "Activated" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Activated" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "已激活" } } + } + }, + "Activation Required" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Activation Required" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "需要激活" } } + } + }, + "Activate LookInside Pro from the LookInside Pro menu before using this feature." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Activate LookInside Pro from the LookInside Pro menu before using this feature." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "使用此功能前,请从 LookInside Pro 菜单激活 LookInside Pro。" } } + } + }, + "Communicating with authorization server…" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Communicating with authorization server…" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在与授权服务器通信…" } } + } + }, + "Current access remains available until" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Current access remains available until" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "当前访问有效期至" } } + } + }, + "Done" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Done" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "完成" } } + } + }, + "Email" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Email" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "邮箱" } } + } + }, + "Cut" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Cut" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "剪切" } } + } + }, + "Copy" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "复制" } } + } + }, + "Edit" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Edit" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "编辑" } } + } + }, + "Ended on" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Ended on" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "结束于" } } + } + }, + "Keychain access to the license key was denied, so Pro features are paused." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Keychain access to the license key was denied, so Pro features are paused." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "钥匙串拒绝了对许可证密钥的访问,Pro 功能已暂停。" } } + } + }, + "Later" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Later" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "稍后" } } + } + }, + "License" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "License" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "授权" } } + } + }, + "License Key" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "License Key" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "许可证" } } + } + }, + "License Expired" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "License Expired" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "许可证已过期" } } + } + }, + "License Review Warning" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "License Review Warning" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "授权复核提醒" } } + } + }, + "Licensed to" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Licensed to" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "授权给" } } + } + }, + "License status becomes available after activation." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "License status becomes available after activation." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "完成激活后可查看授权状态。" } } + } + }, + "LookInside Activation" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "LookInside Activation" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "LookInside 激活" } } + } + }, + "LookInside Pro is active for this machine." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "LookInside Pro is active for this machine." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "LookInside Pro 已在本机激活。" } } + } + }, + "LookInside needs to reach the activation server to keep your license active. Please connect to the internet — access will pause on %@." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "LookInside needs to reach the activation server to keep your license active. Please connect to the internet — access will pause on %@." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "LookInside 需要联网刷新授权。请尽快接入网络,否则将于 %@ 暂停使用。" } } + } + }, + "Never Expires" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Never Expires" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "永久有效" } } + } + }, + "No license state is stored in the Auth Server." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "No license state is stored in the Auth Server." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "Auth Server 尚未保存许可证状态。" } } + } + }, + "Notice" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Notice" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "提示" } } + } + }, + "OK" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "好" } } + } + }, + "Paste" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Paste" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "粘贴" } } + } + }, + "Order ID" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Order ID" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "订单号" } } + } + }, + "Quit LookInside Activation" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Quit LookInside Activation" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "退出 LookInside 激活" } } + } + }, + "Refreshing license status…" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Refreshing license status…" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "正在刷新授权状态…" } } + } + }, + "Refresh the license state or complete activation again to restore access." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Refresh the license state or complete activation again to restore access." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "刷新授权状态或重新完成激活即可恢复访问。" } } + } + }, + "Renews on" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Renews on" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "下次续订" } } + } + }, + "Select All" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Select All" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "全选" } } + } + }, + "Start Trial" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Start Trial" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "开始试用" } } + } + }, + "This license has been activated on multiple devices. Usage may be reviewed manually." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "This license has been activated on multiple devices. Usage may be reviewed manually." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "此许可证已在多台设备上激活,使用情况可能会被人工复核。" } } + } + }, + "Try Again" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Try Again" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "重试" } } + } + }, + "Trial" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Trial" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "试用" } } + } + }, + "Trial mode — this Mac must stay online to keep %@ running." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Trial mode — this Mac must stay online to keep %@ running." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "试用模式 — 本机需保持联网以继续运行 %@。" } } + } + }, + "View Trial License" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "View Trial License" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "查看测试许可证" } } + } + }, + "Started %@" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Started %@" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "始于 %@" } } + } + }, + "Started %@ · Ended %@" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Started %@ · Ended %@" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "始于 %1$@ · 终于 %2$@" } } + } + }, + "Started %@ · Ends %@ · %lld days left" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Started %@ · Ends %@ · %lld days left" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "始于 %1$@ · 至 %2$@ · 剩余 %3$lld 天" } } + } + }, + "Subscription · Active" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Subscription · Active" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "订阅 · 有效" } } + } + }, + "Subscription · Canceled" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Subscription · Canceled" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "订阅 · 已取消" } } + } + }, + "Subscription · Expired" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Subscription · Expired" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "订阅 · 已到期" } } + } + }, + "Subscription · In Review" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Subscription · In Review" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "订阅 · 审核中" } } + } + }, + "Subscription · Refunded" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Subscription · Refunded" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "订阅 · 已退款" } } + } + }, + "Trial Already Used" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Trial Already Used" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "本机已用过试用" } } + } + }, + "Each Mac can only use the trial once. Activate a license to continue." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Each Mac can only use the trial once. Activate a license to continue." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "每台 Mac 仅可使用一次试用。请激活正式版以继续使用。" } } + } + }, + "Allow Access to Your License Key" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Allow Access to Your License Key" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "允许访问许可证密钥" } } + } + }, + "LookInside needs to use the license key saved when you activated. macOS will ask for keychain access next. Choose “Always Allow”, otherwise you will be asked again every time LookInside connects to an app." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "LookInside needs to use the license key saved when you activated. macOS will ask for keychain access next. Choose “Always Allow”, otherwise you will be asked again every time LookInside connects to an app." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "LookInside 需要使用激活时保存的许可证密钥。接下来 macOS 会请求钥匙串访问权限,请选择“始终允许”,否则每次 LookInside 连接 App 时都会再次询问。" } } + } + }, + "Not Now" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Not Now" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "以后再说" } } + } + }, + "Continue" : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Continue" } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "继续" } } + } + }, + "LookInside won’t use the license key until you continue, so Pro features are paused. Click Try Again when you’re ready." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "LookInside won’t use the license key until you continue, so Pro features are paused. Click Try Again when you’re ready." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "在你继续之前,LookInside 不会使用许可证密钥,Pro 功能已暂停。准备好后请点按“重试”。" } } + } + }, + "macOS asks for keychain access each time LookInside connects to an app. Choose “Always Allow” to stop these requests." : { + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "macOS asks for keychain access each time LookInside connects to an app. Choose “Always Allow” to stop these requests." } }, + "zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "每次 LookInside 连接 App 时,macOS 都会请求钥匙串访问权限。选择“始终允许”即可不再询问。" } } + } + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationAccessDecision.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationAccessDecision.swift new file mode 100644 index 00000000..87d9c010 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationAccessDecision.swift @@ -0,0 +1,38 @@ +import Foundation + +/// The access decision the 2.3.x Auth helper returned for `license.check_access` +/// and `license.refresh_status`. +/// +/// The coding keys and the optional `status_summary` match the helper's +/// payload, so an encoded decision is the same JSON the helper sent. +public struct ActivationAccessDecision: Codable, Equatable, Sendable { + public enum Decision: String, Codable, Sendable { + case allow + case allowWithWarning = "allow_with_warning" + case block + } + + public let decision: Decision + public let title: String + public let message: String + public let statusSummary: String? + + public init(decision: Decision, title: String, message: String, statusSummary: String?) { + self.decision = decision + self.title = title + self.message = message + self.statusSummary = statusSummary + } + + /// `true` for `.allow` and `.allowWithWarning`. + public var grantsAccess: Bool { + decision != .block + } + + private enum CodingKeys: String, CodingKey { + case decision + case title + case message + case statusSummary = "status_summary" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationAccessEvaluator.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationAccessEvaluator.swift new file mode 100644 index 00000000..0073fe70 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationAccessEvaluator.swift @@ -0,0 +1,203 @@ +import Foundation + +typealias ActivationEvaluatorClient = ActivationSessionRefreshing & EntitlementStatusFetching + +final class ActivationAccessEvaluator: @unchecked Sendable { + private let stateStore: ActivationStateStore + private let apiClientFactory: @Sendable () -> any ActivationEvaluatorClient + private let now: @Sendable () -> Date + + init( + stateStore: ActivationStateStore, + apiClientFactory: @escaping @Sendable () -> any ActivationEvaluatorClient = { + LookInsideAuthenticatorAPIClient() + }, + now: @escaping @Sendable () -> Date = { Date() } + ) { + self.stateStore = stateStore + self.apiClientFactory = apiClientFactory + self.now = now + } + + func currentDecision() async -> ActivationAccessDecision { + let state = await stateStore.snapshot() + return ActivationAccessEvaluator.decision(from: state, evaluatedAt: now()) + } + + func refreshedDecision() async throws -> ActivationAccessDecision { + let state = await stateStore.snapshot() + guard let session = state.activationSession, + let fingerprint = state.deviceFingerprint + else { + return await currentDecision() + } + + let refreshedSession = try await ensureSessionFresh() ?? session + + let client = apiClientFactory() + let status = try await client.fetchEntitlementStatus( + EntitlementStatusRequest( + licenseID: refreshedSession.licenseID, + deviceID: fingerprint.deviceID, + forceRefresh: true, + activationSessionToken: refreshedSession.token + ) + ) + try await stateStore.recordEntitlementStatus(status) + return await currentDecision() + } + + /// Refreshes the activation session token if it is within the server's + /// 1-day refresh window. Swallows `refresh_window_not_open` (server says + /// "too early") so callers can call this opportunistically before any + /// authenticated request. Other errors propagate so the caller can decide + /// whether to surface a re-activation prompt. + @discardableResult + func ensureSessionFresh() async throws -> ActivationSession? { + let snapshot = await stateStore.snapshot() + guard let session = snapshot.activationSession else { return nil } + + let nowDate = now() + let secondsUntilExpiry = session.expiresAt.timeIntervalSince(nowDate) + guard secondsUntilExpiry <= 24 * 60 * 60 else { return session } + + let client = apiClientFactory() + do { + let refreshed = try await client.refreshActivationSession( + ActivationSessionRefreshRequest(activationSessionToken: session.token) + ) + try await stateStore.recordActivationSession(refreshed) + return refreshed + } catch let LookInsideAuthenticatorAPIClientError.api(_, code, _) + where code == "refresh_window_not_open" + { + // Server says it's too early; fine, sit tight. + return session + } + } + + static func decision( + from state: ActivationPersistedState, + evaluatedAt now: Date + ) -> ActivationAccessDecision { + guard let status = state.entitlementStatus else { + return .activationRequired + } + + switch status.license.lifecycleState { + case .expired, .refunded: + return .expired(at: status.effectiveAccessEndsAt ?? now) + case .pendingReview: + // Force a re-resolution against the server before granting access. + return .activationRequired + case .active, .canceled: + // Canceled subscriptions keep access until the period end. + break + @unknown default: + return .activationRequired + } + + let accessEndsAt = status.effectiveAccessEndsAt ?? state.activationResponse?.activation.expiresAt + if let accessEndsAt, accessEndsAt < now { + return .expired(at: accessEndsAt) + } + + if let connectivityWarning = connectivityWarning(for: status, state: state, now: now) { + return .allowedWithWarning(message: connectivityWarning, statusSummary: summary(for: status)) + } + + let warning = status.warningMessage ?? status.license.deviceWarning + if let warning, warning.isEmpty == false { + return .allowedWithWarning(message: warning, statusSummary: summary(for: status)) + } + + return .allowed(statusSummary: summary(for: status)) + } + + /// Returns a user-facing message when the silent certificate renewer has + /// recently failed and access is about to lapse. The threshold is wider + /// than the renewer's so the warning shows up before the lease actually + /// expires. + private static func connectivityWarning( + for status: EntitlementStatus, + state: ActivationPersistedState, + now: Date + ) -> String? { + guard let endsAt = status.effectiveAccessEndsAt else { return nil } + let succeeded = state.lastRenewSucceededAt ?? .distantPast + let failed = state.lastRenewFailedAt ?? .distantPast + guard failed > succeeded else { return nil } + + let threshold: TimeInterval = + (status.license.licenseClass == .trial) + ? 12 * 60 * 60 + : 7 * 24 * 60 * 60 + guard endsAt.timeIntervalSince(now) < threshold else { return nil } + + let dateString = endsAt.formatted(date: .abbreviated, time: .omitted) + let template = String( + localized: + "LookInside needs to reach the activation server to keep your license active. Please connect to the internet — access will pause on %@.", + bundle: .module + ) + return String(format: template, dateString) + } + + private static func summary(for status: EntitlementStatus) -> String { + let lifecycle = status.license.lifecycleState.rawValue + if let accessEndsAt = status.effectiveAccessEndsAt { + return + "License \(status.license.licenseID) is \(lifecycle) until \(ActivationStateCoding.iso8601Formatter.string(from: accessEndsAt))." + } + return "License \(status.license.licenseID) is \(lifecycle)." + } +} + +extension ActivationAccessDecision { + static let activationRequired = ActivationAccessDecision( + decision: .block, + title: String(localized: "Activation Required", bundle: .module), + message: String( + localized: "Activate LookInside Pro from the LookInside Pro menu before using this feature.", + bundle: .module + ), + statusSummary: String(localized: "No license state is stored in the Auth Server.", bundle: .module) + ) + + static func expired(at endedAt: Date) -> ActivationAccessDecision { + ActivationAccessDecision( + decision: .block, + title: String(localized: "License Expired", bundle: .module), + message: String( + localized: "Refresh the license state or complete activation again to restore access.", bundle: .module + ), + statusSummary: "Access expired at \(ActivationStateCoding.iso8601Formatter.string(from: endedAt))." + ) + } + + static func allowedWithWarning(message: String, statusSummary: String) -> ActivationAccessDecision { + ActivationAccessDecision( + decision: .allowWithWarning, + title: String(localized: "License Review Warning", bundle: .module), + message: message, + statusSummary: statusSummary + ) + } + + static var allowedTitle: String { + String(localized: "Access Granted", bundle: .module) + } + + static var allowedMessage: String { + String(localized: "LookInside Pro is active for this machine.", bundle: .module) + } + + static func allowed(statusSummary: String) -> ActivationAccessDecision { + ActivationAccessDecision( + decision: .allow, + title: allowedTitle, + message: allowedMessage, + statusSummary: statusSummary + ) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationCertificateRenewer.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationCertificateRenewer.swift new file mode 100644 index 00000000..d12f805e --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationCertificateRenewer.swift @@ -0,0 +1,214 @@ +import Foundation + +/// Silently re-issues the intermediate certificate before its server-side +/// 30-day check-in deadline (12-hour for trials). The renewer is best-effort: +/// callers fire-and-forget `tryRenewIfDue()`, so failures are recorded in the +/// background. Each attempt's outcome is persisted via +/// `ActivationStateStore.recordRenewAttempt(...)` so the access evaluator can +/// surface a "please connect to the internet" warning when a run of failures +/// is about to take access offline. +/// +/// Signing the CSR uses the license key, which can raise a keychain prompt, +/// so whether renewal is due and when it may try again are kept apart: +/// - Due: the lease's `renewAfter` has passed and less than 7 days (12 hours +/// for a trial) remain. +/// - Retry: after a failed attempt the next one waits `failureBackoff` +/// (5 min, 15 min, 1 h, 6 h, then 12 h). A success resets it. In the +/// lease's last `urgentWindow` (24 h) the wait is at most +/// `urgentMaximumBackoff` (1 h). +/// - One attempt signs at most one CSR. The CSR is kept in memory and reused +/// by later attempts while the key is unchanged (same public-key hash), and +/// dropped after a success. The service checks only the CSR's own +/// signature and binds the certificate to its public key, so a retry with +/// the same CSR is accepted. A permanent rejection (a 4xx, including the +/// service's validation errors, other than 408 and 429) also drops it, so +/// the next attempt signs a fresh CSR; a transport error or a 5xx keeps it. +/// - An attempt that has to sign asks the signing policy (`.silentRenewal`) +/// first; see `ActivationSigningPolicy` for the launch, denial, explainer +/// and slow-signing rules. An attempt the policy holds back is not a +/// failure and does not move the backoff. +/// - A failure persisted by an earlier launch counts as the first failure, +/// so relaunching does not skip the wait. +actor ActivationCertificateRenewer { + typealias CSRProvider = @Sendable () async throws -> String + typealias SessionRefresher = @Sendable () async throws -> Void + typealias ClientFactory = @Sendable () -> any IntermediateCertificateIssuing + /// Identifies the stored key without using it, or `nil` when unknown. A + /// cached CSR is reused only while this value is the same and not `nil`. + typealias KeyIdentityProvider = @Sendable () throws -> Data? + + static let failureBackoff: [TimeInterval] = [5 * 60, 15 * 60, 60 * 60, 6 * 60 * 60, 12 * 60 * 60] + static let urgentWindow: TimeInterval = 24 * 60 * 60 + static let urgentMaximumBackoff: TimeInterval = 60 * 60 + + private let stateStore: ActivationStateStore + private let clientFactory: ClientFactory + private let csrProvider: CSRProvider + private let keyIdentity: KeyIdentityProvider + private let sessionRefresher: SessionRefresher + private let now: @Sendable () -> Date + private let signingGate: ActivationSigningGate + private var consecutiveFailures = 0 + private var lastFailedAt: Date? + private var hasReadPersistedFailure = false + private var cachedCSR: (keyIdentity: Data, pem: String)? + private var inFlight = false + + init( + stateStore: ActivationStateStore, + clientFactory: @escaping ClientFactory = { LookInsideAuthenticatorAPIClient() }, + csrProvider: @escaping CSRProvider, + keyIdentity: @escaping KeyIdentityProvider = { nil }, + sessionRefresher: @escaping SessionRefresher, + now: @escaping @Sendable () -> Date = { Date() }, + signingGate: ActivationSigningGate = .open() + ) { + self.stateStore = stateStore + self.clientFactory = clientFactory + self.csrProvider = csrProvider + self.keyIdentity = keyIdentity + self.sessionRefresher = sessionRefresher + self.now = now + self.signingGate = signingGate + } + + /// The wait after `failures` failed attempts in a row. + static func retryDelay(afterFailures failures: Int, isUrgent: Bool) -> TimeInterval { + guard failures > 0 else { return 0 } + let delay = failureBackoff[min(failures, failureBackoff.count) - 1] + return isUrgent ? min(delay, urgentMaximumBackoff) : delay + } + + func tryRenewIfDue() async { + let snapshot = await stateStore.snapshot() + let nowDate = now() + + guard let status = snapshot.entitlementStatus, + let lease = status.currentLease, + snapshot.activationSession != nil + else { return } + + guard lease.renewAfter <= nowDate else { return } + + let threshold: TimeInterval = + (status.license.licenseClass == .trial) + ? 12 * 60 * 60 + : 7 * 24 * 60 * 60 + guard lease.expiresAt.timeIntervalSince(nowDate) < threshold else { return } + + readPersistedFailureOnce(snapshot) + let isUrgent = lease.expiresAt.timeIntervalSince(nowDate) < Self.urgentWindow + if let lastFailedAt { + let delay = Self.retryDelay(afterFailures: consecutiveFailures, isUrgent: isUrgent) + guard nowDate >= lastFailedAt.addingTimeInterval(delay) else { return } + } + if inFlight { + return + } + + let reusableCSR = reusableCachedCSR() + // A new CSR signs with the license key: wait while the signing + // policy holds it back. + if reusableCSR == nil, signingGate.admits(.silentRenewal, renewalIsUrgent: isUrgent) == false { + return + } + + inFlight = true + defer { inFlight = false } + + do { + try await sessionRefresher() + + let refreshedSnapshot = await stateStore.snapshot() + guard let session = refreshedSnapshot.activationSession else { + throw ActivationError.stateStoreFailure("activation session missing after refresh") + } + + let csr: String + if let reusableCSR { + csr = reusableCSR + } else { + csr = try await signNewCSR(isUrgent: isUrgent) + } + let client = clientFactory() + let renewedLease = try await client.issueIntermediateCertificate( + IntermediateCertificateIssueRequest( + activationSessionToken: session.token, + deviceIdentifier: session.deviceIdentifier, + certificateSigningRequestPEM: csr + ) + ) + cachedCSR = nil + consecutiveFailures = 0 + lastFailedAt = nil + try await stateStore.recordIssuedLease(renewedLease) + try await stateStore.recordRenewAttempt(success: true, error: nil, at: nowDate) + ActivationLogger.rpc.info( + "certificate renewer: success cert_id=\(renewedLease.certificateID, privacy: .public)" + ) + } catch ActivationError.signingDeferred { + // The signing policy held the key back in its turn; nothing was + // attempted. + } catch { + if Self.rejectsCSRPermanently(error) { + cachedCSR = nil + } + consecutiveFailures += 1 + lastFailedAt = nowDate + try? await stateStore.recordRenewAttempt( + success: false, + error: String(describing: error), + at: nowDate + ) + ActivationLogger.rpc.error( + "certificate renewer: failed error=\(String(describing: error), privacy: .public)" + ) + } + } + + /// `true` when the service rejected the request for good, so resending + /// the same CSR cannot succeed: a 4xx, which includes its validation + /// errors, other than 408 (timeout) and 429 (rate limited). Transport + /// errors and 5xx are worth a retry with the same CSR. + static func rejectsCSRPermanently(_ error: any Error) -> Bool { + guard case let LookInsideAuthenticatorAPIClientError.api(statusCode, _, _) = error else { + return false + } + return (400 ..< 500).contains(statusCode) && statusCode != 408 && statusCode != 429 + } + + /// Signs a CSR through the signing gate and keeps it for retries. + private func signNewCSR(isUrgent: Bool) async throws -> String { + let csrProvider = csrProvider + let csr = try await signingGate.perform(.silentRenewal, renewalIsUrgent: isUrgent) { + try await csrProvider() + } + if let identity = try? keyIdentity() { + cachedCSR = (identity, csr) + } else { + cachedCSR = nil + } + return csr + } + + /// The cached CSR when the stored key is still the one that signed it. + private func reusableCachedCSR() -> String? { + guard let cachedCSR else { return nil } + guard let identity = try? keyIdentity(), identity == cachedCSR.keyIdentity else { + self.cachedCSR = nil + return nil + } + return cachedCSR.pem + } + + private func readPersistedFailureOnce(_ snapshot: ActivationPersistedState) { + guard hasReadPersistedFailure == false else { return } + hasReadPersistedFailure = true + guard lastFailedAt == nil, let failedAt = snapshot.lastRenewFailedAt else { return } + if let succeededAt = snapshot.lastRenewSucceededAt, succeededAt >= failedAt { + return + } + consecutiveFailures = 1 + lastFailedAt = failedAt + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationChallengeSignature.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationChallengeSignature.swift new file mode 100644 index 00000000..566aab3d --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationChallengeSignature.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Input of the 220/221 license handshake signature: the 32-byte nonce from +/// `LookinRequestTypeLicenseChallenge` (hex-encoded) and the Server's instance +/// identifier. Same fields as the helper's `license.sign_challenge` request. +struct ActivationSignChallengeRequest: Codable, Sendable { + let nonce: String + let serverInstanceID: String + let subjectUDIDHint: String? + + init(nonce: String, serverInstanceID: String, subjectUDIDHint: String? = nil) { + self.nonce = nonce + self.serverInstanceID = serverInstanceID + self.subjectUDIDHint = subjectUDIDHint + } + + private enum CodingKeys: String, CodingKey { + case nonce + case serverInstanceID = "server_instance_id" + case subjectUDIDHint = "subject_udid_hint" + } +} + +/// Result of signing a license challenge. The Host forwards the three values +/// unchanged in the 221 `LicenseVerify` request. +public struct ActivationChallengeSignature: Equatable, Sendable { + /// RSA-PKCS1v15-SHA256 signature over `nonce || server_instance_id.utf8`. + public let signature: Data + /// DER bytes of the intermediate certificate issued by LookInside Web. + public let intermediateCertificateDER: Data + /// Device UDID bound into the intermediate certificate lease. + public let udid: String + + public init(signature: Data, intermediateCertificateDER: Data, udid: String) { + self.signature = signature + self.intermediateCertificateDER = intermediateCertificateDER + self.udid = udid + } + + /// The helper's `license.sign_challenge` response payload for this signature. + public var responsePayload: ActivationSignChallengeResponsePayload { + ActivationSignChallengeResponsePayload( + signature: signature.base64EncodedString(), + intermediateCertDER: intermediateCertificateDER.base64EncodedString(), + udid: udid + ) + } +} + +/// Wire shape of the helper's `license.sign_challenge` response payload, kept +/// so the in-process result can be compared byte-for-byte with the helper's. +public struct ActivationSignChallengeResponsePayload: Codable, Equatable, Sendable { + /// Base64-encoded RSA-PKCS1v15-SHA256 signature over `nonce || server_instance_id.utf8`. + public let signature: String + /// Base64-encoded DER bytes of the intermediate certificate issued by LookInsideWeb. + public let intermediateCertDER: String + public let udid: String + + private enum CodingKeys: String, CodingKey { + case signature + case intermediateCertDER = "intermediate_cert_der" + case udid + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationChallengeSigner.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationChallengeSigner.swift new file mode 100644 index 00000000..ea322b9b --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationChallengeSigner.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Pure validation and message-assembly logic for `license.sign_challenge`. +/// +/// Extracted so the gating rules (entitlement liveness, lease validity, nonce +/// shape) can be unit-tested without standing up a Unix socket. The actual +/// RSA signature stays in `IntermediateKeyStore` — this type only +/// decides whether to sign and what bytes go in. +enum ActivationChallengeSigner { + struct ValidatedRequest { + let lease: IntermediateCertificateLease + let message: Data + let certificateDER: Data + } + + static func validate( + request: ActivationSignChallengeRequest, + snapshot: ActivationPersistedState, + decision: ActivationAccessDecision, + evaluatedAt now: Date + ) throws -> ValidatedRequest { + guard let nonce = Data(hexString: request.nonce), nonce.count == 32 else { + throw ActivationError.invalidRequest("`nonce` must be 32 hex-encoded bytes.") + } + guard let serverIDData = request.serverInstanceID.data(using: .utf8), + !request.serverInstanceID.isEmpty + else { + throw ActivationError.invalidRequest("`server_instance_id` must be a non-empty UTF-8 string.") + } + + switch decision.decision { + case .allow, .allowWithWarning: + break + case .block: + throw ActivationError.licenseNotActivated( + "Activation has expired or is awaiting review; refresh license status before signing." + ) + } + + guard let lease = snapshot.entitlementStatus?.currentLease else { + throw ActivationError.licenseNotActivated("No intermediate certificate lease is available on this device.") + } + guard lease.expiresAt > now else { + throw ActivationError.licenseNotActivated("Intermediate certificate has expired.") + } + guard let certificateDER = certificateDER(fromPEM: lease.certificatePEM) else { + throw ActivationError.signingFailed("Stored certificate PEM could not be decoded to DER.") + } + + var message = Data(capacity: nonce.count + serverIDData.count) + message.append(nonce) + message.append(serverIDData) + + return ValidatedRequest(lease: lease, message: message, certificateDER: certificateDER) + } + + static func certificateDER(fromPEM pem: String) -> Data? { + let body = + pem + .split(whereSeparator: \.isNewline) + .filter { !$0.hasPrefix("-----") } + .joined() + return Data(base64Encoded: body) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationConfiguration.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationConfiguration.swift new file mode 100644 index 00000000..17812741 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationConfiguration.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Where the activation runtime keeps its state and key, and which service it +/// talks to. +/// +/// `standard` points at the locations the 2.3.x Auth helper used, so a Mac +/// activated by the helper stays activated and the helper can still read what +/// the Host writes. Tests and tools inject a temporary state directory and a +/// temporary keychain instead. +public struct ActivationConfiguration: Sendable { + /// Directory holding `state.json`. + public var stateDirectoryURL: URL + /// Keychain item that holds the intermediate RSA-2048 key. + public var keyStore: IntermediateKeyStoreConfiguration + /// LookInside Web base URL. + public var serviceBaseURL: URL + /// `appBundleID` reported in the device fingerprint. The helper reported its + /// own bundle identifier; keeping it keeps the server-side device binding + /// unchanged for existing activations. + public var fingerprintAppBundleID: String + /// Activation UI defaults: requested feature and framework version sent + /// in the activation challenge. + public var requestedFeature: String + public var frameworkVersion: String + + public init( + stateDirectoryURL: URL, + keyStore: IntermediateKeyStoreConfiguration = .standard, + serviceBaseURL: URL = LookInsideAuthenticatorAPIClient.serviceBaseURL, + fingerprintAppBundleID: String = ActivationConfiguration.helperBundleIdentifier, + requestedFeature: String = "swiftui.support", + frameworkVersion: String = "1.0.0" + ) { + self.stateDirectoryURL = stateDirectoryURL + self.keyStore = keyStore + self.serviceBaseURL = serviceBaseURL + self.fingerprintAppBundleID = fingerprintAppBundleID + self.requestedFeature = requestedFeature + self.frameworkVersion = frameworkVersion + } + + /// `state.json` inside `stateDirectoryURL`. + public var stateFileURL: URL { + stateDirectoryURL.appendingPathComponent("state.json", isDirectory: false) + } + + /// Bundle identifier of the 2.3.x Auth helper. + public static let helperBundleIdentifier = "app.lookinside.LookInsideAuthServer" + + /// `~/Library/Application Support/LookInside/AuthServer/state`, the helper's + /// state directory. + public static var standardStateDirectoryURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/Application Support/LookInside/AuthServer", + isDirectory: true + ) + .appendingPathComponent("state", isDirectory: true) + } + + /// The helper-compatible configuration. In DEBUG builds the service URL, + /// the state directory and the keychain can be redirected with + /// `ActivationDebugOverrides`. + public static var standard: ActivationConfiguration { + let configuration = ActivationConfiguration( + stateDirectoryURL: standardStateDirectoryURL, + keyStore: .standard, + serviceBaseURL: ActivationEnvironment.serviceBaseURL + ) + #if DEBUG + return ActivationDebugOverrides.apply(to: configuration) + #else + return configuration + #endif + } +} + +/// Resolves the service URL and trusted roots, applying DEBUG overrides. +enum ActivationEnvironment { + static var serviceBaseURL: URL { + #if DEBUG + if let override = ActivationDebugOverrides.serviceBaseURL { + return override + } + #endif + return LookInsideAuthenticatorAPIClient.serviceBaseURL + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationDebugOverrides.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationDebugOverrides.swift new file mode 100644 index 00000000..8d18fe06 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationDebugOverrides.swift @@ -0,0 +1,139 @@ +#if DEBUG + import Foundation + + /// DEBUG-only hooks that point the activation runtime at a local LookInside + /// Web instance, trust a throwaway test root, and keep a test run away from + /// this Mac's real activation. Compiled out of release builds, which talk to + /// production, trust only `rootProd2026`, and use the helper's state + /// directory and login-keychain key. + /// + /// Values come from the setters or, when unset, from the environment: + /// - `LOOKINSIDE_ACTIVATION_API_BASE_URL`: service base URL. + /// - `LOOKINSIDE_ACTIVATION_TEST_ROOT_ID` and + /// `LOOKINSIDE_ACTIVATION_TEST_ROOT_PUBLIC_KEY_PEM`: an extra trusted root. + /// - `LOOKINSIDE_ACTIVATION_STATE_DIR`: directory for `state.json` + /// instead of the helper's. + /// - `LOOKINSIDE_ACTIVATION_KEYCHAIN_PATH`: a file keychain for the license + /// key instead of the login keychain. The key then uses the test-only tag + /// `testKeyApplicationTag` and trusts no other application. The file does + /// not have to exist; without it every key lookup finds nothing. + public enum ActivationDebugOverrides { + private static let lock = NSLock() + private nonisolated(unsafe) static var storedServiceBaseURL: URL? + private nonisolated(unsafe) static var storedAdditionalTrustedRoot: TrustedRootPublicKey? + private nonisolated(unsafe) static var storedStateDirectoryURL: URL? + private nonisolated(unsafe) static var storedKeychainPath: String? + + static let serviceBaseURLEnvironmentKey = "LOOKINSIDE_ACTIVATION_API_BASE_URL" + static let testRootIDEnvironmentKey = "LOOKINSIDE_ACTIVATION_TEST_ROOT_ID" + static let testRootPublicKeyEnvironmentKey = "LOOKINSIDE_ACTIVATION_TEST_ROOT_PUBLIC_KEY_PEM" + static let stateDirectoryEnvironmentKey = "LOOKINSIDE_ACTIVATION_STATE_DIR" + static let keychainPathEnvironmentKey = "LOOKINSIDE_ACTIVATION_KEYCHAIN_PATH" + + /// Key tag used with `keychainPath`, so a test key never carries the + /// helper's tag. + public static let testKeyApplicationTag = "app.lookinside.debug.intermediate-key" + + public static var serviceBaseURL: URL? { + get { + if let stored = lock.withLock({ storedServiceBaseURL }) { + return stored + } + let environment = ProcessInfo.processInfo.environment + guard let value = environment[serviceBaseURLEnvironmentKey], value.isEmpty == false else { + return nil + } + return URL(string: value) + } + set { + lock.withLock { storedServiceBaseURL = newValue } + } + } + + public static var stateDirectoryURL: URL? { + get { + if let stored = lock.withLock({ storedStateDirectoryURL }) { + return stored + } + guard let value = environmentValue(stateDirectoryEnvironmentKey) else { + return nil + } + return URL(fileURLWithPath: (value as NSString).expandingTildeInPath, isDirectory: true) + } + set { + lock.withLock { storedStateDirectoryURL = newValue } + } + } + + public static var keychainPath: String? { + get { + if let stored = lock.withLock({ storedKeychainPath }) { + return stored + } + return environmentValue(keychainPathEnvironmentKey).map { ($0 as NSString).expandingTildeInPath } + } + set { + lock.withLock { storedKeychainPath = newValue } + } + } + + /// `configuration` with the state directory and keychain overrides + /// applied. + static func apply(to configuration: ActivationConfiguration) -> ActivationConfiguration { + var configuration = configuration + if let stateDirectoryURL { + configuration.stateDirectoryURL = stateDirectoryURL + } + if let keychainPath { + configuration.keyStore = IntermediateKeyStoreConfiguration( + applicationTag: testKeyApplicationTag, + keychainPath: keychainPath + ) + } + return configuration + } + + private static func environmentValue(_ key: String) -> String? { + guard let value = ProcessInfo.processInfo.environment[key], value.isEmpty == false else { + return nil + } + return value + } + + public static var additionalTrustedRoot: TrustedRootPublicKey? { + get { + if let stored = lock.withLock({ storedAdditionalTrustedRoot }) { + return stored + } + let environment = ProcessInfo.processInfo.environment + guard let identifier = environment[testRootIDEnvironmentKey], identifier.isEmpty == false, + let pem = environment[testRootPublicKeyEnvironmentKey], pem.isEmpty == false + else { + return nil + } + return TrustedRootPublicKey(certificateID: identifier, publicKeyPEM: pem, publicKeySHA256: "") + } + set { + lock.withLock { storedAdditionalTrustedRoot = newValue } + } + } + } +#endif + +#if DEBUG + public extension ActivationRuntime { + /// DEBUG end-to-end hook: creates the license key when none is stored + /// and returns a PKCS#10 request for it, so a test can issue an + /// intermediate certificate under a throwaway root for a key this + /// process created (and may then use without a keychain prompt). + /// + /// Works only on a file keychain (`LOOKINSIDE_ACTIVATION_KEYCHAIN_PATH`); + /// it refuses to create a key in the login keychain. + func debugMakeTestKeyCertificateSigningRequestPEM(commonName: String) throws -> String { + guard configuration.keyStore.keychainPath != nil else { + throw ActivationError.invalidRequest("The test key needs a file keychain (LOOKINSIDE_ACTIVATION_KEYCHAIN_PATH).") + } + return try keyStore.makeCertificateSigningRequestPEM(commonName: commonName) + } + } +#endif diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationDeviceFingerprint.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationDeviceFingerprint.swift new file mode 100644 index 00000000..418304e4 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationDeviceFingerprint.swift @@ -0,0 +1,60 @@ +import Darwin +import Foundation +import IOKit + +enum ActivationDeviceFingerprint { + /// The helper reported its own bundle identifier as `appBundleID`; the + /// runtime passes the same value so device bindings stay unchanged. + /// + /// Throws `ActivationError.activationFailed` when IOKit does not report + /// this Mac's `IOPlatformUUID`; activation needs it to bind the license. + static func current( + appBundleID: String = ActivationConfiguration.helperBundleIdentifier, + platformUUID: () -> String? = readPlatformUUID + ) throws -> DeviceFingerprint { + let processInfo = ProcessInfo.processInfo + guard let deviceID = platformUUID(), deviceID.isEmpty == false else { + throw ActivationError.activationFailed( + "LookInside could not read this Mac's hardware identifier, which activation requires." + ) + } + return DeviceFingerprint( + deviceID: deviceID, + hardwareModel: hardwareModel(), + operatingSystemVersion: processInfo.operatingSystemVersionString, + appBundleID: appBundleID + ) + } + + /// `IOPlatformUUID` from the registry, or `nil` when it is unavailable. + static func readPlatformUUID() -> String? { + let service = IOServiceGetMatchingService( + kIOMainPortDefault, + IOServiceMatching("IOPlatformExpertDevice") + ) + guard service != 0 else { + return nil + } + defer { IOObjectRelease(service) } + + return IORegistryEntryCreateCFProperty( + service, + kIOPlatformUUIDKey as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? String + } + + private static func hardwareModel() -> String { + var size = 0 + guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0 else { + return "Mac" + } + var buffer = [CChar](repeating: 0, count: size) + guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { + return "Mac" + } + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationError.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationError.swift new file mode 100644 index 00000000..659cda00 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationError.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Errors raised by the in-process activation runtime. +/// +/// `errorCode` keeps the string codes the 2.3.x Auth helper sent over its +/// socket, so logs and callers that branch on a code see the same values. +public enum ActivationError: LocalizedError, Equatable, Sendable { + case invalidRequest(String) + case keychainFailure(String) + /// The keychain refused to use the intermediate key: the user cancelled + /// or denied the access prompt, the password was wrong, or the keychain + /// is locked and cannot ask. Carries the `OSStatus`. + case keychainAccessDenied(Int32) + case stateStoreFailure(String) + case licenseNotActivated(String) + case signingFailed(String) + /// The license key was not used: the Host has not shown a window yet, + /// the user has not confirmed the keychain explainer, or automatic + /// signing waits after a refused keychain prompt. + case signingDeferred + case activationFailed(String) + + public var errorDescription: String? { + switch self { + case let .invalidRequest(message): + return message + case let .keychainFailure(message): + return "Keychain operation failed.\n\(message)" + case let .keychainAccessDenied(status): + return "Keychain access to the license key was denied.\nStatus \(status)." + case let .stateStoreFailure(message): + return "Auth state storage failed.\n\(message)" + case let .licenseNotActivated(message): + return "License is not activated on this device.\n\(message)" + case let .signingFailed(message): + return "Signing failed.\n\(message)" + case .signingDeferred: + return "The license key is not used right now.\nLookInside waits for keychain access to be confirmed." + case let .activationFailed(message): + return message + } + } + + public var errorCode: String { + switch self { + case .invalidRequest: return "invalid_request" + case .keychainFailure, .keychainAccessDenied: return "keychain_failure" + case .stateStoreFailure: return "state_store_failure" + case .licenseNotActivated: return "license_not_activated" + case .signingFailed, .signingDeferred: return "signing_failed" + case .activationFailed: return "activation_failed" + } + } +} + +extension ActivationError { + /// Statuses that mean the keychain refused access rather than failed: + /// user cancel (`errSecUserCanceled`, also what `CSSMERR_CSP_USER_CANCELED` + /// maps to), a wrong password, an ACL denial, or a locked keychain that + /// may not ask. + static let keychainAccessDeniedStatuses: Set = [ + -128, // errSecUserCanceled + -25293, // errSecAuthFailed + -25308, // errSecInteractionNotAllowed + -2_147_416_032, // CSSMERR_CSP_OPERATION_AUTH_DENIED + ] + + /// `errSecInteractionNotAllowed`: the keychain is locked and may not + /// ask. Not a user decision. + static let interactionNotAllowedStatus: Int32 = -25308 + + /// Maps a failed keychain status to `.keychainAccessDenied` or + /// `.keychainFailure`. + static func keychain(status: Int32, message: String) -> ActivationError { + if keychainAccessDeniedStatuses.contains(status) { + return .keychainAccessDenied(status) + } + return .keychainFailure(message) + } + + /// `true` for `.keychainAccessDenied`. + var isKeychainAccessDenied: Bool { + if case .keychainAccessDenied = self { + return true + } + return false + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationLogger.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationLogger.swift new file mode 100644 index 00000000..58013c06 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationLogger.swift @@ -0,0 +1,9 @@ +import os + +enum ActivationLogger { + static let rpc = Logger(subsystem: subsystem, category: "rpc") + static let runtime = Logger(subsystem: subsystem, category: "runtime") + static let ui = Logger(subsystem: subsystem, category: "ui") + + private static let subsystem = "app.lookinside.activation" +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationPersistedState.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationPersistedState.swift new file mode 100644 index 00000000..aabcecf2 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationPersistedState.swift @@ -0,0 +1,38 @@ +import Foundation + +struct ActivationPersistedState: Codable { + var deviceFingerprint: DeviceFingerprint? + var activationSession: ActivationSession? + var entitlementStatus: EntitlementStatus? + var activationResponse: HostActivationResponse? + var updatedAt: Date? + + /// Telemetry written by `ActivationCertificateRenewer`. The evaluator + /// surfaces a connectivity warning when the most recent attempt failed + /// and the lease is approaching expiry, so these timestamps must outlive + /// the in-memory renewer instance. + var lastRenewAttemptAt: Date? + var lastRenewSucceededAt: Date? + var lastRenewFailedAt: Date? + var lastRenewError: String? +} + +extension ActivationPersistedState { + var diskBackedState: ActivationPersistedState { + guard entitlementStatus?.license.licenseClass == .trial else { + return self + } + + return ActivationPersistedState( + deviceFingerprint: deviceFingerprint, + activationSession: nil, + entitlementStatus: nil, + activationResponse: nil, + updatedAt: updatedAt, + lastRenewAttemptAt: lastRenewAttemptAt, + lastRenewSucceededAt: lastRenewSucceededAt, + lastRenewFailedAt: lastRenewFailedAt, + lastRenewError: lastRenewError + ) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationResources.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationResources.swift new file mode 100644 index 00000000..13c85797 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationResources.swift @@ -0,0 +1,9 @@ +import Foundation + +package extension Bundle { + /// Bundle holding the activation string catalog, shared with + /// `LookInsideActivationUI`. + static var activationResources: Bundle { + .module + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationRuntime.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationRuntime.swift new file mode 100644 index 00000000..d07f6263 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationRuntime.swift @@ -0,0 +1,694 @@ +import Foundation + +/// Coarse activation state for synchronous callers such as menu validation. +public enum ActivationState: Int, Sendable { + /// No decision has been made yet. `ActivationRuntime` evaluates the stored + /// state when it is created, so this only shows up before that. + case unknown + case notActivated + case activated + + init(_ decision: ActivationAccessDecision) { + self = decision.grantsAccess ? .activated : .notActivated + } +} + +/// Handle returned by `ActivationRuntime.addDecisionObserver(_:)`. +public final class ActivationObservation: Sendable { + private let onCancel: @Sendable () -> Void + + init(onCancel: @escaping @Sendable () -> Void) { + self.onCancel = onCancel + } + + public func cancel() { + onCancel() + } + + deinit { + onCancel() + } +} + +/// In-process replacement for the LookInside Auth helper. +/// +/// One instance owns the state store, the keychain key, the access evaluator +/// and the silent certificate renewer. It answers what the Host used to ask the +/// helper over its socket: +/// +/// | helper RPC | runtime API | +/// | ------------------------ | ----------------------------------- | +/// | `license.check_access` | `currentDecision()` | +/// | `license.refresh_status` | `refreshedDecision()` | +/// | `license.sign_challenge` | `signChallenge(nonce:serverInstanceID:)` | +/// | `health.ping` | not needed (`statusSummary`) | +/// | `ui.*` | `LookInsideActivationUI` | +/// +/// Every decision it computes is published to observers when it differs from +/// the previous one. +public final class ActivationRuntime: Sendable { + public let configuration: ActivationConfiguration + + let stateStore: ActivationStateStore + let keyStore: IntermediateKeyStore + let evaluator: ActivationAccessEvaluator + let renewer: ActivationCertificateRenewer + private let apiClientFactory: @Sendable () -> LookInsideAuthenticatorAPIClient + private let now: @Sendable () -> Date + private let silentRenewalEnabled: Bool + /// The app-wide signing policy: every use of the license key, by the + /// handshake, by silent renewal or by a user action, goes through it. + let signingGate: ActivationSigningGate + private let broadcaster: DecisionBroadcaster + private let monitor = MonitorTaskBox() + @MainActor private var cachedModel: ActivationModel? + @MainActor private var modelSigningObservation: ActivationObservation? + + /// Creates a runtime and evaluates the stored state right away, so + /// `activationState` is known before the first async call. + public convenience init(configuration: ActivationConfiguration = .standard) { + self.init( + configuration: configuration, + urlSession: .shared, + now: { Date() } + ) + } + + /// `keychainAccessMarkers` remembers, in the Host's own defaults and per + /// key identity, a key the Host created, one that signed without a + /// prompt, or one whose keychain explainer the user confirmed. By default it is + /// used only for the login keychain (`keyStore.keychainPath == nil`); a + /// file keychain is a test keychain, and its keys never need the keychain + /// explainer. + init( + configuration: ActivationConfiguration, + urlSession: URLSession, + now: @escaping @Sendable () -> Date, + silentRenewalEnabled: Bool = true, + keychainAccessMarkers: KeychainAccessMarkers? = nil + ) { + self.configuration = configuration + self.now = now + self.silentRenewalEnabled = silentRenewalEnabled + let markers = + keychainAccessMarkers + ?? (configuration.keyStore.keychainPath == nil ? KeychainAccessMarkers(defaults: .standard) : nil) + let keyStoreBox = KeyStoreBox() + // The stored key's public-key hash: markers are kept per key, so a + // new key is assessed afresh. + let keyIdentity: @Sendable () -> String? = { + guard let identity = try? keyStoreBox.keyStore?.keyIdentity() else { return nil } + return identity.map { String(format: "%02x", $0) }.joined() + } + let signingGate = ActivationSigningGate( + launchedAt: now(), + now: now, + markers: markers, + keyIdentity: markers == nil ? nil : keyIdentity + ) + self.signingGate = signingGate + // A new, renewed or lost license lets every channel handshake again. + broadcaster = DecisionBroadcaster { previous, decision in + if previous?.grantsAccess != decision.grantsAccess || previous?.statusSummary != decision.statusSummary { + signingGate.noteActivationChanged() + } + } + let baseURL = configuration.serviceBaseURL + let apiClientFactory: @Sendable () -> LookInsideAuthenticatorAPIClient = { + LookInsideAuthenticatorAPIClient(urlSession: urlSession, baseURL: baseURL) + } + self.apiClientFactory = apiClientFactory + + let initialState = ActivationStateStore.loadState(from: configuration.stateFileURL) + let stateStore = ActivationStateStore( + url: configuration.stateFileURL, + initialState: initialState, + now: now + ) + self.stateStore = stateStore + let keyStore = IntermediateKeyStore( + configuration: configuration.keyStore, + didCreateKey: { signingGate.noteKeyCreatedByHost() } + ) + keyStoreBox.keyStore = keyStore + self.keyStore = keyStore + let evaluator = ActivationAccessEvaluator( + stateStore: stateStore, + apiClientFactory: apiClientFactory, + now: now + ) + self.evaluator = evaluator + let appBundleID = configuration.fingerprintAppBundleID + renewer = ActivationCertificateRenewer( + stateStore: stateStore, + clientFactory: apiClientFactory, + csrProvider: { + let fingerprint = try ActivationDeviceFingerprint.current(appBundleID: appBundleID) + return try keyStore.makeCertificateSigningRequestPEM( + commonName: "LookInside Device \(fingerprint.deviceID)" + ) + }, + keyIdentity: { try keyStore.keyIdentity() }, + sessionRefresher: { [weak evaluator] in + try await evaluator?.ensureSessionFresh() + }, + now: now, + signingGate: signingGate + ) + broadcaster.seed(ActivationAccessEvaluator.decision(from: initialState, evaluatedAt: now())) + } + + // MARK: - State + + /// Last published decision, or `nil` before the first evaluation. + public var lastDecision: ActivationAccessDecision? { + broadcaster.lastDecision + } + + /// Coarse state from the last published decision. Safe to read from any + /// thread without waiting. + public var activationState: ActivationState { + broadcaster.lastDecision.map(ActivationState.init) ?? .unknown + } + + /// `license.check_access`: evaluates the stored state offline (no network), + /// publishes the decision when it changed and schedules a silent + /// certificate renewal. + /// + /// Offline behaviour is the helper's: an active license keeps access until + /// its lease or license end date, and a recent failed renewal close to that + /// date turns the decision into `allowWithWarning`. + /// + /// Re-reads `state.json` first when another process changed it. + @discardableResult + public func currentDecision() async -> ActivationAccessDecision { + await stateStore.reloadIfChanged() + let decision = await evaluator.currentDecision() + broadcaster.publish(decision) + scheduleSilentRenewal() + return decision + } + + /// `license.refresh_status`: refreshes the activation session when due, + /// fetches the license status from the server, persists it and returns the + /// new decision. Without a stored session it returns `currentDecision()`. + @discardableResult + public func refreshedDecision() async throws -> ActivationAccessDecision { + await stateStore.reloadIfChanged() + let decision = try await evaluator.refreshedDecision() + broadcaster.publish(decision) + scheduleSilentRenewal() + return decision + } + + /// `true` when this process holds license material: a completed activation + /// or a current lease, including an in-memory trial. + public func hasLicenseMaterial() async -> Bool { + await stateStore.reloadIfChanged() + let snapshot = await stateStore.snapshot() + return snapshot.activationResponse != nil || snapshot.entitlementStatus?.currentLease != nil + } + + /// Read-only view of the stored state. + public func snapshot() async -> ActivationSnapshot { + ActivationSnapshot(await stateStore.snapshot()) + } + + // MARK: - License handshake + + /// `license.sign_challenge`: signs `nonce || serverInstanceID.utf8` with the + /// intermediate key (RSA-PKCS1v15-SHA256) and returns the signature, the + /// intermediate certificate DER and the bound UDID, exactly as the helper + /// returned them. + /// + /// Throws `ActivationError.invalidRequest` for a nonce that is not 32 bytes + /// or an empty instance identifier, and `.licenseNotActivated` when access + /// is blocked or no unexpired lease is stored. The signing policy decides + /// whether the key may be used now; when it may not (no window shown yet, + /// the keychain explainer not confirmed, or waiting after a refused + /// keychain prompt) this throws + /// `.signingDeferred` without touching the keychain. A refused prompt + /// throws `.keychainAccessDenied` and stops automatic signing for a while. + /// `channel` names the Server connection, so a failure there is not + /// retried until it reconnects. Key uses run one at a time; see + /// `ActivationSigningGate`. + public func signChallenge( + nonce: Data, + serverInstanceID: String, + channel: String? = nil + ) async throws -> ActivationChallengeSignature { + try await signChallengeMeasured(nonce: nonce, serverInstanceID: serverInstanceID, channel: channel).signature + } + + /// `signChallenge(nonce:serverInstanceID:channel:)` that also returns how + /// long the key took in its turn (`keyUseDuration`): the keychain's time, + /// including a prompt, without the time spent waiting behind other uses + /// of the key. + public func signChallengeMeasured( + nonce: Data, + serverInstanceID: String, + channel: String? = nil + ) async throws -> (signature: ActivationChallengeSignature, keyUseDuration: TimeInterval) { + let request = ActivationSignChallengeRequest( + nonce: nonce.map { String(format: "%02x", $0) }.joined(), + serverInstanceID: serverInstanceID + ) + await stateStore.reloadIfChanged() + let evaluatedAt = now() + let snapshot = await stateStore.snapshot() + let decision = ActivationAccessEvaluator.decision(from: snapshot, evaluatedAt: evaluatedAt) + let validated = try ActivationChallengeSigner.validate( + request: request, + snapshot: snapshot, + decision: decision, + evaluatedAt: evaluatedAt + ) + let keyStore = keyStore + let message = validated.message + let (signature, duration) = try await signingGate.performMeasured(.licenseHandshake, channel: channel) { + try keyStore.sign(message: message) + } + let result = ActivationChallengeSignature( + signature: signature, + intermediateCertificateDER: validated.certificateDER, + udid: validated.lease.udid + ) + return (result, duration) + } + + /// Blocking form of `signChallenge(nonce:serverInstanceID:channel:)` for + /// the Objective-C connection code, which asks from a background queue. + /// Do not call it on the main thread. + public func signChallengeBlocking( + nonce: Data, + serverInstanceID: String, + channel: String? = nil + ) throws -> ActivationChallengeSignature { + try signChallengeMeasuredBlocking(nonce: nonce, serverInstanceID: serverInstanceID, channel: channel).signature + } + + /// Blocking form of `signChallengeMeasured(nonce:serverInstanceID:channel:)`. + /// Do not call it on the main thread. + public func signChallengeMeasuredBlocking( + nonce: Data, + serverInstanceID: String, + channel: String? = nil + ) throws -> (signature: ActivationChallengeSignature, keyUseDuration: TimeInterval) { + let box = ResultBox() + let semaphore = DispatchSemaphore(value: 0) + Task.detached { [self] in + do { + let (signature, duration) = try await signChallengeMeasured( + nonce: nonce, + serverInstanceID: serverInstanceID, + channel: channel + ) + box.set(.success(MeasuredSignature(signature: signature, keyUseDuration: duration))) + } catch { + box.set(.failure(error)) + } + semaphore.signal() + } + semaphore.wait() + let measured = try box.get() + return (measured.signature, measured.keyUseDuration) + } + + /// `true` when a license handshake may start on `channel` now: the Host + /// has shown a window, automatic signing is not waiting after a refused + /// keychain prompt, and no handshake failed on `channel` since it + /// connected, since the activation changed or since a denial wait ended. + /// For a key LookInside did not create, the user must also have confirmed + /// the keychain explainer; when only that is missing, this asks the Host + /// to show it (`ActivationSigningStatus.needsKeychainAccessExplainer`). + public func allowsLicenseHandshake(onChannel channel: String?) -> Bool { + signingGate.allowsHandshake(onChannel: channel) + } + + /// Records that the Server on `channel` rejected the handshake (or sent + /// a malformed challenge), so the handshake is not repeated there. + public func noteLicenseHandshakeFailed(onChannel channel: String) { + signingGate.noteHandshakeFailed(onChannel: channel) + } + + /// Forgets `channel`'s handshake failures after it disconnected. + public func noteLicenseHandshakeChannelEnded(_ channel: String) { + signingGate.noteChannelEnded(channel) + } + + /// Lets automatic signing start. The Host calls it once it has shown its + /// first window: license handshakes may then sign, and silent renewal + /// may after the launch delay. + public func noteFirstWindowShown() { + signingGate.noteFirstWindowShown() + } + + /// Current signing status: whether automatic signing waits after a + /// refused keychain prompt. + public var signingStatus: ActivationSigningStatus { + signingGate.status + } + + /// Calls `handler` with the signing status now and whenever it changes, + /// on an arbitrary thread. A change of `handshakeGeneration` means held + /// back handshakes may start. + public func addSigningStatusObserver( + _ handler: @escaping @Sendable (ActivationSigningStatus) -> Void + ) -> ActivationObservation { + signingGate.addObserver(handler) + } + + /// The license window's Try Again: uses the stored license key once, + /// which may show the keychain prompt. On success automatic signing + /// resumes at once and every channel may handshake again. Never creates + /// a key. + public func retryKeychainAccess() async throws { + let keyStore = keyStore + let probe = Data("LookInside license key access check".utf8) + _ = try await signingGate.perform(.userAction) { + try keyStore.signWithExistingKey(message: probe) + } + } + + // MARK: - Keychain explainer + + /// Continue in the keychain explainer: confirms it for the stored key + /// (it does not come back for that key) and lets the held-back automatic + /// uses start: the handshakes waiting for it (through + /// `ActivationSigningStatus.handshakeGeneration`) and silent renewal. The + /// first of them raises the system prompt and counts as the user's use, + /// so Continue costs no signing of its own. + public func confirmKeychainAccessExplainer() { + signingGate.confirmExplainer() + scheduleSilentRenewal() + } + + /// Not Now in the keychain explainer: counts as a denied prompt, so + /// automatic signing waits (an hour, then a day) and the explainer comes + /// back after the wait. No system prompt is shown. The wait lives in + /// memory: after a relaunch the explainer is asked for again on the + /// first automatic use. + public func declineKeychainAccessExplainer() { + signingGate.declineExplainer() + } + + // MARK: - Activation + + /// Starts a trial: issues a trial session, fetches entitlement status, + /// issues the intermediate certificate and completes local activation. + /// The trial lives in memory only, like it did in the helper. + @MainActor + public func startTrial() async throws { + let model = activationModel() + await model.runTrialFlow() + try finishFlow(model) + } + + /// Activates with a license key and email (same flow as the activation + /// window's Activate button). + @MainActor + public func activate(licenseKey: String, email: String) async throws { + let model = activationModel() + model.licenseKey = licenseKey + model.email = email + await model.runActivationFlow() + try finishFlow(model) + } + + @MainActor + private func finishFlow(_ model: ActivationModel) throws { + if let message = model.errorMessage { + throw ActivationError.activationFailed(message) + } + } + + /// The activation model shared by the activation window and by + /// `startTrial()` / `activate(licenseKey:email:)`. Created on first use. + @MainActor + public func activationModel() -> ActivationModel { + if let cachedModel { + return cachedModel + } + let model = makeActivationModel() + cachedModel = model + let now = now + model.keychainRetryAction = { [weak self] in + try await self?.retryKeychainAccess() + } + // Keeps the license window's keychain notice current while it is open. + modelSigningObservation = signingGate.addObserver { [weak model] status in + let notice = KeychainAccessNotice(status, at: now()) + Task { @MainActor in + model?.setKeychainAccessNotice(notice) + } + } + return model + } + + /// Loads the stored state into the shared activation model. + @MainActor + public func restoreActivationModel() async { + await stateStore.reloadIfChanged() + let snapshot = await stateStore.snapshot() + let notice = KeychainAccessNotice(signingGate.status, at: now()) + let model = activationModel() + model.restorePersistedActivationState( + activationSession: snapshot.activationSession, + entitlementStatus: snapshot.entitlementStatus, + activationResponse: snapshot.activationResponse, + deviceFingerprint: snapshot.deviceFingerprint + ) + model.setKeychainAccessNotice(notice) + } + + /// `true` while automatic signing waits after a refused keychain prompt. + public func isKeychainAccessDenied() -> Bool { + signingGate.status.isKeychainAccessDenied(at: now()) + } + + @MainActor + private func makeActivationModel() -> ActivationModel { + let apiClient = apiClientFactory() + let stateStore = stateStore + let keyStore = keyStore + let signingGate = signingGate + let appBundleID = configuration.fingerprintAppBundleID + let hostCoordinator = HostActivationCoordinator( + replayProtector: InMemoryReplayProtector(), + secureTimestampFetcher: apiClient, + activationIssuer: LocalActivationMaterialIssuer() + ) + return ActivationModel( + configuration: ActivationUIConfiguration( + requestedFeature: configuration.requestedFeature, + frameworkVersion: configuration.frameworkVersion + ), + purchaseClaimResolver: PurchaseClaimResolverProxy(apiClient: apiClient, stateStore: stateStore), + trialIssuer: TrialIssuerProxy(apiClient: apiClient, stateStore: stateStore), + entitlementStatusFetcher: EntitlementStatusFetcherProxy(apiClient: apiClient, stateStore: stateStore), + intermediateCertificateIssuer: IntermediateCertificateIssuerProxy( + apiClient: apiClient, + stateStore: stateStore + ), + deviceFingerprintProvider: { + let fingerprint = try ActivationDeviceFingerprint.current(appBundleID: appBundleID) + try await stateStore.recordDeviceFingerprint(fingerprint) + return fingerprint + }, + certificateSigningRequestProvider: { + let fingerprint = try ActivationDeviceFingerprint.current(appBundleID: appBundleID) + // Activate and Start Trial are user actions: they sign even + // while automatic signing waits. + return try await signingGate.perform(.userAction) { + try keyStore.makeCertificateSigningRequestPEM( + commonName: "LookInside Device \(fingerprint.deviceID)" + ) + } + }, + activationHandler: { [weak self] request in + let response = try await hostCoordinator.activate(request) + try await stateStore.recordActivationResponse(response) + await self?.currentDecision() + return response + } + ) + } + + // MARK: - Renewal + + /// Fires the silent certificate renewer in the background. The renewer + /// decides whether renewal is due, backs off after failures and asks the + /// signing policy before it signs (see `ActivationCertificateRenewer`), + /// so calling it often is cheap. Nothing happens before + /// `noteFirstWindowShown()`. + public func scheduleSilentRenewal() { + guard silentRenewalEnabled, signingGate.status.hasShownFirstWindow else { return } + let renewer = renewer + Task.detached(priority: .background) { + await renewer.tryRenewIfDue() + } + } + + /// Re-evaluates the stored state every `interval` (publishing changes and + /// scheduling renewal), replacing the Host's 5-second helper polling. + /// Calling it again restarts the loop with the new interval. + public func startMonitoring(interval: Duration = .seconds(60)) { + let task = Task.detached(priority: .utility) { [weak self] in + while Task.isCancelled == false { + guard let self else { return } + await self.currentDecision() + try? await Task.sleep(for: interval) + } + } + monitor.replace(with: task) + } + + public func stopMonitoring() { + monitor.replace(with: nil) + } + + // MARK: - Observation + + /// Decisions as they change. The stream starts with the current decision. + public func decisionUpdates() -> AsyncStream { + AsyncStream { continuation in + let observation = broadcaster.add { decision in + continuation.yield(decision) + } + continuation.onTermination = { _ in + observation.cancel() + } + } + } + + /// Calls `handler` with the current decision and then on every change, on + /// an arbitrary thread. Keep the returned observation alive; cancelling or + /// releasing it stops the calls. + public func addDecisionObserver( + _ handler: @escaping @Sendable (ActivationAccessDecision) -> Void + ) -> ActivationObservation { + broadcaster.add(handler) + } +} + +/// Read-only view of `state.json` plus in-memory trial state. +public struct ActivationSnapshot: Sendable { + public let deviceFingerprint: DeviceFingerprint? + public let activationSession: ActivationSession? + public let entitlementStatus: EntitlementStatus? + public let activationResponse: HostActivationResponse? + public let updatedAt: Date? + public let lastRenewAttemptAt: Date? + public let lastRenewSucceededAt: Date? + public let lastRenewFailedAt: Date? + + init(_ state: ActivationPersistedState) { + deviceFingerprint = state.deviceFingerprint + activationSession = state.activationSession + entitlementStatus = state.entitlementStatus + activationResponse = state.activationResponse + updatedAt = state.updatedAt + lastRenewAttemptAt = state.lastRenewAttemptAt + lastRenewSucceededAt = state.lastRenewSucceededAt + lastRenewFailedAt = state.lastRenewFailedAt + } +} + +private final class DecisionBroadcaster: @unchecked Sendable { + private let lock = NSLock() + private var current: ActivationAccessDecision? + private var observers: [UUID: @Sendable (ActivationAccessDecision) -> Void] = [:] + /// Runs on every change, before the observers are called. + private let willNotify: @Sendable (ActivationAccessDecision?, ActivationAccessDecision) -> Void + + init(willNotify: @escaping @Sendable (ActivationAccessDecision?, ActivationAccessDecision) -> Void) { + self.willNotify = willNotify + } + + var lastDecision: ActivationAccessDecision? { + lock.withLock { current } + } + + func seed(_ decision: ActivationAccessDecision) { + lock.withLock { current = decision } + } + + func publish(_ decision: ActivationAccessDecision) { + let change: (ActivationAccessDecision?, [@Sendable (ActivationAccessDecision) -> Void])? = lock.withLock { + guard current != decision else { return nil } + let previous = current + current = decision + return (previous, Array(observers.values)) + } + guard let (previous, handlers) = change else { return } + willNotify(previous, decision) + guard handlers.isEmpty == false else { return } + ActivationLogger.runtime.info( + "activation decision changed: \(decision.decision.rawValue, privacy: .public)" + ) + for handler in handlers { + handler(decision) + } + } + + func add(_ handler: @escaping @Sendable (ActivationAccessDecision) -> Void) -> ActivationObservation { + let identifier = UUID() + let initial: ActivationAccessDecision? = lock.withLock { + observers[identifier] = handler + return current + } + if let initial { + handler(initial) + } + return ActivationObservation { [weak self] in + self?.lock.withLock { _ = self?.observers.removeValue(forKey: identifier) } + } + } +} + +private final class MonitorTaskBox: @unchecked Sendable { + private let lock = NSLock() + private var task: Task? + + func replace(with newTask: Task?) { + let previous = lock.withLock { + let previous = task + task = newTask + return previous + } + previous?.cancel() + } +} + +/// Lets the signing gate's explainer assessment reach the key store, which +/// is created after the gate. +private final class KeyStoreBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: IntermediateKeyStore? + + var keyStore: IntermediateKeyStore? { + get { lock.withLock { stored } } + set { lock.withLock { stored = newValue } } + } +} + +private struct MeasuredSignature: Sendable { + let signature: ActivationChallengeSignature + let keyUseDuration: TimeInterval +} + +private final class ResultBox: @unchecked Sendable { + private let lock = NSLock() + private var result: Result? + + func set(_ result: Result) { + lock.withLock { self.result = result } + } + + func get() throws -> Value { + guard let result = lock.withLock({ result }) else { + throw ActivationError.signingFailed("Signing finished without a result.") + } + return try result.get() + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationServiceProxies.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationServiceProxies.swift new file mode 100644 index 00000000..beaa599b --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationServiceProxies.swift @@ -0,0 +1,69 @@ +import Foundation + +final class PurchaseClaimResolverProxy: PurchaseClaimResolving, @unchecked Sendable { + private let apiClient: LookInsideAuthenticatorAPIClient + private let stateStore: ActivationStateStore + + init(apiClient: LookInsideAuthenticatorAPIClient, stateStore: ActivationStateStore) { + self.apiClient = apiClient + self.stateStore = stateStore + } + + func resolvePurchaseClaim(_ request: PurchaseClaimRequest) async throws -> ActivationSession { + try await stateStore.recordDeviceFingerprint(request.device) + let session = try await apiClient.resolvePurchaseClaim(request) + try await stateStore.recordActivationSession(session) + return session + } +} + +final class TrialIssuerProxy: TrialIssuing, @unchecked Sendable { + private let apiClient: LookInsideAuthenticatorAPIClient + private let stateStore: ActivationStateStore + + init(apiClient: LookInsideAuthenticatorAPIClient, stateStore: ActivationStateStore) { + self.apiClient = apiClient + self.stateStore = stateStore + } + + func issueTrial(_ request: TrialIssueRequest) async throws -> ActivationSession { + try await stateStore.recordDeviceFingerprint(request.device) + let session = try await apiClient.issueTrial(request) + try await stateStore.recordTransientActivationSession(session) + return session + } +} + +final class EntitlementStatusFetcherProxy: EntitlementStatusFetching, @unchecked Sendable { + private let apiClient: LookInsideAuthenticatorAPIClient + private let stateStore: ActivationStateStore + + init(apiClient: LookInsideAuthenticatorAPIClient, stateStore: ActivationStateStore) { + self.apiClient = apiClient + self.stateStore = stateStore + } + + func fetchEntitlementStatus(_ request: EntitlementStatusRequest) async throws -> EntitlementStatus { + let status = try await apiClient.fetchEntitlementStatus(request) + try await stateStore.recordEntitlementStatus(status) + return status + } +} + +final class IntermediateCertificateIssuerProxy: IntermediateCertificateIssuing, @unchecked Sendable { + private let apiClient: LookInsideAuthenticatorAPIClient + private let stateStore: ActivationStateStore + + init(apiClient: LookInsideAuthenticatorAPIClient, stateStore: ActivationStateStore) { + self.apiClient = apiClient + self.stateStore = stateStore + } + + func issueIntermediateCertificate( + _ request: IntermediateCertificateIssueRequest + ) async throws -> IntermediateCertificateLease { + let lease = try await apiClient.issueIntermediateCertificate(request) + try await stateStore.recordIssuedLease(lease) + return lease + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationSigningGate.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationSigningGate.swift new file mode 100644 index 00000000..d28acaad --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationSigningGate.swift @@ -0,0 +1,356 @@ +import Foundation + +/// The runtime's single, thread-safe `ActivationSigningPolicy`. Every use of +/// the license key goes through `perform(_:channel:renewalIsUrgent:_:)`. +/// +/// Rules: +/// - Key uses run one at a time, in a queue. A user action waits only for +/// the use in progress and goes ahead of every queued automatic use. +/// - An automatic use asks the policy when it is queued and again when its +/// turn comes, so a denied prompt cancels everything queued behind it. +/// - The duration of a use is measured from its turn, not from the queue, +/// so only time spent in the keychain counts as a prompt. +/// - When a denial wait ends, a timer clears it: observers see the new +/// status (the license window notice goes away) and the channels whose +/// signing was denied may handshake again. +/// - The keychain explainer (see `ActivationSigningPolicy`) is assessed on +/// the first automatic use, and requested from the Host only when an +/// automatic use needs the key. Continue lets the held-back uses start +/// again; the first of them raises the system prompt as the user's use. +/// - The markers (`KeychainAccessMarkers`) are written for the key's +/// identity: created by the Host, signed without a prompt, explainer +/// confirmed. +final class ActivationSigningGate: @unchecked Sendable { + typealias TimerScheduler = @Sendable (_ delay: TimeInterval, _ fire: @escaping @Sendable () -> Void) -> Void + + private let lock = NSLock() + private let now: @Sendable () -> Date + private let scheduleTimer: TimerScheduler + private let markers: KeychainAccessMarkers? + private let keyIdentity: (@Sendable () -> String?)? + private var policy: ActivationSigningPolicy + private var generation = 0 + private var observers: [UUID: @Sendable (ActivationSigningStatus) -> Void] = [:] + /// `true` until the first automatic use decided whether the key needs + /// the explainer. + private var needsExplainerAssessment: Bool + private var isKeyInUse = false + private var waiters: [Waiter] = [] + + private struct Waiter { + let isUserAction: Bool + let continuation: CheckedContinuation + } + + /// `markers` remembers, per key, what makes the explainer unnecessary; + /// `keyIdentity` names the stored key (its public-key hash, read without + /// using the key) or returns `nil` when there is none. Without either + /// (test keychains) the explainer is never needed. + init( + launchedAt: Date, + now: @escaping @Sendable () -> Date, + markers: KeychainAccessMarkers? = nil, + keyIdentity: (@Sendable () -> String?)? = nil, + scheduleTimer: @escaping TimerScheduler = ActivationSigningGate.wallClockTimer + ) { + policy = ActivationSigningPolicy(launchedAt: launchedAt) + self.now = now + self.markers = markers + self.keyIdentity = keyIdentity + needsExplainerAssessment = markers != nil && keyIdentity != nil + self.scheduleTimer = scheduleTimer + } + + /// A gate that lets every source sign at once, for a renewer built on + /// its own (tests, the end-to-end harness). + static func open(now: @escaping @Sendable () -> Date = { Date() }) -> ActivationSigningGate { + let gate = ActivationSigningGate(launchedAt: .distantPast, now: now) + gate.noteFirstWindowShown() + return gate + } + + /// Fires `fire` after `delay` seconds of wall-clock time, so a Mac that + /// slept past the end of a wait still ends it on wake. + static let wallClockTimer: TimerScheduler = { delay, fire in + DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + max(delay, 0), execute: fire) + } + + var status: ActivationSigningStatus { + lock.withLock { makeStatus() } + } + + // MARK: Decisions + + /// `true` when `source` may use the key now. Does not ask for the + /// explainer. + func allowsSigning(for source: ActivationSigningSource, renewalIsUrgent: Bool = false) -> Bool { + decision(for: source, renewalIsUrgent: renewalIsUrgent) == .allowed + } + + /// `true` when an automatic use from `source` may use the key now. When + /// only the explainer holds it back, asks the Host to show it. + func admits(_ source: ActivationSigningSource, renewalIsUrgent: Bool = false) -> Bool { + settle(decision(for: source, renewalIsUrgent: renewalIsUrgent)) + } + + /// `true` when a license handshake may start on `channel` now. When only + /// the explainer holds it back, asks the Host to show it. + func allowsHandshake(onChannel channel: String?) -> Bool { + assessExplainerIfNeeded() + let date = now() + return settle(lock.withLock { policy.handshakeDecision(onChannel: channel, at: date) }) + } + + private func decision( + for source: ActivationSigningSource, + renewalIsUrgent: Bool + ) -> ActivationSigningDecision { + if source.isAutomatic { + assessExplainerIfNeeded() + } + let date = now() + return lock.withLock { policy.decision(for: source, at: date, renewalIsUrgent: renewalIsUrgent) } + } + + private func settle(_ decision: ActivationSigningDecision) -> Bool { + if decision == .needsExplainer { + update(unblocksHandshakes: false) { $0.requestExplainer() } + } + return decision == .allowed + } + + // MARK: Key use + + /// Runs `body`, which uses the license key, in its turn. An automatic + /// `source` that the policy does not allow, when queued or when its turn + /// comes, throws `ActivationError.signingDeferred` without running. + /// Records the outcome. + func perform( + _ source: ActivationSigningSource, + channel: String? = nil, + renewalIsUrgent: Bool = false, + _ body: @Sendable () async throws -> Value + ) async throws -> Value { + try await performMeasured(source, channel: channel, renewalIsUrgent: renewalIsUrgent, body).value + } + + /// `perform(_:channel:renewalIsUrgent:_:)` that also returns how long + /// `body` took in its turn: the time the keychain needed, without the + /// time spent waiting in the queue. + func performMeasured( + _ source: ActivationSigningSource, + channel: String? = nil, + renewalIsUrgent: Bool = false, + _ body: @Sendable () async throws -> Value + ) async throws -> (value: Value, duration: TimeInterval) { + if source.isAutomatic, admits(source, renewalIsUrgent: renewalIsUrgent) == false { + throw ActivationError.signingDeferred + } + await waitForTurn(isUserAction: source == .userAction) + defer { finishTurn() } + if source.isAutomatic, admits(source, renewalIsUrgent: renewalIsUrgent) == false { + throw ActivationError.signingDeferred + } + let startedAt = Date() + do { + let value = try await body() + let duration = Date().timeIntervalSince(startedAt) + record(.succeeded(duration: duration), from: source, channel: channel) + return (value, duration) + } catch { + record(ActivationSigningOutcome(error: error), from: source, channel: channel) + throw error + } + } + + private func waitForTurn(isUserAction: Bool) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + let runsNow: Bool = lock.withLock { + guard isKeyInUse else { + isKeyInUse = true + return true + } + let waiter = Waiter(isUserAction: isUserAction, continuation: continuation) + if isUserAction, let index = waiters.firstIndex(where: { $0.isUserAction == false }) { + waiters.insert(waiter, at: index) + } else { + waiters.append(waiter) + } + return false + } + if runsNow { + continuation.resume() + } + } + } + + /// How many uses are queued behind the one holding the key. Tests wait + /// on it to know a use has joined the queue. + var queuedUseCount: Int { + lock.withLock { waiters.count } + } + + /// Hands the key to the next queued use, or marks it free. + private func finishTurn() { + let next: Waiter? = lock.withLock { + guard waiters.isEmpty == false else { + isKeyInUse = false + return nil + } + return waiters.removeFirst() + } + next?.continuation.resume() + } + + // MARK: Events + + func record(_ outcome: ActivationSigningOutcome, from source: ActivationSigningSource, channel: String?) { + let date = now() + if outcome == .denied || outcome == .interactionNotAllowed { + ActivationLogger.runtime.error( + "license key use from \(String(describing: source), privacy: .public) refused: \(String(describing: outcome), privacy: .public)" + ) + } + if case let .succeeded(duration) = outcome, duration < ActivationSigningPolicy.promptThreshold, + let markers, let identity = keyIdentity?() + { + markers.noteKeySignedWithoutPrompt(identity) + } + update(unblocksHandshakes: source == .userAction) { + $0.record(outcome, from: source, channel: channel, at: date) + } + } + + func noteFirstWindowShown() { + update(unblocksHandshakes: true) { $0.noteFirstWindowShown() } + } + + func noteHandshakeFailed(onChannel channel: String) { + update(unblocksHandshakes: false) { $0.noteHandshakeFailed(onChannel: channel) } + } + + func noteChannelEnded(_ channel: String) { + update(unblocksHandshakes: false) { $0.noteChannelEnded(channel) } + } + + func noteActivationChanged() { + update(unblocksHandshakes: true) { $0.noteActivationChanged() } + } + + /// The Host created a new key: its access list lists the Host, so it + /// never needs the explainer. + func noteKeyCreatedByHost() { + if let markers, let identity = keyIdentity?() { + markers.noteKeyCreatedByHost(identity) + } + update(unblocksHandshakes: false) { $0.noteKeySignsWithoutPrompt() } + } + + /// Continue in the keychain explainer: remembers it for the stored key, + /// so the explainer does not come back for that key in a later launch, + /// and lets the held-back uses start. The first of them raises the system + /// prompt and counts as the user's use; Continue itself does not sign. + func confirmExplainer() { + if let markers, let identity = keyIdentity?() { + markers.noteExplainerConfirmed(identity) + } + update(unblocksHandshakes: true) { $0.confirmExplainer() } + } + + /// Not Now in the keychain explainer: a denial without a system prompt. + func declineExplainer() { + let date = now() + update(unblocksHandshakes: false) { $0.declineExplainer(at: date) } + } + + /// Ends the denial wait when it is over. The timer set at each denial + /// calls it; a call before the end only sets the timer again. + func noteDenialWaitEndedIfDue() { + let date = now() + let remaining: TimeInterval? = lock.withLock { + guard let deniedUntil = policy.deniedUntil, date < deniedUntil else { return nil } + return deniedUntil.timeIntervalSince(date) + } + if let remaining { + scheduleTimer(remaining) { [weak self] in self?.noteDenialWaitEndedIfDue() } + return + } + update(unblocksHandshakes: true) { $0.noteDenialWaitEnded(at: date) } + } + + func addObserver(_ handler: @escaping @Sendable (ActivationSigningStatus) -> Void) -> ActivationObservation { + let identifier = UUID() + let initial: ActivationSigningStatus = lock.withLock { + observers[identifier] = handler + return makeStatus() + } + handler(initial) + return ActivationObservation { [weak self] in + self?.lock.withLock { _ = self?.observers.removeValue(forKey: identifier) } + } + } + + // MARK: State + + /// Runs the explainer assessment once, outside the lock: it reads the + /// key's identity (attributes only, never a prompt). A stored key needs + /// the explainer unless a marker covers it. + private func assessExplainerIfNeeded() { + let assesses: Bool = lock.withLock { + defer { needsExplainerAssessment = false } + return needsExplainerAssessment + } + guard assesses, let markers, let identity = keyIdentity?(), markers.mayNeedExplainer(forKey: identity) + else { return } + update(unblocksHandshakes: false) { $0.noteExplainerNeeded() } + } + + /// Applies `change` and tells observers when the status changed. With + /// `unblocksHandshakes`, the generation moves when the change lets a + /// held-back handshake start: the first window appeared, the activation + /// changed, Continue confirmed the explainer, a user action cleared a + /// denial wait and failed channels, or the denial wait ended. A new + /// denial wait sets the timer that ends it. + private func update(unblocksHandshakes: Bool, _ change: (inout ActivationSigningPolicy) -> Void) { + let result: (ActivationSigningStatus?, [@Sendable (ActivationSigningStatus) -> Void], Date?) = + lock.withLock { + let before = policy + let statusBefore = makeStatus() + change(&policy) + guard before != policy else { return (nil, [], nil) } + let deniedUntilChanged = before.deniedUntil != policy.deniedUntil + let unblocked = + unblocksHandshakes + && (before.hasShownFirstWindow != policy.hasShownFirstWindow + || deniedUntilChanged + || before.explainer != policy.explainer + || before.failedHandshakeChannels != policy.failedHandshakeChannels) + if unblocked { + generation += 1 + } + let newWait = deniedUntilChanged ? policy.deniedUntil : nil + let status = makeStatus() + guard status != statusBefore else { return (nil, [], newWait) } + return (status, Array(observers.values), newWait) + } + let (status, handlers, newWait) = result + if let newWait { + scheduleTimer(newWait.timeIntervalSince(now())) { [weak self] in self?.noteDenialWaitEndedIfDue() } + } + guard let status else { return } + for handler in handlers { + handler(status) + } + } + + private func makeStatus() -> ActivationSigningStatus { + ActivationSigningStatus( + keychainAccessDeniedUntil: policy.deniedUntil, + hasShownFirstWindow: policy.hasShownFirstWindow, + handshakeGeneration: generation, + needsKeychainAccessExplainer: policy.isExplainerRequested, + isKeychainAccessPostponed: policy.isDenialFromExplainer, + isKeychainAccessAllowedOnce: policy.isKeyAllowedOnce + ) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationSigningPolicy.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationSigningPolicy.swift new file mode 100644 index 00000000..29c4b63d --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationSigningPolicy.swift @@ -0,0 +1,381 @@ +import Foundation + +/// Who wants to use the license key. +public enum ActivationSigningSource: Sendable, Equatable { + /// The 220/221 license handshake with a connected Server. + case licenseHandshake + /// The background certificate renewal. + case silentRenewal + /// A direct user action: Try Again in the license window, Activate or + /// Start Trial. Continue in the keychain explainer does not use the key + /// itself; it lets the held-back automatic use run as the user's. + case userAction + + /// `true` for the sources LookInside starts on its own. + var isAutomatic: Bool { + self != .userAction + } +} + +/// How one use of the license key ended. +enum ActivationSigningOutcome: Sendable, Equatable { + /// The key signed. `duration` is how long the keychain took. + case succeeded(duration: TimeInterval) + /// The user cancelled or denied the keychain prompt, or the password was + /// wrong. + case denied + /// The keychain is locked and may not ask (`errSecInteractionNotAllowed`). + case interactionNotAllowed + /// Any other failure. It does not change when the key may be used. + case failed + + init(error: any Error) { + guard case let .keychainAccessDenied(status) = error as? ActivationError else { + self = .failed + return + } + self = status == ActivationError.interactionNotAllowedStatus ? .interactionNotAllowed : .denied + } +} + +/// What the policy says about one automatic use of the license key. +enum ActivationSigningDecision: Sendable, Equatable { + /// The key may be used now. + case allowed + /// Not now: no window yet, the launch delay, a wait after a denial or a + /// locked keychain, or the quiet period after a slow signing. + case deferred + /// Everything else allows it, but the user has not yet seen the keychain + /// explainer for a key LookInside did not create. + case needsExplainer +} + +/// Whether the Host must explain the coming keychain prompt first. +enum KeychainAccessExplainerState: Sendable, Equatable { + /// The key signs without a prompt, the Host created it, or the Host does + /// not know of a key it did not create. + case notNeeded + /// The key was created by the 2.3.x helper and has not signed without a + /// prompt yet: automatic uses wait until the user confirms the explainer. + case awaitingConfirmation + /// The user chose Continue, in this launch or an earlier one for the + /// same key, or answered a prompt from a user action in this launch. + case confirmed +} + +/// Decides when LookInside may use the license key without the user asking +/// for it. Using the key can raise a login-keychain prompt, so every +/// automatic path (license handshakes, silent renewal) asks this policy +/// first; only direct user actions sign regardless. +/// +/// Rules: +/// - Nothing signs automatically before the Host has shown its first +/// window. Silent renewal also waits `launchDelay` after launch. +/// - A key LookInside did not create (an upgrade from the 2.3.x helper) +/// prompts until the user picks Always Allow. Until such a key has signed +/// without a prompt, the first automatic use waits for the keychain +/// explainer, at most once per key (`KeychainAccessMarkers`). Continue +/// confirms it and makes the next automatic use the user's own: that use +/// raises the one system prompt, and its answer counts like a user +/// action's. Not Now counts as a denial without a system prompt; the +/// explainer comes back when the wait ends. +/// - A denied prompt stops all automatic signing for an hour, and for a day +/// after each further denial. The user can clear the wait with Try Again. +/// When the wait ends, channels whose handshake failed on the denial may +/// handshake again. +/// - A locked keychain that may not ask is not a denial: automatic signing +/// pauses for `interactionNotAllowedDelay` and nothing escalates. +/// - A success clears the denial count only when the user asked for it, or +/// when it finished within `promptThreshold`: then the keychain did not +/// stop to ask, so the key no longer prompts. A slow automatic success was +/// a one-time Allow, and the next denial still escalates. +/// - After a slow automatic success, silent renewal does not sign for +/// `slowSigningQuietPeriod` unless the lease ends within a day, and the +/// license window suggests Always Allow until a signing is fast again: +/// only Always Allow ends the prompt for each newly connected app. +/// - A handshake that failed on a channel (rejected 221, failed signing) is +/// not repeated on that channel until it reconnects, the activation state +/// changes, or (for a denial) the denial wait ends. +/// +/// The type is a value with no clock or I/O; `ActivationRuntime` owns the +/// app-wide instance through `ActivationSigningGate`. It lives in memory +/// only: the denial count and wait (1 h, then 24 h) hold within one launch, +/// and a relaunch starts again from the first-window, explainer and +/// first-use rules. Only the markers about the key persist. +struct ActivationSigningPolicy: Sendable, Equatable { + static let denialBackoff: [TimeInterval] = [60 * 60, 24 * 60 * 60] + static let interactionNotAllowedDelay: TimeInterval = 5 * 60 + static let launchDelay: TimeInterval = 30 + /// A signing at least this slow waited on a keychain prompt. + static let promptThreshold: TimeInterval = 1 + /// Silent renewal's pause after a slow automatic signing. + static let slowSigningQuietPeriod: TimeInterval = 6 * 60 * 60 + + let launchedAt: Date + private(set) var hasShownFirstWindow = false + private(set) var denialCount = 0 + private(set) var deniedUntil: Date? + private(set) var pausedUntil: Date? + private(set) var failedHandshakeChannels: Set = [] + /// Channels in `failedHandshakeChannels` because their signing was denied. + private(set) var deniedHandshakeChannels: Set = [] + private(set) var lastSlowAutomaticSigningAt: Date? + private(set) var explainer = KeychainAccessExplainerState.notNeeded + /// `true` while an automatic use waits for the Host to show the explainer. + private(set) var isExplainerRequested = false + /// `true` once the key signed without a prompt or the Host created it. + private(set) var keySignsWithoutPrompt = false + /// `true` after Continue until an automatic use answers the system + /// prompt: that use counts as the user's. + private(set) var isContinuedUsePending = false + /// `true` while the denial wait came from Not Now in the explainer, so + /// no system prompt was shown or refused. + private(set) var isDenialFromExplainer = false + /// `true` after a slow automatic success (a one-time Allow) until a + /// fast signing or a denial. + private(set) var isKeyAllowedOnce = false + + init(launchedAt: Date) { + self.launchedAt = launchedAt + } + + // MARK: Decisions + + /// What the policy says about a use from `source` at `date`. + /// `renewalIsUrgent` is `true` when the lease ends within a day, which + /// lifts silent renewal's quiet period after a slow signing. + func decision( + for source: ActivationSigningSource, + at date: Date, + renewalIsUrgent: Bool = false + ) -> ActivationSigningDecision { + switch source { + case .userAction: + return .allowed + case .licenseHandshake: + guard hasShownFirstWindow, isWaiting(at: date) == false else { return .deferred } + case .silentRenewal: + guard hasShownFirstWindow, + date >= launchedAt.addingTimeInterval(Self.launchDelay), + isWaiting(at: date) == false + else { return .deferred } + if renewalIsUrgent == false, let lastSlowAutomaticSigningAt, + date < lastSlowAutomaticSigningAt.addingTimeInterval(Self.slowSigningQuietPeriod) + { + return .deferred + } + } + return explainer == .awaitingConfirmation ? .needsExplainer : .allowed + } + + /// `true` when `source` may use the key at `date`. + func allowsSigning( + for source: ActivationSigningSource, + at date: Date, + renewalIsUrgent: Bool = false + ) -> Bool { + decision(for: source, at: date, renewalIsUrgent: renewalIsUrgent) == .allowed + } + + /// What the policy says about a license handshake on `channel` at `date`. + func handshakeDecision(onChannel channel: String?, at date: Date) -> ActivationSigningDecision { + if let channel, failedHandshakeChannels.contains(channel) { + return .deferred + } + return decision(for: .licenseHandshake, at: date) + } + + /// `true` when a license handshake may start on `channel` at `date`. + func allowsHandshake(onChannel channel: String?, at date: Date) -> Bool { + handshakeDecision(onChannel: channel, at: date) == .allowed + } + + /// `true` while automatic signing waits after a denied prompt. + func isKeychainAccessDenied(at date: Date) -> Bool { + guard let deniedUntil else { return false } + return date < deniedUntil + } + + private func isWaiting(at date: Date) -> Bool { + if isKeychainAccessDenied(at: date) { + return true + } + if let pausedUntil, date < pausedUntil { + return true + } + return false + } + + // MARK: Events + + mutating func noteFirstWindowShown() { + hasShownFirstWindow = true + } + + /// Records how a use of the key from `source` ended. `channel` is the + /// handshake's channel, if any. + mutating func record( + _ outcome: ActivationSigningOutcome, + from source: ActivationSigningSource, + channel: String? = nil, + at date: Date + ) { + switch outcome { + case let .succeeded(duration): + pausedUntil = nil + let signedWithoutPrompt = duration < Self.promptThreshold + let answeredByUser = source == .userAction || takeContinuedUse(from: source) + if answeredByUser || signedWithoutPrompt { + denialCount = 0 + deniedUntil = nil + isDenialFromExplainer = false + } + if signedWithoutPrompt { + noteKeySignsWithoutPrompt() + } else if source.isAutomatic { + lastSlowAutomaticSigningAt = date + isKeyAllowedOnce = true + } + if answeredByUser { + // The user answered the prompt themselves. + if explainer == .awaitingConfirmation { + explainer = .confirmed + isExplainerRequested = false + } + failedHandshakeChannels.removeAll() + deniedHandshakeChannels.removeAll() + } + case .denied: + _ = takeContinuedUse(from: source) + applyDenial(at: date) + noteHandshakeFailed(onChannel: channel) + if let channel { + deniedHandshakeChannels.insert(channel) + } + case .interactionNotAllowed: + pausedUntil = date.addingTimeInterval(Self.interactionNotAllowedDelay) + case .failed: + noteHandshakeFailed(onChannel: channel) + } + } + + private mutating func applyDenial(at date: Date, fromExplainer: Bool = false) { + denialCount += 1 + let backoff = Self.denialBackoff + deniedUntil = date.addingTimeInterval(backoff[min(denialCount, backoff.count) - 1]) + pausedUntil = nil + isDenialFromExplainer = fromExplainer + isKeyAllowedOnce = false + } + + /// `true`, once, for the first automatic use after Continue that answered + /// the system prompt. + private mutating func takeContinuedUse(from source: ActivationSigningSource) -> Bool { + guard source.isAutomatic, isContinuedUsePending else { return false } + isContinuedUsePending = false + return true + } + + /// Remembers that the handshake on `channel` failed, so it is not + /// repeated there. + mutating func noteHandshakeFailed(onChannel channel: String?) { + guard let channel else { return } + failedHandshakeChannels.insert(channel) + } + + mutating func noteChannelEnded(_ channel: String) { + failedHandshakeChannels.remove(channel) + deniedHandshakeChannels.remove(channel) + } + + /// The license was activated, renewed, replaced or lost: every channel + /// may try again. + mutating func noteActivationChanged() { + failedHandshakeChannels.removeAll() + deniedHandshakeChannels.removeAll() + } + + /// Ends the denial wait once `date` reached it: the channels whose + /// signing was denied may handshake again. The denial count stays, so + /// the next denial still escalates. + mutating func noteDenialWaitEnded(at date: Date) { + guard let deniedUntil, date >= deniedUntil else { return } + self.deniedUntil = nil + isDenialFromExplainer = false + failedHandshakeChannels.subtract(deniedHandshakeChannels) + deniedHandshakeChannels.removeAll() + } + + // MARK: Keychain explainer + + /// The key was created by the 2.3.x helper, has not been seen to sign + /// without a prompt and its explainer was never confirmed: automatic uses + /// wait for the explainer. + mutating func noteExplainerNeeded() { + guard keySignsWithoutPrompt == false, explainer == .notNeeded else { return } + explainer = .awaitingConfirmation + } + + /// The Host created the key, or the key signed without a prompt: its + /// access list already lists the Host, so no explainer is needed again. + mutating func noteKeySignsWithoutPrompt() { + keySignsWithoutPrompt = true + explainer = .notNeeded + isExplainerRequested = false + isContinuedUsePending = false + isKeyAllowedOnce = false + } + + /// An automatic use found the explainer missing: the Host should show it. + mutating func requestExplainer() { + guard explainer == .awaitingConfirmation else { return } + isExplainerRequested = true + } + + /// Continue: automatic uses may sign, and the next one that reaches the + /// keychain counts as the user's (it raises the prompt Continue + /// announced). Continue itself does not use the key, so it costs no + /// extra prompt. + mutating func confirmExplainer() { + isExplainerRequested = false + guard explainer == .awaitingConfirmation else { return } + explainer = .confirmed + isContinuedUsePending = true + } + + /// Not Now: counts as a denied prompt, without a system prompt. + mutating func declineExplainer(at date: Date) { + isExplainerRequested = false + guard explainer == .awaitingConfirmation else { return } + applyDenial(at: date, fromExplainer: true) + } +} + +/// What the license window and the Host show about the license key. +public struct ActivationSigningStatus: Sendable, Equatable { + /// Automatic signing waits after a denied keychain prompt until this + /// date. `nil` when it does not wait for a denial. + public let keychainAccessDeniedUntil: Date? + /// `true` once the Host showed its first window. + public let hasShownFirstWindow: Bool + /// Changes every time handshakes that failed or were held back may be + /// tried again. + public let handshakeGeneration: Int + /// `true` while an automatic use of a key LookInside did not create waits + /// for the keychain explainer. The Host shows it, then calls + /// `ActivationRuntime.confirmKeychainAccessExplainer()` or + /// `declineKeychainAccessExplainer()`. + public let needsKeychainAccessExplainer: Bool + /// `true` while the denial wait came from Not Now in the explainer: no + /// system prompt was shown, the user only postponed the key's use. + public let isKeychainAccessPostponed: Bool + /// `true` after an automatic signing waited on a prompt and succeeded (a + /// one-time Allow): each newly connected app will ask again until the + /// user picks Always Allow. + public let isKeychainAccessAllowedOnce: Bool + + public func isKeychainAccessDenied(at date: Date = Date()) -> Bool { + guard let keychainAccessDeniedUntil else { return false } + return date < keychainAccessDeniedUntil + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationStateCoding.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationStateCoding.swift new file mode 100644 index 00000000..cccbc7ed --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationStateCoding.swift @@ -0,0 +1,27 @@ +import Foundation + +/// JSON and date coding shared with the 2.3.x Auth helper. +/// +/// `state.json` is read and written by both the helper and the Host, so the +/// encoder settings here (ISO 8601 dates without fractional seconds, sorted +/// keys, no pretty printing) must not change. +enum ActivationStateCoding { + static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + nonisolated(unsafe) static let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationStateStore.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationStateStore.swift new file mode 100644 index 00000000..5956f27e --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/ActivationStateStore.swift @@ -0,0 +1,234 @@ +import CryptoKit +import Foundation + +/// Owns `state.json`. +/// +/// Other processes write the same file: a second LookInside instance, or the +/// 2.3.x helper after a downgrade. Every write therefore starts from what is +/// on disk when the file changed since this store last read or wrote it, and +/// `reloadIfChanged()` picks up a newer file, so one process never writes +/// back a stale snapshot over another one's newer state. +/// +/// A trial lives in memory only. A changed file replaces it only when the +/// file's own license material grants access (another process activated or +/// renewed); an expired or revoked license on disk, or a file that only +/// changed its telemetry, leaves the trial in place. +actor ActivationStateStore { + private let url: URL + private let now: @Sendable () -> Date + private var cachedState: ActivationPersistedState + /// The activation session was recorded as transient (a trial session + /// before its entitlement is known) and is not on disk. + private var holdsTransientSession = false + /// The file version this store last read or wrote. + private var diskVersion: DiskVersion? + + init(url: URL, now: @escaping @Sendable () -> Date = { Date() }) { + self.url = url + self.now = now + cachedState = Self.loadState(from: url) + diskVersion = DiskVersion(url: url) + } + + /// Starts from a state already read with `loadState(from:)`. + init(url: URL, initialState: ActivationPersistedState, now: @escaping @Sendable () -> Date = { Date() }) { + self.url = url + self.now = now + cachedState = initialState + diskVersion = DiskVersion(url: url) + } + + func snapshot() -> ActivationPersistedState { + cachedState + } + + /// Re-reads `state.json` when it changed since this store last read or + /// wrote it. Returns `true` when it adopted the file. + @discardableResult + func reloadIfChanged() -> Bool { + reloadFromDisk() + } + + func recordDeviceFingerprint(_ fingerprint: DeviceFingerprint) throws { + reloadFromDisk() + cachedState.deviceFingerprint = fingerprint + try persist() + } + + func recordActivationSession(_ session: ActivationSession) throws { + reloadFromDisk() + cachedState.activationSession = session + cachedState.updatedAt = Date() + holdsTransientSession = false + try persist() + } + + func recordTransientActivationSession(_ session: ActivationSession) throws { + reloadFromDisk() + cachedState.activationSession = session + cachedState.updatedAt = Date() + holdsTransientSession = true + try persist() + } + + func recordEntitlementStatus(_ status: EntitlementStatus) throws { + reloadFromDisk() + cachedState.entitlementStatus = status + cachedState.updatedAt = Date() + try persist() + } + + func recordIssuedLease(_ lease: IntermediateCertificateLease) throws { + reloadFromDisk() + guard let status = cachedState.entitlementStatus else { + return + } + cachedState.entitlementStatus = EntitlementStatus( + license: status.license.applying(lease: lease), + deviceBinding: status.deviceBinding, + currentLease: lease, + activationSession: cachedState.activationSession ?? status.activationSession, + isEligibleForActivation: status.isEligibleForActivation, + isEligibleForRenewal: status.isEligibleForRenewal, + requiresLiveRefresh: status.requiresLiveRefresh, + lastFetchedAt: Date(), + informationalMessage: status.informationalMessage, + warningMessage: status.warningMessage + ) + cachedState.updatedAt = Date() + try persist() + } + + func recordActivationResponse(_ response: HostActivationResponse) throws { + reloadFromDisk() + cachedState.activationResponse = response + cachedState.updatedAt = Date() + try persist() + } + + func recordRenewAttempt(success: Bool, error: String?, at date: Date = Date()) throws { + reloadFromDisk() + cachedState.lastRenewAttemptAt = date + if success { + cachedState.lastRenewSucceededAt = date + cachedState.lastRenewError = nil + } else { + cachedState.lastRenewFailedAt = date + cachedState.lastRenewError = error + } + cachedState.updatedAt = date + try persist() + } + + private var holdsMemoryOnlyMaterial: Bool { + holdsTransientSession || cachedState.entitlementStatus?.license.licenseClass == .trial + } + + private func persist() throws { + var diskState = cachedState.diskBackedState + if holdsTransientSession { + diskState.activationSession = nil + } + let data: Data + do { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + data = try ActivationStateCoding.encoder.encode(diskState) + try data.write(to: url, options: .atomic) + } catch { + throw ActivationError.stateStoreFailure(error.localizedDescription) + } + diskVersion = DiskVersion(url: url, contents: data) + } + + /// Adopts the file when it changed since this store last read or wrote + /// it (modification date, size, file number, then a content digest). A + /// file that is missing, unchanged or cannot be decoded leaves the cache + /// as it is. A trial held in memory survives unless the new file grants + /// access on its own. + @discardableResult + private func reloadFromDisk() -> Bool { + guard let stamp = DiskStamp(url: url), stamp != diskVersion?.stamp else { + return false + } + guard let data = try? Data(contentsOf: url) else { + return false + } + let version = DiskVersion(stamp: stamp, contents: data) + guard version.digest != diskVersion?.digest else { + diskVersion = version + return false + } + guard let diskState = try? ActivationStateCoding.decoder.decode(ActivationPersistedState.self, from: data) + else { + return false + } + diskVersion = version + var merged = diskState.diskBackedState + if holdsMemoryOnlyMaterial, Self.grantsAccess(merged, at: now()) == false { + merged.activationSession = cachedState.activationSession + merged.entitlementStatus = cachedState.entitlementStatus + merged.activationResponse = cachedState.activationResponse + } else { + holdsTransientSession = false + } + cachedState = merged + return true + } + + /// `true` when `state` holds license material that grants access at + /// `date`: an activation or renewal another process stored. + private static func grantsAccess(_ state: ActivationPersistedState, at date: Date) -> Bool { + ActivationAccessEvaluator.decision(from: state, evaluatedAt: date).grantsAccess + } + + static func loadState(from url: URL) -> ActivationPersistedState { + guard let data = try? Data(contentsOf: url), + let state = try? ActivationStateCoding.decoder.decode(ActivationPersistedState.self, from: data) + else { + return ActivationPersistedState() + } + return state.diskBackedState + } +} + +/// One version of `state.json`: its file attributes and a digest of its +/// contents. The attributes answer "changed?" cheaply; the digest tells a +/// rewrite with the same contents from a real change. +private struct DiskVersion { + let stamp: DiskStamp + let digest: SHA256.Digest + + init(stamp: DiskStamp, contents: Data) { + self.stamp = stamp + digest = SHA256.hash(data: contents) + } + + init?(url: URL, contents: Data? = nil) { + guard let stamp = DiskStamp(url: url), + let contents = contents ?? (try? Data(contentsOf: url)) + else { + return nil + } + self.init(stamp: stamp, contents: contents) + } +} + +/// Identifies one file on disk. An atomic write replaces the file, so the +/// file number changes even when the date and size do not. +private struct DiskStamp: Equatable { + let modificationDate: Date? + let size: Int? + let fileNumber: Int? + + init?(url: URL) { + guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) else { + return nil + } + modificationDate = attributes[.modificationDate] as? Date + size = (attributes[.size] as? NSNumber)?.intValue + fileNumber = (attributes[.systemFileNumber] as? NSNumber)?.intValue + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/DataHexCoding.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/DataHexCoding.swift new file mode 100644 index 00000000..b1b4cddb --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/DataHexCoding.swift @@ -0,0 +1,19 @@ +import Foundation + +extension Data { + /// Parses a lowercase or uppercase hex string into bytes. + /// Returns nil for odd-length or non-hex input. + init?(hexString: String) { + guard hexString.count % 2 == 0 else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(hexString.count / 2) + var index = hexString.startIndex + while index < hexString.endIndex { + let next = hexString.index(index, offsetBy: 2) + guard let byte = UInt8(hexString[index ..< next], radix: 16) else { return nil } + bytes.append(byte) + index = next + } + self.init(bytes) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/InMemoryReplayProtector.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/InMemoryReplayProtector.swift new file mode 100644 index 00000000..e411cc62 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/InMemoryReplayProtector.swift @@ -0,0 +1,18 @@ +import Foundation + +final class InMemoryReplayProtector: ReplayProtecting, @unchecked Sendable { + private let lock = NSLock() + private var expirations: [String: Date] = [:] + + func reserve(_ nonce: String, until expiresAt: Date) throws { + lock.lock() + defer { lock.unlock() } + + let now = Date() + expirations = expirations.filter { $0.value > now } + guard expirations[nonce] == nil else { + throw AuthenticatorError.replayDetected + } + expirations[nonce] = expiresAt + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/IntermediateKeyStore.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/IntermediateKeyStore.swift new file mode 100644 index 00000000..d350c41d --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/IntermediateKeyStore.swift @@ -0,0 +1,315 @@ +import Foundation +import Security + +/// Identifies the keychain item that holds the intermediate RSA-2048 key. +public struct IntermediateKeyStoreConfiguration: Sendable, Equatable { + /// `kSecAttrApplicationTag` of the private key. + public var applicationTag: String + /// Path of a file-based keychain to use instead of the default keychain + /// search list. `nil` (the default) uses the user's login keychain, as the + /// helper did. Tests pass a temporary keychain file. + public var keychainPath: String? + /// Applications, besides the current one, that a newly created key trusts + /// without a keychain prompt. Paths that do not exist when the key is + /// created, or whose code signature the key store does not accept, are + /// skipped. Existing keys are never changed. + public var trustedApplicationPaths: [String] + + public init( + applicationTag: String, + keychainPath: String? = nil, + trustedApplicationPaths: [String] = [] + ) { + self.applicationTag = applicationTag + self.keychainPath = keychainPath + self.trustedApplicationPaths = trustedApplicationPaths + } + + /// Tag the 2.3.x Auth helper used. Reusing it keeps existing keys. + public static let helperApplicationTag = "com.lookinside.authserver.intermediate-key" + + /// The installed 2.3.x Auth helper. A key the Host creates trusts it, so + /// an older LookInside that launches the helper after a downgrade can + /// sign without a keychain prompt. + public static var helperApplicationPath: String { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent( + "Library/Application Support/LookInside/AuthServer/current/lookinside-auth-server.app", + isDirectory: true + ) + .path + } + + public static var standard: IntermediateKeyStoreConfiguration { + IntermediateKeyStoreConfiguration( + applicationTag: helperApplicationTag, + trustedApplicationPaths: [helperApplicationPath] + ) + } +} + +/// The Security calls `IntermediateKeyStore` makes. Tests replace them to +/// simulate keychain statuses. +struct IntermediateKeychainOperations: Sendable { + var copyMatching: @Sendable (CFDictionary, UnsafeMutablePointer?) -> OSStatus + var createRandomKey: @Sendable (CFDictionary, UnsafeMutablePointer?>?) -> SecKey? + + static let system = IntermediateKeychainOperations( + copyMatching: { SecItemCopyMatching($0, $1) }, + createRandomKey: { SecKeyCreateRandomKey($0, $1) } + ) +} + +/// Keeps the intermediate RSA-2048 key in the file-based keychain and signs +/// with it. +/// +/// The query and key attributes match the helper's `AuthServerKeyMaterialStore` +/// exactly (no data-protection keychain, no access group), so the Host finds +/// the key the helper created and creates one the helper can find. The key is +/// generated only when the keychain reports that no key with the tag exists; +/// any other lookup failure (a denied or cancelled prompt, a locked keychain) +/// is thrown, so an existing key is never replaced by a second one. +final class IntermediateKeyStore: @unchecked Sendable { + let configuration: IntermediateKeyStoreConfiguration + private let operations: IntermediateKeychainOperations + private let trustedApplication: @Sendable (String) -> SecTrustedApplication? + private let didCreateKey: @Sendable () -> Void + private let lock = NSLock() + + /// `trustedApplication` turns an existing path from + /// `trustedApplicationPaths` into an access-list entry for a new key, or + /// refuses it with `nil`. By default only the 2.3.x helper signed by the + /// running Host's own team is accepted. `didCreateKey` runs after this + /// store created a key. + init( + configuration: IntermediateKeyStoreConfiguration = .standard, + operations: IntermediateKeychainOperations = .system, + trustedApplication: @escaping @Sendable (String) -> SecTrustedApplication? = + TrustedApplicationSignature.trustedHelperApplication(atPath:), + didCreateKey: @escaping @Sendable () -> Void = {} + ) { + self.configuration = configuration + self.operations = operations + self.trustedApplication = trustedApplication + self.didCreateKey = didCreateKey + } + + private var applicationTag: Data { + Data(configuration.applicationTag.utf8) + } + + func makeCertificateSigningRequestPEM(commonName: String) throws -> String { + let privateKey = try ensurePrivateKey() + do { + return try CertificateSigningRequestBuilder.makePKCS10PEM( + privateKey: privateKey, + commonName: commonName + ) + } catch let CertificateSigningRequestError.signingFailedWithStatus(status, message) { + throw ActivationError.keychain(status: status, message: message) + } catch let error as CertificateSigningRequestError { + throw ActivationError.keychainFailure(String(describing: error)) + } + } + + /// Signs `message` with the stored intermediate RSA-2048 key using + /// RSA-PKCS1v15-SHA256. Used for the 220/221 license handshake. + func sign(message: Data) throws -> Data { + let privateKey = try ensurePrivateKey() + return try Self.sign(message: message, with: privateKey) + } + + /// Signs `message` with the stored key and never creates one. Throws + /// `ActivationError.licenseNotActivated` when no key is stored. + func signWithExistingKey(message: Data) throws -> Data { + let existing = try lock.withLock { try existingPrivateKey() } + guard let existing else { + throw ActivationError.licenseNotActivated("No license key is stored on this Mac.") + } + return try Self.sign(message: message, with: existing) + } + + static func sign(message: Data, with privateKey: SecKey) throws -> Data { + var errorRef: Unmanaged? + guard + let signature = SecKeyCreateSignature( + privateKey, + .rsaSignatureMessagePKCS1v15SHA256, + message as CFData, + &errorRef + ) as Data? + else { + let error = errorRef?.takeRetainedValue() as Error? + let message = error?.localizedDescription ?? "SecKeyCreateSignature returned no signature." + if let error = error as NSError?, error.domain == NSOSStatusErrorDomain, + ActivationError.keychainAccessDeniedStatuses.contains(Int32(error.code)) + { + throw ActivationError.keychainAccessDenied(Int32(error.code)) + } + throw ActivationError.signingFailed(message) + } + return signature + } + + /// `true` when a key with the configured tag exists. Never creates one, + /// and answers `false` when the keychain cannot be searched. + func hasPrivateKey() -> Bool { + (try? existingPrivateKey()) != nil + } + + func ensurePrivateKey() throws -> SecKey { + lock.lock() + defer { lock.unlock() } + if let existing = try existingPrivateKey() { + return existing + } + var attributes = generationAttributes() + if let keychain = try openKeychain() { + attributes[kSecUseKeychain] = keychain + } + if let access = makeAccess() { + attributes[kSecAttrAccess] = access + } + var errorRef: Unmanaged? + guard let key = operations.createRandomKey(attributes as CFDictionary, &errorRef) else { + let message = + (errorRef?.takeRetainedValue() as Error?)?.localizedDescription + ?? "Private key creation returned no key reference." + throw ActivationError.keychainFailure(message) + } + didCreateKey() + return key + } + + /// The stored key's public-key hash (`kSecAttrApplicationLabel`), or + /// `nil` when no key is stored. Reads attributes only, so it never uses + /// the key or raises a keychain prompt. + func keyIdentity() throws -> Data? { + var query = lookupQuery() + query[kSecReturnRef] = nil + query[kSecReturnAttributes] = true + if let keychain = try openKeychain() { + query[kSecMatchSearchList] = [keychain] + } + var item: CFTypeRef? + let status = operations.copyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + let attributes = item as? [String: Any] + return attributes?[kSecAttrApplicationLabel as String] as? Data + case errSecItemNotFound: + return nil + default: + throw ActivationError.keychain( + status: status, + message: "SecItemCopyMatching failed with status \(status)." + ) + } + } + + /// The stored key, or `nil` only when the keychain answers + /// `errSecItemNotFound`. Every other failure throws. + func existingPrivateKey() throws -> SecKey? { + var query = lookupQuery() + if let keychain = try openKeychain() { + // An injected keychain that cannot be opened throws above + // instead of falling back to the default search list. + query[kSecMatchSearchList] = [keychain] + } + + var item: CFTypeRef? + let status = operations.copyMatching(query as CFDictionary, &item) + switch status { + case errSecSuccess: + guard let item, CFGetTypeID(item) == SecKeyGetTypeID() else { + throw ActivationError.keychainFailure("The keychain returned an item that is not a key.") + } + return (item as! SecKey) + case errSecItemNotFound: + return nil + default: + throw ActivationError.keychain( + status: status, + message: "SecItemCopyMatching failed with status \(status)." + ) + } + } + + /// Lookup query, identical to the helper's. + func lookupQuery() -> [CFString: Any] { + [ + kSecClass: kSecClassKey, + kSecAttrApplicationTag: applicationTag, + kSecAttrKeyType: kSecAttrKeyTypeRSA, + kSecReturnRef: true, + ] + } + + /// Key generation attributes, identical to the helper's. + func generationAttributes() -> [CFString: Any] { + [ + kSecAttrKeyType: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits: 2048, + kSecPrivateKeyAttrs: [ + kSecAttrIsPermanent: true, + kSecAttrApplicationTag: applicationTag, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ], + ] + } + + /// Access for a new key: trusted by the current application and by every + /// existing, accepted path in `trustedApplicationPaths`. `nil` keeps the + /// keychain's default access (only the creating application), which is + /// also the fallback when the access object cannot be built. + func makeAccess() -> SecAccess? { + let others: [SecTrustedApplication] = configuration.trustedApplicationPaths.compactMap { path in + guard FileManager.default.fileExists(atPath: path) else { return nil } + guard let application = trustedApplication(path) else { + ActivationLogger.runtime.error( + "license key access: skipping an application whose signature does not match LookInside" + ) + return nil + } + return application + } + guard others.isEmpty == false else { + return nil + } + var currentApplication: SecTrustedApplication? + guard SecTrustedApplicationCreateFromPath(nil, ¤tApplication) == errSecSuccess, + let currentApplication + else { + return nil + } + let applications = [currentApplication] + others + var access: SecAccess? + let status = SecAccessCreate( + Self.accessDescriptor as CFString, + applications as CFArray, + &access + ) + guard status == errSecSuccess else { + ActivationLogger.runtime.error( + "keychain access for the new key could not be created, status=\(status, privacy: .public)" + ) + return nil + } + return access + } + + /// Name the keychain shows for the key in access prompts. + static let accessDescriptor = "LookInside License Key" + + private func openKeychain() throws -> SecKeychain? { + guard let path = configuration.keychainPath else { + return nil + } + var keychain: SecKeychain? + let status = SecKeychainOpen(path, &keychain) + guard status == errSecSuccess, let keychain else { + throw ActivationError.keychainFailure("SecKeychainOpen failed with status \(status).") + } + return keychain + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/KeychainAccessMarkers.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/KeychainAccessMarkers.swift new file mode 100644 index 00000000..50d0d8bd --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/KeychainAccessMarkers.swift @@ -0,0 +1,67 @@ +import Foundation + +/// What the Host remembers about the license key between launches, to decide +/// whether the keychain explainer is needed. +/// +/// The markers live in the Host's own defaults, never in `state.json`, which +/// the 2.3.x helper also reads and writes. Each marker stores the identity +/// of the key it is about (the public-key hash, see +/// `IntermediateKeyStore.keyIdentity()`), so a marker for one key never +/// covers another: when the key changes, the explainer may show again. +/// +/// They only make the explainer go away: a key the Host created, or one +/// that signed without a prompt, has an access list that already lists the +/// Host; a key whose explainer the user confirmed with Continue does not get +/// it again, even when it still prompts (one-time Allow). +final class KeychainAccessMarkers: Sendable { + static let keyCreatedByHostKey = "LookInsideActivation.LicenseKeyCreatedByHost.KeyIdentity" + static let keySignedWithoutPromptKey = "LookInsideActivation.LicenseKeySignedWithoutPrompt.KeyIdentity" + static let explainerConfirmedKey = "LookInsideActivation.KeychainExplainerConfirmed.KeyIdentity" + + private let read: @Sendable (String) -> String? + private let write: @Sendable (String, String) -> Void + + /// Markers in `defaults` (the Host's standard defaults in the app). + convenience init(defaults: UserDefaults) { + nonisolated(unsafe) let defaults = defaults + self.init( + read: { defaults.string(forKey: $0) }, + write: { defaults.set($1, forKey: $0) } + ) + } + + /// Markers read and set through `read` and `write` (marker name, key + /// identity), for tests. + init( + read: @escaping @Sendable (String) -> String?, + write: @escaping @Sendable (String, String) -> Void + ) { + self.read = read + self.write = write + } + + /// `true` while the key with `identity` may still need the explainer: + /// the Host did not create it, it has not signed without a prompt, and + /// the user has not confirmed the explainer for it. + func mayNeedExplainer(forKey identity: String) -> Bool { + [Self.keyCreatedByHostKey, Self.keySignedWithoutPromptKey, Self.explainerConfirmedKey] + .allSatisfy { read($0) != identity } + } + + func noteKeyCreatedByHost(_ identity: String) { + note(Self.keyCreatedByHostKey, identity) + } + + func noteKeySignedWithoutPrompt(_ identity: String) { + note(Self.keySignedWithoutPromptKey, identity) + } + + func noteExplainerConfirmed(_ identity: String) { + note(Self.explainerConfirmedKey, identity) + } + + private func note(_ marker: String, _ identity: String) { + guard read(marker) != identity else { return } + write(marker, identity) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/LocalActivationMaterialIssuer.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/LocalActivationMaterialIssuer.swift new file mode 100644 index 00000000..7815b18b --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/LocalActivationMaterialIssuer.swift @@ -0,0 +1,47 @@ +import CryptoKit +import Foundation + +struct LocalActivationMaterialIssuer: ActivationMaterialIssuing { + private let clock: any Clock + + init(clock: any Clock = SystemClock()) { + self.clock = clock + } + + func issueActivation( + for challenge: ClientActivationChallenge, + license: LicenseEnvelope, + policy: ActivationPolicy, + secureTimestamp: SecureTimestampToken? + ) async throws -> SignedActivationEnvelope { + let issuedAt = clock.now() + let expiresAt = min( + issuedAt.addingTimeInterval(policy.activationLifetime), + license.certificateChain.intermediateExpiresAt + ) + let summary: [String: String] = [ + "license_id": license.licenseID, + "device_id": challenge.device.deviceID, + "issued_at": ActivationStateCoding.iso8601Formatter.string(from: issuedAt), + "expires_at": ActivationStateCoding.iso8601Formatter.string(from: expiresAt), + "timestamped": secureTimestamp == nil ? "false" : "true", + ] + let payloadData = try JSONSerialization.data(withJSONObject: summary, options: [.sortedKeys]) + let artifact = ActivationArtifact( + name: "activation.json", + payload: payloadData, + digest: SHA256.hash(data: payloadData).compactMap { String(format: "%02x", $0) }.joined() + ) + + return SignedActivationEnvelope( + activationID: UUID().uuidString.lowercased(), + challengeNonce: challenge.nonce, + licenseID: license.licenseID, + issuedAt: issuedAt, + expiresAt: expiresAt, + intermediateCertificateID: license.certificateChain.intermediateCertificateID, + boundUDID: challenge.device.deviceID, + artifacts: [artifact] + ) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/TrustedApplicationSignature.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/TrustedApplicationSignature.swift new file mode 100644 index 00000000..85dedeb3 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Runtime/TrustedApplicationSignature.swift @@ -0,0 +1,166 @@ +import Foundation +import Security + +/// Decides whether the application at a user-writable path may join a new +/// license key's access list. +/// +/// The installed 2.3.x helper lives under Application Support, where any +/// process of the user can replace it. It is trusted only when its code +/// signature satisfies the requirement the helper itself enforced: +/// - an Apple-issued chain (`anchor apple generic`); +/// - the helper's identifier, `app.lookinside.LookInsideAuthServer`; +/// - a Mac App Store leaf (OID 1.2.840.113635.100.6.1.9), or a Developer ID +/// intermediate (1.2.840.113635.100.6.2.6) with a Developer ID +/// Application leaf (1.2.840.113635.100.6.1.13); +/// - the same team as the running Host. The team is read from the Host's +/// own signature at run time, so no team identifier is built in. An ad-hoc +/// signed or unsigned Host has no team, and then nothing is trusted. +/// +/// The access-list entry is made first and the bundle checked after, at the +/// same path. The bundle's code-directory hash is read before and after; a +/// bundle that changed in between, or fails the check, is refused. +enum TrustedApplicationSignature { + static let helperIdentifier = "app.lookinside.LookInsideAuthServer" + + /// The Security calls the check makes. Tests replace them. + struct Operations: Sendable { + /// Code-directory hash of the bundle at a path, or `nil` when it is + /// not signed or not readable. + var codeDirectoryHash: @Sendable (_ path: String) -> Data? + /// Access-list entry for the application at a path. + var makeApplication: @Sendable (_ path: String) -> SecTrustedApplication? + /// Code-directory hash of the bundle at a path when its signature is + /// valid and satisfies a requirement, otherwise `nil`. + var validatedCodeDirectoryHash: @Sendable (_ path: String, _ requirement: String) -> Data? + + static let system = Operations( + codeDirectoryHash: { TrustedApplicationSignature.codeDirectoryHash(path: $0, requirement: nil) }, + makeApplication: { path in + var application: SecTrustedApplication? + guard SecTrustedApplicationCreateFromPath(path, &application) == errSecSuccess else { + return nil + } + return application + }, + validatedCodeDirectoryHash: { TrustedApplicationSignature.codeDirectoryHash(path: $0, requirement: $1) } + ) + } + + /// Code requirement for the helper signed by `teamID`, or `nil` when + /// `teamID` is missing or not a team identifier. + static func requirement(forTeamID teamID: String?, identifier: String = helperIdentifier) -> String? { + guard let teamID, isTeamIdentifier(teamID), isBundleIdentifier(identifier) else { + return nil + } + return [ + "anchor apple generic", + #"identifier "\#(identifier)""#, + #"certificate leaf[subject.OU] = "\#(teamID)""#, + "(certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */" + + " or certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */" + + " and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */)", + ].joined(separator: " and ") + } + + /// The access-list entry for the helper at `path` when a Host signed by + /// `hostTeamID` may trust it, otherwise `nil`. No Security call is made + /// when the Host has no team. + static func trustedApplication( + atPath path: String, + hostTeamID: String?, + operations: Operations = .system + ) -> SecTrustedApplication? { + guard let requirement = requirement(forTeamID: hostTeamID), + let before = operations.codeDirectoryHash(path), + let application = operations.makeApplication(path), + let after = operations.validatedCodeDirectoryHash(path, requirement), + before == after + else { + return nil + } + return application + } + + /// `trustedApplication(atPath:hostTeamID:operations:)` for the running + /// Host, checking the bundle's signature on disk. + static func trustedHelperApplication(atPath path: String) -> SecTrustedApplication? { + trustedApplication(atPath: path, hostTeamID: currentProcessTeamID()) + } + + /// Team identifier in the running process's signature, or `nil` for an + /// ad-hoc signed or unsigned process. + static func currentProcessTeamID() -> String? { + var code: SecCode? + guard SecCodeCopySelf([], &code) == errSecSuccess, let code else { + return nil + } + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, [], &staticCode) == errSecSuccess, let staticCode else { + return nil + } + return signingInformation(staticCode)?[kSecCodeInfoTeamIdentifier as String] as? String + } + + /// Code-directory hash of the code at `path` when its signature is valid + /// and, with a `requirement`, satisfies it. `nil` otherwise. + static func codeDirectoryHash(path: String, requirement: String?) -> Data? { + var staticCode: SecStaticCode? + guard + SecStaticCodeCreateWithPath(URL(fileURLWithPath: path) as CFURL, [], &staticCode) == errSecSuccess, + let staticCode + else { + return nil + } + var secRequirement: SecRequirement? + if let requirement { + guard SecRequirementCreateWithString(requirement as CFString, [], &secRequirement) == errSecSuccess, + secRequirement != nil + else { + return nil + } + } + let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSStrictValidate) + guard SecStaticCodeCheckValidity(staticCode, flags, secRequirement) == errSecSuccess else { + return nil + } + return signingInformation(staticCode)?[kSecCodeInfoUnique as String] as? Data + } + + /// `true` when the code at `path` has a valid signature that satisfies + /// `requirement`. + static func staticCodeSatisfies(path: String, requirement: String) -> Bool { + codeDirectoryHash(path: path, requirement: requirement) != nil + } + + private static func signingInformation(_ staticCode: SecStaticCode) -> [String: Any]? { + var information: CFDictionary? + guard + SecCodeCopySigningInformation( + staticCode, + SecCSFlags(rawValue: kSecCSSigningInformation), + &information + ) == errSecSuccess + else { + return nil + } + return information as? [String: Any] + } + + /// Ten upper-case letters or digits, so the value cannot change the + /// requirement's meaning. + private static func isTeamIdentifier(_ value: String) -> Bool { + value.count == 10 + && value.unicodeScalars.allSatisfy { scalar in + ("A" ... "Z").contains(scalar) || ("0" ... "9").contains(scalar) + } + } + + /// Letters, digits, dots and hyphens only. + private static func isBundleIdentifier(_ value: String) -> Bool { + value.isEmpty == false + && value.unicodeScalars.allSatisfy { scalar in + ("a" ... "z").contains(scalar) || ("A" ... "Z").contains(scalar) || ("0" ... "9").contains(scalar) + || scalar == "." || scalar == "-" + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivation/Server/ServerSecureTimestampService.swift b/Packages/LookInsideActivation/Sources/LookInsideActivation/Server/ServerSecureTimestampService.swift new file mode 100644 index 00000000..d03a992d --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivation/Server/ServerSecureTimestampService.swift @@ -0,0 +1,26 @@ +import Foundation + +public struct ServerSecureTimestampService: Sendable { + private let clock: any Clock + private let trustValidator: any TrustChainValidating + private let rootTimestampSigner: any RootTimestampSigning + + public init( + clock: any Clock = SystemClock(), + trustValidator: any TrustChainValidating = DateBoundTrustChainValidator(), + rootTimestampSigner: any RootTimestampSigning + ) { + self.clock = clock + self.trustValidator = trustValidator + self.rootTimestampSigner = rootTimestampSigner + } + + public func issueTimestamp( + for request: SecureTimestampRequest, + license: LicenseEnvelope + ) async throws -> SecureTimestampToken { + let now = clock.now() + try trustValidator.validate(license.certificateChain, at: now) + return try await rootTimestampSigner.sign(request: request, at: now) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationView.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationView.swift new file mode 100644 index 00000000..ee35c8b3 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationView.swift @@ -0,0 +1,79 @@ +import Foundation +import LookInsideActivation +import Observation +import SwiftUI +import WindowAnimation + +public struct ActivationView: View { + @Bindable private var model: ActivationModel + private let onDone: () -> Void + + /// `onDone` runs when the license summary's Done button is clicked. The + /// window controller passes a closure that closes the activation window. + public init(model: ActivationModel, onDone: @escaping () -> Void = {}) { + _model = Bindable(model) + self.onDone = onDone + } + + public var body: some View { + Group { + if model.isRefreshingLicenseStatus { + LicenseStatusRefreshProgressView( + message: model.licenseStatusRefreshMessage + ?? String(localized: "Refreshing license status…", bundle: .activationUI) + ) + } else if let status = model.entitlementStatus, + model.currentStep == .licenseStatus, + !model.isShowingActivationForm + { + LicenseSummaryView( + status: status, + isTrial: model.isTrialActive, + keychainAccessNotice: model.keychainAccessNotice, + offersKeychainAccessRetry: model.offersKeychainAccessRetry, + isRetryingKeychainAccess: model.isRetryingKeychainAccess, + onRetryKeychainAccess: { Task { await model.retryKeychainAccess() } }, + onDone: onDone, + onUpgradeFromTrial: { model.beginFullActivationFromTrial() } + ) + } else { + ActivationFormView(model: model) + } + } + .frame(width: 450) + .animation(.spring, value: model.activationResponse?.activation.activationID) + .animation(.spring, value: model.highlightedMessage) + .animation(.spring, value: model.isBusy) + .animation(.spring, value: model.isShowingActivationForm) + .animation(.spring, value: model.licenseStatusRefreshMessage) + .animation(.spring, value: model.keychainAccessNotice) + .modifier(WindowAnimationModifier()) + } +} + +private struct LicenseStatusRefreshProgressView: View { + private let spacing: CGFloat = 16 + + let message: String + + var body: some View { + VStack(alignment: .center, spacing: 0) { + HStack { + Text("License", bundle: .activationUI).bold() + Spacer() + } + .padding(spacing) + + Divider() + + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text(message) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(spacing) + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationWindowController.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationWindowController.swift new file mode 100644 index 00000000..9139ea26 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationWindowController.swift @@ -0,0 +1,130 @@ +#if os(macOS) + import AppKit + import Foundation + import LookInsideActivation + import SwiftUI + + @MainActor + public final class ActivationWindowController: NSWindowController, NSWindowDelegate { + public let model: ActivationModel + public let configuration: ActivationUIConfiguration + public let activationWindow: NSWindow + + public init( + model: ActivationModel, + configuration: ActivationUIConfiguration = .init() + ) { + self.model = model + self.configuration = configuration + + let closer = WindowCloser() + let hostingController = NSHostingController( + rootView: ActivationView(model: model, onDone: { closer.close() }) + ) + hostingController.sizingOptions = [.preferredContentSize] + let window = NSWindow(contentViewController: hostingController) + window.title = configuration.windowTitle + window.styleMask = [.titled, .closable, .miniaturizable] + window.isReleasedWhenClosed = false + activationWindow = window + closer.window = window + + super.init(window: window) + window.delegate = self + shouldCascadeWindows = true + } + + @available(*, unavailable) + public required init?(coder _: NSCoder) { + nil + } + + public func present() { + showWindow(nil) + ActivationWindowPlacement.centerWindowOnMouseScreen(activationWindow) + ActivationWindowPlacement.pinWindowAboveNormalWindows(activationWindow) + activationWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + /// Closes the activation window, as the license summary's Done button + /// does. The helper quit its process here; the Host keeps running. + public func dismiss() { + activationWindow.close() + } + + /// The helper quit when its window closed; inside the Host the window + /// only hides (`isReleasedWhenClosed` is `false`) and can be shown again. + public func windowShouldClose(_: NSWindow) -> Bool { + true + } + } + + /// Lets the SwiftUI view close the window that hosts it, which does not + /// exist yet when the view is created. + @MainActor + private final class WindowCloser { + weak var window: NSWindow? + + func close() { + window?.close() + } + } + + enum ActivationWindowPlacement { + struct ScreenBounds { + let frame: CGRect + let visibleFrame: CGRect + } + + @MainActor static func centerWindowOnMouseScreen(_ window: NSWindow) { + let bounds = screenBoundsContainingMouse( + NSEvent.mouseLocation, + screens: NSScreen.screens.map { + ScreenBounds(frame: $0.frame, visibleFrame: $0.visibleFrame) + } + ) + + guard let bounds else { + window.center() + return + } + + window.setFrame( + centeredFrame(for: window.frame, in: bounds.visibleFrame), + display: true + ) + } + + @MainActor static func pinWindowAboveNormalWindows(_ window: NSWindow) { + window.level = .floating + } + + static func screenBoundsContainingMouse( + _ mouseLocation: CGPoint, + screens: [ScreenBounds] + ) -> ScreenBounds? { + screens.first { $0.frame.contains(mouseLocation) } + } + + static func centeredFrame(for windowFrame: CGRect, in visibleFrame: CGRect) -> CGRect { + CGRect( + x: visibleFrame.midX - windowFrame.width / 2, + y: visibleFrame.midY - windowFrame.height / 2, + width: windowFrame.width, + height: windowFrame.height + ) + } + } +#else + import Foundation + + @available(iOS, unavailable) + @MainActor + public final class ActivationWindowController { + public init( + model _: ActivationModel, + configuration _: ActivationUIConfiguration = .init() + ) {} + } +#endif diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationWindowCoordinator.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationWindowCoordinator.swift new file mode 100644 index 00000000..19adafa1 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/ActivationWindowCoordinator.swift @@ -0,0 +1,279 @@ +import AppKit +import Foundation +import LookInsideActivation +import SwiftUI + +/// Alert the Host asks the activation UI to show (the helper's `ui.show_alert`). +public struct ActivationAlert: Equatable, Sendable { + public enum Style: String, Sendable { + case informational + case warning + case critical + } + + public let title: String + public let message: String + public let style: Style + + public init(title: String, message: String, style: Style) { + self.title = title + self.message = message + self.style = style + } +} + +/// Where an activation alert or prompt appears. +public enum ActivationAlertPresentation { + /// For a direct user action, such as a menu command: a sheet on the + /// activation window when it is visible, otherwise an app-modal alert. + case modal + /// For an alert raised by a background flow (a connection, a reload, a + /// detection). It never runs a modal loop, so inspection keeps going: a + /// sheet on the activation window when it is visible, else on `window` + /// when it is visible and has no sheet, else on the activation window, + /// which is shown for it. + case attached(to: NSWindow?) +} + +/// Owns the activation window and the activation prompts, replacing the +/// helper's `ui.*` RPCs. All windows are shown inside the Host process. +@MainActor +public final class ActivationWindowCoordinator { + private let runtime: ActivationRuntime + private let activationWindowController: ActivationWindowController + private var acknowledgedAlertKeys = Set() + private var presentedAlertKeys = Set() + private var keychainExplainerPanel: NSPanel? + + public var activationModel: ActivationModel { + activationWindowController.model + } + + public var activationWindow: NSWindow { + activationWindowController.activationWindow + } + + public init( + runtime: ActivationRuntime, + uiConfiguration: ActivationUIConfiguration? = nil + ) { + self.runtime = runtime + activationWindowController = ActivationWindowController( + model: runtime.activationModel(), + configuration: uiConfiguration + ?? ActivationUIConfiguration( + requestedFeature: runtime.configuration.requestedFeature, + frameworkVersion: runtime.configuration.frameworkVersion + ) + ) + } + + /// `ui.show_activation`. + public func showActivationWindow() async { + await runtime.restoreActivationModel() + activationWindowController.present() + } + + /// `ui.show_license`. + public func showLicenseWindow() async { + await runtime.restoreActivationModel() + activationWindowController.present() + } + + /// `license.refresh_status` with the helper's window behaviour: shows the + /// license window with a progress message while the refresh runs. + @discardableResult + public func refreshLicenseStatus() async throws -> ActivationAccessDecision { + await runtime.restoreActivationModel() + activationModel.beginLicenseStatusRefresh() + activationWindowController.present() + do { + let decision = try await runtime.refreshedDecision() + await runtime.restoreActivationModel() + activationModel.finishLicenseStatusRefresh() + return decision + } catch { + await runtime.restoreActivationModel() + activationModel.finishLicenseStatusRefresh() + throw error + } + } + + /// `ui.show_activation_prompt`: asks whether to activate and opens the + /// activation window on "Activate". + public func showActivationPrompt(presentation: ActivationAlertPresentation = .modal) { + let alert = NSAlert() + alert.messageText = String(localized: "Activate LookInside Pro?", bundle: .activationUI) + alert.informativeText = String( + localized: + "This target app includes SwiftUI views. Activate LookInside Pro to inspect SwiftUI view details.", + bundle: .activationUI + ) + alert.alertStyle = .informational + alert.addButton(withTitle: String(localized: "Activate", bundle: .activationUI)) + alert.addButton(withTitle: String(localized: "Later", bundle: .activationUI)) + alert.icon = NSApp.applicationIconImage + + let completion: (NSApplication.ModalResponse) -> Void = { [weak self] response in + guard response == .alertFirstButtonReturn else { return } + Task { [weak self] in + await self?.showActivationWindow() + } + } + + present(alert, presentation: presentation, completion: completion) + } + + /// Shows the keychain explainer for a license key LookInside did not + /// create, centred over `window` when it is visible. The panel does not + /// activate LookInside or take keyboard focus, so it cannot catch typing + /// meant for another window. Continue confirms the explainer and lets the + /// held-back use of the key run, which raises the system prompt; Not Now + /// counts as a denial. A panel already on screen is kept. + public func showKeychainAccessExplainer(over window: NSWindow?) { + guard keychainExplainerPanel == nil else { return } + let runtime = runtime + let panel = NSPanel( + contentRect: .zero, + styleMask: [.titled, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + let finish: @MainActor (Bool) -> Void = { [weak self, weak panel] confirmed in + panel?.close() + self?.keychainExplainerPanel = nil + if confirmed { + runtime.confirmKeychainAccessExplainer() + } else { + runtime.declineKeychainAccessExplainer() + } + } + panel.contentViewController = NSHostingController( + rootView: KeychainAccessExplainerView( + onContinue: { finish(true) }, + onNotNow: { finish(false) } + ) + ) + panel.title = "LookInside" + panel.becomesKeyOnlyIfNeeded = true + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.layoutIfNeeded() + if let window, window.isVisible { + let frame = window.frame + panel.setFrameOrigin( + NSPoint( + x: frame.midX - panel.frame.width / 2, + y: frame.midY - panel.frame.height / 2 + ) + ) + } else { + panel.center() + } + keychainExplainerPanel = panel + panel.orderFront(nil) + } + + /// `ui.show_alert`. An alert with the same style, title and message is + /// shown once until acknowledged, and not again after that. + public func showAlert( + _ payload: ActivationAlert, + presentation: ActivationAlertPresentation = .modal + ) { + let alertKey = payload.deduplicationKey + guard acknowledgedAlertKeys.contains(alertKey) == false, + presentedAlertKeys.contains(alertKey) == false + else { + return + } + presentedAlertKeys.insert(alertKey) + + let alert = NSAlert() + alert.messageText = Self.localizedPayloadString(payload.title) + alert.informativeText = Self.localizedPayloadString(payload.message) + alert.alertStyle = payload.style.nsAlertStyle + alert.icon = NSApp.applicationIconImage + alert.addButton(withTitle: String(localized: "OK", bundle: .activationUI)) + + let completion: (NSApplication.ModalResponse) -> Void = { [weak self] _ in + self?.acknowledgedAlertKeys.insert(alertKey) + self?.presentedAlertKeys.remove(alertKey) + } + + present(alert, presentation: presentation, completion: completion) + } + + private func present( + _ alert: NSAlert, + presentation: ActivationAlertPresentation, + completion: @escaping (NSApplication.ModalResponse) -> Void + ) { + let activationWindow = activationWindowController.activationWindow + switch Self.alertTarget( + for: presentation, + activationWindowIsVisible: activationWindow.isVisible + ) { + case .activationWindow: + alert.beginSheetModal(for: activationWindow, completionHandler: completion) + case let .sheet(window): + alert.beginSheetModal(for: window, completionHandler: completion) + case .shownActivationWindow: + activationWindowController.present() + alert.beginSheetModal(for: activationWindow, completionHandler: completion) + case .appModal: + NSApp.activate(ignoringOtherApps: true) + completion(alert.runModal()) + } + } + + enum AlertTarget: Equatable { + case activationWindow + case sheet(NSWindow) + case shownActivationWindow + case appModal + } + + /// Where `present(_:presentation:completion:)` puts an alert. Only + /// `.modal` ever ends in an app-modal alert. + static func alertTarget( + for presentation: ActivationAlertPresentation, + activationWindowIsVisible: Bool + ) -> AlertTarget { + if activationWindowIsVisible { + return .activationWindow + } + switch presentation { + case .modal: + return .appModal + case let .attached(window): + if let window, window.isVisible, window.attachedSheet == nil { + return .sheet(window) + } + return .shownActivationWindow + } + } + + private static func localizedPayloadString(_ value: String) -> String { + String(localized: String.LocalizationValue(value), bundle: .main) + } +} + +private extension ActivationAlert.Style { + var nsAlertStyle: NSAlert.Style { + switch self { + case .informational: + return .informational + case .warning: + return .warning + case .critical: + return .critical + } + } +} + +extension ActivationAlert { + var deduplicationKey: String { + "\(style.rawValue)\u{1f}\(title)\u{1f}\(message)" + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Bundle+ActivationUI.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Bundle+ActivationUI.swift new file mode 100644 index 00000000..ff8120bd --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Bundle+ActivationUI.swift @@ -0,0 +1,9 @@ +import Foundation +import LookInsideActivation + +extension Bundle { + /// The activation string catalog lives in the core module's resources. + static var activationUI: Bundle { + .activationResources + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/LicenseStatusBadge.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/LicenseStatusBadge.swift new file mode 100644 index 00000000..0015ceca --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/LicenseStatusBadge.swift @@ -0,0 +1,51 @@ +import LookInsideActivation +import SwiftUI + +struct LicenseStatusBadge: View { + let state: SummaryDisplayState + + var body: some View { + Text(label, bundle: .activationUI) + .font(.caption.weight(.medium)) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Capsule().fill(tint.opacity(0.15)) + ) + .foregroundStyle(tint) + } + + private var label: LocalizedStringKey { + switch state { + case .trial: + return "Trial" + case .subscriptionActive: + return "Subscription · Active" + case .subscriptionCanceled: + return "Subscription · Canceled" + case .subscriptionExpired: + return "Subscription · Expired" + case .subscriptionRefunded: + return "Subscription · Refunded" + case .subscriptionInReview: + return "Subscription · In Review" + @unknown default: + return "License" + } + } + + private var tint: Color { + switch state { + case .trial: + return .accentColor + case .subscriptionActive: + return .green + case .subscriptionCanceled, .subscriptionInReview: + return .orange + case .subscriptionExpired, .subscriptionRefunded: + return .red + @unknown default: + return .orange + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/LicenseStatusView.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/LicenseStatusView.swift new file mode 100644 index 00000000..236d8a17 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/LicenseStatusView.swift @@ -0,0 +1,41 @@ +import LookInsideActivation +import SwiftUI + +public struct LicenseStatusView: View { + private let spacing: CGFloat = 16 + + private let status: EntitlementStatus? + private let configuration: ActivationUIConfiguration + + public init( + status: EntitlementStatus?, + configuration: ActivationUIConfiguration = .init() + ) { + self.status = status + self.configuration = configuration + } + + public var body: some View { + VStack(alignment: .center, spacing: 0) { + HStack { + Text("License", bundle: .activationUI).bold() + Spacer() + } + .padding(spacing) + + Divider() + + if let status { + LicenseDetailRows(status: status) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(spacing) + } else { + Text("License status becomes available after activation.", bundle: .activationUI) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(spacing) + } + } + .frame(width: 450) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/ActivationFormView.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/ActivationFormView.swift new file mode 100644 index 00000000..7747f062 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/ActivationFormView.swift @@ -0,0 +1,86 @@ +import LookInsideActivation +import Observation +import SwiftUI + +struct ActivationFormView: View { + private let spacing: CGFloat = 16 + + @Bindable var model: ActivationModel + + var body: some View { + VStack(alignment: .center, spacing: 0) { + HStack { + Text("Activate", bundle: .activationUI).bold() + Spacer() + } + .padding(spacing) + + Divider() + + Group { + if model.isBusy { + HStack { + Text("Communicating with authorization server…", bundle: .activationUI) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + ProgressView().scaleEffect(0.5) + } + } else { + VStack(alignment: .leading, spacing: 8) { + LabeledRow(label: "Email") { + TextField("you@example.com", text: $model.email) + .lineLimit(1) + } + LabeledRow(label: "License Key") { + TextField("XXXXX-XXXXX-XXXXX-XXXXX-XXXXX", text: $model.licenseKey) + .font(.system(.body, design: .monospaced)) + .lineLimit(1) + } + } + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(spacing) + + if let message = model.highlightedMessage, !message.isEmpty { + Divider() + Text(message) + .foregroundColor(.red) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(spacing) + } + + Divider() + + HStack { + if model.isTrialActive { + Button(String(localized: "View Trial License", bundle: .activationUI)) { + model.returnToTrialSummary() + } + .buttonStyle(.bordered) + .disabled(model.isBusy) + } else { + Button(String(localized: "Start Trial", bundle: .activationUI)) { + Task { await model.runTrialFlow() } + } + .buttonStyle(.bordered) + .disabled(model.isBusy) + } + Spacer() + Button(String(localized: "Activate", bundle: .activationUI)) { + Task { await model.runActivationFlow() } + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(model.isBusy || model.email.isEmpty || model.licenseKey.isEmpty) + } + .padding(spacing) + } + .disabled(model.isBusy) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/KeychainAccessExplainerView.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/KeychainAccessExplainerView.swift new file mode 100644 index 00000000..46a9761f --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/KeychainAccessExplainerView.swift @@ -0,0 +1,42 @@ +import AppKit +import SwiftUI + +/// Tells an upgraded user what the coming keychain prompt is for before +/// LookInside first uses a license key it did not create. +struct KeychainAccessExplainerView: View { + let onContinue: () -> Void + let onNotNow: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 48, height: 48) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 8) { + Text("Allow Access to Your License Key", bundle: .activationUI) + .font(.headline) + Text( + "LookInside needs to use the license key saved when you activated. macOS will ask for keychain access next. Choose “Always Allow”, otherwise you will be asked again every time LookInside connects to an app.", + bundle: .activationUI + ) + .fixedSize(horizontal: false, vertical: true) + } + } + HStack { + Spacer() + Button(action: onNotNow) { + Text("Not Now", bundle: .activationUI) + } + .keyboardShortcut(.cancelAction) + Button(action: onContinue) { + Text("Continue", bundle: .activationUI) + } + .keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 420) + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LabeledRow.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LabeledRow.swift new file mode 100644 index 00000000..79c6eb8b --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LabeledRow.swift @@ -0,0 +1,17 @@ +import LookInsideActivation +import SwiftUI + +struct LabeledRow: View { + let label: LocalizedStringKey + @ViewBuilder let content: () -> Content + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(label, bundle: .activationUI) + .font(.callout) + .foregroundStyle(.secondary) + .frame(width: 72, alignment: .leading) + content() + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseDetailRow.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseDetailRow.swift new file mode 100644 index 00000000..1d4f090d --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseDetailRow.swift @@ -0,0 +1,27 @@ +import LookInsideActivation +import SwiftUI + +struct LicenseDetailRow: View { + let icon: String + let title: LocalizedStringKey + let value: String + var valueColor: Color = .primary + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Image(systemName: icon) + .foregroundStyle(.secondary) + .frame(width: 20) + VStack(alignment: .leading, spacing: 2) { + Text(title, bundle: .activationUI) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.callout) + .foregroundStyle(valueColor) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseDetailRows.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseDetailRows.swift new file mode 100644 index 00000000..c02c4020 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseDetailRows.swift @@ -0,0 +1,94 @@ +import LookInsideActivation +import SwiftUI + +/// Status-aware license detail block shared by `LicenseSummaryView` +/// (the activation flow's "you're in" panel) and the standalone +/// `LicenseStatusView` exposed to the host app. +struct LicenseDetailRows: View { + let status: EntitlementStatus + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + LicenseStatusBadge(state: status.displayState) + Spacer(minLength: 0) + } + + if let issuedTo = status.license.issuedTo, !issuedTo.isEmpty { + LicenseDetailRow( + icon: "person.crop.circle", + title: "Licensed to", + value: issuedTo + ) + } + + LicenseDetailRow( + icon: "calendar", + title: accessRowTitle, + value: accessRowValue + ) + + if let warning = status.warningMessage ?? status.license.deviceWarning, + !warning.isEmpty + { + LicenseDetailRow( + icon: "exclamationmark.triangle", + title: "Notice", + value: warning, + valueColor: .orange + ) + } + } + } + + private var accessRowTitle: LocalizedStringKey { + switch status.displayState { + case .trial: + return "Trial" + case .subscriptionActive: + return "Renews on" + case .subscriptionCanceled: + return "Access until" + case .subscriptionExpired, .subscriptionRefunded: + return "Ended on" + case .subscriptionInReview: + return "Access until" + @unknown default: + return "Access until" + } + } + + private var accessRowValue: String { + // Show the entitlement end date (subscription `current_period_end_date` + // or trial `expires_at`). Certificate leases are an internal renewal pulse. + let endsAt = status.entitlementEndsAt + switch status.displayState { + case let .trial(daysRemaining): + let startedAt = status.license.issuedAt.formatted(date: .long, time: .omitted) + guard let endsAt else { + return String(localized: "Started \(startedAt)", bundle: .activationUI) + } + let endedAt = endsAt.formatted(date: .long, time: .omitted) + if daysRemaining <= 0 { + return String(localized: "Started \(startedAt) · Ended \(endedAt)", bundle: .activationUI) + } + return String( + localized: "Started \(startedAt) · Ends \(endedAt) · \(daysRemaining) days left", bundle: .activationUI + ) + case .subscriptionActive, + .subscriptionCanceled, + .subscriptionExpired, + .subscriptionRefunded, + .subscriptionInReview: + guard let endsAt else { + return String(localized: "Never Expires", bundle: .activationUI) + } + return endsAt.formatted(date: .long, time: .omitted) + @unknown default: + guard let endsAt else { + return String(localized: "Never Expires", bundle: .activationUI) + } + return endsAt.formatted(date: .long, time: .omitted) + } + } +} diff --git a/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseSummaryView.swift b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseSummaryView.swift new file mode 100644 index 00000000..f202ba36 --- /dev/null +++ b/Packages/LookInsideActivation/Sources/LookInsideActivationUI/Views/LicenseSummaryView.swift @@ -0,0 +1,82 @@ +import ConfettiSwiftUI +import LookInsideActivation +import SwiftUI + +struct LicenseSummaryView: View { + private let spacing: CGFloat = 16 + + let status: EntitlementStatus + let isTrial: Bool + let keychainAccessNotice: String? + let offersKeychainAccessRetry: Bool + let isRetryingKeychainAccess: Bool + let onRetryKeychainAccess: () -> Void + let onDone: () -> Void + let onUpgradeFromTrial: () -> Void + + @State private var confetti: Int = 0 + @State private var hasFiredConfetti: Bool = false + + var body: some View { + VStack(alignment: .center, spacing: 0) { + HStack { + Text("License", bundle: .activationUI).bold() + Spacer() + } + .padding(spacing) + + Divider() + + LicenseDetailRows(status: status) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(spacing) + + if let keychainAccessNotice { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Label(keychainAccessNotice, systemImage: "key") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + if offersKeychainAccessRetry { + Button(String(localized: "Try Again", bundle: .activationUI), action: onRetryKeychainAccess) + .controlSize(.small) + .disabled(isRetryingKeychainAccess) + } + } + .padding([.horizontal, .bottom], spacing) + } + + Divider() + + HStack { + if isTrial { + Button( + String(localized: "Activate Full Version", bundle: .activationUI), action: onUpgradeFromTrial + ) + .buttonStyle(.bordered) + } + Spacer() + Button(String(localized: "Done", bundle: .activationUI), action: onDone) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + } + .padding(spacing) + } + .background( + Color.clear + .confettiCannon( + trigger: $confetti, + num: 60, + openingAngle: .degrees(0), + closingAngle: .degrees(360), + radius: 260 + ) + ) + .onAppear { + guard !hasFiredConfetti else { return } + hasFiredConfetti = true + confetti += 1 + } + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/ChallengeSignatureCompatibilityTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/ChallengeSignatureCompatibilityTests.swift new file mode 100644 index 00000000..5b70e057 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/ChallengeSignatureCompatibilityTests.swift @@ -0,0 +1,154 @@ +import Foundation +@testable import LookInsideActivation +import Security +import Testing + +/// `license.sign_challenge` and `license.check_access` produce exactly what +/// the 2.3.x helper produced for the same state, key and inputs. +struct ChallengeSignatureCompatibilityTests { + private func helperState() throws -> ActivationPersistedState { + try ActivationStateCoding.decoder.decode( + ActivationPersistedState.self, + from: HelperFixtures.data("helper-state-full.json") + ) + } + + @Test func checkAccessDecisionMatchesHelperBytes() throws { + let input = try HelperFixtures.challengeInput() + let decision = try ActivationAccessEvaluator.decision(from: helperState(), evaluatedAt: input.evaluatedAt) + + #expect( + try ActivationStateCoding.encoder.encode(decision) + == (HelperFixtures.data("helper-check-access.json")) + ) + } + + @Test func signatureMatchesHelperBytes() throws { + let input = try HelperFixtures.challengeInput() + let state = try helperState() + let decision = ActivationAccessEvaluator.decision(from: state, evaluatedAt: input.evaluatedAt) + let validated = try ActivationChallengeSigner.validate( + request: ActivationSignChallengeRequest( + nonce: input.nonce.map { String(format: "%02x", $0) }.joined(), + serverInstanceID: input.serverInstanceID + ), + snapshot: state, + decision: decision, + evaluatedAt: input.evaluatedAt + ) + let signature = try ActivationChallengeSignature( + signature: IntermediateKeyStore.sign( + message: validated.message, + with: HelperFixtures.testPrivateKey(input) + ), + intermediateCertificateDER: validated.certificateDER, + udid: validated.lease.udid + ) + + #expect( + try ActivationStateCoding.encoder.encode(signature.responsePayload) + == (HelperFixtures.data("helper-sign-challenge.json")) + ) + } + + @Test func expiredLeaseIsRefusedWithHelperCodeAndMessage() throws { + let input = try HelperFixtures.challengeInput() + let expected = try HelperFixtures.jsonObject("helper-sign-challenge-expired.json") + let state = try helperState() + let decision = ActivationAccessEvaluator.decision(from: state, evaluatedAt: input.expiredAt) + #expect(decision.decision.rawValue == expected["decision"] as? String) + + do { + _ = try ActivationChallengeSigner.validate( + request: ActivationSignChallengeRequest( + nonce: input.nonce.map { String(format: "%02x", $0) }.joined(), + serverInstanceID: input.serverInstanceID + ), + snapshot: state, + decision: decision, + evaluatedAt: input.expiredAt + ) + Issue.record("expected a refusal") + } catch let error as ActivationError { + #expect(error.errorCode == expected["code"] as? String) + #expect(error.localizedDescription == expected["message"] as? String) + } + } + + /// Full in-process path: the runtime reads the helper's `state.json`, + /// creates the key under its tag in an injected keychain and answers with + /// the helper's certificate and UDID and a signature over the same message + /// the helper signed. (Legacy keychains cannot take an imported key under + /// an application tag, so the byte-identical signature for a fixed key is + /// covered by `signatureMatchesHelperBytes`.) + @Test func runtimeSignChallengeMatchesHelperPayload() async throws { + let input = try HelperFixtures.challengeInput() + let keychain = try TemporaryKeychain() + defer { keychain.delete() } + let keyStoreConfiguration = IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)", + keychainPath: keychain.path + ) + + let directory = try HelperFixtures.makeStateDirectory(copying: "helper-state-full.json") + defer { try? FileManager.default.removeItem(at: directory) } + let runtime = ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration( + stateDirectoryURL: directory, + keyStore: keyStoreConfiguration + ), + urlSession: .shared, + now: { input.evaluatedAt }, + silentRenewalEnabled: false + ) + // The Host lets handshakes sign once its first window is up. + runtime.noteFirstWindowShown() + + let signature = try await runtime.signChallenge(nonce: input.nonce, serverInstanceID: input.serverInstanceID) + let helperPayload = try ActivationStateCoding.decoder.decode( + ActivationSignChallengeResponsePayload.self, + from: HelperFixtures.data("helper-sign-challenge.json") + ) + #expect(signature.responsePayload.intermediateCertDER == helperPayload.intermediateCertDER) + #expect(signature.responsePayload.udid == helperPayload.udid) + + var message = input.nonce + message.append(Data(input.serverInstanceID.utf8)) + let runtimePublicKey = try #require( + try SecKeyCopyPublicKey(Self.storedPrivateKey(keyStoreConfiguration, keychain: keychain)) + ) + #expect(Self.verify(signature.signature, message: message, publicKey: runtimePublicKey)) + let helperPublicKey = try #require(try SecKeyCopyPublicKey(HelperFixtures.testPrivateKey(input))) + let helperSignature = try #require(Data(base64Encoded: helperPayload.signature)) + #expect(Self.verify(helperSignature, message: message, publicKey: helperPublicKey)) + + let blocking = try await Task.detached { + try runtime.signChallengeBlocking(nonce: input.nonce, serverInstanceID: input.serverInstanceID) + }.value + #expect(blocking == signature) + } + + private static func storedPrivateKey( + _ configuration: IntermediateKeyStoreConfiguration, + keychain: TemporaryKeychain + ) throws -> SecKey { + var query = IntermediateKeyStore(configuration: configuration).lookupQuery() + query[kSecMatchSearchList] = try [#require(keychain.keychain)] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let item else { + throw HelperFixtures.FixtureError.malformed("stored key lookup failed: \(status)") + } + return item as! SecKey + } + + private static func verify(_ signature: Data, message: Data, publicKey: SecKey) -> Bool { + SecKeyVerifySignature( + publicKey, + .rsaSignatureMessagePKCS1v15SHA256, + message as CFData, + signature as CFData, + nil + ) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/KeyStoreCompatibilityTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/KeyStoreCompatibilityTests.swift new file mode 100644 index 00000000..718c2ce1 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/KeyStoreCompatibilityTests.swift @@ -0,0 +1,90 @@ +import Foundation +@testable import LookInsideActivation +import Security +import Testing + +/// The intermediate key must be found by, and findable from, the 2.3.x +/// helper: same tag, class, type, size and attributes, in the file-based +/// login keychain. +struct KeyStoreCompatibilityTests { + private static let helperTag = Data("com.lookinside.authserver.intermediate-key".utf8) + + /// `AuthServerKeyMaterialStore.existingPrivateKey()` query. + @Test func lookupQueryMatchesHelper() { + let store = IntermediateKeyStore(configuration: .standard) + let helperQuery: [CFString: Any] = [ + kSecClass: kSecClassKey, + kSecAttrApplicationTag: Self.helperTag, + kSecAttrKeyType: kSecAttrKeyTypeRSA, + kSecReturnRef: true, + ] + #expect(NSDictionary(dictionary: store.lookupQuery()) == NSDictionary(dictionary: helperQuery)) + } + + /// `AuthServerKeyMaterialStore.softwareKeyAttributes()`. + @Test func generationAttributesMatchHelper() { + let store = IntermediateKeyStore(configuration: .standard) + let helperAttributes: [CFString: Any] = [ + kSecAttrKeyType: kSecAttrKeyTypeRSA, + kSecAttrKeySizeInBits: 2048, + kSecPrivateKeyAttrs: [ + kSecAttrIsPermanent: true, + kSecAttrApplicationTag: Self.helperTag, + kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ], + ] + #expect( + NSDictionary(dictionary: store.generationAttributes()) == NSDictionary(dictionary: helperAttributes) + ) + } + + @Test func standardQueriesStayOnTheFileBasedKeychain() { + let store = IntermediateKeyStore(configuration: .standard) + let forbidden: [CFString] = [kSecUseDataProtectionKeychain, kSecAttrAccessGroup, kSecAttrSynchronizable] + for key in forbidden { + #expect(store.lookupQuery()[key] == nil) + #expect(store.generationAttributes()[key] == nil) + let privateAttributes = store.generationAttributes()[kSecPrivateKeyAttrs] as? [CFString: Any] + #expect(privateAttributes?[key] == nil) + } + #expect(IntermediateKeyStoreConfiguration.standard.keychainPath == nil) + } + + /// Creates the key only when absent and reuses it afterwards, in a + /// temporary keychain under a test-only tag. + @Test func generatesOnceThenReusesKeyInInjectedKeychain() throws { + let keychain = try TemporaryKeychain() + defer { keychain.delete() } + let store = IntermediateKeyStore( + configuration: IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)", + keychainPath: keychain.path + ) + ) + + #expect(store.hasPrivateKey() == false) + let message = Data("lookinside".utf8) + let first = try store.sign(message: message) + #expect(store.hasPrivateKey()) + let second = try store.sign(message: message) + // RSA-PKCS1v15 is deterministic: equal signatures mean the same key. + #expect(first == second) + #expect(first.count == 256) + + let csr = try store.makeCertificateSigningRequestPEM(commonName: "LookInside Device test") + #expect(csr.hasPrefix("-----BEGIN CERTIFICATE REQUEST-----")) + + let reopened = IntermediateKeyStore(configuration: store.configuration) + #expect(try reopened.sign(message: message) == first) + } + + @Test func unopenableInjectedKeychainNeverFallsBackToDefaultSearchList() { + let store = IntermediateKeyStore( + configuration: IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)", + keychainPath: "/nonexistent/LookInsideActivationTests/missing.keychain-db" + ) + ) + #expect(store.hasPrivateKey() == false) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/StateFileCompatibilityTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/StateFileCompatibilityTests.swift new file mode 100644 index 00000000..1f039187 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Compatibility/StateFileCompatibilityTests.swift @@ -0,0 +1,121 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +/// `state.json` is shared with the 2.3.x Auth helper: the new Host must read +/// what the helper wrote and write what the helper can read, byte for byte. +struct StateFileCompatibilityTests { + /// The synthetic old-user state (`make_state_json` in + /// `Scripts/reborn-old-user-vm.sh`) decodes and re-encodes with the same + /// keys and values. + @Test func oldUserStateRoundTripsKeyForKey() throws { + let original = try HelperFixtures.data("olduser-state.json") + let state = try ActivationStateCoding.decoder.decode(ActivationPersistedState.self, from: original) + let reencoded = try ActivationStateCoding.encoder.encode(state) + + #expect(try HelperFixtures.jsonObject(reencoded) == HelperFixtures.jsonObject(original)) + } + + /// The re-encoded old-user state is the exact bytes the helper's codec + /// produced for the same input. + @Test func oldUserStateReencodesToHelperBytes() throws { + let state = try ActivationStateCoding.decoder.decode( + ActivationPersistedState.self, + from: HelperFixtures.data("olduser-state.json") + ) + let reencoded = try ActivationStateCoding.encoder.encode(state) + + #expect(try reencoded == (HelperFixtures.data("olduser-state.helper-reencoded.json"))) + } + + /// A helper-written full-license state reads back without losing fields. + @Test(arguments: ["helper-state-full.json", "helper-state-trial.json"]) + func helperStateRoundTripsByteForByte(fixture: String) throws { + let original = try HelperFixtures.data(fixture) + let state = try ActivationStateCoding.decoder.decode(ActivationPersistedState.self, from: original) + + #expect(try ActivationStateCoding.encoder.encode(state) == original) + } + + @Test func storeLoadsHelperWrittenState() async throws { + let directory = try HelperFixtures.makeStateDirectory(copying: "helper-state-full.json") + defer { try? FileManager.default.removeItem(at: directory) } + + let store = ActivationStateStore(url: directory.appendingPathComponent("state.json")) + let snapshot = await store.snapshot() + + #expect(snapshot.deviceFingerprint == HelperFixtureValues.fingerprint) + #expect(snapshot.activationSession?.licenseID == "lic_fixture_full") + #expect(snapshot.entitlementStatus?.license.licenseClass == .full) + #expect(snapshot.entitlementStatus?.currentLease?.udid == HelperFixtureValues.udid) + #expect(snapshot.activationResponse?.activation.activationID == "act_fixture") + #expect(snapshot.lastRenewError == "offline") + } + + /// Replaying the generator's writes through the new store produces the + /// helper's file byte for byte. + @Test func storeWritesHelperBytesForFullLicense() async throws { + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("state.json") + + let base = HelperFixtureValues.base + let day = HelperFixtureValues.day + let pem = try HelperFixtureValues.certificatePEM() + let session = HelperFixtureValues.session(licenseID: "lic_fixture_full", token: "fixture-token-full") + let lease = HelperFixtureValues.lease(licenseID: "lic_fixture_full", expiresIn: 25 * day, certificatePEM: pem) + + let store = ActivationStateStore(url: url) + try await store.recordDeviceFingerprint(HelperFixtureValues.fingerprint) + try await store.recordActivationSession(session) + try await store.recordEntitlementStatus( + HelperFixtureValues.status(licenseClass: .full, lease: lease, session: session) + ) + try await store.recordActivationResponse(HelperFixtureValues.activationResponse(lease: lease)) + try await store.recordRenewAttempt(success: true, error: nil, at: base.addingTimeInterval(-3 * day)) + try await store.recordRenewAttempt(success: false, error: "offline", at: base.addingTimeInterval(-2 * day)) + + #expect(try Data(contentsOf: url) == (HelperFixtures.data("helper-state-full.json"))) + } + + /// A trial stays in memory: only the fingerprint and renewal telemetry + /// reach the disk, exactly as the helper wrote them. + @Test func storeKeepsTrialInMemoryLikeHelper() async throws { + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("state.json") + + let pem = try HelperFixtureValues.certificatePEM() + let session = HelperFixtureValues.session(licenseID: "lic_fixture_trial", token: "fixture-token-trial") + let lease = HelperFixtureValues.lease( + licenseID: "lic_fixture_trial", + expiresIn: 2 * HelperFixtureValues.day, + certificatePEM: pem + ) + + let store = ActivationStateStore(url: url) + try await store.recordDeviceFingerprint(HelperFixtureValues.fingerprint) + try await store.recordTransientActivationSession(session) + try await store.recordEntitlementStatus( + HelperFixtureValues.status(licenseClass: .trial, lease: lease, session: session) + ) + try await store.recordRenewAttempt(success: true, error: nil, at: HelperFixtureValues.base) + + #expect(try Data(contentsOf: url) == (HelperFixtures.data("helper-state-trial.json"))) + let inMemory = await store.snapshot() + #expect(inMemory.entitlementStatus?.license.licenseClass == .trial) + #expect(inMemory.activationSession == session) + } + + @Test func standardConfigurationUsesHelperLocations() { + let configuration = ActivationConfiguration.standard + #expect( + configuration.stateFileURL.path.hasSuffix( + "Library/Application Support/LookInside/AuthServer/state/state.json" + ) + ) + #expect(configuration.keyStore.applicationTag == "com.lookinside.authserver.intermediate-key") + #expect(configuration.keyStore.keychainPath == nil) + #expect(configuration.fingerprintAppBundleID == "app.lookinside.LookInsideAuthServer") + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/challenge-input.json b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/challenge-input.json new file mode 100644 index 00000000..9f5b65e1 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/challenge-input.json @@ -0,0 +1,7 @@ +{ + "evaluatedAt" : 1767225600, + "expiredAt" : 1769817600, + "nonceHex" : "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "serverInstanceID" : "srv-fixture-0001", + "testOnlyPrivateKeyPKCS1DERBase64" : "MIIEpQIBAAKCAQEA+SrVpz6O9l4K4Py5ymqsTSh8lSXPF5VMkyW305GidRraN2SwL2qd6EBS6AVJzjpKdJulidIimAMLhbo3gaTksHdbqt4VusQD4S06Q\/se6ZmV+phFFe6JpRBeqTbt4dnOxuwQ19NrDGOU7i363tSQeq0AUm1LC7WhhCDFwf2D37e4b0wYZv\/RCrs9FnjjSBOBfcyavijuH\/b74+ReE2OxX86B3j\/L31QaSi6npX3GnyQQSIAHxgWnUiY9dwXYK5fHfqrooSVZ\/IRIUQhRzxVZCsUxxHUOH\/wGAYOWgklWO\/TKDtmPlbVZNzAHQubWsY2XwSPNJCi\/MOoBBkiaTE5pmwIDAQABAoIBABKJZalL9b+aZRDL4tgn2EQW1cqt+QD2JUlx+uhAfQEqGD+BIojEhvuqUJpDGnSMBAU2alODtvmI8HVO0nMddoeYuZ2CnCpvpSI+Oe5CYzbAK4o5hDPXfshkgvmJXpuSZD6KsJrkRAiBHnYbV\/72Rv4smxa59TIcnYGCg3PYWm4GmJuD5zStYbapiWkdBaEDsBGISSd3dlMa5B1sDJWiJiENMXW8mz6d9ZBV0QtUBNd3VnwbXSh58afChxZMuyJOxHH\/umoX5mC91BCo490aNWQ3V1jb3nUHqZeWHomRlqRLvbcWNTezMDb2MqRZVaFE8K2Hk8ayD3Bc8+nJydON4ZECgYEA\/p5LDiHiGC1Jcxa8MXiwWH9W8LZ+frz3h0vt2sURhMX7d1QHXMzwYeceXu\/A\/6N0k6xUwiTkOadEF3GISnqhU+H1UL0FS6\/vBddeVtCXXS26cH897tWeiw7srpY3fzD0nqZiRYrXRnXX6bwg\/Ga3QPvCi5cyHMCU7mmtE4poA+0CgYEA+oT4E4Bq3W2acjdZoOwSGLHoYtLFHooFD+QWqt7SbZ\/uAKlyn1WeVDB64i2rS1OCOpYQs0rF9JUYGgGtcrDCAr8KFfqPeHmviJO9sgK7GKgZH2StISIuVsH2dgVO4iNtcfmjvFJ2\/0yMY\/YWa2jBx71y1GLXRUqR9y9+dUacAqcCgYEAycLtfrliy\/224jf10M3uD3J4li5Gdm+PRerpBB59D1VDOv+fBe5v3WSjMF+T8\/62df5s+5r4LqdY0arpcPtTs4CuP9hIxf0Xzu0mmGyaRP\/jaRmrHgKwq0icTIxEVcmZTc5SuQCYLgFbop+tE9ThCeF1+99HmnkQiwu6Vm2qltECgYEA1umHYEOEad0C6ATv0Qt0ItdMwqqKDD7fRNipUYDCCOVwagTznhj3MMY+RRZkosRLLjNatFTrKX1WJclWoNNePLji0CQZPIM3wPTtgW6yTt62XqYhmBEYq7jN1EWNfJZj56j\/fClKiI2EyqRsa0x9IPs3LVh10K2+Ns35sGYYpHUCgYEAmOY3mYtI0eHeRDf0Rp4K7S\/GdJddmOJLxJnW6YMUowo6kiy+GmQBkLy3F1Eg2V0Lm848+MVh3CQJzG3hSQV\/ZrdO51U4qcWzIUTQMYX73MNQFFKPXoUzY5hFwbNOHUXrn\/tYAu\/VCWRvtSDRn6ohD8\/K1IdaiW0o6NVGrU7gNAg=" +} \ No newline at end of file diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-check-access.json b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-check-access.json new file mode 100644 index 00000000..b85cc125 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-check-access.json @@ -0,0 +1 @@ +{"decision":"allow","message":"LookInside Pro is active for this machine.","status_summary":"License lic_fixture_full is active until 2026-01-26T00:00:00.000Z.","title":"Access Granted"} \ No newline at end of file diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-sign-challenge-expired.json b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-sign-challenge-expired.json new file mode 100644 index 00000000..832684bd --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-sign-challenge-expired.json @@ -0,0 +1 @@ +{"code":"license_not_activated","decision":"block","message":"License is not activated on this device.\nActivation has expired or is awaiting review; refresh license status before signing."} \ No newline at end of file diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-sign-challenge.json b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-sign-challenge.json new file mode 100644 index 00000000..3032068d --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-sign-challenge.json @@ -0,0 +1 @@ +{"intermediate_cert_der":"MIIDEDCCAfigAwIBAgIUKArZC3CprFYt7SBIOpctf9iOQiUwDQYJKoZIhvcNAQELBQAwKDEmMCQGA1UEAwwdTG9va0luc2lkZSBBY3RpdmF0aW9uIEZpeHR1cmUwHhcNMjYxMDA0MTUzNjMyWhcNMzYxMDAxMTUzNjMyWjAoMSYwJAYDVQQDDB1Mb29rSW5zaWRlIEFjdGl2YXRpb24gRml4dHVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPkq1ac+jvZeCuD8ucpqrE0ofJUlzxeVTJMlt9ORonUa2jdksC9qnehAUugFSc46SnSbpYnSIpgDC4W6N4Gk5LB3W6reFbrEA+EtOkP7HumZlfqYRRXuiaUQXqk27eHZzsbsENfTawxjlO4t+t7UkHqtAFJtSwu1oYQgxcH9g9+3uG9MGGb\/0Qq7PRZ440gTgX3Mmr4o7h\/2++PkXhNjsV\/Ogd4\/y99UGkoup6V9xp8kEEiAB8YFp1ImPXcF2CuXx36q6KElWfyESFEIUc8VWQrFMcR1Dh\/8BgGDloJJVjv0yg7Zj5W1WTcwB0Lm1rGNl8EjzSQovzDqAQZImkxOaZsCAwEAAaMyMDAwHQYDVR0OBBYEFChEIaF7Q1MlS3GYYIdEiualUjerMA8GA1UdEwEB\/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFbhLmwLyH28hlXQOXFt7Sejb2kZfIDJYYXhnjPwCzbyEW0jaoyIzBgdxOEOxGYTi7dPJxqSA3xmaZyYWxKRZtFIpe0qrJYdYAwWDr2A5HQCFPEPj9gcW4JksNitcJzWkfj1ktjJtAPKEMta7nZqFo4LhbRd444vnGRzZ3CHqDaIsfW\/CRLAafXYfJATsusZTj+BaWsTV6BQdp337q9\/u1IDj4QXzujt8UAzjPfca9JDK7ylGGZMUcP1tVgcgqFSFWBWmdRt1Q8O8pXOUm1e93DNUbQ9NWI7oj477siSn6M\/eah7WBJb7JdKcXYywEabFpl3CQRGqeei+iVsFiydSrU=","signature":"c71AdXLR1dUGzydJ6lqIwMB39noodskb7edSNKb56JGyTVG0MH+ztEvuLzZJOAD89IngXYh\/m+S+w9Rutj0ppK0iBhl3II\/wObEEyOFJX7nmIUO5DMmwRPDpvhio9XICxNEiVZV4qqvxIMPOmQ0PPPjEtXSNjqWu0pZ5aLfT6bopHWXUa+gjCpDuXO0yTVbGTZFvR1s5urUEvLqDvQ+vt5R3VFLfmjDQIKvF7Z1TjgwpcgCoX\/RKmOLSv5bgB\/t2OqCdP7qLjebGOSaXN1EN4qWeY+iKS7EaoQpObLWb95lIx2r6VdIhMDykOlu3veTLOgLdhQYv6wkwxfZzKeZvvA==","udid":"00000000-0000-4000-8000-00000000F1A7"} \ No newline at end of file diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-state-full.json b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-state-full.json new file mode 100644 index 00000000..a3f3da45 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-state-full.json @@ -0,0 +1 @@ +{"activationResponse":{"activation":{"activationID":"act_fixture","artifacts":[{"digest":"00ff","name":"activation.json","payload":"eyJkZXZpY2VfaWQiOiJmaXh0dXJlIiwibGljZW5zZV9pZCI6ImxpY19maXh0dXJlX2Z1bGwifQ=="}],"boundUDID":"00000000-0000-4000-8000-00000000F1A7","challengeNonce":"nonce_fixture","expiresAt":"2026-01-26T00:00:00Z","intermediateCertificateID":"int_fixture_lic_fixture_full","issuedAt":"2025-12-31T00:00:00Z","licenseID":"lic_fixture_full","signature":""},"path":"direct","secureTimestamp":{"challengeNonce":"nonce_fixture","rootCertificateID":"root_prod_2026","signature":"AQIDBA==","signedAt":"2025-12-31T00:00:00Z"}},"activationSession":{"createdAt":"2025-12-31T00:00:00Z","deviceIdentifier":"00000000-0000-4000-8000-00000000F1A7","email":"fixture@example.invalid","expiresAt":"2026-01-04T00:00:00Z","licenseID":"lic_fixture_full","orderID":"ord_fixture","sessionID":"ses_fixture_lic_fixture_full","token":"fixture-token-full"},"deviceFingerprint":{"appBundleID":"app.lookinside.LookInsideAuthServer","deviceID":"00000000-0000-4000-8000-00000000F1A7","hardwareModel":"Mac15,6","operatingSystemVersion":"Version 15.4 (Build 24E248)"},"entitlementStatus":{"activationSession":{"createdAt":"2025-12-31T00:00:00Z","deviceIdentifier":"00000000-0000-4000-8000-00000000F1A7","email":"fixture@example.invalid","expiresAt":"2026-01-04T00:00:00Z","licenseID":"lic_fixture_full","orderID":"ord_fixture","sessionID":"ses_fixture_lic_fixture_full","token":"fixture-token-full"},"currentLease":{"certificateID":"int_fixture_lic_fixture_full","certificatePEM":"-----BEGIN CERTIFICATE-----\nMIIDEDCCAfigAwIBAgIUKArZC3CprFYt7SBIOpctf9iOQiUwDQYJKoZIhvcNAQEL\nBQAwKDEmMCQGA1UEAwwdTG9va0luc2lkZSBBY3RpdmF0aW9uIEZpeHR1cmUwHhcN\nMjYxMDA0MTUzNjMyWhcNMzYxMDAxMTUzNjMyWjAoMSYwJAYDVQQDDB1Mb29rSW5z\naWRlIEFjdGl2YXRpb24gRml4dHVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC\nAQoCggEBAPkq1ac+jvZeCuD8ucpqrE0ofJUlzxeVTJMlt9ORonUa2jdksC9qnehA\nUugFSc46SnSbpYnSIpgDC4W6N4Gk5LB3W6reFbrEA+EtOkP7HumZlfqYRRXuiaUQ\nXqk27eHZzsbsENfTawxjlO4t+t7UkHqtAFJtSwu1oYQgxcH9g9+3uG9MGGb\/0Qq7\nPRZ440gTgX3Mmr4o7h\/2++PkXhNjsV\/Ogd4\/y99UGkoup6V9xp8kEEiAB8YFp1Im\nPXcF2CuXx36q6KElWfyESFEIUc8VWQrFMcR1Dh\/8BgGDloJJVjv0yg7Zj5W1WTcw\nB0Lm1rGNl8EjzSQovzDqAQZImkxOaZsCAwEAAaMyMDAwHQYDVR0OBBYEFChEIaF7\nQ1MlS3GYYIdEiualUjerMA8GA1UdEwEB\/wQFMAMBAf8wDQYJKoZIhvcNAQELBQAD\nggEBAFbhLmwLyH28hlXQOXFt7Sejb2kZfIDJYYXhnjPwCzbyEW0jaoyIzBgdxOEO\nxGYTi7dPJxqSA3xmaZyYWxKRZtFIpe0qrJYdYAwWDr2A5HQCFPEPj9gcW4JksNit\ncJzWkfj1ktjJtAPKEMta7nZqFo4LhbRd444vnGRzZ3CHqDaIsfW\/CRLAafXYfJAT\nsusZTj+BaWsTV6BQdp337q9\/u1IDj4QXzujt8UAzjPfca9JDK7ylGGZMUcP1tVgc\ngqFSFWBWmdRt1Q8O8pXOUm1e93DNUbQ9NWI7oj477siSn6M\/eah7WBJb7JdKcXYy\nwEabFpl3CQRGqeei+iVsFiydSrU=\n-----END CERTIFICATE-----\n","expiresAt":"2026-01-26T00:00:00Z","fullChainPEM":"-----BEGIN CERTIFICATE-----\nMIIDEDCCAfigAwIBAgIUKArZC3CprFYt7SBIOpctf9iOQiUwDQYJKoZIhvcNAQEL\nBQAwKDEmMCQGA1UEAwwdTG9va0luc2lkZSBBY3RpdmF0aW9uIEZpeHR1cmUwHhcN\nMjYxMDA0MTUzNjMyWhcNMzYxMDAxMTUzNjMyWjAoMSYwJAYDVQQDDB1Mb29rSW5z\naWRlIEFjdGl2YXRpb24gRml4dHVyZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC\nAQoCggEBAPkq1ac+jvZeCuD8ucpqrE0ofJUlzxeVTJMlt9ORonUa2jdksC9qnehA\nUugFSc46SnSbpYnSIpgDC4W6N4Gk5LB3W6reFbrEA+EtOkP7HumZlfqYRRXuiaUQ\nXqk27eHZzsbsENfTawxjlO4t+t7UkHqtAFJtSwu1oYQgxcH9g9+3uG9MGGb\/0Qq7\nPRZ440gTgX3Mmr4o7h\/2++PkXhNjsV\/Ogd4\/y99UGkoup6V9xp8kEEiAB8YFp1Im\nPXcF2CuXx36q6KElWfyESFEIUc8VWQrFMcR1Dh\/8BgGDloJJVjv0yg7Zj5W1WTcw\nB0Lm1rGNl8EjzSQovzDqAQZImkxOaZsCAwEAAaMyMDAwHQYDVR0OBBYEFChEIaF7\nQ1MlS3GYYIdEiualUjerMA8GA1UdEwEB\/wQFMAMBAf8wDQYJKoZIhvcNAQELBQAD\nggEBAFbhLmwLyH28hlXQOXFt7Sejb2kZfIDJYYXhnjPwCzbyEW0jaoyIzBgdxOEO\nxGYTi7dPJxqSA3xmaZyYWxKRZtFIpe0qrJYdYAwWDr2A5HQCFPEPj9gcW4JksNit\ncJzWkfj1ktjJtAPKEMta7nZqFo4LhbRd444vnGRzZ3CHqDaIsfW\/CRLAafXYfJAT\nsusZTj+BaWsTV6BQdp337q9\/u1IDj4QXzujt8UAzjPfca9JDK7ylGGZMUcP1tVgc\ngqFSFWBWmdRt1Q8O8pXOUm1e93DNUbQ9NWI7oj477siSn6M\/eah7WBJb7JdKcXYy\nwEabFpl3CQRGqeei+iVsFiydSrU=\n-----END CERTIFICATE-----\n","issuanceReason":"initialActivation","issuedAt":"2025-12-27T00:00:00Z","licenseID":"lic_fixture_full","publicKeyPEM":"-----BEGIN PUBLIC KEY-----\nZml4dHVyZQ==\n-----END PUBLIC KEY-----\n","renewAfter":"2026-01-21T00:00:00Z","status":"active","udid":"00000000-0000-4000-8000-00000000F1A7"},"deviceBinding":{"appBundleID":"app.lookinside.LookInsideAuthServer","firstSeenAt":"2025-08-04T00:00:00Z","hardwareModel":"Mac15,6","lastSeenAt":"2025-12-31T00:00:00Z","licenseID":"lic_fixture_full","machineName":"Fixture Mac","operatingSystemVersion":"Version 15.4 (Build 24E248)","status":"active","udid":"00000000-0000-4000-8000-00000000F1A7"},"informationalMessage":"Fixture info","isEligibleForActivation":true,"isEligibleForRenewal":true,"lastFetchedAt":"2025-12-31T00:00:00Z","license":{"audience":"personal","certificateChain":{"boundUDID":"00000000-0000-4000-8000-00000000F1A7","intermediateCertificateID":"int_fixture_lic_fixture_full","intermediateExpiresAt":"2026-01-26T00:00:00Z","intermediateIssuedAt":"2025-12-27T00:00:00Z","renewAfter":"2026-01-21T00:00:00Z","rootCertificateID":"root_prod_2026"},"expiresAt":"2026-08-04T00:00:00Z","issuedAt":"2025-08-04T00:00:00Z","issuedTo":"fixture@example.invalid","licenseClass":"full","licenseID":"lic_fixture_full","lifecycleState":"active","nextCertificateRenewalAt":"2026-01-21T00:00:00Z","serviceEndsAt":"2026-08-04T00:00:00Z"},"requiresLiveRefresh":false},"lastRenewAttemptAt":"2025-12-30T00:00:00Z","lastRenewError":"offline","lastRenewFailedAt":"2025-12-30T00:00:00Z","lastRenewSucceededAt":"2025-12-29T00:00:00Z","updatedAt":"2025-12-30T00:00:00Z"} \ No newline at end of file diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-state-trial.json b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-state-trial.json new file mode 100644 index 00000000..924f46ad --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/helper-state-trial.json @@ -0,0 +1 @@ +{"deviceFingerprint":{"appBundleID":"app.lookinside.LookInsideAuthServer","deviceID":"00000000-0000-4000-8000-00000000F1A7","hardwareModel":"Mac15,6","operatingSystemVersion":"Version 15.4 (Build 24E248)"},"lastRenewAttemptAt":"2026-01-01T00:00:00Z","lastRenewSucceededAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z"} \ No newline at end of file diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/olduser-state.helper-reencoded.json b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/olduser-state.helper-reencoded.json new file mode 100644 index 00000000..7e6b1f73 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/olduser-state.helper-reencoded.json @@ -0,0 +1 @@ +{"activationSession":{"createdAt":"2025-12-31T00:00:00Z","deviceIdentifier":"00000000-0000-4000-8000-0000000001D0","email":"old-user@example.invalid","expiresAt":"2026-01-04T00:00:00Z","licenseID":"lic_reborn_olduser_fixture","orderID":"ord_reborn_olduser_fixture","sessionID":"ses_reborn_olduser_fixture","token":"reborn-fixture-token-not-valid"},"deviceFingerprint":{"appBundleID":"app.lookinside.LookInsideAuthServer","deviceID":"00000000-0000-4000-8000-0000000001D0","hardwareModel":"VirtualMac2,1","operatingSystemVersion":"Version 15.4 (Build 24E248)"},"entitlementStatus":{"activationSession":{"createdAt":"2025-12-31T00:00:00Z","deviceIdentifier":"00000000-0000-4000-8000-0000000001D0","email":"old-user@example.invalid","expiresAt":"2026-01-04T00:00:00Z","licenseID":"lic_reborn_olduser_fixture","orderID":"ord_reborn_olduser_fixture","sessionID":"ses_reborn_olduser_fixture","token":"reborn-fixture-token-not-valid"},"currentLease":{"certificateID":"int_reborn_olduser_fixture","certificatePEM":"-----BEGIN CERTIFICATE-----\nUkVCT1JOLUZJWFRVUkU=\n-----END CERTIFICATE-----\n","expiresAt":"2026-01-06T00:00:00Z","fullChainPEM":"-----BEGIN CERTIFICATE-----\nUkVCT1JOLUZJWFRVUkU=\n-----END CERTIFICATE-----\n","issuanceReason":"renewal","issuedAt":"2025-12-07T00:00:00Z","licenseID":"lic_reborn_olduser_fixture","publicKeyPEM":"-----BEGIN PUBLIC KEY-----\nUkVCT1JOLUZJWFRVUkU=\n-----END PUBLIC KEY-----\n","renewAfter":"2025-12-08T00:00:00Z","status":"active","udid":"00000000-0000-4000-8000-0000000001D0"},"deviceBinding":{"appBundleID":"app.lookinside.LookInsideAuthServer","firstSeenAt":"2025-08-04T00:00:00Z","hardwareModel":"VirtualMac2,1","lastSeenAt":"2025-12-31T00:00:00Z","licenseID":"lic_reborn_olduser_fixture","operatingSystemVersion":"Version 15.4 (Build 24E248)","status":"active","udid":"00000000-0000-4000-8000-0000000001D0"},"isEligibleForActivation":true,"isEligibleForRenewal":true,"lastFetchedAt":"2025-12-31T00:00:00Z","license":{"audience":"personal","certificateChain":{"boundUDID":"00000000-0000-4000-8000-0000000001D0","intermediateCertificateID":"int_reborn_olduser_fixture","intermediateExpiresAt":"2026-01-06T00:00:00Z","intermediateIssuedAt":"2025-12-07T00:00:00Z","renewAfter":"2025-12-08T00:00:00Z","rootCertificateID":"rootProd2026"},"expiresAt":"2026-08-04T00:00:00Z","issuedAt":"2025-08-04T00:00:00Z","issuedTo":"old-user@example.invalid","licenseClass":"full","licenseID":"lic_reborn_olduser_fixture","lifecycleState":"active","nextCertificateRenewalAt":"2025-12-08T00:00:00Z","serviceEndsAt":"2026-08-04T00:00:00Z"},"requiresLiveRefresh":false},"lastRenewAttemptAt":"2025-12-07T00:00:00Z","lastRenewSucceededAt":"2025-12-07T00:00:00Z","updatedAt":"2025-12-31T00:00:00Z"} \ No newline at end of file diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/olduser-state.json b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/olduser-state.json new file mode 100644 index 00000000..cfe26df2 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Fixtures/olduser-state.json @@ -0,0 +1 @@ +{"activationSession": {"createdAt": "2025-12-31T00:00:00Z", "deviceIdentifier": "00000000-0000-4000-8000-0000000001D0", "email": "old-user@example.invalid", "expiresAt": "2026-01-04T00:00:00Z", "licenseID": "lic_reborn_olduser_fixture", "orderID": "ord_reborn_olduser_fixture", "sessionID": "ses_reborn_olduser_fixture", "token": "reborn-fixture-token-not-valid"}, "deviceFingerprint": {"appBundleID": "app.lookinside.LookInsideAuthServer", "deviceID": "00000000-0000-4000-8000-0000000001D0", "hardwareModel": "VirtualMac2,1", "operatingSystemVersion": "Version 15.4 (Build 24E248)"}, "entitlementStatus": {"activationSession": {"createdAt": "2025-12-31T00:00:00Z", "deviceIdentifier": "00000000-0000-4000-8000-0000000001D0", "email": "old-user@example.invalid", "expiresAt": "2026-01-04T00:00:00Z", "licenseID": "lic_reborn_olduser_fixture", "orderID": "ord_reborn_olduser_fixture", "sessionID": "ses_reborn_olduser_fixture", "token": "reborn-fixture-token-not-valid"}, "currentLease": {"certificateID": "int_reborn_olduser_fixture", "certificatePEM": "-----BEGIN CERTIFICATE-----\nUkVCT1JOLUZJWFRVUkU=\n-----END CERTIFICATE-----\n", "expiresAt": "2026-01-06T00:00:00Z", "fullChainPEM": "-----BEGIN CERTIFICATE-----\nUkVCT1JOLUZJWFRVUkU=\n-----END CERTIFICATE-----\n", "issuanceReason": "renewal", "issuedAt": "2025-12-07T00:00:00Z", "licenseID": "lic_reborn_olduser_fixture", "publicKeyPEM": "-----BEGIN PUBLIC KEY-----\nUkVCT1JOLUZJWFRVUkU=\n-----END PUBLIC KEY-----\n", "renewAfter": "2025-12-08T00:00:00Z", "status": "active", "udid": "00000000-0000-4000-8000-0000000001D0"}, "deviceBinding": {"appBundleID": "app.lookinside.LookInsideAuthServer", "firstSeenAt": "2025-08-04T00:00:00Z", "hardwareModel": "VirtualMac2,1", "lastSeenAt": "2025-12-31T00:00:00Z", "licenseID": "lic_reborn_olduser_fixture", "operatingSystemVersion": "Version 15.4 (Build 24E248)", "status": "active", "udid": "00000000-0000-4000-8000-0000000001D0"}, "isEligibleForActivation": true, "isEligibleForRenewal": true, "lastFetchedAt": "2025-12-31T00:00:00Z", "license": {"audience": "personal", "certificateChain": {"boundUDID": "00000000-0000-4000-8000-0000000001D0", "intermediateCertificateID": "int_reborn_olduser_fixture", "intermediateExpiresAt": "2026-01-06T00:00:00Z", "intermediateIssuedAt": "2025-12-07T00:00:00Z", "renewAfter": "2025-12-08T00:00:00Z", "rootCertificateID": "rootProd2026"}, "expiresAt": "2026-08-04T00:00:00Z", "issuedAt": "2025-08-04T00:00:00Z", "issuedTo": "old-user@example.invalid", "licenseClass": "full", "licenseID": "lic_reborn_olduser_fixture", "lifecycleState": "active", "nextCertificateRenewalAt": "2025-12-08T00:00:00Z", "serviceEndsAt": "2026-08-04T00:00:00Z"}, "requiresLiveRefresh": false}, "lastRenewAttemptAt": "2025-12-07T00:00:00Z", "lastRenewSucceededAt": "2025-12-07T00:00:00Z", "updatedAt": "2025-12-31T00:00:00Z"} \ No newline at end of file diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationAccessEvaluatorTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationAccessEvaluatorTests.swift new file mode 100644 index 00000000..a5f62121 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationAccessEvaluatorTests.swift @@ -0,0 +1,392 @@ +import Foundation +@testable import LookInsideActivation +@testable import LookInsideActivationUI +import Testing + +struct ActivationAccessEvaluatorTests { + @Test func emptyStateRequiresActivation() { + let decision = ActivationAccessEvaluator.decision( + from: ActivationPersistedState(), + evaluatedAt: TestData.baseNow + ) + #expect(decision.decision == .block) + #expect(decision.title == ActivationAccessDecision.activationRequired.title) + } + + @Test func expiredLicenseBlocksAccess() { + let license = Self.makeLicense( + expiresAt: TestData.baseNow.addingTimeInterval(-10) + ) + let status = TestData.makeEntitlementStatus(license: license) + var state = ActivationPersistedState() + state.entitlementStatus = status + + let decision = ActivationAccessEvaluator.decision( + from: state, + evaluatedAt: TestData.baseNow + ) + + #expect(decision.decision == .block) + #expect(decision.title == String(localized: "License Expired")) + } + + @Test func warningMessagePromotesAllowToAllowWithWarning() { + let license = Self.makeLicense() + let status = TestData.makeEntitlementStatus( + license: license, + warningMessage: "Review the license terms" + ) + var state = ActivationPersistedState() + state.entitlementStatus = status + + let decision = ActivationAccessEvaluator.decision( + from: state, + evaluatedAt: TestData.baseNow + ) + + #expect(decision.decision == .allowWithWarning) + #expect(decision.message == "Review the license terms") + } + + @Test func connectivityWarningTakesPriorityOverGenericWarning() { + let now = TestData.baseNow + let endsAt = now.addingTimeInterval(3 * 24 * 60 * 60) + let license = Self.makeLicense(expiresAt: endsAt) + let lease = TestData.makeIntermediateCertificateLease( + issuedAt: now.addingTimeInterval(-30 * 24 * 60 * 60), + expiresAt: endsAt, + renewAfter: now.addingTimeInterval(-29 * 24 * 60 * 60) + ) + let status = TestData.makeEntitlementStatus( + license: license, + currentLease: lease, + warningMessage: "Review the license terms" + ) + var state = ActivationPersistedState() + state.entitlementStatus = status + state.lastRenewSucceededAt = now.addingTimeInterval(-7 * 24 * 60 * 60) + state.lastRenewFailedAt = now.addingTimeInterval(-60) + + let decision = ActivationAccessEvaluator.decision(from: state, evaluatedAt: now) + + #expect(decision.decision == .allowWithWarning) + #expect(decision.message != "Review the license terms") + let dateString = endsAt.formatted(date: .abbreviated, time: .omitted) + #expect(decision.message.contains(dateString)) + } + + @Test func freshLicenseAllowsAccess() { + let license = Self.makeLicense() + let status = TestData.makeEntitlementStatus(license: license) + var state = ActivationPersistedState() + state.entitlementStatus = status + + let decision = ActivationAccessEvaluator.decision( + from: state, + evaluatedAt: TestData.baseNow + ) + + #expect(decision.decision == .allow) + #expect(decision.title == ActivationAccessDecision.allowedTitle) + } + + @Test func refundedLifecycleBlocksAccess() { + let license = Self.makeLicense(lifecycleState: .refunded) + let status = TestData.makeEntitlementStatus(license: license) + var state = ActivationPersistedState() + state.entitlementStatus = status + + let decision = ActivationAccessEvaluator.decision( + from: state, + evaluatedAt: TestData.baseNow + ) + + #expect(decision.decision == .block) + #expect(decision.title == String(localized: "License Expired")) + } + + @Test func expiredLifecycleBlocksAccessEvenWithFutureExpiry() { + let license = Self.makeLicense( + lifecycleState: .expired, + expiresAt: TestData.baseNow.addingTimeInterval(3600) + ) + let status = TestData.makeEntitlementStatus(license: license) + var state = ActivationPersistedState() + state.entitlementStatus = status + + let decision = ActivationAccessEvaluator.decision( + from: state, + evaluatedAt: TestData.baseNow + ) + + #expect(decision.decision == .block) + #expect(decision.title == String(localized: "License Expired")) + } + + @Test func pendingReviewLifecycleRequiresReactivation() { + let license = Self.makeLicense(lifecycleState: .pendingReview) + let status = TestData.makeEntitlementStatus(license: license) + var state = ActivationPersistedState() + state.entitlementStatus = status + + let decision = ActivationAccessEvaluator.decision( + from: state, + evaluatedAt: TestData.baseNow + ) + + #expect(decision.decision == .block) + #expect(decision.title == ActivationAccessDecision.activationRequired.title) + } + + @Test func canceledLifecycleAllowsAccessUntilPeriodEnd() { + let license = Self.makeLicense(lifecycleState: .canceled) + let status = TestData.makeEntitlementStatus(license: license) + var state = ActivationPersistedState() + state.entitlementStatus = status + + let decision = ActivationAccessEvaluator.decision( + from: state, + evaluatedAt: TestData.baseNow + ) + + #expect(decision.decision == .allow) + } + + @Test func connectivityWarningTriggersWhenRenewerHasFailedAndExpiryIsNear() { + let now = TestData.baseNow + let endsAt = now.addingTimeInterval(3 * 24 * 60 * 60) // 3d < 7d full threshold + let license = Self.makeLicense(expiresAt: endsAt) + let lease = TestData.makeIntermediateCertificateLease( + issuedAt: now.addingTimeInterval(-30 * 24 * 60 * 60), + expiresAt: endsAt, + renewAfter: now.addingTimeInterval(-29 * 24 * 60 * 60) + ) + let status = TestData.makeEntitlementStatus(license: license, currentLease: lease) + var state = ActivationPersistedState() + state.entitlementStatus = status + state.lastRenewSucceededAt = now.addingTimeInterval(-7 * 24 * 60 * 60) + state.lastRenewFailedAt = now.addingTimeInterval(-60) + + let decision = ActivationAccessEvaluator.decision(from: state, evaluatedAt: now) + + #expect(decision.decision == .allowWithWarning) + #expect(decision.title == String(localized: "License Review Warning")) + // The localized template embeds the formatted access-end date. + let dateString = endsAt.formatted(date: .abbreviated, time: .omitted) + #expect(decision.message.contains(dateString)) + } + + @Test func connectivityWarningSuppressedWhenLastRenewSucceeded() { + let now = TestData.baseNow + let endsAt = now.addingTimeInterval(3 * 24 * 60 * 60) + let license = Self.makeLicense(expiresAt: endsAt) + let lease = TestData.makeIntermediateCertificateLease( + issuedAt: now.addingTimeInterval(-30 * 24 * 60 * 60), + expiresAt: endsAt, + renewAfter: now.addingTimeInterval(-29 * 24 * 60 * 60) + ) + let status = TestData.makeEntitlementStatus(license: license, currentLease: lease) + var state = ActivationPersistedState() + state.entitlementStatus = status + state.lastRenewFailedAt = now.addingTimeInterval(-3600) + state.lastRenewSucceededAt = now.addingTimeInterval(-60) + + let decision = ActivationAccessEvaluator.decision(from: state, evaluatedAt: now) + + #expect(decision.decision == .allow) + } + + @Test func leaseExpiringBeforeEntitlementCapsAccessWindow() { + let entitlementEnd = TestData.baseNow.addingTimeInterval(30 * 24 * 60 * 60) + let leaseEnd = TestData.baseNow.addingTimeInterval(-60) + let license = Self.makeLicense(expiresAt: entitlementEnd) + let status = TestData.makeEntitlementStatus( + license: license, + currentLease: TestData.makeIntermediateCertificateLease(expiresAt: leaseEnd) + ) + var state = ActivationPersistedState() + state.entitlementStatus = status + + let decision = ActivationAccessEvaluator.decision( + from: state, + evaluatedAt: TestData.baseNow + ) + + #expect(decision.decision == .block) + #expect(decision.title == String(localized: "License Expired")) + } + + @Test func ensureSessionFreshSwallowsRefreshWindowNotOpen() async throws { + let now = TestData.baseNow + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideAuthServerTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("state.json") + let store = ActivationStateStore(url: url) + let session = TestData.makeActivationSession( + token: "old-token", + expiresAt: now.addingTimeInterval(12 * 60 * 60) // within 1d refresh window + ) + try await store.recordActivationSession(session) + + let mock = StubEvaluatorClient( + refreshResult: .failure( + LookInsideAuthenticatorAPIClientError.api( + statusCode: 409, + code: "refresh_window_not_open", + message: "too early" + ) + ) + ) + let evaluator = ActivationAccessEvaluator( + stateStore: store, + apiClientFactory: { mock }, + now: { now } + ) + + try await evaluator.ensureSessionFresh() + + #expect(mock.refreshCalls == 1) + let snapshot = await store.snapshot() + #expect(snapshot.activationSession?.token == "old-token") // unchanged + } + + @Test func ensureSessionFreshPersistsRefreshedSession() async throws { + let now = TestData.baseNow + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideAuthServerTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("state.json") + let store = ActivationStateStore(url: url) + let session = TestData.makeActivationSession( + token: "old-token", + expiresAt: now.addingTimeInterval(12 * 60 * 60) + ) + try await store.recordActivationSession(session) + + let refreshed = TestData.makeActivationSession( + token: "new-token", + expiresAt: now.addingTimeInterval(3 * 24 * 60 * 60) + ) + let mock = StubEvaluatorClient(refreshResult: .success(refreshed)) + let evaluator = ActivationAccessEvaluator( + stateStore: store, + apiClientFactory: { mock }, + now: { now } + ) + + try await evaluator.ensureSessionFresh() + + let snapshot = await store.snapshot() + #expect(snapshot.activationSession?.token == "new-token") + } + + @Test func refreshedDecisionFetchesWithRefreshedSession() async throws { + let now = TestData.baseNow + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideAuthServerTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("state.json") + let store = ActivationStateStore(url: url) + let oldSession = TestData.makeActivationSession( + token: "old-token", + expiresAt: now.addingTimeInterval(12 * 60 * 60) + ) + let refreshed = TestData.makeActivationSession( + token: "new-token", + expiresAt: now.addingTimeInterval(3 * 24 * 60 * 60) + ) + let license = Self.makeLicense() + let status = TestData.makeEntitlementStatus(license: license) + try await store.recordActivationSession(oldSession) + try await store.recordDeviceFingerprint(TestData.makeChallenge().device) + + let mock = StubEvaluatorClient( + refreshResult: .success(refreshed), + fetchResult: .success(status) + ) + let evaluator = ActivationAccessEvaluator( + stateStore: store, + apiClientFactory: { mock }, + now: { now } + ) + + let decision = try await evaluator.refreshedDecision() + + #expect(decision.decision == .allow) + #expect(mock.refreshCalls == 1) + #expect(mock.fetchCalls == 1) + #expect(mock.lastFetchRequest?.activationSessionToken == "new-token") + } + + @Test func ensureSessionFreshSkipsWhenSessionNotNearExpiry() async throws { + let now = TestData.baseNow + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideAuthServerTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("state.json") + let store = ActivationStateStore(url: url) + let session = TestData.makeActivationSession( + token: "old-token", + expiresAt: now.addingTimeInterval(2 * 24 * 60 * 60) // 2d > 1d window + ) + try await store.recordActivationSession(session) + + let mock = StubEvaluatorClient(refreshResult: .success(session)) + let evaluator = ActivationAccessEvaluator( + stateStore: store, + apiClientFactory: { mock }, + now: { now } + ) + + try await evaluator.ensureSessionFresh() + #expect(mock.refreshCalls == 0) + } + + private static func makeLicense( + lifecycleState: LicenseLifecycleState = .active, + expiresAt: Date = TestData.baseNow.addingTimeInterval(3600) + ) -> LicenseEnvelope { + LicenseEnvelope( + licenseID: "license-xyz", + licenseClass: .full, + lifecycleState: lifecycleState, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: expiresAt, + certificateChain: TestData.makeCertificateChain( + intermediateExpiresAt: expiresAt + ) + ) + } +} + +private final class StubEvaluatorClient: ActivationEvaluatorClient, @unchecked Sendable { + private(set) var refreshCalls = 0 + private(set) var fetchCalls = 0 + private(set) var lastFetchRequest: EntitlementStatusRequest? + var refreshResult: Result + var fetchResult: Result? + + init( + refreshResult: Result, + fetchResult: Result? = nil + ) { + self.refreshResult = refreshResult + self.fetchResult = fetchResult + } + + func refreshActivationSession( + _: ActivationSessionRefreshRequest + ) async throws -> ActivationSession { + refreshCalls += 1 + return try refreshResult.get() + } + + func fetchEntitlementStatus( + _ request: EntitlementStatusRequest + ) async throws -> EntitlementStatus { + fetchCalls += 1 + lastFetchRequest = request + guard let fetchResult else { + throw TestFailure.fetchFailed + } + return try fetchResult.get() + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationCertificateRenewerTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationCertificateRenewerTests.swift new file mode 100644 index 00000000..e685cfb8 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationCertificateRenewerTests.swift @@ -0,0 +1,325 @@ +import Foundation +@testable import LookInsideActivation +@testable import LookInsideActivationUI +import Testing + +struct ActivationCertificateRenewerTests { + @Test func skipsWhenNoLeasePresent() async { + let store = makeStore() + let mock = MockIntermediateIssuer() + let refresherCalls = CallCounter() + + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { mock }, + csrProvider: { "csr" }, + sessionRefresher: { refresherCalls.increment() }, + now: { TestData.baseNow } + ) + await renewer.tryRenewIfDue() + #expect(mock.callCount == 0) + #expect(refresherCalls.value == 0) + } + + @Test func skipsWhenLeaseStillFarFromExpiry() async throws { + let now = TestData.baseNow + let store = makeStore() + try await primeState( + store: store, + licenseClass: .full, + leaseRenewAfter: now.addingTimeInterval(-3600), + leaseExpiresAt: now.addingTimeInterval(20 * 24 * 60 * 60) // 20d > 7d threshold + ) + + let mock = MockIntermediateIssuer() + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { mock }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { now } + ) + await renewer.tryRenewIfDue() + + #expect(mock.callCount == 0) + let snapshot = await store.snapshot() + #expect(snapshot.lastRenewAttemptAt == nil) + } + + @Test func skipsWhenRenewAfterStillInFuture() async throws { + let now = TestData.baseNow + let store = makeStore() + try await primeState( + store: store, + licenseClass: .full, + leaseRenewAfter: now.addingTimeInterval(3600), // not yet + leaseExpiresAt: now.addingTimeInterval(6 * 3600) + ) + + let mock = MockIntermediateIssuer() + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { mock }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { now } + ) + await renewer.tryRenewIfDue() + + #expect(mock.callCount == 0) + } + + @Test func renewsAndCallsRefresherBeforeIssuing() async throws { + let now = TestData.baseNow + let store = makeStore() + try await primeState( + store: store, + licenseClass: .full, + leaseRenewAfter: now.addingTimeInterval(-3600), + leaseExpiresAt: now.addingTimeInterval(6 * 3600) // 6h < 7d threshold + ) + + let renewedLease = TestData.makeIntermediateCertificateLease( + certificateID: "lease-renewed", + issuedAt: now, + expiresAt: now.addingTimeInterval(30 * 24 * 60 * 60) + ) + let mock = MockIntermediateIssuer(result: .success(renewedLease)) + let orderLog = OrderLog() + + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { mock }, + csrProvider: { + orderLog.append("csr") + return "csr-pem" + }, + sessionRefresher: { orderLog.append("refresh") }, + now: { now } + ) + + await renewer.tryRenewIfDue() + + #expect(mock.callCount == 1) + #expect(orderLog.values == ["refresh", "csr"]) + #expect(mock.lastRequest?.certificateSigningRequestPEM == "csr-pem") + + let snapshot = await store.snapshot() + #expect(snapshot.entitlementStatus?.currentLease?.certificateID == "lease-renewed") + #expect(snapshot.lastRenewSucceededAt == now) + #expect(snapshot.lastRenewFailedAt == nil) + #expect(snapshot.lastRenewError == nil) + } + + @Test func trialUsesTwelveHourThreshold() async throws { + let now = TestData.baseNow + let store = makeStore() + + try await primeState( + store: store, + licenseClass: .trial, + leaseRenewAfter: now.addingTimeInterval(-60), + leaseExpiresAt: now.addingTimeInterval(20 * 60 * 60) // 20h above 12h threshold + ) + let mock = MockIntermediateIssuer() + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { mock }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { now } + ) + await renewer.tryRenewIfDue() + #expect(mock.callCount == 0) + + try await primeState( + store: store, + licenseClass: .trial, + leaseRenewAfter: now.addingTimeInterval(-60), + leaseExpiresAt: now.addingTimeInterval(10 * 60 * 60) // 10h within 12h threshold + ) + await renewer.tryRenewIfDue() + #expect(mock.callCount == 1) + } + + @Test func issueFailurePersistsLastRenewFailure() async throws { + let now = TestData.baseNow + let store = makeStore() + try await primeState( + store: store, + licenseClass: .full, + leaseRenewAfter: now.addingTimeInterval(-3600), + leaseExpiresAt: now.addingTimeInterval(6 * 3600) + ) + + let mock = MockIntermediateIssuer(result: .failure(MockError.boom)) + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { mock }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { now } + ) + + await renewer.tryRenewIfDue() + + let snapshot = await store.snapshot() + #expect(snapshot.lastRenewAttemptAt == now) + #expect(snapshot.lastRenewFailedAt == now) + #expect(snapshot.lastRenewSucceededAt == nil) + #expect(snapshot.lastRenewError != nil) + } + + @Test func sessionRefresherFailureSkipsIssueAndRecordsFailure() async throws { + let now = TestData.baseNow + let store = makeStore() + try await primeState( + store: store, + licenseClass: .full, + leaseRenewAfter: now.addingTimeInterval(-3600), + leaseExpiresAt: now.addingTimeInterval(6 * 3600) + ) + + let mock = MockIntermediateIssuer() + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { mock }, + csrProvider: { "csr" }, + sessionRefresher: { throw MockError.boom }, + now: { now } + ) + await renewer.tryRenewIfDue() + + #expect(mock.callCount == 0) + let snapshot = await store.snapshot() + #expect(snapshot.lastRenewFailedAt == now) + } + + @Test func backsOffAfterFailures() async throws { + let baseNow = TestData.baseNow + let store = makeStore() + try await primeState( + store: store, + licenseClass: .full, + leaseRenewAfter: baseNow.addingTimeInterval(-3600), + leaseExpiresAt: baseNow.addingTimeInterval(6 * 24 * 3600) + ) + + let mock = MockIntermediateIssuer(result: .failure(MockError.boom)) + let clockBox = ClockBox(value: baseNow) + + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { mock }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { clockBox.snapshot() } + ) + + await renewer.tryRenewIfDue() + clockBox.set(baseNow.addingTimeInterval(70)) // the old one-minute retry + await renewer.tryRenewIfDue() + #expect(mock.callCount == 1) + + clockBox.set(baseNow.addingTimeInterval(5 * 60)) + await renewer.tryRenewIfDue() + #expect(mock.callCount == 2) + + clockBox.set(baseNow.addingTimeInterval(19 * 60)) + await renewer.tryRenewIfDue() + #expect(mock.callCount == 2) + clockBox.set(baseNow.addingTimeInterval(20 * 60)) + await renewer.tryRenewIfDue() + #expect(mock.callCount == 3) + } +} + +// MARK: - Helpers + +private enum MockError: Error { case boom } + +private final class CallCounter: @unchecked Sendable { + private(set) var value = 0 + func increment() { + value += 1 + } +} + +private final class OrderLog: @unchecked Sendable { + private(set) var values: [String] = [] + func append(_ entry: String) { + values.append(entry) + } +} + +private final class ClockBox: @unchecked Sendable { + private var current: Date + init(value: Date) { + current = value + } + + func set(_ value: Date) { + current = value + } + + func snapshot() -> Date { + current + } +} + +private final class MockIntermediateIssuer: IntermediateCertificateIssuing, @unchecked Sendable { + private(set) var callCount = 0 + private(set) var lastRequest: IntermediateCertificateIssueRequest? + var result: Result + + init( + result: Result = .success( + TestData.makeIntermediateCertificateLease() + ) + ) { + self.result = result + } + + func issueIntermediateCertificate( + _ request: IntermediateCertificateIssueRequest + ) async throws -> IntermediateCertificateLease { + callCount += 1 + lastRequest = request + return try result.get() + } +} + +private func makeStore() -> ActivationStateStore { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideAuthServerTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("state.json") + return ActivationStateStore(url: url) +} + +private func primeState( + store: ActivationStateStore, + licenseClass: LicenseClass, + leaseRenewAfter: Date, + leaseExpiresAt: Date +) async throws { + let session = TestData.makeActivationSession( + expiresAt: TestData.baseNow.addingTimeInterval(3 * 24 * 60 * 60) + ) + let license = LicenseEnvelope( + licenseID: "license-123", + licenseClass: licenseClass, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow.addingTimeInterval(-86400), + expiresAt: TestData.baseNow.addingTimeInterval(365 * 86400), + certificateChain: TestData.makeCertificateChain() + ) + let lease = TestData.makeIntermediateCertificateLease( + issuedAt: leaseRenewAfter.addingTimeInterval(-86400), + expiresAt: leaseExpiresAt, + renewAfter: leaseRenewAfter + ) + let status = TestData.makeEntitlementStatus(license: license, currentLease: lease) + try await store.recordActivationSession(session) + try await store.recordEntitlementStatus(status) +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationChallengeSignerTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationChallengeSignerTests.swift new file mode 100644 index 00000000..426c43ca --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationChallengeSignerTests.swift @@ -0,0 +1,173 @@ +import Foundation +@testable import LookInsideActivation +@testable import LookInsideActivationUI +import Testing + +struct ActivationChallengeSignerTests { + @Test func validRequestProducesNonceConcatenatedMessage() throws { + let snapshot = Self.snapshot(with: Self.makeLease()) + let request = Self.makeRequest() + let decision = ActivationAccessDecision.allowed(statusSummary: "ok") + + let validated = try ActivationChallengeSigner.validate( + request: request, + snapshot: snapshot, + decision: decision, + evaluatedAt: TestData.baseNow + ) + + let expectedMessage = Self.expectedMessage( + nonceHex: request.nonce, + serverInstanceID: request.serverInstanceID + ) + #expect(validated.message == expectedMessage) + #expect(validated.lease.certificateID == "lease-cert-001") + #expect(validated.certificateDER == Data([0, 0, 0])) + } + + @Test func blockDecisionRejectsSigning() { + let snapshot = Self.snapshot(with: Self.makeLease()) + let request = Self.makeRequest() + let decision = ActivationAccessDecision.expired(at: TestData.baseNow) + + #expect(throws: ActivationError.self) { + try ActivationChallengeSigner.validate( + request: request, + snapshot: snapshot, + decision: decision, + evaluatedAt: TestData.baseNow + ) + } + } + + @Test func expiredLeaseRejectsSigning() { + let expiredLease = Self.makeLease( + issuedAt: TestData.baseNow.addingTimeInterval(-3600), + expiresAt: TestData.baseNow.addingTimeInterval(-60) + ) + let snapshot = Self.snapshot(with: expiredLease) + let request = Self.makeRequest() + let decision = ActivationAccessDecision.allowed(statusSummary: "ok") + + #expect(throws: ActivationError.self) { + try ActivationChallengeSigner.validate( + request: request, + snapshot: snapshot, + decision: decision, + evaluatedAt: TestData.baseNow + ) + } + } + + @Test func missingLeaseRejectsSigning() { + var snapshot = ActivationPersistedState() + let license = LicenseEnvelope( + licenseID: "license-xyz", + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain() + ) + snapshot.entitlementStatus = TestData.makeEntitlementStatus(license: license) + let request = Self.makeRequest() + let decision = ActivationAccessDecision.allowed(statusSummary: "ok") + + #expect(throws: ActivationError.self) { + try ActivationChallengeSigner.validate( + request: request, + snapshot: snapshot, + decision: decision, + evaluatedAt: TestData.baseNow + ) + } + } + + @Test func malformedNonceRejectsSigning() { + let snapshot = Self.snapshot(with: Self.makeLease()) + let request = ActivationSignChallengeRequest( + nonce: "not-hex", + serverInstanceID: "srv-instance", + subjectUDIDHint: nil + ) + let decision = ActivationAccessDecision.allowed(statusSummary: "ok") + + #expect(throws: ActivationError.self) { + try ActivationChallengeSigner.validate( + request: request, + snapshot: snapshot, + decision: decision, + evaluatedAt: TestData.baseNow + ) + } + } + + @Test func decodesPemBodyIgnoringHeaderAndWhitespace() { + let pem = """ + -----BEGIN CERTIFICATE----- + AAAA + -----END CERTIFICATE----- + """ + let der = ActivationChallengeSigner.certificateDER(fromPEM: pem) + #expect(der == Data([0, 0, 0])) + } + + private static func makeLease( + issuedAt: Date = TestData.baseNow, + expiresAt: Date = TestData.baseNow.addingTimeInterval(3600) + ) -> IntermediateCertificateLease { + IntermediateCertificateLease( + certificateID: "lease-cert-001", + licenseID: "license-123", + udid: "device-123", + issuedAt: issuedAt, + expiresAt: expiresAt, + issuanceReason: .initialActivation, + certificatePEM: "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----", + publicKeyPEM: "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----", + fullChainPEM: "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----" + ) + } + + private static func snapshot(with lease: IntermediateCertificateLease) -> ActivationPersistedState { + let license = LicenseEnvelope( + licenseID: "license-xyz", + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain() + ) + var snapshot = ActivationPersistedState() + snapshot.entitlementStatus = TestData.makeEntitlementStatus( + license: license, + currentLease: lease + ) + return snapshot + } + + private static func makeRequest( + nonce: String = String(repeating: "ab", count: 32), + serverInstanceID: String = "srv-instance" + ) -> ActivationSignChallengeRequest { + ActivationSignChallengeRequest( + nonce: nonce, + serverInstanceID: serverInstanceID, + subjectUDIDHint: nil + ) + } + + private static func expectedMessage(nonceHex: String, serverInstanceID: String) -> Data { + var message = Data() + var index = nonceHex.startIndex + while index < nonceHex.endIndex { + let next = nonceHex.index(index, offsetBy: 2) + message.append(UInt8(nonceHex[index ..< next], radix: 16) ?? 0) + index = next + } + message.append(contentsOf: Array(serverInstanceID.utf8)) + return message + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationErrorTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationErrorTests.swift new file mode 100644 index 00000000..ab6e1319 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationErrorTests.swift @@ -0,0 +1,30 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +struct ActivationErrorTests { + struct Sample: Sendable { + let error: ActivationError + let expectedCode: String + } + + /// The codes are the strings the 2.3.x helper sent over its socket. + @Test(arguments: [ + Sample(error: .invalidRequest("bad"), expectedCode: "invalid_request"), + Sample(error: .keychainFailure("k"), expectedCode: "keychain_failure"), + Sample(error: .keychainAccessDenied(-128), expectedCode: "keychain_failure"), + Sample(error: .stateStoreFailure("s"), expectedCode: "state_store_failure"), + Sample(error: .licenseNotActivated("l"), expectedCode: "license_not_activated"), + Sample(error: .signingFailed("f"), expectedCode: "signing_failed"), + Sample(error: .signingDeferred, expectedCode: "signing_failed"), + Sample(error: .activationFailed("a"), expectedCode: "activation_failed"), + ]) + func errorCodeMapsEachCase(sample: Sample) { + #expect(sample.error.errorCode == sample.expectedCode) + } + + @Test func licenseNotActivatedMessageMatchesHelper() { + let error = ActivationError.licenseNotActivated("detail") + #expect(error.errorDescription == "License is not activated on this device.\ndetail") + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationModelTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationModelTests.swift new file mode 100644 index 00000000..1ca78613 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationModelTests.swift @@ -0,0 +1,571 @@ +import Foundation +@testable import LookInsideActivation +@testable import LookInsideActivationUI +import Testing + +@MainActor +struct ActivationModelTests { + @Test func activationInputsNormalizePastedSingleLineValues() { + let session = TestData.makeActivationSession() + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain() + ) + let model = makeModel( + session: session, + purchaseClaimResolver: RecordingPurchaseClaimResolver(session: session), + statusFetcher: RecordingEntitlementStatusFetcher(status: TestData.makeEntitlementStatus(license: license)), + certificateIssuer: RecordingIntermediateCertificateIssuer( + lease: TestData.makeIntermediateCertificateLease() + ) + ) + + model.email = " buyer@example.com\n" + model.licenseKey = " abcde – fghij\n-klmno\t-pqrst " + + #expect(model.email == "buyer@example.com") + #expect(model.licenseKey == "ABCDE-FGHIJ-KLMNO-PQRST") + } + + @Test func resolvePurchaseClaimFetchesStatusAndAdvancesToEligibilityReview() async { + let session = TestData.makeActivationSession() + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain(boundUDID: session.deviceIdentifier) + ) + let status = TestData.makeEntitlementStatus( + license: license, + activationSession: session + ) + let purchaseClaimResolver = RecordingPurchaseClaimResolver(session: session) + let statusFetcher = RecordingEntitlementStatusFetcher(status: status) + let certificateIssuer = RecordingIntermediateCertificateIssuer( + lease: TestData.makeIntermediateCertificateLease(udid: session.deviceIdentifier) + ) + let device = TestData.makeChallenge().device + + let model = ActivationModel( + configuration: .init(requestedFeature: "swiftui.support", frameworkVersion: "2.0.0"), + purchaseClaimResolver: purchaseClaimResolver, + trialIssuer: RecordingTrialIssuer(session: session), + entitlementStatusFetcher: statusFetcher, + intermediateCertificateIssuer: certificateIssuer, + deviceFingerprintProvider: { device }, + certificateSigningRequestProvider: { + "-----BEGIN CERTIFICATE REQUEST-----\ncsr\n-----END CERTIFICATE REQUEST-----" + }, + activationHandler: { request in + HostActivationResponse( + activation: SignedActivationEnvelope( + activationID: "activation-123", + challengeNonce: request.challenge.nonce, + licenseID: request.license.licenseID, + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + intermediateCertificateID: request.license.certificateChain.intermediateCertificateID, + boundUDID: request.challenge.device.deviceID, + artifacts: [] + ), + path: .direct, + secureTimestamp: nil + ) + }, + clock: FixedClock(currentDate: TestData.baseNow) + ) + model.licenseKey = session.orderID ?? "" + model.email = session.email ?? "" + + await model.resolvePurchaseClaim() + + #expect(model.currentStep == ActivationModel.Step.eligibilityReview) + #expect(model.errorMessage == nil) + #expect(model.activationSession == session) + #expect(model.lastDeviceFingerprint == device) + #expect(purchaseClaimResolver.resolveCount == 1) + #expect(statusFetcher.fetchCount == 1) + #expect(statusFetcher.lastRequest?.forceRefresh == true) + } + + @Test func issueIntermediateCertificateMergesLeaseIntoEntitlementStatus() async { + let session = TestData.makeActivationSession() + let lease = TestData.makeIntermediateCertificateLease( + certificateID: "lease-cert-002", + udid: session.deviceIdentifier + ) + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain(boundUDID: session.deviceIdentifier) + ) + let purchaseClaimResolver = RecordingPurchaseClaimResolver(session: session) + let statusFetcher = RecordingEntitlementStatusFetcher( + status: TestData.makeEntitlementStatus( + license: license, + activationSession: session, + isEligibleForActivation: true + ) + ) + let certificateIssuer = RecordingIntermediateCertificateIssuer(lease: lease) + + let model = makeModel( + session: session, + purchaseClaimResolver: purchaseClaimResolver, + statusFetcher: statusFetcher, + certificateIssuer: certificateIssuer + ) + model.licenseKey = session.orderID ?? "" + model.email = session.email ?? "" + + await model.resolvePurchaseClaim() + await model.issueIntermediateCertificate() + + #expect(model.currentStep == ActivationModel.Step.finalActivation) + #expect(model.issuedLease == lease) + #expect(model.entitlementStatus?.currentLease == lease) + #expect(model.entitlementStatus?.license.certificateChain.boundUDID == session.deviceIdentifier) + #expect(certificateIssuer.issueCount == 1) + #expect(model.canCompleteActivation == true) + } + + @Test func restorePersistedActivationStateShowsLicenseStatus() { + let session = TestData.makeActivationSession() + let lease = TestData.makeIntermediateCertificateLease( + certificateID: "lease-cert-restored", + udid: session.deviceIdentifier + ) + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain(boundUDID: session.deviceIdentifier) + ) + let status = TestData.makeEntitlementStatus( + license: license, + currentLease: lease, + activationSession: session + ) + let response = HostActivationResponse( + activation: SignedActivationEnvelope( + activationID: "activation-restored", + challengeNonce: "nonce-restored", + licenseID: session.licenseID, + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + intermediateCertificateID: lease.certificateID, + boundUDID: session.deviceIdentifier, + artifacts: [] + ), + path: .direct, + secureTimestamp: nil + ) + let device = DeviceFingerprint( + deviceID: session.deviceIdentifier, + hardwareModel: "Mac16,1", + operatingSystemVersion: "15.0", + appBundleID: "app.lookinside.AuthServer" + ) + let model = makeModel( + session: session, + purchaseClaimResolver: RecordingPurchaseClaimResolver(session: session), + statusFetcher: RecordingEntitlementStatusFetcher(status: status), + certificateIssuer: RecordingIntermediateCertificateIssuer(lease: lease) + ) + + model.restorePersistedActivationState( + activationSession: session, + entitlementStatus: status, + activationResponse: response, + deviceFingerprint: device + ) + + #expect(model.currentStep == ActivationModel.Step.licenseStatus) + #expect(model.activationSession == session) + #expect(model.entitlementStatus?.currentLease == lease) + #expect(model.issuedLease == lease) + #expect(model.activationResponse == response) + #expect(model.lastDeviceFingerprint == device) + } + + @Test func restorePersistedActivationStateShowsLicenseStatusFromActivationResponse() { + let session = TestData.makeActivationSession() + let lease = TestData.makeIntermediateCertificateLease(udid: session.deviceIdentifier) + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain(boundUDID: session.deviceIdentifier) + ) + let status = TestData.makeEntitlementStatus( + license: license, + activationSession: session + ) + let response = TestData.makeHostActivationResponse( + activationID: "activation-response-only", + session: session, + lease: lease + ) + let model = makeModel( + session: session, + purchaseClaimResolver: RecordingPurchaseClaimResolver(session: session), + statusFetcher: RecordingEntitlementStatusFetcher(status: status), + certificateIssuer: RecordingIntermediateCertificateIssuer(lease: lease) + ) + + model.restorePersistedActivationState( + activationSession: nil, + entitlementStatus: status, + activationResponse: response, + deviceFingerprint: nil + ) + + #expect(model.currentStep == ActivationModel.Step.licenseStatus) + #expect(model.activationSession == session) + #expect(model.issuedLease == nil) + #expect(model.activationResponse == response) + } + + @Test func restorePersistedActivationStateReturnsToClaimFormWithoutActivatedLicense() { + let session = TestData.makeActivationSession() + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain(boundUDID: session.deviceIdentifier) + ) + let status = TestData.makeEntitlementStatus( + license: license, + activationSession: session + ) + let model = makeModel( + session: session, + purchaseClaimResolver: RecordingPurchaseClaimResolver(session: session), + statusFetcher: RecordingEntitlementStatusFetcher(status: status), + certificateIssuer: RecordingIntermediateCertificateIssuer( + lease: TestData.makeIntermediateCertificateLease() + ) + ) + model.beginFullActivationFromTrial() + + model.restorePersistedActivationState( + activationSession: nil, + entitlementStatus: status, + activationResponse: nil, + deviceFingerprint: nil + ) + + #expect(model.currentStep == ActivationModel.Step.claimForm) + #expect(model.activationSession == session) + #expect(model.issuedLease == nil) + #expect(model.activationResponse == nil) + #expect(model.isShowingActivationForm == false) + #expect(model.lastDeviceFingerprint?.deviceID == status.deviceBinding?.udid) + } + + @Test func licenseStatusRefreshProgressUsesDedicatedModelState() { + let session = TestData.makeActivationSession() + let lease = TestData.makeIntermediateCertificateLease( + certificateID: "lease-refresh-progress", + udid: session.deviceIdentifier + ) + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain(boundUDID: session.deviceIdentifier) + ) + let status = TestData.makeEntitlementStatus( + license: license, + currentLease: lease, + activationSession: session + ) + let model = makeModel( + session: session, + purchaseClaimResolver: RecordingPurchaseClaimResolver(session: session), + statusFetcher: RecordingEntitlementStatusFetcher(status: status), + certificateIssuer: RecordingIntermediateCertificateIssuer(lease: lease) + ) + + model.restorePersistedActivationState( + activationSession: session, + entitlementStatus: status, + activationResponse: TestData.makeHostActivationResponse(session: session, lease: lease), + deviceFingerprint: nil + ) + + model.beginLicenseStatusRefresh(message: "Refreshing test license…") + + #expect(model.isRefreshingLicenseStatus == true) + #expect(model.licenseStatusRefreshMessage == "Refreshing test license…") + #expect(model.currentStep == ActivationModel.Step.licenseStatus) + #expect(model.isShowingActivationForm == false) + + model.finishLicenseStatusRefresh() + + #expect(model.isRefreshingLicenseStatus == false) + #expect(model.licenseStatusRefreshMessage == nil) + } + + @Test func completeActivationBuildsHostRequestFromIssuedLease() async { + let session = TestData.makeActivationSession() + let lease = TestData.makeIntermediateCertificateLease( + certificateID: "lease-cert-003", + udid: session.deviceIdentifier + ) + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain() + ) + let purchaseClaimResolver = RecordingPurchaseClaimResolver(session: session) + let statusFetcher = RecordingEntitlementStatusFetcher( + status: TestData.makeEntitlementStatus( + license: license, + activationSession: session, + isEligibleForActivation: true + ) + ) + let certificateIssuer = RecordingIntermediateCertificateIssuer(lease: lease) + let device = TestData.makeChallenge().device + let recordedRequest = LockIsolated(nil) + + let model = ActivationModel( + configuration: .init(requestedFeature: "swiftui.support", frameworkVersion: "2.1.0"), + purchaseClaimResolver: purchaseClaimResolver, + trialIssuer: RecordingTrialIssuer(session: session), + entitlementStatusFetcher: statusFetcher, + intermediateCertificateIssuer: certificateIssuer, + deviceFingerprintProvider: { device }, + certificateSigningRequestProvider: { "csr" }, + activationHandler: { request in + await recordedRequest.setValue(request) + return HostActivationResponse( + activation: SignedActivationEnvelope( + activationID: "activation-123", + challengeNonce: request.challenge.nonce, + licenseID: request.license.licenseID, + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + intermediateCertificateID: request.license.certificateChain.intermediateCertificateID, + boundUDID: request.challenge.device.deviceID, + artifacts: [] + ), + path: .direct, + secureTimestamp: nil + ) + }, + clock: FixedClock(currentDate: TestData.baseNow) + ) + model.licenseKey = session.orderID ?? "" + model.email = session.email ?? "" + + await model.resolvePurchaseClaim() + await model.issueIntermediateCertificate() + await model.completeActivation() + + let request = await recordedRequest.value + #expect(model.currentStep == ActivationModel.Step.licenseStatus) + #expect(request?.license.certificateChain.intermediateCertificateID == lease.certificateID) + #expect(request?.license.certificateChain.boundUDID == session.deviceIdentifier) + #expect(request?.challenge.requestedFeature == "swiftui.support") + #expect(model.activationResponse?.activation.boundUDID == session.deviceIdentifier) + } + + @Test func resolvePurchaseClaimRequiresOrderAndEmail() async { + let session = TestData.makeActivationSession() + let purchaseClaimResolver = RecordingPurchaseClaimResolver(session: session) + let statusFetcher = RecordingEntitlementStatusFetcher( + status: TestData.makeEntitlementStatus( + license: LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain() + ) + ) + ) + let certificateIssuer = RecordingIntermediateCertificateIssuer( + lease: TestData.makeIntermediateCertificateLease() + ) + + let model = makeModel( + session: session, + purchaseClaimResolver: purchaseClaimResolver, + statusFetcher: statusFetcher, + certificateIssuer: certificateIssuer + ) + + await model.resolvePurchaseClaim() + + #expect(model.errorMessage == ActivationModelError.missingClaimInput.errorDescription) + #expect(purchaseClaimResolver.resolveCount == 0) + } + + @Test func activationFailureAppendsRestartPromptWhenHelperUpdateExists() async { + let session = TestData.makeActivationSession() + let purchaseClaimResolver = RecordingPurchaseClaimResolver(session: session) + purchaseClaimResolver.error = TestFailure.fetchFailed + let statusFetcher = RecordingEntitlementStatusFetcher( + status: TestData.makeEntitlementStatus( + license: LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain() + ) + ) + ) + let recoveryProviderCalled = LockIsolated(false) + let model = ActivationModel( + configuration: .init(requestedFeature: "swiftui.support", frameworkVersion: "2.0.0"), + purchaseClaimResolver: purchaseClaimResolver, + trialIssuer: RecordingTrialIssuer(session: session), + entitlementStatusFetcher: statusFetcher, + intermediateCertificateIssuer: RecordingIntermediateCertificateIssuer( + lease: TestData.makeIntermediateCertificateLease() + ), + deviceFingerprintProvider: { TestData.makeChallenge().device }, + certificateSigningRequestProvider: { "csr" }, + activationHandler: { _ in + TestData.makeHostActivationResponse(session: session) + }, + activationFailureRecoveryMessageProvider: { + await recoveryProviderCalled.setValue(true) + return "Restart LookInside to load the updated authenticator." + }, + clock: FixedClock(currentDate: TestData.baseNow) + ) + model.licenseKey = session.orderID ?? "" + model.email = session.email ?? "" + + await model.resolvePurchaseClaim() + + #expect(model.errorMessage?.contains("fetchFailed") == true) + #expect(model.errorMessage?.contains("Restart LookInside") == true) + #expect(await recoveryProviderCalled.value == true) + } + + @Test func localActivationValidationSkipsRestartPrompt() async { + let session = TestData.makeActivationSession() + let recoveryProviderCalled = LockIsolated(false) + let model = ActivationModel( + configuration: .init(requestedFeature: "swiftui.support", frameworkVersion: "2.0.0"), + purchaseClaimResolver: RecordingPurchaseClaimResolver(session: session), + trialIssuer: RecordingTrialIssuer(session: session), + entitlementStatusFetcher: RecordingEntitlementStatusFetcher( + status: TestData.makeEntitlementStatus( + license: LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain() + ) + ) + ), + intermediateCertificateIssuer: RecordingIntermediateCertificateIssuer( + lease: TestData.makeIntermediateCertificateLease() + ), + deviceFingerprintProvider: { TestData.makeChallenge().device }, + certificateSigningRequestProvider: { "csr" }, + activationHandler: { _ in + TestData.makeHostActivationResponse(session: session) + }, + activationFailureRecoveryMessageProvider: { + await recoveryProviderCalled.setValue(true) + return "Restart LookInside to load the updated authenticator." + }, + clock: FixedClock(currentDate: TestData.baseNow) + ) + + await model.resolvePurchaseClaim() + + #expect(model.errorMessage == ActivationModelError.missingClaimInput.errorDescription) + #expect(await recoveryProviderCalled.value == false) + } + + private func makeModel( + session: ActivationSession, + purchaseClaimResolver: RecordingPurchaseClaimResolver, + statusFetcher: RecordingEntitlementStatusFetcher, + certificateIssuer: RecordingIntermediateCertificateIssuer + ) -> ActivationModel { + let device = TestData.makeChallenge().device + + return ActivationModel( + configuration: .init(requestedFeature: "swiftui.support", frameworkVersion: "2.0.0"), + purchaseClaimResolver: purchaseClaimResolver, + trialIssuer: RecordingTrialIssuer(session: session), + entitlementStatusFetcher: statusFetcher, + intermediateCertificateIssuer: certificateIssuer, + deviceFingerprintProvider: { device }, + certificateSigningRequestProvider: { "csr-\(session.sessionID)" }, + activationHandler: { request in + HostActivationResponse( + activation: SignedActivationEnvelope( + activationID: "activation-\(session.sessionID)", + challengeNonce: request.challenge.nonce, + licenseID: request.license.licenseID, + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + intermediateCertificateID: request.license.certificateChain.intermediateCertificateID, + boundUDID: request.challenge.device.deviceID, + artifacts: [] + ), + path: .direct, + secureTimestamp: nil + ) + }, + clock: FixedClock(currentDate: TestData.baseNow) + ) + } +} + +private actor LockIsolated { + private var storage: Value + + init(_ value: Value) { + storage = value + } + + var value: Value { + storage + } + + func setValue(_ value: Value) { + storage = value + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationPolicyTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationPolicyTests.swift new file mode 100644 index 00000000..ad4ed531 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationPolicyTests.swift @@ -0,0 +1,18 @@ +@testable import LookInsideActivation +import Testing + +@Test(arguments: [ + (LicenseClass.full, true, ActivationPath.direct), + (LicenseClass.full, false, ActivationPath.direct), + (LicenseClass.trial, true, ActivationPath.timeAnchored), + (LicenseClass.trial, false, ActivationPath.direct), +]) +func activationPolicySelectsExpectedPath( + licenseClass: LicenseClass, + requiresSecureTimestampForTrial: Bool, + expectedPath: ActivationPath +) { + let policy = ActivationPolicy(requiresSecureTimestampForTrial: requiresSecureTimestampForTrial) + + #expect(policy.activationPath(for: licenseClass) == expectedPath) +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationStateStoreTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationStateStoreTests.swift new file mode 100644 index 00000000..65554c7e --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationStateStoreTests.swift @@ -0,0 +1,166 @@ +import Foundation +@testable import LookInsideActivation +@testable import LookInsideActivationUI +import Testing + +struct ActivationStateStoreTests { + @Test func persistsAndReloadsFingerprintAcrossStoreInstances() async throws { + let url = Self.makeTempStateURL() + defer { try? FileManager.default.removeItem(at: url) } + + let firstStore = ActivationStateStore(url: url) + let fingerprint = DeviceFingerprint( + deviceID: "device-1", + hardwareModel: "Mac16,1", + operatingSystemVersion: "15.0", + appBundleID: "app.lookinside.AuthServer" + ) + try await firstStore.recordDeviceFingerprint(fingerprint) + + let reopened = ActivationStateStore(url: url) + let snapshot = await reopened.snapshot() + #expect(snapshot.deviceFingerprint == fingerprint) + } + + @Test func recordIssuedLeaseNoOpsWhenEntitlementStatusAbsent() async throws { + let url = Self.makeTempStateURL() + defer { try? FileManager.default.removeItem(at: url) } + + let store = ActivationStateStore(url: url) + let lease = TestData.makeIntermediateCertificateLease() + try await store.recordIssuedLease(lease) + + let snapshot = await store.snapshot() + #expect(snapshot.entitlementStatus == nil) + } + + @Test func persistsPaidEntitlementAcrossStoreInstances() async throws { + let url = Self.makeTempStateURL() + defer { try? FileManager.default.removeItem(at: url) } + + let store = ActivationStateStore(url: url) + let session = TestData.makeActivationSession(licenseID: "lic_paid_abc") + let license = Self.makeLicense(licenseID: "lic_paid_abc", licenseClass: .full) + let status = TestData.makeEntitlementStatus(license: license) + + try await store.recordActivationSession(session) + try await store.recordEntitlementStatus(status) + + let reopened = ActivationStateStore(url: url) + let snapshot = await reopened.snapshot() + #expect(snapshot.activationSession?.licenseID == "lic_paid_abc") + #expect(snapshot.entitlementStatus?.license.licenseID == "lic_paid_abc") + #expect(snapshot.entitlementStatus?.license.licenseClass == .full) + } + + @Test func persistsPaidLeaseAndActivationResponseAcrossStoreInstances() async throws { + let url = Self.makeTempStateURL() + defer { try? FileManager.default.removeItem(at: url) } + + let store = ActivationStateStore(url: url) + let session = TestData.makeActivationSession(licenseID: "lic_paid_restored") + let lease = TestData.makeIntermediateCertificateLease( + certificateID: "lease-cert-restored", + licenseID: session.licenseID, + udid: session.deviceIdentifier + ) + let license = Self.makeLicense( + licenseID: session.licenseID, + licenseClass: .full + ) + let status = TestData.makeEntitlementStatus( + license: license, + activationSession: session + ) + let fingerprint = DeviceFingerprint( + deviceID: session.deviceIdentifier, + hardwareModel: "Mac16,1", + operatingSystemVersion: "15.0", + appBundleID: "app.lookinside.AuthServer" + ) + let activationResponse = TestData.makeHostActivationResponse( + activationID: "activation-restored", + session: session, + lease: lease + ) + + try await store.recordDeviceFingerprint(fingerprint) + try await store.recordActivationSession(session) + try await store.recordEntitlementStatus(status) + try await store.recordIssuedLease(lease) + try await store.recordActivationResponse(activationResponse) + + let reopened = ActivationStateStore(url: url) + let snapshot = await reopened.snapshot() + #expect(snapshot.deviceFingerprint == fingerprint) + #expect(snapshot.activationSession == session) + #expect(snapshot.entitlementStatus?.currentLease == lease) + #expect(snapshot.entitlementStatus?.license.certificateChain.intermediateCertificateID == lease.certificateID) + #expect(snapshot.entitlementStatus?.license.certificateChain.boundUDID == lease.udid) + #expect(snapshot.activationResponse == activationResponse) + } + + @Test func keepsTransientActivationSessionInMemoryOnly() async throws { + let url = Self.makeTempStateURL() + defer { try? FileManager.default.removeItem(at: url) } + + let store = ActivationStateStore(url: url) + let session = TestData.makeActivationSession(licenseID: "lic_trial_abc") + + try await store.recordTransientActivationSession(session) + + let currentSnapshot = await store.snapshot() + #expect(currentSnapshot.activationSession?.licenseID == "lic_trial_abc") + + let reopened = ActivationStateStore(url: url) + let snapshot = await reopened.snapshot() + #expect(snapshot.activationSession == nil) + } + + @Test func keepsTrialEntitlementInMemoryOnly() async throws { + let url = Self.makeTempStateURL() + defer { try? FileManager.default.removeItem(at: url) } + + let store = ActivationStateStore(url: url) + let session = TestData.makeActivationSession(licenseID: "lic_trial_abc") + let license = Self.makeLicense(licenseID: "lic_trial_abc", licenseClass: .trial) + let status = TestData.makeEntitlementStatus(license: license) + + try await store.recordActivationSession(session) + try await store.recordEntitlementStatus(status) + + let currentSnapshot = await store.snapshot() + #expect(currentSnapshot.activationSession?.licenseID == "lic_trial_abc") + #expect(currentSnapshot.entitlementStatus?.license.licenseClass == .trial) + + let reopened = ActivationStateStore(url: url) + let snapshot = await reopened.snapshot() + #expect(snapshot.activationSession == nil) + #expect(snapshot.entitlementStatus == nil) + #expect(snapshot.activationResponse == nil) + } + + private static func makeLicense( + licenseID: String, + licenseClass: LicenseClass, + expiresAt: Date = TestData.baseNow.addingTimeInterval(3600) + ) -> LicenseEnvelope { + LicenseEnvelope( + licenseID: licenseID, + licenseClass: licenseClass, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: expiresAt, + certificateChain: TestData.makeCertificateChain( + intermediateExpiresAt: expiresAt + ) + ) + } + + private static func makeTempStateURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideAuthServerTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("state.json") + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationSupportModelsTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationSupportModelsTests.swift new file mode 100644 index 00000000..9df8ae89 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationSupportModelsTests.swift @@ -0,0 +1,86 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +struct ActivationSupportModelsTests { + @Test func certificateChainDefaultsRenewAfterToOneDayAfterIssuance() { + let expiresAt = TestData.baseNow.addingTimeInterval(30 * 24 * 60 * 60) + let chain = CertificateChain( + rootCertificateID: TestData.embeddedRootCertificateID, + intermediateCertificateID: "ic_host_cn_001", + intermediateIssuedAt: TestData.baseNow, + intermediateExpiresAt: expiresAt + ) + + #expect(chain.renewAfter == TestData.baseNow.addingTimeInterval(24 * 60 * 60)) + } + + @Test func certificateChainClampsRenewAfterToExpiryForShortValidity() { + let expiresAt = TestData.baseNow.addingTimeInterval(6 * 60 * 60) + let chain = CertificateChain( + rootCertificateID: TestData.embeddedRootCertificateID, + intermediateCertificateID: "ic_host_cn_001", + intermediateIssuedAt: TestData.baseNow, + intermediateExpiresAt: expiresAt + ) + + #expect(chain.renewAfter == expiresAt) + } + + @Test func licenseEnvelopePersistsCanceledLifecycleAndDeviceWarning() { + let license = LicenseEnvelope( + licenseID: "license-123", + audience: .personal, + licenseClass: .full, + lifecycleState: .canceled, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + serviceEndsAt: TestData.baseNow.addingTimeInterval(1800), + deviceWarning: "This license has been activated on multiple devices. Usage may be reviewed manually.", + certificateChain: TestData.makeCertificateChain() + ) + + #expect(license.lifecycleState == .canceled) + #expect(license.audience == .personal) + #expect(license.deviceWarning?.isEmpty == false) + } + + @Test func signedActivationEnvelopeCarriesBoundUDID() { + let envelope = SignedActivationEnvelope( + activationID: "activation-123", + challengeNonce: "nonce-123", + licenseID: "license-123", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + intermediateCertificateID: "lease-cert-001", + boundUDID: "device-123", + artifacts: [] + ) + + #expect(envelope.boundUDID == "device-123") + } + + @Test func applyingLeaseUpdatesCertificateChainBindingAndRenewalWindow() { + let license = LicenseEnvelope( + licenseID: "license-123", + licenseClass: .full, + lifecycleState: .refunded, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain() + ) + let lease = TestData.makeIntermediateCertificateLease( + certificateID: "lease-cert-002", + expiresAt: TestData.baseNow.addingTimeInterval(45 * 24 * 60 * 60) + ) + + let updated = license.applying(lease: lease) + + #expect(updated.lifecycleState == LicenseLifecycleState.refunded) + #expect(updated.certificateChain.intermediateCertificateID == "lease-cert-002") + #expect(updated.certificateChain.boundUDID == lease.udid) + #expect(updated.certificateChain.renewAfter == lease.renewAfter) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationWindowCoordinatorTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationWindowCoordinatorTests.swift new file mode 100644 index 00000000..ce5b3ee0 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationWindowCoordinatorTests.swift @@ -0,0 +1,78 @@ +import Foundation +@testable import LookInsideActivation +@testable import LookInsideActivationUI +import Testing + +@MainActor +struct ActivationWindowCoordinatorTests { + @Test func restoresPersistedPaidLicenseIntoWindowModelAfterStoreReopen() async throws { + let url = Self.makeTempStateURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + let session = TestData.makeActivationSession(licenseID: "lic_window_restore") + let lease = TestData.makeIntermediateCertificateLease( + certificateID: "lease-window-restore", + licenseID: session.licenseID, + udid: session.deviceIdentifier + ) + let license = LicenseEnvelope( + licenseID: session.licenseID, + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(3600), + certificateChain: TestData.makeCertificateChain( + boundUDID: session.deviceIdentifier + ) + ) + let status = TestData.makeEntitlementStatus( + license: license, + activationSession: session + ) + let fingerprint = DeviceFingerprint( + deviceID: session.deviceIdentifier, + hardwareModel: "Mac16,1", + operatingSystemVersion: "15.0", + appBundleID: "app.lookinside.AuthServer" + ) + let activationResponse = TestData.makeHostActivationResponse( + activationID: "activation-window-restore", + session: session, + lease: lease + ) + + let store = ActivationStateStore(url: url) + try await store.recordDeviceFingerprint(fingerprint) + try await store.recordActivationSession(session) + try await store.recordEntitlementStatus(status) + try await store.recordIssuedLease(lease) + try await store.recordActivationResponse(activationResponse) + + let runtime = ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: url.deletingLastPathComponent()) + ) + let coordinator = ActivationWindowCoordinator(runtime: runtime) + + await runtime.restoreActivationModel() + + #expect( + coordinator.activationModel.currentStep + == ActivationModel.Step.licenseStatus + ) + #expect(coordinator.activationModel.activationSession == session) + #expect(coordinator.activationModel.entitlementStatus?.currentLease == lease) + #expect(coordinator.activationModel.issuedLease == lease) + #expect(coordinator.activationModel.activationResponse == activationResponse) + #expect(coordinator.activationModel.lastDeviceFingerprint == fingerprint) + } + + private static func makeTempStateURL() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent( + "LookInsideActivationWindowCoordinatorTests-\(UUID().uuidString)", + isDirectory: true + ) + .appendingPathComponent("state.json") + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationWindowPlacementTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationWindowPlacementTests.swift new file mode 100644 index 00000000..2d3e7dff --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ActivationWindowPlacementTests.swift @@ -0,0 +1,53 @@ +import AppKit +@testable import LookInsideActivation +@testable import LookInsideActivationUI +import Testing + +@MainActor +struct ActivationWindowPlacementTests { + private typealias Placement = ActivationWindowPlacement + + @Test func screenBoundsContainingMouseUsesScreenUnderCursor() { + let leftScreen = Placement.ScreenBounds( + frame: CGRect(x: -1440, y: 0, width: 1440, height: 900), + visibleFrame: CGRect(x: -1440, y: 25, width: 1440, height: 875) + ) + let rightScreen = Placement.ScreenBounds( + frame: CGRect(x: 0, y: 0, width: 1728, height: 1117), + visibleFrame: CGRect(x: 0, y: 38, width: 1728, height: 1079) + ) + + let bounds = Placement.screenBoundsContainingMouse( + CGPoint(x: -240, y: 500), + screens: [rightScreen, leftScreen] + ) + + #expect(bounds?.frame == leftScreen.frame) + #expect(bounds?.visibleFrame == leftScreen.visibleFrame) + } + + @Test func centeredFrameUsesScreenVisibleFrame() { + let windowFrame = CGRect(x: 0, y: 0, width: 400, height: 200) + let visibleFrame = CGRect(x: 100, y: 50, width: 1000, height: 700) + + let centeredFrame = Placement.centeredFrame( + for: windowFrame, + in: visibleFrame + ) + + #expect(centeredFrame == CGRect(x: 400, y: 300, width: 400, height: 200)) + } + + @Test func pinWindowAboveNormalWindowsUsesFloatingLevel() { + let window = NSWindow( + contentRect: CGRect(x: 0, y: 0, width: 400, height: 200), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + + Placement.pinWindowAboveNormalWindows(window) + + #expect(window.level == .floating) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/EntitlementDisplayStateTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/EntitlementDisplayStateTests.swift new file mode 100644 index 00000000..12823d21 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/EntitlementDisplayStateTests.swift @@ -0,0 +1,167 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +struct EntitlementDisplayStateTests { + @Test func trialReportsCeilingDaysRemaining() { + let trialEnd = TestData.baseNow.addingTimeInterval(14 * 24 * 60 * 60) + let license = Self.makeLicense( + licenseClass: .trial, + lifecycleState: .active, + serviceEndsAt: trialEnd + ) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.displayState == .trial(daysRemaining: 14)) + #expect(status.entitlementEndsAt == trialEnd) + } + + @Test func trialNearExpiryRoundsUpToOneDay() { + let trialEnd = TestData.baseNow.addingTimeInterval(60 * 60) + let license = Self.makeLicense( + licenseClass: .trial, + lifecycleState: .active, + serviceEndsAt: trialEnd + ) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.displayState == .trial(daysRemaining: 1)) + } + + @Test func trialPastExpiryReportsZeroDays() { + let trialEnd = TestData.baseNow.addingTimeInterval(-60) + let license = Self.makeLicense( + licenseClass: .trial, + lifecycleState: .active, + serviceEndsAt: trialEnd + ) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.displayState == .trial(daysRemaining: 0)) + } + + @Test func fullActiveLicenseMapsToSubscriptionActive() { + let license = Self.makeLicense(licenseClass: .full, lifecycleState: .active) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.displayState == .subscriptionActive) + } + + @Test func fullCanceledLicenseMapsToSubscriptionCanceled() { + let license = Self.makeLicense(licenseClass: .full, lifecycleState: .canceled) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.displayState == .subscriptionCanceled) + } + + @Test func fullExpiredLicenseMapsToSubscriptionExpired() { + let license = Self.makeLicense(licenseClass: .full, lifecycleState: .expired) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.displayState == .subscriptionExpired) + } + + @Test func fullRefundedLicenseMapsToSubscriptionRefunded() { + let license = Self.makeLicense(licenseClass: .full, lifecycleState: .refunded) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.displayState == .subscriptionRefunded) + } + + @Test func fullPendingReviewMapsToSubscriptionInReview() { + let license = Self.makeLicense(licenseClass: .full, lifecycleState: .pendingReview) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.displayState == .subscriptionInReview) + } + + @Test func effectiveAccessEndsAtPrefersEarlierOfLeaseAndEntitlement() { + let entitlementEnd = TestData.baseNow.addingTimeInterval(14 * 24 * 60 * 60) + let leaseEnd = TestData.baseNow.addingTimeInterval(30 * 24 * 60 * 60) + let license = Self.makeLicense( + licenseClass: .trial, + lifecycleState: .active, + serviceEndsAt: entitlementEnd + ) + let lease = TestData.makeIntermediateCertificateLease(expiresAt: leaseEnd) + let status = TestData.makeEntitlementStatus(license: license, currentLease: lease) + + #expect(status.effectiveAccessEndsAt == entitlementEnd) + } + + @Test func effectiveAccessEndsAtPrefersLeaseWhenLeaseEndsFirst() { + let entitlementEnd = TestData.baseNow.addingTimeInterval(30 * 24 * 60 * 60) + let leaseEnd = TestData.baseNow.addingTimeInterval(14 * 24 * 60 * 60) + let license = Self.makeLicense( + licenseClass: .full, + lifecycleState: .active, + serviceEndsAt: entitlementEnd + ) + let lease = TestData.makeIntermediateCertificateLease(expiresAt: leaseEnd) + let status = TestData.makeEntitlementStatus(license: license, currentLease: lease) + + #expect(status.effectiveAccessEndsAt == leaseEnd) + } + + @Test func effectiveAccessEndsAtIsNilForPerpetualWithoutLease() { + let license = Self.makeLicense( + licenseClass: .full, + lifecycleState: .active, + expiresAt: nil, + serviceEndsAt: nil + ) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.effectiveAccessEndsAt == nil) + } + + @Test func certificateRenewalAtPrefersLeaseRenewAfter() { + let leaseRenewAfter = TestData.baseNow.addingTimeInterval(24 * 60 * 60) + let envelopeRenewalAt = TestData.baseNow.addingTimeInterval(48 * 60 * 60) + let license = Self.makeLicense( + licenseClass: .full, + lifecycleState: .active, + nextCertificateRenewalAt: envelopeRenewalAt + ) + let lease = TestData.makeIntermediateCertificateLease( + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(30 * 24 * 60 * 60), + renewAfter: leaseRenewAfter + ) + let status = TestData.makeEntitlementStatus(license: license, currentLease: lease) + + #expect(status.certificateRenewalAt == leaseRenewAfter) + } + + @Test func certificateRenewalAtFallsBackToEnvelopeWhenNoLease() { + let envelopeRenewalAt = TestData.baseNow.addingTimeInterval(24 * 60 * 60) + let license = Self.makeLicense( + licenseClass: .full, + lifecycleState: .active, + nextCertificateRenewalAt: envelopeRenewalAt + ) + let status = TestData.makeEntitlementStatus(license: license) + + #expect(status.certificateRenewalAt == envelopeRenewalAt) + } + + private static func makeLicense( + licenseClass: LicenseClass, + lifecycleState: LicenseLifecycleState, + expiresAt: Date? = TestData.baseNow.addingTimeInterval(3600), + serviceEndsAt: Date? = nil, + nextCertificateRenewalAt: Date? = nil + ) -> LicenseEnvelope { + LicenseEnvelope( + licenseID: "license-xyz", + licenseClass: licenseClass, + lifecycleState: lifecycleState, + issuedTo: "Acme", + issuedAt: TestData.baseNow, + expiresAt: expiresAt, + serviceEndsAt: serviceEndsAt, + nextCertificateRenewalAt: nextCertificateRenewalAt, + certificateChain: TestData.makeCertificateChain() + ) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/HostActivationCoordinatorTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/HostActivationCoordinatorTests.swift new file mode 100644 index 00000000..91ec83a5 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/HostActivationCoordinatorTests.swift @@ -0,0 +1,155 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +struct HostActivationCoordinatorTests { + @Test func fullLicenseUsesDirectPathAndSkipsTimestampFetch() async throws { + let fixture = CoordinatorFixture(licenseClass: .full) + let sut = fixture.makeHostCoordinator() + + let response = try await sut.activate(fixture.request) + + #expect(response.path == .direct) + #expect(response.secureTimestamp == nil) + #expect(fixture.timestampFetcher.fetchCount == 0) + #expect(fixture.timestampValidator.validateCount == 0) + #expect(fixture.activationIssuer.issueCount == 1) + #expect(fixture.activationIssuer.lastSecureTimestamp == nil) + } + + @Test func trialLicenseFetchesAndValidatesTimestampBeforeIssuingActivation() async throws { + let fixture = CoordinatorFixture(licenseClass: .trial) + let sut = fixture.makeHostCoordinator() + + let response = try await sut.activate(fixture.request) + + #expect(response.path == .timeAnchored) + #expect(response.secureTimestamp?.challengeNonce == fixture.request.challenge.nonce) + #expect(fixture.timestampFetcher.fetchCount == 1) + #expect(fixture.timestampValidator.validateCount == 1) + #expect(fixture.activationIssuer.issueCount == 1) + #expect(fixture.activationIssuer.lastSecureTimestamp?.challengeNonce == fixture.request.challenge.nonce) + #expect(fixture.replayProtector.reserveCount == 1) + } + + @Test func trialLicenseFailsWithoutTimestampProvider() async throws { + let fixture = CoordinatorFixture(licenseClass: .trial) + let sut = HostActivationCoordinator( + policy: fixture.policy, + clock: fixture.clock, + trustValidator: fixture.trustValidator, + challengeValidator: fixture.challengeValidator, + replayProtector: fixture.replayProtector, + activationIssuer: fixture.activationIssuer + ) + + await #expect(throws: AuthenticatorError.secureTimestampProviderUnavailable) { + try await sut.activate(fixture.request) + } + + #expect(fixture.activationIssuer.issueCount == 0) + } + + @Test func expiredLicenseStopsActivationBeforeIssue() async throws { + let fixture = CoordinatorFixture(licenseClass: .full, licenseExpiresAt: .secondsFromBase(-1)) + let sut = fixture.makeHostCoordinator() + + await #expect(throws: AuthenticatorError.licenseExpired) { + try await sut.activate(fixture.request) + } + + #expect(fixture.trustValidator.validateCount == 0) + #expect(fixture.activationIssuer.issueCount == 0) + } + + @Test func trustValidationFailureStopsActivation() async throws { + let fixture = CoordinatorFixture(licenseClass: .full) + fixture.trustValidator.error = AuthenticatorError.intermediateCertificateExpired + let sut = fixture.makeHostCoordinator() + + await #expect(throws: AuthenticatorError.intermediateCertificateExpired) { + try await sut.activate(fixture.request) + } + + #expect(fixture.trustValidator.validateCount == 1) + #expect(fixture.challengeValidator.validateCount == 0) + #expect(fixture.activationIssuer.issueCount == 0) + } + + @Test func challengeValidationFailureStopsActivation() async throws { + let fixture = CoordinatorFixture(licenseClass: .full) + fixture.challengeValidator.error = AuthenticatorError.challengeExpired + let sut = fixture.makeHostCoordinator() + + await #expect(throws: AuthenticatorError.challengeExpired) { + try await sut.activate(fixture.request) + } + + #expect(fixture.challengeValidator.validateCount == 1) + #expect(fixture.replayProtector.reserveCount == 0) + #expect(fixture.activationIssuer.issueCount == 0) + } + + @Test func replayProtectionFailureStopsActivation() async throws { + let fixture = CoordinatorFixture(licenseClass: .full) + fixture.replayProtector.error = AuthenticatorError.replayDetected + let sut = fixture.makeHostCoordinator() + + await #expect(throws: AuthenticatorError.replayDetected) { + try await sut.activate(fixture.request) + } + + #expect(fixture.replayProtector.reserveCount == 1) + #expect(fixture.activationIssuer.issueCount == 0) + } + + @Test func timestampFetchFailureStopsActivation() async throws { + let fixture = CoordinatorFixture(licenseClass: .trial) + fixture.timestampFetcher.error = TestFailure.fetchFailed + let sut = fixture.makeHostCoordinator() + + await #expect(throws: TestFailure.fetchFailed) { + try await sut.activate(fixture.request) + } + + #expect(fixture.timestampFetcher.fetchCount == 1) + #expect(fixture.timestampValidator.validateCount == 0) + #expect(fixture.activationIssuer.issueCount == 0) + } + + @Test func timestampValidationFailureStopsActivation() async throws { + let fixture = CoordinatorFixture(licenseClass: .trial) + fixture.timestampValidator.error = AuthenticatorError.secureTimestampRootMismatch + let sut = fixture.makeHostCoordinator() + + await #expect(throws: AuthenticatorError.secureTimestampRootMismatch) { + try await sut.activate(fixture.request) + } + + #expect(fixture.timestampFetcher.fetchCount == 1) + #expect(fixture.timestampValidator.validateCount == 1) + #expect(fixture.activationIssuer.issueCount == 0) + } + + @Test func activationIssuerFailurePropagates() async throws { + let fixture = CoordinatorFixture(licenseClass: .full) + fixture.activationIssuer.error = TestFailure.issueFailed + let sut = fixture.makeHostCoordinator() + + await #expect(throws: TestFailure.issueFailed) { + try await sut.activate(fixture.request) + } + + #expect(fixture.activationIssuer.issueCount == 1) + } + + @Test func perpetualLicenseWithoutExpiryIsAccepted() async throws { + let fixture = CoordinatorFixture(licenseClass: .full, licenseExpiresAt: .none) + let sut = fixture.makeHostCoordinator() + + let response = try await sut.activate(fixture.request) + + #expect(response.activation.licenseID == fixture.request.license.licenseID) + #expect(fixture.activationIssuer.issueCount == 1) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/LicenseSummaryMappingTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/LicenseSummaryMappingTests.swift new file mode 100644 index 00000000..19b0ab9e --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/LicenseSummaryMappingTests.swift @@ -0,0 +1,54 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +struct LicenseSummaryMappingTests { + @Test func trialStartDateComesFromLicenseSummaryIssuedAt() throws { + let trialStartedAt = TestData.baseNow.addingTimeInterval(-8 * 24 * 60 * 60) + let renewedLeaseIssuedAt = TestData.baseNow + let serviceEndsAt = trialStartedAt.addingTimeInterval(14 * 24 * 60 * 60) + let client = LookInsideAuthenticatorAPIClient() + let lease = TestData.makeIntermediateCertificateLease( + licenseID: "lic_trial_mapping", + issuedAt: renewedLeaseIssuedAt, + expiresAt: renewedLeaseIssuedAt.addingTimeInterval(24 * 60 * 60), + renewAfter: renewedLeaseIssuedAt, + issuanceReason: .renewal + ) + let summary = LicenseSummaryPayload( + licenseID: "lic_trial_mapping", + licenseClass: "trial", + lifecycleState: "active", + issuedTo: nil, + issuedAt: AuthServerTestISO8601.string(from: trialStartedAt), + email: nil, + productID: "prod_test", + servicePeriodEndAt: AuthServerTestISO8601.string(from: serviceEndsAt), + currentCertificateID: lease.certificateID, + renewAfter: AuthServerTestISO8601.string(from: lease.renewAfter), + lastVerifiedAt: AuthServerTestISO8601.string(from: renewedLeaseIssuedAt), + notices: [], + deviceWarning: nil, + canIssueCertificate: true, + blockedReason: nil + ) + + let license = try client.makeLicenseEnvelope( + from: summary, + currentLease: lease, + deviceID: lease.udid + ) + + #expect(license.issuedAt == trialStartedAt) + #expect(license.serviceEndsAt == serviceEndsAt) + #expect(license.certificateChain.intermediateIssuedAt == renewedLeaseIssuedAt) + } +} + +private enum AuthServerTestISO8601 { + static func string(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ServerSecureTimestampServiceTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ServerSecureTimestampServiceTests.swift new file mode 100644 index 00000000..d9077f24 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ServerSecureTimestampServiceTests.swift @@ -0,0 +1,59 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +struct ServerSecureTimestampServiceTests { + @Test func serviceValidatesTrustAndSignsTimestamp() async throws { + let fixture = CoordinatorFixture(licenseClass: .trial) + let service = ServerSecureTimestampService( + clock: fixture.clock, + trustValidator: fixture.trustValidator, + rootTimestampSigner: fixture.rootTimestampSigner + ) + + let token = try await service.issueTimestamp( + for: fixture.timestampRequest, + license: fixture.request.license + ) + + #expect(token.rootCertificateID == fixture.request.license.certificateChain.rootCertificateID) + #expect(fixture.trustValidator.validateCount == 1) + #expect(fixture.rootTimestampSigner.signCount == 1) + #expect(fixture.rootTimestampSigner.lastRequest == fixture.timestampRequest) + #expect(fixture.rootTimestampSigner.lastNow == fixture.now) + } + + @Test func trustValidationFailureStopsSigning() async throws { + let fixture = CoordinatorFixture(licenseClass: .trial) + fixture.trustValidator.error = AuthenticatorError.intermediateCertificateExpired + let service = ServerSecureTimestampService( + clock: fixture.clock, + trustValidator: fixture.trustValidator, + rootTimestampSigner: fixture.rootTimestampSigner + ) + + await #expect(throws: AuthenticatorError.intermediateCertificateExpired) { + try await service.issueTimestamp(for: fixture.timestampRequest, license: fixture.request.license) + } + + #expect(fixture.trustValidator.validateCount == 1) + #expect(fixture.rootTimestampSigner.signCount == 0) + } + + @Test func signerFailurePropagates() async throws { + let fixture = CoordinatorFixture(licenseClass: .trial) + fixture.rootTimestampSigner.error = TestFailure.signFailed + let service = ServerSecureTimestampService( + clock: fixture.clock, + trustValidator: fixture.trustValidator, + rootTimestampSigner: fixture.rootTimestampSigner + ) + + await #expect(throws: TestFailure.signFailed) { + try await service.issueTimestamp(for: fixture.timestampRequest, license: fixture.request.license) + } + + #expect(fixture.trustValidator.validateCount == 1) + #expect(fixture.rootTimestampSigner.signCount == 1) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ValidatorsTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ValidatorsTests.swift new file mode 100644 index 00000000..3a16f058 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Ported/ValidatorsTests.swift @@ -0,0 +1,220 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +struct DateBoundTrustChainValidatorTests { + @Test func acceptsIntermediateCertificateWithinWindow() throws { + let validator = DateBoundTrustChainValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let chain = TestData.makeCertificateChain( + intermediateIssuedAt: now.addingTimeInterval(-60), + intermediateExpiresAt: now.addingTimeInterval(60) + ) + + try validator.validate(chain, at: now) + } + + @Test func rejectsIntermediateCertificateBeforeIssueTime() throws { + let validator = DateBoundTrustChainValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let chain = TestData.makeCertificateChain( + intermediateIssuedAt: now.addingTimeInterval(31), + intermediateExpiresAt: now.addingTimeInterval(60) + ) + + #expect(throws: AuthenticatorError.intermediateCertificateNotYetValid) { + try validator.validate(chain, at: now) + } + } + + @Test func acceptsIntermediateCertificateWithinClockSkew() throws { + let validator = DateBoundTrustChainValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let chain = TestData.makeCertificateChain( + intermediateIssuedAt: now.addingTimeInterval(30), + intermediateExpiresAt: now.addingTimeInterval(60) + ) + + try validator.validate(chain, at: now) + } + + @Test func rejectsIntermediateCertificateAfterExpiry() throws { + let validator = DateBoundTrustChainValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let chain = TestData.makeCertificateChain( + intermediateIssuedAt: now.addingTimeInterval(-60), + intermediateExpiresAt: now.addingTimeInterval(-1) + ) + + #expect(throws: AuthenticatorError.intermediateCertificateExpired) { + try validator.validate(chain, at: now) + } + } +} + +struct DefaultChallengeValidatorTests { + @Test func acceptsChallengeInsideFreshnessWindow() throws { + let validator = DefaultChallengeValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let policy = ActivationPolicy(acceptedClockSkew: 30, challengeTimeToLive: 180) + let challenge = TestData.makeChallenge(issuedAt: now.addingTimeInterval(-60)) + + try validator.validate(challenge, now: now, policy: policy) + } + + @Test func acceptsChallengeAtFutureSkewBoundary() throws { + let validator = DefaultChallengeValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let policy = ActivationPolicy(acceptedClockSkew: 30, challengeTimeToLive: 180) + let challenge = TestData.makeChallenge(issuedAt: now.addingTimeInterval(30)) + + try validator.validate(challenge, now: now, policy: policy) + } + + @Test func rejectsChallengeBeyondFutureSkewBoundary() throws { + let validator = DefaultChallengeValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let policy = ActivationPolicy(acceptedClockSkew: 30, challengeTimeToLive: 180) + let challenge = TestData.makeChallenge(issuedAt: now.addingTimeInterval(31)) + + #expect(throws: AuthenticatorError.challengeIssuedInFuture) { + try validator.validate(challenge, now: now, policy: policy) + } + } + + @Test func acceptsChallengeAtExpirationBoundary() throws { + let validator = DefaultChallengeValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let policy = ActivationPolicy(acceptedClockSkew: 30, challengeTimeToLive: 180) + let challenge = TestData.makeChallenge(issuedAt: now.addingTimeInterval(-180)) + + try validator.validate(challenge, now: now, policy: policy) + } + + @Test func rejectsExpiredChallenge() throws { + let validator = DefaultChallengeValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let policy = ActivationPolicy(acceptedClockSkew: 30, challengeTimeToLive: 180) + let challenge = TestData.makeChallenge(issuedAt: now.addingTimeInterval(-181)) + + #expect(throws: AuthenticatorError.challengeExpired) { + try validator.validate(challenge, now: now, policy: policy) + } + } +} + +struct DefaultSecureTimestampValidatorTests { + @Test func acceptsMatchingFreshToken() throws { + let validator = DefaultSecureTimestampValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let policy = ActivationPolicy(secureTimestampTimeToLive: 120) + let request = TestData.makeTimestampRequest(challengeNonce: "nonce-123") + let trustedRootID = EmbeddedTrustedRoots.rootProd2026.certificateID + let token = SecureTimestampToken( + challengeNonce: "nonce-123", + signedAt: now.addingTimeInterval(-119), + rootCertificateID: trustedRootID, + signature: Data() + ) + + try validator.validate( + token, + against: request, + trustedRootCertificateID: trustedRootID, + now: now, + policy: policy + ) + } + + @Test func rejectsRootMismatch() throws { + let validator = DefaultSecureTimestampValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let request = TestData.makeTimestampRequest(challengeNonce: "nonce-123") + let trustedRootID = EmbeddedTrustedRoots.rootProd2026.certificateID + let token = SecureTimestampToken( + challengeNonce: "nonce-123", + signedAt: now, + rootCertificateID: "other-root", + signature: Data() + ) + + #expect(throws: AuthenticatorError.secureTimestampRootMismatch) { + try validator.validate( + token, + against: request, + trustedRootCertificateID: trustedRootID, + now: now, + policy: ActivationPolicy() + ) + } + } + + @Test func rejectsNonceMismatch() throws { + let validator = DefaultSecureTimestampValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let request = TestData.makeTimestampRequest(challengeNonce: "nonce-123") + let trustedRootID = EmbeddedTrustedRoots.rootProd2026.certificateID + let token = SecureTimestampToken( + challengeNonce: "nonce-other", + signedAt: now, + rootCertificateID: trustedRootID, + signature: Data() + ) + + #expect(throws: AuthenticatorError.secureTimestampNonceMismatch) { + try validator.validate( + token, + against: request, + trustedRootCertificateID: trustedRootID, + now: now, + policy: ActivationPolicy() + ) + } + } + + @Test func acceptsTokenAtExpirationBoundary() throws { + let validator = DefaultSecureTimestampValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let policy = ActivationPolicy(secureTimestampTimeToLive: 120) + let request = TestData.makeTimestampRequest(challengeNonce: "nonce-123") + let trustedRootID = EmbeddedTrustedRoots.rootProd2026.certificateID + let token = SecureTimestampToken( + challengeNonce: "nonce-123", + signedAt: now.addingTimeInterval(-120), + rootCertificateID: trustedRootID, + signature: Data() + ) + + try validator.validate( + token, + against: request, + trustedRootCertificateID: trustedRootID, + now: now, + policy: policy + ) + } + + @Test func rejectsExpiredToken() throws { + let validator = DefaultSecureTimestampValidator() + let now = Date(timeIntervalSince1970: 1_710_000_000) + let policy = ActivationPolicy(secureTimestampTimeToLive: 120) + let request = TestData.makeTimestampRequest(challengeNonce: "nonce-123") + let trustedRootID = EmbeddedTrustedRoots.rootProd2026.certificateID + let token = SecureTimestampToken( + challengeNonce: "nonce-123", + signedAt: now.addingTimeInterval(-121), + rootCertificateID: trustedRootID, + signature: Data() + ) + + #expect(throws: AuthenticatorError.secureTimestampExpired) { + try validator.validate( + token, + against: request, + trustedRootCertificateID: trustedRootID, + now: now, + policy: policy + ) + } + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/ActivationRuntimeTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/ActivationRuntimeTests.swift new file mode 100644 index 00000000..3c269f0c --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/ActivationRuntimeTests.swift @@ -0,0 +1,278 @@ +import Foundation +import IOKit +@testable import LookInsideActivation +import Testing + +/// The in-process facade that replaces the helper's socket RPCs. Every +/// runtime here uses a temporary state directory, a test-only key tag in a +/// keychain path that does not exist, and an unreachable local service URL. +struct ActivationRuntimeTests { + private final class DecisionRecorder: @unchecked Sendable { + private let lock = NSLock() + private var stored: [ActivationAccessDecision] = [] + + func append(_ decision: ActivationAccessDecision) { + lock.withLock { stored.append(decision) } + } + + var decisions: [ActivationAccessDecision] { + lock.withLock { stored } + } + } + + private final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + + init(_ date: Date) { + current = date + } + + var now: Date { + get { lock.withLock { current } } + set { lock.withLock { current = newValue } } + } + } + + private func makeRuntime( + fixture: String?, + clock: Clock + ) throws -> (ActivationRuntime, URL) { + let directory = try HelperFixtures.makeStateDirectory(copying: fixture) + let runtime = ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: directory), + urlSession: .shared, + now: { clock.now }, + silentRenewalEnabled: false + ) + return (runtime, directory) + } + + @Test func emptyStateIsNotActivatedAndBlocks() async throws { + let (runtime, directory) = try makeRuntime(fixture: nil, clock: Clock(HelperFixtureValues.base)) + defer { try? FileManager.default.removeItem(at: directory) } + + #expect(runtime.activationState == .notActivated) + let decision = await runtime.currentDecision() + #expect(decision == .activationRequired) + #expect(await runtime.hasLicenseMaterial() == false) + #expect(FileManager.default.fileExists(atPath: directory.appendingPathComponent("state.json").path) == false) + } + + @Test func helperStateIsActivatedBeforeFirstAsyncCall() async throws { + let input = try HelperFixtures.challengeInput() + let (runtime, directory) = try makeRuntime( + fixture: "helper-state-full.json", + clock: Clock(input.evaluatedAt) + ) + defer { try? FileManager.default.removeItem(at: directory) } + + #expect(runtime.activationState == .activated) + let decision = await runtime.currentDecision() + #expect( + try ActivationStateCoding.encoder.encode(decision) + == (HelperFixtures.data("helper-check-access.json")) + ) + #expect(await runtime.hasLicenseMaterial()) + let snapshot = await runtime.snapshot() + #expect(snapshot.activationResponse?.activation.licenseID == "lic_fixture_full") + } + + /// Offline behaviour: access holds until the lease ends, then blocks. + @Test func decisionFollowsTheClockOfflineAndPublishesChanges() async throws { + let input = try HelperFixtures.challengeInput() + let clock = Clock(input.evaluatedAt) + let (runtime, directory) = try makeRuntime(fixture: "helper-state-full.json", clock: clock) + defer { try? FileManager.default.removeItem(at: directory) } + + let recorder = DecisionRecorder() + let observation = runtime.addDecisionObserver { recorder.append($0) } + defer { observation.cancel() } + #expect(recorder.decisions.map(\.decision) == [.allow]) + + await runtime.currentDecision() + #expect(recorder.decisions.count == 1) + + clock.now = input.expiredAt + let expired = await runtime.currentDecision() + #expect(expired.decision == .block) + #expect(runtime.activationState == .notActivated) + #expect(recorder.decisions.map(\.decision) == [.allow, .block]) + + observation.cancel() + clock.now = input.evaluatedAt + await runtime.currentDecision() + #expect(recorder.decisions.count == 2) + #expect(runtime.activationState == .activated) + } + + @Test func decisionStreamStartsWithCurrentDecision() async throws { + let input = try HelperFixtures.challengeInput() + let (runtime, directory) = try makeRuntime( + fixture: "helper-state-full.json", + clock: Clock(input.evaluatedAt) + ) + defer { try? FileManager.default.removeItem(at: directory) } + + var iterator = runtime.decisionUpdates().makeAsyncIterator() + let first = await iterator.next() + #expect(first?.decision == .allow) + } + + @Test func signChallengeRejectsMalformedInputLikeHelper() async throws { + let input = try HelperFixtures.challengeInput() + let (runtime, directory) = try makeRuntime( + fixture: "helper-state-full.json", + clock: Clock(input.evaluatedAt) + ) + defer { try? FileManager.default.removeItem(at: directory) } + + await #expect(throws: ActivationError.invalidRequest("`nonce` must be 32 hex-encoded bytes.")) { + try await runtime.signChallenge(nonce: Data(count: 16), serverInstanceID: "srv") + } + await #expect( + throws: ActivationError.invalidRequest("`server_instance_id` must be a non-empty UTF-8 string.") + ) { + try await runtime.signChallenge(nonce: input.nonce, serverInstanceID: "") + } + } + + @Test func signChallengeRefusesWithoutActivation() async throws { + let (runtime, directory) = try makeRuntime(fixture: nil, clock: Clock(HelperFixtureValues.base)) + defer { try? FileManager.default.removeItem(at: directory) } + + do { + _ = try await runtime.signChallenge(nonce: Data(count: 32), serverInstanceID: "srv") + Issue.record("expected a refusal") + } catch let error as ActivationError { + #expect(error.errorCode == "license_not_activated") + } + } + + /// `currentDecision()` schedules the silent renewal once the lease is in + /// its renewal window and the Host allowed renewal after its first + /// window; a failed attempt (unreachable local service) is recorded in + /// `state.json` for the connectivity warning. + @Test func currentDecisionSchedulesSilentRenewal() async throws { + let input = try HelperFixtures.challengeInput() + let directory = try HelperFixtures.makeStateDirectory(copying: "helper-state-full.json") + defer { try? FileManager.default.removeItem(at: directory) } + let renewalTime = input.evaluatedAt.addingTimeInterval(22 * HelperFixtureValues.day) + let clock = Clock(renewalTime) + let runtime = ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: directory), + urlSession: .shared, + now: { clock.now } + ) + + // Launching: no window yet, so no renewal (and no keychain prompt). + let storedAttempt = await runtime.snapshot().lastRenewAttemptAt + await runtime.currentDecision() + try await Task.sleep(for: .milliseconds(300)) + #expect(await runtime.snapshot().lastRenewAttemptAt == storedAttempt) + + runtime.noteFirstWindowShown() + await runtime.currentDecision() + try await Task.sleep(for: .milliseconds(300)) + #expect(await runtime.snapshot().lastRenewAttemptAt == storedAttempt) + + let launchDelayLater = renewalTime.addingTimeInterval(ActivationSigningPolicy.launchDelay) + clock.now = launchDelayLater + await runtime.currentDecision() + var recorded: Date? + for _ in 0 ..< 100 { + recorded = await runtime.snapshot().lastRenewFailedAt + if recorded == launchDelayLater { + break + } + try await Task.sleep(for: .milliseconds(50)) + } + #expect(recorded == launchDelayLater) + let onDisk = ActivationStateStore.loadState(from: directory.appendingPathComponent("state.json")) + #expect(onDisk.lastRenewFailedAt == launchDelayLater) + #expect(onDisk.lastRenewAttemptAt == launchDelayLater) + } + + /// A trial lives in memory only; a new runtime over the same directory + /// does not see it, like a relaunched helper. + @Test func trialStateDoesNotSurviveRuntimeRestart() async throws { + let directory = try HelperFixtures.makeStateDirectory(copying: "helper-state-trial.json") + defer { try? FileManager.default.removeItem(at: directory) } + let runtime = ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: directory), + urlSession: .shared, + now: { HelperFixtureValues.base }, + silentRenewalEnabled: false + ) + + #expect(runtime.activationState == .notActivated) + let snapshot = await runtime.snapshot() + #expect(snapshot.deviceFingerprint == HelperFixtureValues.fingerprint) + #expect(snapshot.entitlementStatus == nil) + } + + /// `ActivationDeviceFingerprint` reads the same IOPlatformUUID the helper + /// used as the device identifier. The value is compared, never printed. + @Test func fingerprintUsesIOPlatformUUID() throws { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice")) + try #require(service != 0) + defer { IOObjectRelease(service) } + let expected = + IORegistryEntryCreateCFProperty( + service, + kIOPlatformUUIDKey as CFString, + kCFAllocatorDefault, + 0 + )?.takeRetainedValue() as? String + + let fingerprint = try ActivationDeviceFingerprint.current() + #expect(fingerprint.deviceID == expected) + #expect(fingerprint.appBundleID == "app.lookinside.LookInsideAuthServer") + #expect(fingerprint.operatingSystemVersion == ProcessInfo.processInfo.operatingSystemVersionString) + } + + #if DEBUG + @Test func debugOverridesRedirectServiceAndAddTrustedRoot() { + let testRoot = TrustedRootPublicKey( + certificateID: "root_test_activation", + publicKeyPEM: "-----BEGIN PUBLIC KEY-----\nAA==\n-----END PUBLIC KEY-----\n", + publicKeySHA256: "" + ) + let url = URL(string: "http://127.0.0.1:8787")! + ActivationDebugOverrides.serviceBaseURL = url + ActivationDebugOverrides.additionalTrustedRoot = testRoot + defer { + ActivationDebugOverrides.serviceBaseURL = nil + ActivationDebugOverrides.additionalTrustedRoot = nil + } + + #expect(ActivationConfiguration.standard.serviceBaseURL == url) + #expect(EmbeddedTrustedRoots.publicKey(for: "root_test_activation") == testRoot) + #expect(EmbeddedTrustedRoots.publicKey(for: "root_prod_2026") == EmbeddedTrustedRoots.rootProd2026) + } + + @Test func debugOverridesRedirectStateDirectoryAndKeychain() { + let standard = ActivationConfiguration.standard + #expect(standard.stateDirectoryURL == ActivationConfiguration.standardStateDirectoryURL) + #expect(standard.keyStore == .standard) + + let stateDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("activation-debug-override-\(UUID().uuidString)", isDirectory: true) + let keychainPath = stateDirectory.appendingPathComponent("test.keychain-db").path + ActivationDebugOverrides.stateDirectoryURL = stateDirectory + ActivationDebugOverrides.keychainPath = keychainPath + defer { + ActivationDebugOverrides.stateDirectoryURL = nil + ActivationDebugOverrides.keychainPath = nil + } + + let redirected = ActivationConfiguration.standard + #expect(redirected.stateDirectoryURL == stateDirectory) + #expect(redirected.stateFileURL.deletingLastPathComponent() == stateDirectory) + #expect(redirected.keyStore.keychainPath == keychainPath) + #expect(redirected.keyStore.applicationTag == ActivationDebugOverrides.testKeyApplicationTag) + #expect(redirected.keyStore.applicationTag != IntermediateKeyStoreConfiguration.helperApplicationTag) + #expect(redirected.keyStore.trustedApplicationPaths.isEmpty) + } + #endif +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/ActivationWindowBehaviorTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/ActivationWindowBehaviorTests.swift new file mode 100644 index 00000000..7c4d3971 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/ActivationWindowBehaviorTests.swift @@ -0,0 +1,67 @@ +import AppKit +import Foundation +@testable import LookInsideActivation +@testable import LookInsideActivationUI +import Testing + +/// The activation UI now runs inside the Host: closing it must never end the +/// process, and alerts raised by background flows must not run a modal loop. +@MainActor +struct ActivationWindowBehaviorTests { + private func makeController() throws -> (ActivationWindowController, URL) { + _ = NSApplication.shared + let directory = try HelperFixtures.makeStateDirectory() + let runtime = ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: directory), + urlSession: .shared, + now: { TestData.baseNow }, + silentRenewalEnabled: false + ) + return (ActivationWindowController(model: runtime.activationModel()), directory) + } + + /// The license summary's Done button calls `dismiss()`'s path; this test + /// returning at all shows the process was not ended. + @Test func doneClosesTheWindowAndKeepsTheProcess() throws { + let (controller, directory) = try makeController() + defer { try? FileManager.default.removeItem(at: directory) } + + controller.activationWindow.orderFront(nil) + #expect(controller.activationWindow.isVisible) + controller.dismiss() + #expect(controller.activationWindow.isVisible == false) + // Closed, not released: the window can be shown again. + controller.activationWindow.orderFront(nil) + #expect(controller.activationWindow.isVisible) + controller.dismiss() + } + + @Test func directActionsMayBeModalBackgroundFlowsNeverAre() { + typealias Coordinator = ActivationWindowCoordinator + #expect(Coordinator.alertTarget(for: .modal, activationWindowIsVisible: false) == .appModal) + #expect(Coordinator.alertTarget(for: .modal, activationWindowIsVisible: true) == .activationWindow) + #expect( + Coordinator.alertTarget(for: .attached(to: nil), activationWindowIsVisible: true) == .activationWindow + ) + #expect( + Coordinator.alertTarget(for: .attached(to: nil), activationWindowIsVisible: false) + == .shownActivationWindow + ) + + let hidden = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 200, height: 100), + styleMask: [.titled], + backing: .buffered, + defer: true + ) + hidden.isReleasedWhenClosed = false + #expect( + Coordinator.alertTarget(for: .attached(to: hidden), activationWindowIsVisible: false) + == .shownActivationWindow + ) + + hidden.orderFront(nil) + #expect(Coordinator.alertTarget(for: .attached(to: hidden), activationWindowIsVisible: false) == .sheet(hidden)) + hidden.orderOut(nil) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/KeyUseRulesTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/KeyUseRulesTests.swift new file mode 100644 index 00000000..808cd3d9 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/KeyUseRulesTests.swift @@ -0,0 +1,895 @@ +import Foundation +@testable import LookInsideActivation +import Security +import Testing + +/// For a key the 2.3.x helper created, every use raises a keychain prompt +/// until the user picks Always Allow. These tests pin the rules that keep +/// automatic uses bounded: renewal backoff and CSR reuse, the one-at-a-time +/// key queue, the end of a denial wait, and the keychain explainer. +struct KeyUseRulesTests { + final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + + init(_ date: Date) { + current = date + } + + var now: Date { + get { lock.withLock { current } } + set { lock.withLock { current = newValue } } + } + } + + final class Log: @unchecked Sendable { + private let lock = NSLock() + private var stored: [String] = [] + + func append(_ entry: String) { + lock.withLock { stored.append(entry) } + } + + var values: [String] { + lock.withLock { stored } + } + + func count(of entry: String) -> Int { + values.filter { $0 == entry }.count + } + } + + /// An issuer that fails while `failures` is above zero, recording each + /// CSR it receives. + final class Issuer: IntermediateCertificateIssuing, @unchecked Sendable { + private let lock = NSLock() + private var remainingFailures: Int + private var received: [String] = [] + + init(failures: Int) { + remainingFailures = failures + } + + var csrs: [String] { + lock.withLock { received } + } + + func issueIntermediateCertificate( + _ request: IntermediateCertificateIssueRequest + ) async throws -> IntermediateCertificateLease { + let fails: Bool = lock.withLock { + received.append(request.certificateSigningRequestPEM) + guard remainingFailures > 0 else { return false } + remainingFailures -= 1 + return true + } + if fails { + throw URLError(.notConnectedToInternet) + } + return TestData.makeIntermediateCertificateLease( + certificateID: "renewed", + issuedAt: TestData.baseNow, + expiresAt: TestData.baseNow.addingTimeInterval(30 * 24 * 3600), + renewAfter: TestData.baseNow.addingTimeInterval(20 * 24 * 3600) + ) + } + } + + final class Box: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + + init(_ value: Value) { + stored = value + } + + var value: Value { + get { lock.withLock { stored } } + set { lock.withLock { stored = newValue } } + } + } + + static let launch = TestData.baseNow + static let afterLaunch = launch.addingTimeInterval(ActivationSigningPolicy.launchDelay) + + static func makeDueStore( + now: Date, + expiresIn: TimeInterval = 6 * 24 * 3600, + lastFailureAt: Date? = nil + ) async throws -> ActivationStateStore { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideActivationTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("state.json") + let store = ActivationStateStore(url: url) + let license = LicenseEnvelope( + licenseID: "license-123", + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: now.addingTimeInterval(-86400), + expiresAt: now.addingTimeInterval(365 * 86400), + certificateChain: TestData.makeCertificateChain() + ) + let lease = TestData.makeIntermediateCertificateLease( + issuedAt: now.addingTimeInterval(-20 * 86400), + expiresAt: now.addingTimeInterval(expiresIn), + renewAfter: now.addingTimeInterval(-3600) + ) + try await store.recordActivationSession( + TestData.makeActivationSession(expiresAt: now.addingTimeInterval(3 * 86400)) + ) + try await store.recordEntitlementStatus(TestData.makeEntitlementStatus(license: license, currentLease: lease)) + if let lastFailureAt { + try await store.recordRenewAttempt(success: false, error: "offline", at: lastFailureAt) + } + return store + } + + // MARK: - Renewal backoff + + @Test func retryDelaysGrowAndCapAtTwelveHours() { + let delays = (1 ... 7).map { ActivationCertificateRenewer.retryDelay(afterFailures: $0, isUrgent: false) } + #expect(delays == [300, 900, 3600, 21600, 43200, 43200, 43200]) + let urgent = (1 ... 7).map { ActivationCertificateRenewer.retryDelay(afterFailures: $0, isUrgent: true) } + #expect(urgent == [300, 900, 3600, 3600, 3600, 3600, 3600]) + #expect(ActivationCertificateRenewer.retryDelay(afterFailures: 0, isUrgent: false) == 0) + } + + /// Offline: the issuer fails after the CSR was signed. Retries reuse + /// that CSR, so a one-time-Allow user sees one prompt for the whole run. + @Test func retriesReuseTheSignedCSRWhileTheKeyIsUnchanged() async throws { + let clock = Clock(Self.launch) + let store = try await Self.makeDueStore(now: Self.launch) + let issuer = Issuer(failures: 3) + let signings = Log() + let identity = Box(Data([1])) + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { issuer }, + csrProvider: { + signings.append("sign") + return "csr-\(signings.values.count)" + }, + keyIdentity: { identity.value }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: .open(now: { clock.now }) + ) + + await renewer.tryRenewIfDue() + for minutes in [5.0, 20, 80] { + clock.now = Self.launch.addingTimeInterval(minutes * 60) + await renewer.tryRenewIfDue() + } + #expect(signings.values.count == 1) + #expect(issuer.csrs == ["csr-1", "csr-1", "csr-1", "csr-1"]) + #expect(await store.snapshot().lastRenewSucceededAt == clock.now) + + // The next cycle signs a new CSR. + try await store.recordIssuedLease( + TestData.makeIntermediateCertificateLease( + issuedAt: Self.launch, + expiresAt: clock.now.addingTimeInterval(3 * 86400), + renewAfter: clock.now + ) + ) + await renewer.tryRenewIfDue() + #expect(signings.values.count == 2) + } + + @Test func aChangedKeySignsAgain() async throws { + let clock = Clock(Self.launch) + let store = try await Self.makeDueStore(now: Self.launch) + let issuer = Issuer(failures: 1) + let signings = Log() + let identity = Box(Data([1])) + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { issuer }, + csrProvider: { + signings.append("sign") + return "csr-\(signings.values.count)" + }, + keyIdentity: { identity.value }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: .open(now: { clock.now }) + ) + + await renewer.tryRenewIfDue() + identity.value = Data([2]) + clock.now = Self.launch.addingTimeInterval(5 * 60) + await renewer.tryRenewIfDue() + #expect(issuer.csrs == ["csr-1", "csr-2"]) + } + + /// An issuer that fails once with `error`, then succeeds. + final class FailingOnceIssuer: IntermediateCertificateIssuing, @unchecked Sendable { + private let lock = NSLock() + private var pending: (any Error)? + private var received: [String] = [] + + init(_ error: any Error) { + pending = error + } + + var csrs: [String] { + lock.withLock { received } + } + + func issueIntermediateCertificate( + _ request: IntermediateCertificateIssueRequest + ) async throws -> IntermediateCertificateLease { + let error: (any Error)? = lock.withLock { + received.append(request.certificateSigningRequestPEM) + defer { pending = nil } + return pending + } + if let error { + throw error + } + return TestData.makeIntermediateCertificateLease(certificateID: "renewed") + } + } + + /// A permanent rejection (4xx) drops the cached CSR, so the retry signs + /// a fresh one; a 5xx, a timeout or rate limiting keeps it. + @Test(arguments: [(400, true), (422, true), (401, true), (408, false), (429, false), (500, false), (503, false)]) + func aPermanentRejectionDropsTheCachedCSR(statusCode: Int, signsAgain: Bool) async throws { + let clock = Clock(Self.launch) + let store = try await Self.makeDueStore(now: Self.launch) + let issuer = FailingOnceIssuer( + LookInsideAuthenticatorAPIClientError.api(statusCode: statusCode, code: "rejected", message: "no") + ) + let signings = Log() + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { issuer }, + csrProvider: { + signings.append("sign") + return "csr-\(signings.values.count)" + }, + keyIdentity: { Data([1]) }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: .open(now: { clock.now }) + ) + + await renewer.tryRenewIfDue() + clock.now = Self.launch.addingTimeInterval(5 * 60) + await renewer.tryRenewIfDue() + #expect(issuer.csrs == ["csr-1", signsAgain ? "csr-2" : "csr-1"]) + #expect(ActivationCertificateRenewer.rejectsCSRPermanently(URLError(.timedOut)) == false) + } + + @Test func withoutAKeyIdentityEachAttemptSignsOnce() async throws { + let clock = Clock(Self.launch) + let store = try await Self.makeDueStore(now: Self.launch) + let issuer = Issuer(failures: 5) + let signings = Log() + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { issuer }, + csrProvider: { + signings.append("sign") + return "csr" + }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: .open(now: { clock.now }) + ) + // Calls every 30 s for an hour: attempts at 0, 5 and 20 minutes. + for step in 0 ... 120 { + clock.now = Self.launch.addingTimeInterval(Double(step) * 30) + await renewer.tryRenewIfDue() + } + #expect(signings.values.count == 3) + #expect(issuer.csrs.count == 3) + } + + @Test func inTheLastDayRetriesAreAtMostHourly() async throws { + let clock = Clock(Self.launch) + let store = try await Self.makeDueStore(now: Self.launch, expiresIn: 20 * 3600) + let issuer = Issuer(failures: 100) + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { issuer }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: .open(now: { clock.now }) + ) + for step in 0 ... (6 * 60) { + clock.now = Self.launch.addingTimeInterval(Double(step) * 60) + await renewer.tryRenewIfDue() + } + // 0, 5 min, 20 min, 80 min, then hourly: 140, 200, 260, 320 min. + #expect(issuer.csrs.count == 8) + } + + @Test func aFailureFromAnEarlierLaunchDelaysTheFirstAttempt() async throws { + let clock = Clock(Self.launch) + let store = try await Self.makeDueStore(now: Self.launch, lastFailureAt: Self.launch.addingTimeInterval(-60)) + let issuer = Issuer(failures: 0) + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { issuer }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: .open(now: { clock.now }) + ) + await renewer.tryRenewIfDue() + #expect(issuer.csrs.isEmpty) + clock.now = Self.launch.addingTimeInterval(4 * 60) + await renewer.tryRenewIfDue() + #expect(issuer.csrs.count == 1) + } + + /// A slow automatic signing was a one-time Allow: renewal does not sign + /// again for six hours, unless the lease ends within a day. + @Test(arguments: [false, true]) + func slowAutomaticSigningPausesRenewal(leaseEndsWithinADay: Bool) async throws { + let clock = Clock(Self.afterLaunch) + let store = try await Self.makeDueStore( + now: Self.afterLaunch, + expiresIn: leaseEndsWithinADay ? 20 * 3600 : 6 * 86400 + ) + let issuer = Issuer(failures: 0) + let gate = ActivationSigningGate(launchedAt: Self.launch, now: { clock.now }) + gate.noteFirstWindowShown() + gate.record(.succeeded(duration: 4), from: .licenseHandshake, channel: "c1") + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { issuer }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: gate + ) + + clock.now = Self.afterLaunch.addingTimeInterval(5 * 3600) + await renewer.tryRenewIfDue() + #expect(issuer.csrs.count == (leaseEndsWithinADay ? 1 : 0)) + + clock.now = Self.afterLaunch.addingTimeInterval(6 * 3600) + await renewer.tryRenewIfDue() + #expect(issuer.csrs.count == 1) + } + + @Test func aHeldBackAttemptIsNotAFailure() async throws { + let clock = Clock(Self.launch) + let store = try await Self.makeDueStore(now: Self.launch) + let issuer = Issuer(failures: 0) + let gate = ActivationSigningGate(launchedAt: Self.launch, now: { clock.now }) + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { issuer }, + csrProvider: { "csr" }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: gate + ) + await renewer.tryRenewIfDue() + #expect(await store.snapshot().lastRenewAttemptAt == nil) + + gate.noteFirstWindowShown() + clock.now = Self.afterLaunch + await renewer.tryRenewIfDue() + #expect(issuer.csrs.count == 1) + } + + // MARK: - One key use at a time + + /// A signer that blocks until released, recording how many uses run at + /// once. + final class BlockingSigner: @unchecked Sendable { + private let lock = NSLock() + private var running = 0 + private(set) var maximumRunning = 0 + private var gates: [CheckedContinuation] = [] + private var started: [String] = [] + + var startedLabels: [String] { + lock.withLock { started } + } + + var waitingCount: Int { + lock.withLock { gates.count } + } + + func sign(_ label: String) async { + lock.withLock { + running += 1 + maximumRunning = max(maximumRunning, running) + started.append(label) + } + await withCheckedContinuation { continuation in + lock.withLock { gates.append(continuation) } + } + lock.withLock { running -= 1 } + } + + /// Lets the oldest blocked use finish. + func releaseOne() { + let next: CheckedContinuation? = lock.withLock { + gates.isEmpty ? nil : gates.removeFirst() + } + next?.resume() + } + } + + /// Polls `condition` until it holds. The budget (30 s) only matters + /// on a loaded machine; normally the condition holds within a few polls. + static func waitUntil(_ condition: () -> Bool) async throws { + for _ in 0 ..< 6000 where condition() == false { + try await Task.sleep(for: .milliseconds(5)) + } + #expect(condition()) + } + + @Test func keyUsesRunOneAtATimeAndUserActionsGoFirst() async throws { + let gate = ActivationSigningGate.open() + let signer = BlockingSigner() + let first = Task { try await gate.perform(.licenseHandshake, channel: "c1") { await signer.sign("c1") } } + try await Self.waitUntil { signer.startedLabels == ["c1"] } + + // Each use joins the queue before the next one starts, so the + // queue order is fixed: c2, renewal, then user jumping ahead of both. + let second = Task { try await gate.perform(.licenseHandshake, channel: "c2") { await signer.sign("c2") } } + try await Self.waitUntil { gate.queuedUseCount == 1 } + let renewal = Task { try await gate.perform(.silentRenewal) { await signer.sign("renewal") } } + try await Self.waitUntil { gate.queuedUseCount == 2 } + let user = Task { try await gate.perform(.userAction) { await signer.sign("user") } } + try await Self.waitUntil { gate.queuedUseCount == 3 } + #expect(signer.startedLabels == ["c1"]) + + for expected in [["c1", "user"], ["c1", "user", "c2"], ["c1", "user", "c2", "renewal"]] { + signer.releaseOne() + try await Self.waitUntil { signer.startedLabels == expected } + } + signer.releaseOne() + for task in [first, second, renewal, user] { + try await task.value + } + #expect(signer.maximumRunning == 1) + } + + /// A denial in the use in progress cancels every automatic use queued + /// behind it. + @Test func aDenialCancelsTheAutomaticUsesQueuedBehindIt() async throws { + let gate = ActivationSigningGate.open() + let signer = BlockingSigner() + let ran = Log() + let denied = Task { + try await gate.perform(.licenseHandshake, channel: "c1") { + await signer.sign("c1") + throw ActivationError.keychainAccessDenied(errSecUserCanceled) + } + } + try await Self.waitUntil { signer.startedLabels == ["c1"] } + let queued = [ + Task { try await gate.perform(.licenseHandshake, channel: "c2") { ran.append("c2") } }, + Task { try await gate.perform(.silentRenewal) { ran.append("renewal") } }, + ] + try await Self.waitUntil { gate.queuedUseCount == 2 } + + signer.releaseOne() + await #expect(throws: ActivationError.keychainAccessDenied(errSecUserCanceled)) { try await denied.value } + for task in queued { + await #expect(throws: ActivationError.signingDeferred) { try await task.value } + } + #expect(ran.values.isEmpty) + // Try Again still asks, and clears the wait. + try await gate.perform(.userAction) { ran.append("user") } + try await gate.perform(.licenseHandshake, channel: "c2") { ran.append("c2") } + #expect(ran.values == ["user", "c2"]) + } + + /// Time spent waiting in the queue is not keychain time: a fast use + /// that waited still counts as signing without a prompt, and its + /// measured duration leaves the wait out. + @Test func durationIsMeasuredFromTheTurn() async throws { + let (markers, stored) = Self.markers() + markers.noteExplainerConfirmed("key-a") + let gate = ActivationSigningGate( + launchedAt: .distantPast, + now: { Date() }, + markers: markers, + keyIdentity: { "key-a" } + ) + gate.noteFirstWindowShown() + let signer = BlockingSigner() + let blocking = Task { try await gate.perform(.userAction) { await signer.sign("user") } } + try await Self.waitUntil { signer.startedLabels == ["user"] } + let waiting = Task { try await gate.performMeasured(.licenseHandshake, channel: "c1") { () } } + try await Task.sleep(for: .milliseconds(1200)) + signer.releaseOne() + try await blocking.value + let measured = try await waiting.value + #expect(measured.duration < ActivationSigningPolicy.promptThreshold) + #expect(stored.value[KeychainAccessMarkers.keySignedWithoutPromptKey] == "key-a") + } + + // MARK: - End of a denial wait + + final class TimerRecorder: @unchecked Sendable { + private let lock = NSLock() + private var stored: [(TimeInterval, @Sendable () -> Void)] = [] + + var scheduler: ActivationSigningGate.TimerScheduler { + { [self] delay, fire in lock.withLock { stored.append((delay, fire)) } } + } + + var delays: [TimeInterval] { + lock.withLock { stored.map(\.0) } + } + + func fireLast() { + let fire = lock.withLock { stored.last?.1 } + fire?() + } + } + + @Test func policyEndsTheWaitAndFreesOnlyDeniedChannels() { + var policy = ActivationSigningPolicy(launchedAt: Self.launch) + policy.noteFirstWindowShown() + policy.record(.denied, from: .licenseHandshake, channel: "denied", at: Self.afterLaunch) + policy.noteHandshakeFailed(onChannel: "rejected") + + policy.noteDenialWaitEnded(at: Self.afterLaunch.addingTimeInterval(59 * 60)) + #expect(policy.deniedUntil != nil) + + let end = Self.afterLaunch.addingTimeInterval(3600) + policy.noteDenialWaitEnded(at: end) + #expect(policy.deniedUntil == nil) + #expect(policy.allowsHandshake(onChannel: "denied", at: end)) + #expect(policy.allowsHandshake(onChannel: "rejected", at: end) == false) + // The count stays: the next denial waits a day. + policy.record(.denied, from: .silentRenewal, at: end) + #expect(policy.deniedUntil == end.addingTimeInterval(24 * 3600)) + } + + @Test func gateFiresATimerWhenTheWaitEnds() { + let clock = Clock(Self.afterLaunch) + let timers = TimerRecorder() + let gate = ActivationSigningGate(launchedAt: Self.launch, now: { clock.now }, scheduleTimer: timers.scheduler) + gate.noteFirstWindowShown() + let statuses = Box<[ActivationSigningStatus]>([]) + let observation = gate.addObserver { status in statuses.value.append(status) } + defer { observation.cancel() } + + gate.record(.denied, from: .licenseHandshake, channel: "c1") + #expect(timers.delays == [3600]) + #expect(gate.allowsHandshake(onChannel: "c1") == false) + + // A timer that fires early sets itself again. + clock.now = Self.afterLaunch.addingTimeInterval(1800) + timers.fireLast() + #expect(timers.delays == [3600, 1800]) + #expect(gate.status.isKeychainAccessDenied(at: clock.now)) + + let generation = gate.status.handshakeGeneration + clock.now = Self.afterLaunch.addingTimeInterval(3600) + timers.fireLast() + #expect(gate.status.keychainAccessDeniedUntil == nil) + #expect(gate.status.handshakeGeneration == generation + 1) + #expect(gate.allowsHandshake(onChannel: "c1")) + #expect(statuses.value.last?.isKeychainAccessDenied(at: clock.now) == false) + } + + // MARK: - Keychain explainer + + static func markers() -> (KeychainAccessMarkers, Box<[String: String]>) { + let stored = Box<[String: String]>([:]) + let markers = KeychainAccessMarkers( + read: { stored.value[$0] }, + write: { stored.value[$0] = $1 } + ) + return (markers, stored) + } + + /// A gate over a stored key named `key` (`nil`: no key), counting how + /// often the key's identity is read in `identityReads`. + static func explainerGate( + clock: Clock, + markers: KeychainAccessMarkers, + key: String? = "key-a", + identityReads: Log = Log() + ) -> ActivationSigningGate { + ActivationSigningGate( + launchedAt: launch, + now: { clock.now }, + markers: markers, + keyIdentity: { + identityReads.append("read") + return key + }, + scheduleTimer: { _, _ in } + ) + } + + @Test func policyAsksForTheExplainerOnlyWhenNothingElseHoldsTheKeyBack() { + var policy = ActivationSigningPolicy(launchedAt: Self.launch) + policy.noteExplainerNeeded() + #expect(policy.decision(for: .licenseHandshake, at: Self.afterLaunch) == .deferred) + policy.noteFirstWindowShown() + #expect(policy.decision(for: .licenseHandshake, at: Self.afterLaunch) == .needsExplainer) + #expect(policy.decision(for: .silentRenewal, at: Self.launch) == .deferred) + #expect(policy.decision(for: .silentRenewal, at: Self.afterLaunch) == .needsExplainer) + #expect(policy.decision(for: .userAction, at: Self.launch) == .allowed) + + policy.declineExplainer(at: Self.afterLaunch) + #expect(policy.decision(for: .licenseHandshake, at: Self.afterLaunch) == .deferred) + #expect(policy.isKeychainAccessDenied(at: Self.afterLaunch)) + #expect(policy.isDenialFromExplainer) + let later = Self.afterLaunch.addingTimeInterval(3600) + #expect(policy.decision(for: .licenseHandshake, at: later) == .needsExplainer) + + policy.confirmExplainer() + #expect(policy.decision(for: .licenseHandshake, at: later) == .allowed) + #expect(policy.isContinuedUsePending) + // The explainer shows at most once: a denial after Continue only + // waits. + policy.record(.denied, from: .licenseHandshake, channel: "c1", at: later) + #expect(policy.explainer == .confirmed) + #expect(policy.isContinuedUsePending == false) + #expect(policy.isDenialFromExplainer == false) + + policy.record(.succeeded(duration: 0.1), from: .silentRenewal, at: later) + #expect(policy.explainer == .notNeeded) + policy.noteExplainerNeeded() + #expect(policy.explainer == .notNeeded) + } + + /// Continue does not use the key: the held-back handshake runs and is + /// the one signing, counted as the user's. A slow answer (the user + /// thinking over the dialog) does not mark the key as prompt-free, but + /// the explainer does not come back in the next launch either. + @Test func continueLetsTheHeldBackUseBeTheOnlySigning() async throws { + let clock = Clock(Self.afterLaunch) + let (markers, stored) = Self.markers() + let gate = Self.explainerGate(clock: clock, markers: markers) + let keychain = Log() + + // Nothing is assessed or requested before the first window. + #expect(gate.allowsHandshake(onChannel: "c1") == false) + #expect(gate.status.needsKeychainAccessExplainer == false) + gate.noteFirstWindowShown() + + #expect(gate.allowsHandshake(onChannel: "c1") == false) + #expect(gate.status.needsKeychainAccessExplainer) + await #expect(throws: ActivationError.signingDeferred) { + try await gate.perform(.silentRenewal) { keychain.append("renewal") } + } + #expect(keychain.values.isEmpty) + + let generation = gate.status.handshakeGeneration + gate.confirmExplainer() + #expect(keychain.values.isEmpty) + #expect(gate.status.needsKeychainAccessExplainer == false) + #expect(gate.status.handshakeGeneration == generation + 1) + #expect(stored.value[KeychainAccessMarkers.explainerConfirmedKey] == "key-a") + + // The held-back handshake starts again and signs once. + #expect(gate.allowsHandshake(onChannel: "c1")) + try await gate.perform(.licenseHandshake, channel: "c1") { keychain.append("c1") } + #expect(keychain.values == ["c1"]) + + // The next launch does not show the explainer for this key. + let next = Self.explainerGate(clock: clock, markers: markers) + next.noteFirstWindowShown() + #expect(next.allowsHandshake(onChannel: "c1")) + #expect(next.status.needsKeychainAccessExplainer == false) + } + + @Test func aSlowContinuedUseCountsAsTheUsersAndSuggestsAlwaysAllow() { + let clock = Clock(Self.afterLaunch) + let (markers, stored) = Self.markers() + let gate = Self.explainerGate(clock: clock, markers: markers) + gate.noteFirstWindowShown() + #expect(gate.allowsHandshake(onChannel: "c1") == false) + gate.confirmExplainer() + + // One-time Allow: slow, but the user's answer. + gate.record(.succeeded(duration: 8), from: .licenseHandshake, channel: "c1") + #expect(gate.status.isKeychainAccessAllowedOnce) + #expect(KeychainAccessNotice(gate.status, at: clock.now) == .allowedOnce) + #expect(stored.value[KeychainAccessMarkers.keySignedWithoutPromptKey] == nil) + + // The next app connects and asks again: still a one-time Allow. + gate.record(.succeeded(duration: 6), from: .licenseHandshake, channel: "c2") + #expect(gate.status.isKeychainAccessAllowedOnce) + + // Always Allow: the next signing is fast and the hint goes away. + gate.record(.succeeded(duration: 0.05), from: .licenseHandshake, channel: "c3") + #expect(gate.status.isKeychainAccessAllowedOnce == false) + #expect(KeychainAccessNotice(gate.status, at: clock.now) == nil) + #expect(stored.value[KeychainAccessMarkers.keySignedWithoutPromptKey] == "key-a") + } + + @Test func aDenialAfterContinueWaitsWithoutTheExplainer() { + let clock = Clock(Self.afterLaunch) + let (markers, _) = Self.markers() + let gate = Self.explainerGate(clock: clock, markers: markers) + gate.noteFirstWindowShown() + #expect(gate.allowsHandshake(onChannel: "c1") == false) + gate.confirmExplainer() + gate.record(.denied, from: .licenseHandshake, channel: "c1") + #expect(gate.status.isKeychainAccessDenied(at: clock.now)) + #expect(KeychainAccessNotice(gate.status, at: clock.now) == .denied) + + clock.now = Self.afterLaunch.addingTimeInterval(3600) + gate.noteDenialWaitEndedIfDue() + #expect(gate.allowsHandshake(onChannel: "c1")) + #expect(gate.status.needsKeychainAccessExplainer == false) + } + + @Test func markersBelongToOneKey() { + let clock = Clock(Self.afterLaunch) + let (markers, _) = Self.markers() + markers.noteExplainerConfirmed("key-a") + markers.noteKeySignedWithoutPrompt("key-a") + #expect(markers.mayNeedExplainer(forKey: "key-a") == false) + #expect(markers.mayNeedExplainer(forKey: "key-b")) + + let gate = Self.explainerGate(clock: clock, markers: markers, key: "key-b") + gate.noteFirstWindowShown() + #expect(gate.allowsHandshake(onChannel: "c1") == false) + #expect(gate.status.needsKeychainAccessExplainer) + } + + @Test func notNowCountsAsADenialAndAsksAgainAfterTheWait() { + let clock = Clock(Self.afterLaunch) + let (markers, stored) = Self.markers() + let gate = Self.explainerGate(clock: clock, markers: markers) + gate.noteFirstWindowShown() + #expect(gate.allowsHandshake(onChannel: "c1") == false) + #expect(gate.status.needsKeychainAccessExplainer) + + gate.declineExplainer() + #expect(gate.status.needsKeychainAccessExplainer == false) + #expect(gate.status.isKeychainAccessDenied(at: clock.now)) + #expect(gate.status.isKeychainAccessPostponed) + #expect(KeychainAccessNotice(gate.status, at: clock.now) == .postponed) + #expect(gate.allowsHandshake(onChannel: "c1") == false) + #expect(gate.status.needsKeychainAccessExplainer == false) + + clock.now = Self.afterLaunch.addingTimeInterval(3600) + gate.noteDenialWaitEndedIfDue() + #expect(gate.status.isKeychainAccessPostponed == false) + #expect(gate.allowsHandshake(onChannel: "c1") == false) + #expect(gate.status.needsKeychainAccessExplainer) + #expect(stored.value.isEmpty) + + // Not Now is not remembered: the next launch explains again. + let next = Self.explainerGate(clock: clock, markers: markers) + next.noteFirstWindowShown() + #expect(next.allowsHandshake(onChannel: "c1") == false) + #expect(next.status.needsKeychainAccessExplainer) + } + + @Test func noticeCopyMatchesTheReason() { + let at = Self.afterLaunch + func status(deniedFor: TimeInterval?, postponed: Bool = false, allowedOnce: Bool = false) + -> ActivationSigningStatus + { + ActivationSigningStatus( + keychainAccessDeniedUntil: deniedFor.map { at.addingTimeInterval($0) }, + hasShownFirstWindow: true, + handshakeGeneration: 0, + needsKeychainAccessExplainer: false, + isKeychainAccessPostponed: postponed, + isKeychainAccessAllowedOnce: allowedOnce + ) + } + #expect(KeychainAccessNotice(status(deniedFor: nil), at: at) == nil) + #expect(KeychainAccessNotice(status(deniedFor: 60), at: at) == .denied) + #expect(KeychainAccessNotice(status(deniedFor: 60, postponed: true), at: at) == .postponed) + #expect(KeychainAccessNotice(status(deniedFor: -1, postponed: true), at: at) == nil) + #expect(KeychainAccessNotice(status(deniedFor: nil, allowedOnce: true), at: at) == .allowedOnce) + + #expect(KeychainAccessNotice.postponed.message.contains("denied") == false) + #expect(KeychainAccessNotice.allowedOnce.message.contains("Always Allow")) + #expect(KeychainAccessNotice.denied.offersRetry) + #expect(KeychainAccessNotice.postponed.offersRetry) + #expect(KeychainAccessNotice.allowedOnce.offersRetry == false) + } + + @Test func aSlowUserSigningConfirmsForTheLaunchOnly() { + let clock = Clock(Self.afterLaunch) + let (markers, stored) = Self.markers() + let gate = Self.explainerGate(clock: clock, markers: markers) + gate.noteFirstWindowShown() + #expect(gate.allowsHandshake(onChannel: "c1") == false) + + gate.record(.succeeded(duration: 5), from: .userAction, channel: nil) + #expect(gate.allowsHandshake(onChannel: "c1")) + #expect(stored.value.isEmpty) + + let next = Self.explainerGate(clock: clock, markers: markers) + next.noteFirstWindowShown() + #expect(next.allowsHandshake(onChannel: "c1") == false) + } + + @Test(arguments: [true, false]) + func hostCreatedOrMissingKeysNeverNeedTheExplainer(createdByHost: Bool) { + let clock = Clock(Self.afterLaunch) + let (markers, _) = Self.markers() + if createdByHost { + markers.noteKeyCreatedByHost("key-a") + } + let gate = Self.explainerGate(clock: clock, markers: markers, key: createdByHost ? "key-a" : nil) + gate.noteFirstWindowShown() + #expect(gate.allowsHandshake(onChannel: "c1")) + #expect(gate.status.needsKeychainAccessExplainer == false) + } + + @Test func theKeyIsAssessedOnlyOnce() { + let clock = Clock(Self.afterLaunch) + let (markers, _) = Self.markers() + let reads = Log() + let gate = Self.explainerGate(clock: clock, markers: markers, identityReads: reads) + gate.noteFirstWindowShown() + for _ in 0 ..< 3 { + _ = gate.allowsHandshake(onChannel: "c1") + } + #expect(reads.values.count == 1) + } + + @Test func aKeyCreatedDuringTheLaunchEndsTheExplainer() { + let clock = Clock(Self.afterLaunch) + let (markers, stored) = Self.markers() + let gate = Self.explainerGate(clock: clock, markers: markers) + gate.noteFirstWindowShown() + #expect(gate.allowsHandshake(onChannel: "c1") == false) + gate.noteKeyCreatedByHost() + #expect(gate.allowsHandshake(onChannel: "c1")) + #expect(gate.status.needsKeychainAccessExplainer == false) + #expect(stored.value[KeychainAccessMarkers.keyCreatedByHostKey] == "key-a") + } + + /// The key store reports a key it created, which sets the marker through + /// the runtime. A test keychain never uses markers by default. + @Test func runtimeMarksAKeyItCreates() throws { + var interactionAllowed: DarwinBoolean = true + SecKeychainGetUserInteractionAllowed(&interactionAllowed) + SecKeychainSetUserInteractionAllowed(false) + defer { SecKeychainSetUserInteractionAllowed(interactionAllowed.boolValue) } + let keychain = try TemporaryKeychain() + defer { keychain.delete() } + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let (markers, stored) = Self.markers() + let configuration = HelperFixtures.isolatedConfiguration( + stateDirectoryURL: directory, + keyStore: IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)", + keychainPath: keychain.path + ) + ) + let runtime = ActivationRuntime( + configuration: configuration, + urlSession: .shared, + now: { Self.afterLaunch }, + silentRenewalEnabled: false, + keychainAccessMarkers: markers + ) + _ = try runtime.keyStore.sign(message: Data("create".utf8)) + let identity = try runtime.keyStore.keyIdentity() + #expect(identity?.count == 20) + let hex = identity.map { $0.map { String(format: "%02x", $0) }.joined() } + #expect(stored.value[KeychainAccessMarkers.keyCreatedByHostKey] == hex) + _ = try runtime.keyStore.sign(message: Data("again".utf8)) + #expect(try runtime.keyStore.keyIdentity() == identity) + + let unmarked = ActivationRuntime( + configuration: configuration, + urlSession: .shared, + now: { Self.afterLaunch }, + silentRenewalEnabled: false + ) + unmarked.noteFirstWindowShown() + #expect(unmarked.allowsLicenseHandshake(onChannel: "c1")) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/KeychainAccessTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/KeychainAccessTests.swift new file mode 100644 index 00000000..3a9b7249 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/KeychainAccessTests.swift @@ -0,0 +1,356 @@ +import Foundation +@testable import LookInsideActivation +import Security +import Testing + +/// Keychain lookups that fail for any reason other than "not found" must +/// never lead to a new key: an upgraded user who cancels the first-access +/// prompt still owns the helper's key, and a second key under the same tag +/// would break the existing activation. +struct KeychainAccessTests { + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + lock.withLock { count += 1 } + } + + var value: Int { + lock.withLock { count } + } + } + + private func makeStore(lookupStatus: OSStatus, generations: Recorder) -> IntermediateKeyStore { + IntermediateKeyStore( + configuration: IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)" + ), + operations: IntermediateKeychainOperations( + copyMatching: { _, _ in lookupStatus }, + createRandomKey: { _, _ in + generations.increment() + return nil + } + ) + ) + } + + @Test(arguments: [ + errSecUserCanceled, + errSecAuthFailed, + errSecInteractionNotAllowed, + ]) + func deniedLookupThrowsAccessDeniedWithoutGenerating(status: OSStatus) { + let generations = Recorder() + let store = makeStore(lookupStatus: status, generations: generations) + + #expect(throws: ActivationError.keychainAccessDenied(status)) { + try store.sign(message: Data("lookinside".utf8)) + } + #expect(throws: ActivationError.keychainAccessDenied(status)) { + try store.makeCertificateSigningRequestPEM(commonName: "LookInside Device test") + } + #expect(store.hasPrivateKey() == false) + #expect(generations.value == 0) + } + + @Test(arguments: [errSecNotAvailable, errSecDecode, errSecParam, errSecNoSuchKeychain]) + func otherLookupFailuresThrowWithoutGenerating(status: OSStatus) { + let generations = Recorder() + let store = makeStore(lookupStatus: status, generations: generations) + + #expect { + try store.sign(message: Data("lookinside".utf8)) + } throws: { error in + guard case .keychainFailure = error as? ActivationError else { return false } + return true + } + #expect(generations.value == 0) + } + + @Test func onlyItemNotFoundGeneratesAKey() { + let generations = Recorder() + let store = makeStore(lookupStatus: errSecItemNotFound, generations: generations) + + // The fake generator returns no key, so signing still fails, but only + // after it was asked to create one. + #expect(throws: ActivationError.self) { + try store.sign(message: Data("lookinside".utf8)) + } + #expect(generations.value == 1) + } + + @Test func deniedStatusesMapToAccessDeniedAndKeepTheHelperCode() { + #expect(ActivationError.keychain(status: errSecUserCanceled, message: "m") == .keychainAccessDenied(-128)) + #expect(ActivationError.keychain(status: errSecParam, message: "m") == .keychainFailure("m")) + #expect(ActivationError.keychainAccessDenied(-128).errorCode == "keychain_failure") + } + + /// A key the Host creates also trusts the installed 2.3.x helper, so an + /// older LookInside can still sign after a downgrade without a prompt. + @Test func newKeyTrustsConfiguredApplications() throws { + // A test must never wait on a keychain prompt. The setting is + // process-wide, so it is put back afterwards. + var interactionAllowed: DarwinBoolean = true + SecKeychainGetUserInteractionAllowed(&interactionAllowed) + SecKeychainSetUserInteractionAllowed(false) + defer { SecKeychainSetUserInteractionAllowed(interactionAllowed.boolValue) } + let keychain = try TemporaryKeychain() + defer { keychain.delete() } + let trustedPath = "/usr/bin/security" + let store = IntermediateKeyStore( + configuration: IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)", + keychainPath: keychain.path, + trustedApplicationPaths: [trustedPath, "/nonexistent/lookinside-auth-server.app"] + ), + trustedApplication: { path in + var application: SecTrustedApplication? + SecTrustedApplicationCreateFromPath(path, &application) + return application + } + ) + + let message = Data("lookinside".utf8) + let signature = try store.sign(message: message) + #expect(signature.count == 256) + #expect(try store.sign(message: message) == signature) + + let key = try #require(try store.existingPrivateKey()) + var access: SecAccess? + #expect(SecKeychainItemCopyAccess(unsafeBitCast(key, to: SecKeychainItem.self), &access) == errSecSuccess) + let trustedPaths = try Self.trustedApplicationPaths(in: #require(access)) + #expect(trustedPaths.contains(trustedPath)) + #expect(trustedPaths.contains { $0.contains("lookinside-auth-server") } == false) + } + + @Test func keyWithoutExistingTrustedPathsKeepsDefaultAccess() { + let store = IntermediateKeyStore( + configuration: IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)", + trustedApplicationPaths: ["/nonexistent/lookinside-auth-server.app"] + ) + ) + #expect(store.makeAccess() == nil) + } + + /// The helper path is user-writable: a bundle there whose signature the + /// store does not accept never joins the key's access list. + @Test func applicationWithRejectedSignatureIsNotTrusted() { + let checked = Recorder() + let store = IntermediateKeyStore( + configuration: IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)", + trustedApplicationPaths: ["/usr/bin/security"] + ), + trustedApplication: { _ in + checked.increment() + return nil + } + ) + #expect(store.makeAccess() == nil) + #expect(checked.value == 1) + } + + @Test func standardConfigurationTrustsTheInstalledHelper() { + let paths = IntermediateKeyStoreConfiguration.standard.trustedApplicationPaths + #expect(paths.count == 1) + #expect( + paths.first?.hasSuffix( + "Library/Application Support/LookInside/AuthServer/current/lookinside-auth-server.app" + ) == true + ) + } + + private static func trustedApplicationPaths(in access: SecAccess) -> [String] { + var aclList: CFArray? + guard SecAccessCopyACLList(access, &aclList) == errSecSuccess, + let acls = aclList as? [SecACL] + else { + return [] + } + var paths: [String] = [] + for acl in acls { + var applications: CFArray? + var description: CFString? + var selector = SecKeychainPromptSelector() + guard SecACLCopyContents(acl, &applications, &description, &selector) == errSecSuccess, + let applications = applications as? [SecTrustedApplication] + else { + continue + } + for application in applications { + var data: CFData? + guard SecTrustedApplicationCopyData(application, &data) == errSecSuccess, + let bytes = data as Data? + else { + continue + } + let path = String(decoding: bytes.prefix { $0 != 0 }, as: UTF8.self) + paths.append(path) + } + } + return paths + } +} + +struct DeviceFingerprintTests { + @Test func missingPlatformUUIDThrowsInsteadOfCrashing() { + #expect { + try ActivationDeviceFingerprint.current(platformUUID: { nil }) + } throws: { error in + guard case .activationFailed = error as? ActivationError else { return false } + return true + } + #expect(throws: ActivationError.self) { + try ActivationDeviceFingerprint.current(platformUUID: { "" }) + } + } + + @Test func platformUUIDBecomesTheDeviceID() throws { + let fingerprint = try ActivationDeviceFingerprint.current( + appBundleID: "app.lookinside.LookInsideAuthServer", + platformUUID: { "00000000-0000-4000-8000-000000000001" } + ) + #expect(fingerprint.deviceID == "00000000-0000-4000-8000-000000000001") + #expect(fingerprint.appBundleID == "app.lookinside.LookInsideAuthServer") + } +} + +/// The installed helper joins a new key's access list only when its code +/// signature matches the helper's own requirement and the running Host's +/// team, and the bundle did not change while the entry was made. +struct TrustedApplicationSignatureTests { + private final class Calls: @unchecked Sendable { + private let lock = NSLock() + private var stored: [String] = [] + + func append(_ call: String) { + lock.withLock { stored.append(call) } + } + + var values: [String] { + lock.withLock { stored } + } + } + + private static func operations( + calls: Calls, + hashBefore: Data?, + hashAfter: Data? + ) -> TrustedApplicationSignature.Operations { + TrustedApplicationSignature.Operations( + codeDirectoryHash: { _ in + calls.append("hash") + return hashBefore + }, + makeApplication: { _ in + calls.append("make") + var application: SecTrustedApplication? + SecTrustedApplicationCreateFromPath("/usr/bin/security", &application) + return application + }, + validatedCodeDirectoryHash: { _, requirement in + calls.append("validate \(requirement)") + return hashAfter + } + ) + } + + @Test func requirementMatchesTheHelperValidator() throws { + let requirement = try #require(TrustedApplicationSignature.requirement(forTeamID: "ABCDE12345")) + #expect(requirement.hasPrefix("anchor apple generic and ")) + #expect(requirement.contains(#"identifier "app.lookinside.LookInsideAuthServer""#)) + #expect(requirement.contains(#"certificate leaf[subject.OU] = "ABCDE12345""#)) + #expect(requirement.contains("certificate leaf[field.1.2.840.113635.100.6.1.9]")) + #expect(requirement.contains("certificate 1[field.1.2.840.113635.100.6.2.6]")) + #expect(requirement.contains("certificate leaf[field.1.2.840.113635.100.6.1.13]")) + var parsed: SecRequirement? + #expect(SecRequirementCreateWithString(requirement as CFString, [], &parsed) == errSecSuccess) + #expect(parsed != nil) + } + + @Test(arguments: [nil, "", "abcde12345", "ABCDE1234", #"A" or true"#, "ABCDE12345 "]) + func noRequirementWithoutAValidTeam(teamID: String?) { + #expect(TrustedApplicationSignature.requirement(forTeamID: teamID) == nil) + } + + @Test func noRequirementForAnUnsafeIdentifier() { + #expect(TrustedApplicationSignature.requirement(forTeamID: "ABCDE12345", identifier: #"x" or true"#) == nil) + } + + @Test func adHocHostTrustsNothingAndNeverChecks() { + let calls = Calls() + let trusted = TrustedApplicationSignature.trustedApplication( + atPath: "/p", + hostTeamID: nil, + operations: Self.operations(calls: calls, hashBefore: Data([1]), hashAfter: Data([1])) + ) + #expect(trusted == nil) + #expect(calls.values.isEmpty) + } + + /// The entry is made before the check, and the bundle must be the same + /// before and after. + @Test func entryIsMadeFirstAndCheckedAfter() throws { + let calls = Calls() + let trusted = TrustedApplicationSignature.trustedApplication( + atPath: "/p", + hostTeamID: "ABCDE12345", + operations: Self.operations(calls: calls, hashBefore: Data([1]), hashAfter: Data([1])) + ) + #expect(trusted != nil) + let requirement = try #require(TrustedApplicationSignature.requirement(forTeamID: "ABCDE12345")) + #expect(calls.values == ["hash", "make", "validate \(requirement)"]) + } + + @Test func bundleReplacedDuringTheCheckIsRefused() { + #expect( + TrustedApplicationSignature.trustedApplication( + atPath: "/p", + hostTeamID: "ABCDE12345", + operations: Self.operations( + calls: Calls(), + hashBefore: Data([1]), + hashAfter: Data([2]) + ) + ) == nil + ) + } + + @Test func failedCheckOrUnsignedBundleIsRefused() { + for (before, after) in [(Data([1]), nil), (nil, Data([1]))] as [(Data?, Data?)] { + let calls = Calls() + #expect( + TrustedApplicationSignature.trustedApplication( + atPath: "/p", + hostTeamID: "ABCDE12345", + operations: Self.operations(calls: calls, hashBefore: before, hashAfter: after) + ) == nil + ) + if before == nil { + #expect(calls.values == ["hash"]) + } + } + } + + /// The real check: an Apple platform binary satisfies an Apple anchor + /// requirement but not the helper's requirement, and a missing path + /// satisfies nothing. + @Test func staticCodeCheckUsesTheBundleSignature() throws { + #expect(TrustedApplicationSignature.staticCodeSatisfies(path: "/usr/bin/security", requirement: "anchor apple")) + #expect(TrustedApplicationSignature.codeDirectoryHash(path: "/usr/bin/security", requirement: nil) != nil) + let helperRequirement = try #require(TrustedApplicationSignature.requirement(forTeamID: "ABCDE12345")) + #expect( + TrustedApplicationSignature.staticCodeSatisfies(path: "/usr/bin/security", requirement: helperRequirement) + == false + ) + #expect( + TrustedApplicationSignature.staticCodeSatisfies( + path: "/nonexistent/lookinside-auth-server.app", + requirement: "anchor apple" + ) == false + ) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/SigningPolicyTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/SigningPolicyTests.swift new file mode 100644 index 00000000..cf9ca798 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/SigningPolicyTests.swift @@ -0,0 +1,377 @@ +import Foundation +@testable import LookInsideActivation +import Security +import Testing + +/// Using the license key can raise a login-keychain prompt. After the user +/// refuses it, no automatic path (handshake or silent renewal) may ask again +/// until the wait ends or the user clicks Try Again, and a channel whose +/// handshake failed must not loop. +struct SigningPolicyTests { + private final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + + init(_ date: Date) { + current = date + } + + var now: Date { + get { lock.withLock { current } } + set { lock.withLock { current = newValue } } + } + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + lock.withLock { count += 1 } + } + + var value: Int { + lock.withLock { count } + } + } + + private static let launch = TestData.baseNow + private static let afterLaunch = launch.addingTimeInterval(ActivationSigningPolicy.launchDelay) + + private func readyPolicy() -> ActivationSigningPolicy { + var policy = ActivationSigningPolicy(launchedAt: Self.launch) + policy.noteFirstWindowShown() + return policy + } + + // MARK: - Policy + + @Test func nothingSignsAutomaticallyBeforeTheFirstWindow() { + var policy = ActivationSigningPolicy(launchedAt: Self.launch) + let later = Self.launch.addingTimeInterval(3600) + + #expect(policy.allowsSigning(for: .licenseHandshake, at: later) == false) + #expect(policy.allowsHandshake(onChannel: "c1", at: later) == false) + #expect(policy.allowsSigning(for: .silentRenewal, at: later) == false) + #expect(policy.allowsSigning(for: .userAction, at: Self.launch)) + + policy.noteFirstWindowShown() + // Handshakes start with the first window; renewal also waits for the + // launch delay. + #expect(policy.allowsHandshake(onChannel: "c1", at: Self.launch)) + #expect(policy.allowsSigning(for: .silentRenewal, at: Self.launch) == false) + #expect(policy.allowsSigning(for: .silentRenewal, at: Self.afterLaunch)) + } + + @Test(arguments: [ActivationSigningSource.licenseHandshake, .silentRenewal]) + func denialStopsEveryAutomaticPath(from source: ActivationSigningSource) { + var policy = readyPolicy() + let deniedAt = Self.afterLaunch + policy.record(.denied, from: source, channel: source == .licenseHandshake ? "c1" : nil, at: deniedAt) + + for minutes in [1.0, 30, 59] { + let date = deniedAt.addingTimeInterval(minutes * 60) + #expect(policy.allowsSigning(for: .licenseHandshake, at: date) == false) + #expect(policy.allowsHandshake(onChannel: "c2", at: date) == false) + #expect(policy.allowsSigning(for: .silentRenewal, at: date) == false) + #expect(policy.allowsSigning(for: .userAction, at: date)) + #expect(policy.isKeychainAccessDenied(at: date)) + } + let hourLater = deniedAt.addingTimeInterval(60 * 60) + #expect(policy.allowsSigning(for: .silentRenewal, at: hourLater)) + #expect(policy.allowsHandshake(onChannel: "c2", at: hourLater)) + #expect(policy.isKeychainAccessDenied(at: hourLater) == false) + } + + @Test func repeatedDenialWaitsADay() { + var policy = readyPolicy() + let first = Self.afterLaunch + policy.record(.denied, from: .silentRenewal, at: first) + let second = first.addingTimeInterval(60 * 60) + policy.record(.denied, from: .licenseHandshake, at: second) + + #expect(policy.allowsSigning(for: .silentRenewal, at: second.addingTimeInterval(23 * 60 * 60)) == false) + #expect(policy.allowsSigning(for: .silentRenewal, at: second.addingTimeInterval(24 * 60 * 60))) + policy.record(.denied, from: .silentRenewal, at: second.addingTimeInterval(24 * 60 * 60)) + #expect(policy.deniedUntil == second.addingTimeInterval(48 * 60 * 60)) + } + + @Test func failedChannelIsNotRetriedUntilItReconnects() { + var policy = readyPolicy() + let date = Self.afterLaunch + // A rejected 221 or a failed signing on c1. + policy.noteHandshakeFailed(onChannel: "c1") + policy.record(.failed, from: .licenseHandshake, channel: "c2", at: date) + + #expect(policy.allowsHandshake(onChannel: "c1", at: date.addingTimeInterval(3600)) == false) + #expect(policy.allowsHandshake(onChannel: "c2", at: date.addingTimeInterval(3600)) == false) + #expect(policy.allowsHandshake(onChannel: "c3", at: date)) + + policy.noteChannelEnded("c1") + #expect(policy.allowsHandshake(onChannel: "c1", at: date)) + #expect(policy.allowsHandshake(onChannel: "c2", at: date) == false) + } + + @Test func activationChangeLetsFailedChannelsTryAgain() { + var policy = readyPolicy() + policy.noteHandshakeFailed(onChannel: "c1") + policy.noteHandshakeFailed(onChannel: "c2") + + policy.noteActivationChanged() + #expect(policy.allowsHandshake(onChannel: "c1", at: Self.afterLaunch)) + #expect(policy.allowsHandshake(onChannel: "c2", at: Self.afterLaunch)) + } + + @Test func explicitRetryClearsTheWaitAndFailedChannels() { + var policy = readyPolicy() + let deniedAt = Self.afterLaunch + policy.record(.denied, from: .licenseHandshake, channel: "c1", at: deniedAt) + policy.record(.denied, from: .silentRenewal, at: deniedAt.addingTimeInterval(3600)) + + // Try Again took long: the user answered a prompt. It still clears. + let retriedAt = deniedAt.addingTimeInterval(7200) + policy.record(.succeeded(duration: 8), from: .userAction, at: retriedAt) + #expect(policy.isKeychainAccessDenied(at: retriedAt) == false) + #expect(policy.denialCount == 0) + #expect(policy.allowsHandshake(onChannel: "c1", at: retriedAt)) + #expect(policy.allowsSigning(for: .silentRenewal, at: retriedAt)) + } + + @Test func deniedRetryWaitsLonger() { + var policy = readyPolicy() + let deniedAt = Self.afterLaunch + policy.record(.denied, from: .silentRenewal, at: deniedAt) + policy.record(.denied, from: .userAction, at: deniedAt.addingTimeInterval(60)) + #expect(policy.deniedUntil == deniedAt.addingTimeInterval(60 + 24 * 60 * 60)) + } + + /// A one-time Allow answers the prompt, so the signing is slow: it does + /// not count as "the key no longer prompts", and the next denial + /// escalates. A fast signing means no prompt and resets the count. + @Test func onlyAFastAutomaticSuccessResetsTheDenialCount() { + var policy = readyPolicy() + let deniedAt = Self.afterLaunch + policy.record(.denied, from: .silentRenewal, at: deniedAt) + + let resumed = deniedAt.addingTimeInterval(3600) + policy.record(.succeeded(duration: 6), from: .licenseHandshake, channel: "c1", at: resumed) + #expect(policy.denialCount == 1) + policy.record(.denied, from: .licenseHandshake, channel: "c2", at: resumed) + #expect(policy.deniedUntil == resumed.addingTimeInterval(24 * 60 * 60)) + + let later = resumed.addingTimeInterval(24 * 60 * 60) + policy.record(.succeeded(duration: 0.05), from: .silentRenewal, at: later) + #expect(policy.denialCount == 0) + #expect(policy.deniedUntil == nil) + } + + @Test func lockedKeychainIsNotADenial() { + var policy = readyPolicy() + let date = Self.afterLaunch + policy.record(.interactionNotAllowed, from: .licenseHandshake, channel: "c1", at: date) + + #expect(policy.denialCount == 0) + #expect(policy.isKeychainAccessDenied(at: date) == false) + #expect(policy.allowsSigning(for: .silentRenewal, at: date.addingTimeInterval(60)) == false) + // No channel memory: the same channel retries after the short pause. + let afterPause = date.addingTimeInterval(ActivationSigningPolicy.interactionNotAllowedDelay) + #expect(policy.allowsHandshake(onChannel: "c1", at: afterPause)) + #expect(policy.allowsSigning(for: .silentRenewal, at: afterPause)) + + policy.record(.interactionNotAllowed, from: .silentRenewal, at: afterPause) + policy.record(.interactionNotAllowed, from: .silentRenewal, at: afterPause.addingTimeInterval(300)) + #expect(policy.denialCount == 0) + #expect(policy.pausedUntil == afterPause.addingTimeInterval(600)) + } + + @Test func outcomesMapFromKeychainErrors() { + #expect(ActivationSigningOutcome(error: ActivationError.keychainAccessDenied(errSecUserCanceled)) == .denied) + #expect(ActivationSigningOutcome(error: ActivationError.keychainAccessDenied(errSecAuthFailed)) == .denied) + #expect( + ActivationSigningOutcome(error: ActivationError.keychainAccessDenied(errSecInteractionNotAllowed)) + == .interactionNotAllowed + ) + #expect(ActivationSigningOutcome(error: ActivationError.keychainFailure("x")) == .failed) + #expect(ActivationSigningOutcome(error: URLError(.notConnectedToInternet)) == .failed) + } + + // MARK: - Gate + + @Test func gateRunsNothingWhileWaiting() async throws { + let clock = Clock(Self.afterLaunch) + let gate = ActivationSigningGate.open(now: { clock.now }) + let keychainCalls = Counter() + + await #expect(throws: ActivationError.keychainAccessDenied(errSecUserCanceled)) { + try await gate.perform(.licenseHandshake, channel: "c1") { + keychainCalls.increment() + throw ActivationError.keychainAccessDenied(errSecUserCanceled) + } + } + // The slow-signing retry, another channel and renewal all stop + // before the keychain. + for source in [ActivationSigningSource.licenseHandshake, .silentRenewal] { + await #expect(throws: ActivationError.signingDeferred) { + try await gate.perform(source, channel: "c2") { + keychainCalls.increment() + } + } + } + #expect(keychainCalls.value == 1) + #expect(gate.allowsHandshake(onChannel: "c3") == false) + + // Try Again is the only way to ask during the wait. + try await gate.perform(.userAction) { + keychainCalls.increment() + } + #expect(keychainCalls.value == 2) + #expect(gate.allowsHandshake(onChannel: "c1")) + } + + @Test func gateTellsObserversWhenHeldBackHandshakesMayStart() { + let clock = Clock(Self.afterLaunch) + let gate = ActivationSigningGate(launchedAt: Self.launch, now: { clock.now }) + let statuses = StatusRecorder() + let observation = gate.addObserver { statuses.append($0) } + defer { observation.cancel() } + + gate.noteFirstWindowShown() + gate.record(.denied, from: .silentRenewal, channel: nil) + gate.noteHandshakeFailed(onChannel: "c1") + gate.noteChannelEnded("c1") + gate.record(.succeeded(duration: 3), from: .userAction, channel: nil) + + let recorded = statuses.values + #expect(recorded.map(\.handshakeGeneration) == [0, 1, 1, 2]) + #expect(recorded.map { $0.isKeychainAccessDenied(at: clock.now) } == [false, false, true, false]) + } + + private final class StatusRecorder: @unchecked Sendable { + private let lock = NSLock() + private var stored: [ActivationSigningStatus] = [] + + func append(_ status: ActivationSigningStatus) { + lock.withLock { stored.append(status) } + } + + var values: [ActivationSigningStatus] { + lock.withLock { stored } + } + } + + // MARK: - Runtime + + private func makeRuntime(directory: URL, clock: Clock, keyStore: IntermediateKeyStoreConfiguration? = nil) + -> ActivationRuntime + { + ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: directory, keyStore: keyStore), + urlSession: .shared, + now: { clock.now }, + silentRenewalEnabled: false + ) + } + + /// `signChallenge` reaches the keychain only when the policy allows it. + /// The isolated keychain path does not exist, so a call that reaches the + /// key store fails with `.keychainFailure`; `.signingDeferred` proves the + /// keychain was never touched. + @Test func runtimeHandshakeSigningFollowsThePolicy() async throws { + let input = try HelperFixtures.challengeInput() + let directory = try HelperFixtures.makeStateDirectory(copying: "helper-state-full.json") + defer { try? FileManager.default.removeItem(at: directory) } + let clock = Clock(input.evaluatedAt) + let runtime = makeRuntime(directory: directory, clock: clock) + + await #expect(throws: ActivationError.signingDeferred) { + try await runtime.signChallenge(nonce: input.nonce, serverInstanceID: "srv", channel: "c1") + } + + runtime.noteFirstWindowShown() + await #expect { + try await runtime.signChallenge(nonce: input.nonce, serverInstanceID: "srv", channel: "c1") + } throws: { error in + guard case .keychainFailure = error as? ActivationError else { return false } + return true + } + // The failed signing is remembered for c1 only. + #expect(runtime.allowsLicenseHandshake(onChannel: "c1") == false) + #expect(runtime.allowsLicenseHandshake(onChannel: "c2")) + runtime.noteLicenseHandshakeChannelEnded("c1") + #expect(runtime.allowsLicenseHandshake(onChannel: "c1")) + + runtime.signingGate.record(.denied, from: .silentRenewal, channel: nil) + await #expect(throws: ActivationError.signingDeferred) { + try await runtime.signChallenge(nonce: input.nonce, serverInstanceID: "srv", channel: "c2") + } + #expect(runtime.isKeychainAccessDenied()) + } + + @Test func runtimeActivationChangeResetsChannelMemory() async throws { + let input = try HelperFixtures.challengeInput() + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let clock = Clock(input.evaluatedAt) + let runtime = makeRuntime(directory: directory, clock: clock) + runtime.noteFirstWindowShown() + + runtime.noteLicenseHandshakeFailed(onChannel: "c1") + await runtime.currentDecision() + #expect(runtime.allowsLicenseHandshake(onChannel: "c1") == false) + + // Another process activates this Mac. + try HelperFixtures.data("helper-state-full.json") + .write(to: directory.appendingPathComponent("state.json"), options: .atomic) + #expect(await runtime.currentDecision().grantsAccess) + #expect(runtime.allowsLicenseHandshake(onChannel: "c1")) + } + + @MainActor + @Test func runtimeTryAgainClearsTheWaitAndUpdatesTheLicenseWindow() async throws { + var interactionAllowed: DarwinBoolean = true + SecKeychainGetUserInteractionAllowed(&interactionAllowed) + SecKeychainSetUserInteractionAllowed(false) + defer { SecKeychainSetUserInteractionAllowed(interactionAllowed.boolValue) } + let keychain = try TemporaryKeychain() + defer { keychain.delete() } + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let clock = Clock(Self.afterLaunch) + let runtime = makeRuntime( + directory: directory, + clock: clock, + keyStore: IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.\(UUID().uuidString)", + keychainPath: keychain.path + ) + ) + runtime.noteFirstWindowShown() + let model = runtime.activationModel() + + // No key yet: Try Again never creates one. + await #expect(throws: ActivationError.self) { + try await runtime.retryKeychainAccess() + } + #expect(runtime.keyStore.hasPrivateKey() == false) + + _ = try runtime.keyStore.sign(message: Data("create".utf8)) + runtime.signingGate.record(.denied, from: .licenseHandshake, channel: "c1") + try await waitUntil { model.keychainAccessNotice != nil } + let generation = runtime.signingStatus.handshakeGeneration + + await model.retryKeychainAccess() + #expect(runtime.isKeychainAccessDenied() == false) + #expect(runtime.allowsLicenseHandshake(onChannel: "c1")) + #expect(runtime.signingStatus.handshakeGeneration == generation + 1) + try await waitUntil { model.keychainAccessNotice == nil } + } + + @MainActor + private func waitUntil(_ condition: @MainActor () -> Bool) async throws { + // 30 s budget for a loaded machine; normally a few polls suffice. + for _ in 0 ..< 1500 where condition() == false { + try await Task.sleep(for: .milliseconds(20)) + } + #expect(condition()) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/SilentRenewalTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/SilentRenewalTests.swift new file mode 100644 index 00000000..6254e5d8 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/SilentRenewalTests.swift @@ -0,0 +1,222 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +/// Silent renewal signs with the license key, which raises a login-keychain +/// prompt for a key the 2.3.x helper created. It must wait for the app to be +/// on screen and must not ask again every minute after a refusal. +struct SilentRenewalTests { + private final class Clock: @unchecked Sendable { + private let lock = NSLock() + private var current: Date + + init(_ date: Date) { + current = date + } + + var now: Date { + get { lock.withLock { current } } + set { lock.withLock { current = newValue } } + } + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func increment() { + lock.withLock { count += 1 } + } + + var value: Int { + lock.withLock { count } + } + } + + private final class Switch: @unchecked Sendable { + private let lock = NSLock() + private var isOn: Bool + + init(_ isOn: Bool) { + self.isOn = isOn + } + + var value: Bool { + get { lock.withLock { isOn } } + set { lock.withLock { isOn = newValue } } + } + } + + private struct Issuer: IntermediateCertificateIssuing { + func issueIntermediateCertificate( + _: IntermediateCertificateIssueRequest + ) async throws -> IntermediateCertificateLease { + TestData.makeIntermediateCertificateLease( + expiresAt: TestData.baseNow.addingTimeInterval(30 * 24 * 60 * 60), + renewAfter: TestData.baseNow.addingTimeInterval(20 * 24 * 60 * 60) + ) + } + } + + @Test func gateWaitsForFirstWindowAndLaunchDelay() { + let launch = TestData.baseNow + var policy = ActivationSigningPolicy(launchedAt: launch) + + #expect(policy.allowsSigning(for: .silentRenewal, at: launch.addingTimeInterval(120)) == false) + policy.noteFirstWindowShown() + #expect(policy.allowsSigning(for: .silentRenewal, at: launch.addingTimeInterval(10)) == false) + #expect( + policy.allowsSigning( + for: .silentRenewal, + at: launch.addingTimeInterval(ActivationSigningPolicy.launchDelay) + ) + ) + } + + @Test func runtimeKeepsGateClosedUntilAllowed() throws { + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let clock = Clock(TestData.baseNow) + let runtime = ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: directory), + urlSession: .shared, + now: { clock.now } + ) + + clock.now = TestData.baseNow.addingTimeInterval(3600) + #expect(runtime.signingGate.allowsSigning(for: .silentRenewal) == false) + #expect(runtime.allowsLicenseHandshake(onChannel: "channel") == false) + runtime.noteFirstWindowShown() + #expect(runtime.signingGate.allowsSigning(for: .silentRenewal)) + #expect(runtime.allowsLicenseHandshake(onChannel: "channel")) + } + + @Test func keychainDenialBacksOffOneHourThenOneDay() async throws { + let start = TestData.baseNow + let clock = Clock(start) + let store = try await Self.makeDueStore(now: start) + let csrRequests = Counter() + let denies = Switch(true) + let gate = ActivationSigningGate.open(now: { clock.now }) + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { Issuer() }, + csrProvider: { + csrRequests.increment() + if denies.value { + throw ActivationError.keychainAccessDenied(-128) + } + return "csr" + }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: gate + ) + + await renewer.tryRenewIfDue() + #expect(csrRequests.value == 1) + #expect(gate.status.isKeychainAccessDenied(at: clock.now)) + #expect(await store.snapshot().lastRenewFailedAt == start) + + // The 60-second retry would ask again; the backoff does not. + clock.now = start.addingTimeInterval(120) + await renewer.tryRenewIfDue() + clock.now = start.addingTimeInterval(59 * 60) + await renewer.tryRenewIfDue() + #expect(csrRequests.value == 1) + + let secondAttempt = start.addingTimeInterval(60 * 60) + clock.now = secondAttempt + await renewer.tryRenewIfDue() + #expect(csrRequests.value == 2) + + clock.now = secondAttempt.addingTimeInterval(23 * 60 * 60) + await renewer.tryRenewIfDue() + #expect(csrRequests.value == 2) + + denies.value = false + clock.now = secondAttempt.addingTimeInterval(24 * 60 * 60) + await renewer.tryRenewIfDue() + #expect(csrRequests.value == 3) + #expect(gate.status.isKeychainAccessDenied(at: clock.now) == false) + #expect(await store.snapshot().lastRenewSucceededAt == clock.now) + } + + @Test func otherFailuresBackOffWithoutADenialWait() async throws { + let start = TestData.baseNow + let clock = Clock(start) + let store = try await Self.makeDueStore(now: start) + let csrRequests = Counter() + let gate = ActivationSigningGate.open(now: { clock.now }) + let renewer = ActivationCertificateRenewer( + stateStore: store, + clientFactory: { Issuer() }, + csrProvider: { + csrRequests.increment() + throw ActivationError.keychainFailure("unrelated") + }, + sessionRefresher: {}, + now: { clock.now }, + signingGate: gate + ) + + await renewer.tryRenewIfDue() + clock.now = start.addingTimeInterval(61) + await renewer.tryRenewIfDue() + #expect(csrRequests.value == 1) + clock.now = start.addingTimeInterval(5 * 60) + await renewer.tryRenewIfDue() + #expect(csrRequests.value == 2) + #expect(gate.status.isKeychainAccessDenied(at: clock.now) == false) + } + + @MainActor + @Test func licenseWindowExplainsADeniedKeychain() async throws { + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let runtime = ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: directory), + urlSession: .shared, + now: { TestData.baseNow }, + silentRenewalEnabled: false + ) + + await runtime.restoreActivationModel() + #expect(runtime.activationModel().keychainAccessNotice == nil) + + runtime.signingGate.record(.denied, from: .silentRenewal, channel: nil) + #expect(runtime.isKeychainAccessDenied()) + await runtime.restoreActivationModel() + let notice = try #require(runtime.activationModel().keychainAccessNotice) + #expect(notice.contains("Keychain")) + + runtime.signingGate.record(.succeeded(duration: 0), from: .userAction, channel: nil) + await runtime.restoreActivationModel() + #expect(runtime.activationModel().keychainAccessNotice == nil) + } + + private static func makeDueStore(now: Date) async throws -> ActivationStateStore { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideActivationTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent("state.json") + let store = ActivationStateStore(url: url) + let session = TestData.makeActivationSession(expiresAt: now.addingTimeInterval(3 * 24 * 60 * 60)) + let license = LicenseEnvelope( + licenseID: "license-123", + licenseClass: .full, + lifecycleState: .active, + issuedTo: "Acme", + issuedAt: now.addingTimeInterval(-86400), + expiresAt: now.addingTimeInterval(365 * 86400), + certificateChain: TestData.makeCertificateChain() + ) + let lease = TestData.makeIntermediateCertificateLease( + issuedAt: now.addingTimeInterval(-20 * 86400), + expiresAt: now.addingTimeInterval(6 * 86400), + renewAfter: now.addingTimeInterval(-3600) + ) + try await store.recordActivationSession(session) + try await store.recordEntitlementStatus(TestData.makeEntitlementStatus(license: license, currentLease: lease)) + return store + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/StateSharingTests.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/StateSharingTests.swift new file mode 100644 index 00000000..6ca50a5b --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Runtime/StateSharingTests.swift @@ -0,0 +1,208 @@ +import Foundation +@testable import LookInsideActivation +import Testing + +/// `state.json` is shared by every LookInside instance and by a 2.3.x helper +/// that may still run after a downgrade. A process must never write its +/// stale snapshot over newer state another process stored. +struct StateSharingTests { + private func makeRuntime(directory: URL, now: Date) -> ActivationRuntime { + ActivationRuntime( + configuration: HelperFixtures.isolatedConfiguration(stateDirectoryURL: directory), + urlSession: .shared, + now: { now }, + silentRenewalEnabled: false + ) + } + + private func fixtureState() throws -> ActivationPersistedState { + try ActivationStateCoding.decoder.decode( + ActivationPersistedState.self, + from: HelperFixtures.data("helper-state-full.json") + ) + } + + @Test func twoRuntimesOnOneDirectoryKeepEachOthersWrites() async throws { + let input = try HelperFixtures.challengeInput() + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let stateURL = directory.appendingPathComponent("state.json") + + let first = makeRuntime(directory: directory, now: input.evaluatedAt) + let second = makeRuntime(directory: directory, now: input.evaluatedAt) + #expect(first.activationState == .notActivated) + #expect(second.activationState == .notActivated) + + // The first instance activates. + let full = try fixtureState() + let fingerprint = try #require(full.deviceFingerprint) + let session = try #require(full.activationSession) + let status = try #require(full.entitlementStatus) + let response = try #require(full.activationResponse) + try await first.stateStore.recordDeviceFingerprint(fingerprint) + try await first.stateStore.recordActivationSession(session) + try await first.stateStore.recordEntitlementStatus(status) + try await first.stateStore.recordActivationResponse(response) + #expect(await first.currentDecision().grantsAccess) + + // The second instance still holds its empty snapshot. Its next write + // starts from the file, so the activation survives. + let failedAt = input.evaluatedAt.addingTimeInterval(-60) + try await second.stateStore.recordRenewAttempt(success: false, error: "offline", at: failedAt) + let onDisk = ActivationStateStore.loadState(from: stateURL) + #expect(onDisk.activationResponse == response) + #expect(onDisk.entitlementStatus?.currentLease == status.currentLease) + #expect(onDisk.lastRenewFailedAt == failedAt) + + // Each instance picks up the other's change on its next evaluation. + #expect(await second.currentDecision().grantsAccess) + #expect(second.activationState == .activated) + _ = await first.currentDecision() + #expect(await first.snapshot().lastRenewFailedAt == failedAt) + } + + @Test func fileWrittenByAnotherProcessIsReloadedOnlyWhenChanged() async throws { + let input = try HelperFixtures.challengeInput() + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let stateURL = directory.appendingPathComponent("state.json") + let runtime = makeRuntime(directory: directory, now: input.evaluatedAt) + + #expect(await runtime.stateStore.reloadIfChanged() == false) + #expect(await runtime.currentDecision().grantsAccess == false) + + // A 2.3.x helper (or another Host) writes the file. + try HelperFixtures.data("helper-state-full.json").write(to: stateURL, options: .atomic) + #expect(await runtime.currentDecision().grantsAccess) + #expect(await runtime.stateStore.reloadIfChanged() == false) + } + + @Test func trialInMemorySurvivesAnotherProcessWritingTelemetry() async throws { + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("state.json") + let trialProcess = ActivationStateStore(url: url) + let otherProcess = ActivationStateStore(url: url) + + let pem = try HelperFixtureValues.certificatePEM() + let session = HelperFixtureValues.session(licenseID: "lic_fixture_trial", token: "fixture-token-trial") + let lease = HelperFixtureValues.lease( + licenseID: "lic_fixture_trial", + expiresIn: 2 * HelperFixtureValues.day, + certificatePEM: pem + ) + try await trialProcess.recordTransientActivationSession(session) + try await trialProcess.recordEntitlementStatus( + HelperFixtureValues.status(licenseClass: .trial, lease: lease, session: session) + ) + try await otherProcess.recordDeviceFingerprint(HelperFixtureValues.fingerprint) + + #expect(await trialProcess.reloadIfChanged()) + let snapshot = await trialProcess.snapshot() + #expect(snapshot.deviceFingerprint == HelperFixtureValues.fingerprint) + #expect(snapshot.activationSession == session) + #expect(snapshot.entitlementStatus?.license.licenseClass == .trial) + // The trial still never reaches the disk. + let onDisk = ActivationStateStore.loadState(from: url) + #expect(onDisk.activationSession == nil) + #expect(onDisk.entitlementStatus == nil) + } + + /// Starts a trial in `store` the way the trial flow does: a transient + /// session first, then the trial entitlement. + private func startTrial(in store: ActivationStateStore, certificatePEM pem: String) async throws + -> ActivationSession + { + let session = HelperFixtureValues.session(licenseID: "lic_fixture_trial", token: "fixture-token-trial") + let lease = HelperFixtureValues.lease( + licenseID: "lic_fixture_trial", + expiresIn: 2 * HelperFixtureValues.day, + certificatePEM: pem + ) + try await store.recordTransientActivationSession(session) + try await store.recordEntitlementStatus( + HelperFixtureValues.status(licenseClass: .trial, lease: lease, session: session) + ) + return session + } + + /// State another process (or the 2.3.x helper) writes for a full license + /// whose lease ends `expiresIn` after the fixture date. + private func writeFullLicense(to url: URL, expiresIn: TimeInterval, certificatePEM pem: String) throws { + let session = HelperFixtureValues.session(licenseID: "lic_fixture_full", token: "fixture-token-full") + let lease = HelperFixtureValues.lease( + licenseID: "lic_fixture_full", + expiresIn: expiresIn, + certificatePEM: pem + ) + let state = ActivationPersistedState( + deviceFingerprint: HelperFixtureValues.fingerprint, + activationSession: session, + entitlementStatus: HelperFixtureValues.status(licenseClass: .full, lease: lease, session: session), + activationResponse: HelperFixtureValues.activationResponse(lease: lease), + updatedAt: HelperFixtureValues.base + ) + try ActivationStateCoding.encoder.encode(state).write(to: url, options: .atomic) + } + + /// An expired license on disk does not replace a trial running in + /// memory, neither while the trial starts nor when an older process + /// writes that license again later. + @Test func trialOverAnExpiredLicenseSurvivesWrites() async throws { + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("state.json") + let pem = try HelperFixtureValues.certificatePEM() + try writeFullLicense(to: url, expiresIn: -HelperFixtureValues.day, certificatePEM: pem) + let store = ActivationStateStore(url: url, now: { HelperFixtureValues.base }) + #expect(await store.snapshot().entitlementStatus?.license.licenseClass == .full) + + let trialSession = try await startTrial(in: store, certificatePEM: pem) + try await store.recordRenewAttempt(success: false, error: "offline", at: HelperFixtureValues.base) + var snapshot = await store.snapshot() + #expect(snapshot.activationSession == trialSession) + #expect(snapshot.entitlementStatus?.license.licenseClass == .trial) + + // The 2.3.x helper writes its expired license back. + try writeFullLicense(to: url, expiresIn: -HelperFixtureValues.day, certificatePEM: pem) + try await store.recordDeviceFingerprint(HelperFixtureValues.fingerprint) + #expect(await store.reloadIfChanged() == false) + snapshot = await store.snapshot() + #expect(snapshot.activationSession == trialSession) + #expect(snapshot.entitlementStatus?.license.licenseClass == .trial) + #expect(snapshot.deviceFingerprint == HelperFixtureValues.fingerprint) + } + + /// A license another process activates wins over the in-memory trial. + @Test func newActiveLicenseFromAnotherProcessReplacesTheTrial() async throws { + let directory = try HelperFixtures.makeStateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("state.json") + let pem = try HelperFixtureValues.certificatePEM() + let store = ActivationStateStore(url: url, now: { HelperFixtureValues.base }) + _ = try await startTrial(in: store, certificatePEM: pem) + + try writeFullLicense(to: url, expiresIn: 20 * HelperFixtureValues.day, certificatePEM: pem) + try await store.recordRenewAttempt(success: true, error: nil, at: HelperFixtureValues.base) + let snapshot = await store.snapshot() + #expect(snapshot.entitlementStatus?.license.licenseClass == .full) + #expect(snapshot.activationSession?.licenseID == "lic_fixture_full") + // The full license stays on disk, with this process's telemetry. + let onDisk = ActivationStateStore.loadState(from: url) + #expect(onDisk.entitlementStatus?.license.licenseClass == .full) + #expect(onDisk.activationSession?.licenseID == "lic_fixture_full") + #expect(onDisk.lastRenewSucceededAt == HelperFixtureValues.base) + } + + /// Rewriting the file with the same contents (a touch, a copy) is not a + /// change and does not reload. + @Test func sameContentsDoNotReload() async throws { + let directory = try HelperFixtures.makeStateDirectory(copying: "helper-state-full.json") + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("state.json") + let store = ActivationStateStore(url: url) + + try Data(contentsOf: url).write(to: url, options: .atomic) + #expect(await store.reloadIfChanged() == false) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/HelperFixtures.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/HelperFixtures.swift new file mode 100644 index 00000000..4897b1a4 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/HelperFixtures.swift @@ -0,0 +1,282 @@ +import Foundation +@testable import LookInsideActivation +import Security + +/// Fixtures produced by running the original 2.3.x Auth helper code +/// (`Scripts/reborn-activation-fixtures/generate.sh`). +enum HelperFixtures { + static func url(_ name: String) throws -> URL { + guard let root = Bundle.module.resourceURL else { + throw FixtureError.missing(name) + } + let url = root.appendingPathComponent("Fixtures", isDirectory: true) + .appendingPathComponent(name, isDirectory: false) + guard FileManager.default.fileExists(atPath: url.path) else { + throw FixtureError.missing(name) + } + return url + } + + static func data(_ name: String) throws -> Data { + try Data(contentsOf: url(name)) + } + + static func jsonObject(_ name: String) throws -> NSDictionary { + try jsonObject(data(name)) + } + + static func jsonObject(_ data: Data) throws -> NSDictionary { + guard let object = try JSONSerialization.jsonObject(with: data) as? NSDictionary else { + throw FixtureError.notAnObject + } + return object + } + + /// Inputs the generator used for the challenge fixtures. + struct ChallengeInput { + let nonce: Data + let serverInstanceID: String + let evaluatedAt: Date + let expiredAt: Date + let testOnlyPrivateKeyDER: Data + } + + static func challengeInput() throws -> ChallengeInput { + let object = try jsonObject("challenge-input.json") + guard let nonceHex = object["nonceHex"] as? String, + let nonce = Data(hexString: nonceHex), + let serverInstanceID = object["serverInstanceID"] as? String, + let evaluatedAt = object["evaluatedAt"] as? Double, + let expiredAt = object["expiredAt"] as? Double, + let keyBase64 = object["testOnlyPrivateKeyPKCS1DERBase64"] as? String, + let keyDER = Data(base64Encoded: keyBase64) + else { + throw FixtureError.malformed("challenge-input.json") + } + return ChallengeInput( + nonce: nonce, + serverInstanceID: serverInstanceID, + evaluatedAt: Date(timeIntervalSince1970: evaluatedAt), + expiredAt: Date(timeIntervalSince1970: expiredAt), + testOnlyPrivateKeyDER: keyDER + ) + } + + static func testPrivateKey(_ input: ChallengeInput) throws -> SecKey { + var error: Unmanaged? + guard + let key = SecKeyCreateWithData( + input.testOnlyPrivateKeyDER as CFData, + [kSecAttrKeyType: kSecAttrKeyTypeRSA, kSecAttrKeyClass: kSecAttrKeyClassPrivate] as CFDictionary, + &error + ) + else { + throw FixtureError.malformed("test key: \(String(describing: error?.takeRetainedValue()))") + } + return key + } + + /// Copies a fixture into a fresh temporary directory as `state.json` and + /// returns that directory. + static func makeStateDirectory(copying fixture: String? = nil) throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideActivationTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + if let fixture { + try FileManager.default.copyItem( + at: url(fixture), + to: directory.appendingPathComponent("state.json", isDirectory: false) + ) + } + return directory + } + + /// Configuration that never touches the real state, key or service: a + /// temporary state directory, a test-only key tag and an unreachable + /// local service URL. + static func isolatedConfiguration( + stateDirectoryURL: URL, + keyStore: IntermediateKeyStoreConfiguration? = nil + ) -> ActivationConfiguration { + ActivationConfiguration( + stateDirectoryURL: stateDirectoryURL, + keyStore: keyStore + ?? IntermediateKeyStoreConfiguration( + applicationTag: "com.lookinside.activation.tests.unused-\(UUID().uuidString)", + keychainPath: stateDirectoryURL.appendingPathComponent("missing.keychain-db").path + ), + serviceBaseURL: URL(string: "http://127.0.0.1:9")! + ) + } + + enum FixtureError: Error { + case missing(String) + case notAnObject + case malformed(String) + } +} + +/// Rebuilds the values the fixture generator recorded, with the same fixed +/// clock, so the new store can replay the generator's writes. +enum HelperFixtureValues { + static let base = Date(timeIntervalSince1970: 1_767_225_600) // 2026-01-01T00:00:00Z + static let day: TimeInterval = 24 * 60 * 60 + static let udid = "00000000-0000-4000-8000-00000000F1A7" + + static let fingerprint = DeviceFingerprint( + deviceID: udid, + hardwareModel: "Mac15,6", + operatingSystemVersion: "Version 15.4 (Build 24E248)", + appBundleID: "app.lookinside.LookInsideAuthServer" + ) + + /// The generator stored a freshly created self-signed certificate; read + /// it back from the full-state fixture (without our decoder). + static func certificatePEM() throws -> String { + let object = try HelperFixtures.jsonObject("helper-state-full.json") + guard let status = object["entitlementStatus"] as? NSDictionary, + let lease = status["currentLease"] as? NSDictionary, + let pem = lease["certificatePEM"] as? String + else { + throw HelperFixtures.FixtureError.malformed("helper-state-full.json") + } + return pem + } + + static func session(licenseID: String, token: String) -> ActivationSession { + ActivationSession( + sessionID: "ses_fixture_\(licenseID)", + licenseID: licenseID, + orderID: "ord_fixture", + email: "fixture@example.invalid", + deviceIdentifier: udid, + token: token, + expiresAt: base.addingTimeInterval(3 * day), + createdAt: base.addingTimeInterval(-day) + ) + } + + static func lease(licenseID: String, expiresIn: TimeInterval, certificatePEM: String) + -> IntermediateCertificateLease + { + IntermediateCertificateLease( + certificateID: "int_fixture_\(licenseID)", + licenseID: licenseID, + udid: udid, + issuedAt: base.addingTimeInterval(-5 * day), + expiresAt: base.addingTimeInterval(expiresIn), + renewAfter: base.addingTimeInterval(20 * day), + issuanceReason: .initialActivation, + certificatePEM: certificatePEM, + publicKeyPEM: "-----BEGIN PUBLIC KEY-----\nZml4dHVyZQ==\n-----END PUBLIC KEY-----\n", + fullChainPEM: certificatePEM, + status: .active + ) + } + + static func status( + licenseClass: LicenseClass, + lease: IntermediateCertificateLease, + session: ActivationSession + ) -> EntitlementStatus { + let license = LicenseEnvelope( + licenseID: lease.licenseID, + audience: .personal, + licenseClass: licenseClass, + lifecycleState: .active, + issuedTo: "fixture@example.invalid", + issuedAt: base.addingTimeInterval(-150 * day), + expiresAt: base.addingTimeInterval(215 * day), + serviceEndsAt: base.addingTimeInterval(215 * day), + nextCertificateRenewalAt: lease.renewAfter, + deviceWarning: nil, + certificateChain: CertificateChain( + rootCertificateID: EmbeddedTrustedRoots.rootProd2026.certificateID, + intermediateCertificateID: lease.certificateID, + intermediateIssuedAt: lease.issuedAt, + intermediateExpiresAt: lease.expiresAt, + renewAfter: lease.renewAfter, + boundUDID: udid + ) + ) + return EntitlementStatus( + license: license, + deviceBinding: DeviceBinding( + licenseID: lease.licenseID, + udid: udid, + machineName: "Fixture Mac", + hardwareModel: fingerprint.hardwareModel, + operatingSystemVersion: fingerprint.operatingSystemVersion, + appBundleID: fingerprint.appBundleID, + firstSeenAt: base.addingTimeInterval(-150 * day), + lastSeenAt: base.addingTimeInterval(-day) + ), + currentLease: lease, + activationSession: session, + isEligibleForActivation: true, + isEligibleForRenewal: true, + requiresLiveRefresh: false, + lastFetchedAt: base.addingTimeInterval(-day), + informationalMessage: "Fixture info", + warningMessage: nil + ) + } + + static func activationResponse(lease: IntermediateCertificateLease) -> HostActivationResponse { + HostActivationResponse( + activation: SignedActivationEnvelope( + activationID: "act_fixture", + challengeNonce: "nonce_fixture", + licenseID: lease.licenseID, + issuedAt: base.addingTimeInterval(-day), + expiresAt: base.addingTimeInterval(25 * day), + intermediateCertificateID: lease.certificateID, + boundUDID: udid, + artifacts: [ + ActivationArtifact( + name: "activation.json", + payload: Data(#"{"device_id":"fixture","license_id":"lic_fixture_full"}"#.utf8), + digest: "00ff" + ), + ] + ), + path: .direct, + secureTimestamp: SecureTimestampToken( + challengeNonce: "nonce_fixture", + signedAt: base.addingTimeInterval(-day), + rootCertificateID: EmbeddedTrustedRoots.rootProd2026.certificateID, + signature: Data([1, 2, 3, 4]) + ) + ) + } +} + +/// A temporary file-based keychain, deleted (and removed from the search +/// list) by `delete()`. Never the login keychain. +final class TemporaryKeychain { + let directory: URL + let path: String + private(set) var keychain: SecKeychain? + + init() throws { + directory = FileManager.default.temporaryDirectory + .appendingPathComponent("LookInsideActivationKeychain-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + path = directory.appendingPathComponent("test.keychain-db").path + let password = UUID().uuidString + var created: SecKeychain? + let status = SecKeychainCreate(path, UInt32(password.utf8.count), password, false, nil, &created) + guard status == errSecSuccess, let created else { + throw HelperFixtures.FixtureError.malformed("SecKeychainCreate failed: \(status)") + } + keychain = created + } + + func delete() { + if let keychain { + SecKeychainDelete(keychain) + } + keychain = nil + try? FileManager.default.removeItem(at: directory) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/TestSupport.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/TestSupport.swift new file mode 100644 index 00000000..b5f67de5 --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/TestSupport.swift @@ -0,0 +1,431 @@ +import Foundation +@testable import LookInsideActivation + +enum TestFailure: Error, Equatable { + case fetchFailed + case issueFailed + case signFailed +} + +enum RelativeDate { + case secondsFromBase(TimeInterval) + case none +} + +enum TestData { + static let baseNow = Date(timeIntervalSince1970: 1_710_000_000) + static let embeddedRootCertificateID = EmbeddedTrustedRoots.rootProd2026.certificateID + + static func makeCertificateChain( + rootCertificateID: String = embeddedRootCertificateID, + intermediateCertificateID: String = "intermediate-cert", + intermediateIssuedAt: Date = baseNow.addingTimeInterval(-60), + intermediateExpiresAt: Date = baseNow.addingTimeInterval(3600), + renewAfter: Date? = nil, + boundUDID: String? = nil + ) -> CertificateChain { + CertificateChain( + rootCertificateID: rootCertificateID, + intermediateCertificateID: intermediateCertificateID, + intermediateIssuedAt: intermediateIssuedAt, + intermediateExpiresAt: intermediateExpiresAt, + renewAfter: renewAfter, + boundUDID: boundUDID + ) + } + + static func makeChallenge( + nonce: String = "nonce-123", + issuedAt: Date = baseNow, + requestedFeature: String = "swiftui.bootstrap" + ) -> ClientActivationChallenge { + ClientActivationChallenge( + nonce: nonce, + issuedAt: issuedAt, + requestedFeature: requestedFeature, + frameworkVersion: "1.0.0", + device: DeviceFingerprint( + deviceID: "device-123", + hardwareModel: "iPhone17,1", + operatingSystemVersion: "18.0", + appBundleID: "com.lookinside.client" + ) + ) + } + + static func makeTimestampRequest( + challengeNonce: String = "nonce-123", + clientIssuedAt: Date = baseNow + ) -> SecureTimestampRequest { + SecureTimestampRequest( + licenseID: "license-123", + challengeNonce: challengeNonce, + clientIssuedAt: clientIssuedAt, + deviceIdentifier: "device-123", + requestedFeature: "swiftui.bootstrap" + ) + } + + static func makeActivationSession( + sessionID: String = "session-123", + licenseID: String = "license-123", + orderID: String? = "ord_123", + email: String? = "user@example.com", + deviceIdentifier: String = "device-123", + token: String = "session-token", + expiresAt: Date = baseNow.addingTimeInterval(900), + createdAt: Date = baseNow + ) -> ActivationSession { + ActivationSession( + sessionID: sessionID, + licenseID: licenseID, + orderID: orderID, + email: email, + deviceIdentifier: deviceIdentifier, + token: token, + expiresAt: expiresAt, + createdAt: createdAt + ) + } + + static func makeDeviceBinding( + licenseID: String = "license-123", + udid: String = "device-123" + ) -> DeviceBinding { + DeviceBinding( + licenseID: licenseID, + udid: udid, + machineName: "MacBook Pro", + hardwareModel: "Mac15,6", + operatingSystemVersion: "macOS 15.4", + appBundleID: "com.lookinside.app", + firstSeenAt: baseNow.addingTimeInterval(-3600), + lastSeenAt: baseNow + ) + } + + static func makeIntermediateCertificateLease( + certificateID: String = "lease-cert-001", + licenseID: String = "license-123", + udid: String = "device-123", + issuedAt: Date = baseNow, + expiresAt: Date = baseNow.addingTimeInterval(30 * 24 * 60 * 60), + renewAfter: Date? = nil, + issuanceReason: IntermediateCertificateIssuanceReason = .initialActivation, + status: IntermediateCertificateLeaseStatus = .active + ) -> IntermediateCertificateLease { + IntermediateCertificateLease( + certificateID: certificateID, + licenseID: licenseID, + udid: udid, + issuedAt: issuedAt, + expiresAt: expiresAt, + renewAfter: renewAfter, + issuanceReason: issuanceReason, + certificatePEM: "-----BEGIN CERTIFICATE-----\nlease\n-----END CERTIFICATE-----", + publicKeyPEM: "-----BEGIN PUBLIC KEY-----\nlease\n-----END PUBLIC KEY-----", + fullChainPEM: "-----BEGIN CERTIFICATE-----\nchain\n-----END CERTIFICATE-----", + status: status + ) + } + + static func makeHostActivationResponse( + activationID: String = "activation-123", + challengeNonce: String = "nonce-123", + session: ActivationSession = makeActivationSession(), + lease: IntermediateCertificateLease = makeIntermediateCertificateLease() + ) -> HostActivationResponse { + HostActivationResponse( + activation: SignedActivationEnvelope( + activationID: activationID, + challengeNonce: challengeNonce, + licenseID: session.licenseID, + issuedAt: baseNow, + expiresAt: baseNow.addingTimeInterval(3600), + intermediateCertificateID: lease.certificateID, + boundUDID: session.deviceIdentifier, + artifacts: [] + ), + path: .direct, + secureTimestamp: nil + ) + } + + static func makeEntitlementStatus( + license: LicenseEnvelope, + deviceBinding: DeviceBinding? = makeDeviceBinding(), + currentLease: IntermediateCertificateLease? = nil, + activationSession: ActivationSession? = nil, + isEligibleForActivation: Bool = true, + isEligibleForRenewal: Bool = false, + warningMessage: String? = nil + ) -> EntitlementStatus { + EntitlementStatus( + license: license, + deviceBinding: deviceBinding, + currentLease: currentLease, + activationSession: activationSession, + isEligibleForActivation: isEligibleForActivation, + isEligibleForRenewal: isEligibleForRenewal, + lastFetchedAt: baseNow, + warningMessage: warningMessage + ) + } +} + +struct FixedClock: Clock { + let currentDate: Date + + func now() -> Date { + currentDate + } +} + +final class RecordingTrustValidator: TrustChainValidating, @unchecked Sendable { + private(set) var validateCount = 0 + private(set) var lastCertificateChain: CertificateChain? + private(set) var lastNow: Date? + var error: Error? + + func validate(_ certificateChain: CertificateChain, at now: Date) throws { + validateCount += 1 + lastCertificateChain = certificateChain + lastNow = now + if let error { + throw error + } + } +} + +final class RecordingChallengeValidator: ChallengeValidating, @unchecked Sendable { + private(set) var validateCount = 0 + private(set) var lastChallenge: ClientActivationChallenge? + private(set) var lastNow: Date? + private(set) var lastPolicy: ActivationPolicy? + var error: Error? + + func validate(_ challenge: ClientActivationChallenge, now: Date, policy: ActivationPolicy) throws { + validateCount += 1 + lastChallenge = challenge + lastNow = now + lastPolicy = policy + if let error { + throw error + } + } +} + +final class RecordingReplayProtector: ReplayProtecting, @unchecked Sendable { + private(set) var reserveCount = 0 + private(set) var lastNonce: String? + private(set) var lastExpiry: Date? + var error: Error? + + func reserve(_ nonce: String, until expiresAt: Date) throws { + reserveCount += 1 + lastNonce = nonce + lastExpiry = expiresAt + if let error { + throw error + } + } +} + +final class RecordingSecureTimestampFetcher: SecureTimestampFetching, @unchecked Sendable { + private(set) var fetchCount = 0 + private(set) var lastRequest: SecureTimestampRequest? + var error: Error? + var token: SecureTimestampToken + + init(token: SecureTimestampToken) { + self.token = token + } + + func fetchTimestamp(for request: SecureTimestampRequest) async throws -> SecureTimestampToken { + fetchCount += 1 + lastRequest = request + if let error { + throw error + } + return token + } +} + +final class RecordingSecureTimestampValidator: SecureTimestampValidating, @unchecked Sendable { + private(set) var validateCount = 0 + private(set) var lastToken: SecureTimestampToken? + private(set) var lastRequest: SecureTimestampRequest? + private(set) var lastTrustedRootCertificateID: String? + private(set) var lastNow: Date? + private(set) var lastPolicy: ActivationPolicy? + var error: Error? + + func validate( + _ token: SecureTimestampToken, + against request: SecureTimestampRequest, + trustedRootCertificateID: String, + now: Date, + policy: ActivationPolicy + ) throws { + validateCount += 1 + lastToken = token + lastRequest = request + lastTrustedRootCertificateID = trustedRootCertificateID + lastNow = now + lastPolicy = policy + if let error { + throw error + } + } +} + +final class RecordingActivationIssuer: ActivationMaterialIssuing, @unchecked Sendable { + private(set) var issueCount = 0 + private(set) var lastChallenge: ClientActivationChallenge? + private(set) var lastLicense: LicenseEnvelope? + private(set) var lastPolicy: ActivationPolicy? + private(set) var lastSecureTimestamp: SecureTimestampToken? + var error: Error? + var activation: SignedActivationEnvelope + + init(activation: SignedActivationEnvelope) { + self.activation = activation + } + + func issueActivation( + for challenge: ClientActivationChallenge, + license: LicenseEnvelope, + policy: ActivationPolicy, + secureTimestamp: SecureTimestampToken? + ) async throws -> SignedActivationEnvelope { + issueCount += 1 + lastChallenge = challenge + lastLicense = license + lastPolicy = policy + lastSecureTimestamp = secureTimestamp + if let error { + throw error + } + return activation + } +} + +final class RecordingRootTimestampSigner: RootTimestampSigning, @unchecked Sendable { + private(set) var signCount = 0 + private(set) var lastRequest: SecureTimestampRequest? + private(set) var lastNow: Date? + var error: Error? + var token: SecureTimestampToken + + init(token: SecureTimestampToken) { + self.token = token + } + + func sign(request: SecureTimestampRequest, at now: Date) async throws -> SecureTimestampToken { + signCount += 1 + lastRequest = request + lastNow = now + if let error { + throw error + } + return token + } +} + +struct CoordinatorFixture { + let now: Date + let licenseClass: LicenseClass + let policy: ActivationPolicy + let clock: FixedClock + let trustValidator: RecordingTrustValidator + let challengeValidator: RecordingChallengeValidator + let replayProtector: RecordingReplayProtector + let timestampFetcher: RecordingSecureTimestampFetcher + let timestampValidator: RecordingSecureTimestampValidator + let activationIssuer: RecordingActivationIssuer + let rootTimestampSigner: RecordingRootTimestampSigner + let request: HostActivationRequest + let timestampRequest: SecureTimestampRequest + + init( + licenseClass: LicenseClass, + licenseExpiresAt: RelativeDate = .secondsFromBase(3600), + requiresSecureTimestampForTrial: Bool = true + ) { + now = TestData.baseNow + self.licenseClass = licenseClass + policy = ActivationPolicy( + acceptedClockSkew: 30, + challengeTimeToLive: 180, + secureTimestampTimeToLive: 120, + activationLifetime: 600, + requiresSecureTimestampForTrial: requiresSecureTimestampForTrial + ) + clock = FixedClock(currentDate: now) + trustValidator = RecordingTrustValidator() + challengeValidator = RecordingChallengeValidator() + replayProtector = RecordingReplayProtector() + + let challenge = TestData.makeChallenge(issuedAt: now) + let certificateChain = TestData.makeCertificateChain() + let expiresAt: Date? = + switch licenseExpiresAt { + case let .secondsFromBase(interval): + now.addingTimeInterval(interval) + case .none: + nil + } + let license = LicenseEnvelope( + licenseID: "license-123", + licenseClass: licenseClass, + issuedTo: "Acme", + issuedAt: now, + expiresAt: expiresAt, + certificateChain: certificateChain + ) + request = HostActivationRequest(challenge: challenge, license: license) + timestampRequest = request.secureTimestampRequest + + let secureTimestamp = SecureTimestampToken( + challengeNonce: challenge.nonce, + signedAt: now, + rootCertificateID: certificateChain.rootCertificateID, + signature: Data() + ) + timestampFetcher = RecordingSecureTimestampFetcher(token: secureTimestamp) + timestampValidator = RecordingSecureTimestampValidator() + + let activation = SignedActivationEnvelope( + activationID: "activation-123", + challengeNonce: challenge.nonce, + licenseID: license.licenseID, + issuedAt: now, + expiresAt: now.addingTimeInterval(policy.activationLifetime), + intermediateCertificateID: certificateChain.intermediateCertificateID, + boundUDID: challenge.device.deviceID, + artifacts: [ + ActivationArtifact( + name: "swiftui.bundle", + payload: Data("signed-material".utf8), + digest: "digest-123" + ), + ], + signature: Data() + ) + activationIssuer = RecordingActivationIssuer(activation: activation) + rootTimestampSigner = RecordingRootTimestampSigner(token: secureTimestamp) + } + + func makeHostCoordinator() -> HostActivationCoordinator { + HostActivationCoordinator( + policy: policy, + clock: clock, + trustValidator: trustValidator, + challengeValidator: challengeValidator, + replayProtector: replayProtector, + secureTimestampFetcher: timestampFetcher, + secureTimestampValidator: timestampValidator, + activationIssuer: activationIssuer + ) + } +} diff --git a/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/UITestSupport.swift b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/UITestSupport.swift new file mode 100644 index 00000000..daa333ba --- /dev/null +++ b/Packages/LookInsideActivation/Tests/LookInsideActivationTests/Support/UITestSupport.swift @@ -0,0 +1,84 @@ +import Foundation +@testable import LookInsideActivation + +final class RecordingPurchaseClaimResolver: PurchaseClaimResolving, @unchecked Sendable { + private(set) var resolveCount = 0 + private(set) var lastRequest: PurchaseClaimRequest? + var error: Error? + var session: ActivationSession + + init(session: ActivationSession) { + self.session = session + } + + func resolvePurchaseClaim(_ request: PurchaseClaimRequest) async throws -> ActivationSession { + resolveCount += 1 + lastRequest = request + if let error { + throw error + } + return session + } +} + +final class RecordingEntitlementStatusFetcher: EntitlementStatusFetching, @unchecked Sendable { + private(set) var fetchCount = 0 + private(set) var lastRequest: EntitlementStatusRequest? + var error: Error? + var status: EntitlementStatus + + init(status: EntitlementStatus) { + self.status = status + } + + func fetchEntitlementStatus(_ request: EntitlementStatusRequest) async throws -> EntitlementStatus { + fetchCount += 1 + lastRequest = request + if let error { + throw error + } + return status + } +} + +final class RecordingTrialIssuer: TrialIssuing, @unchecked Sendable { + private(set) var issueCount = 0 + private(set) var lastRequest: TrialIssueRequest? + var error: Error? + var session: ActivationSession + + init(session: ActivationSession) { + self.session = session + } + + func issueTrial(_ request: TrialIssueRequest) async throws -> ActivationSession { + issueCount += 1 + lastRequest = request + if let error { + throw error + } + return session + } +} + +final class RecordingIntermediateCertificateIssuer: IntermediateCertificateIssuing, @unchecked Sendable { + private(set) var issueCount = 0 + private(set) var lastRequest: IntermediateCertificateIssueRequest? + var error: Error? + var lease: IntermediateCertificateLease + + init(lease: IntermediateCertificateLease) { + self.lease = lease + } + + func issueIntermediateCertificate( + _ request: IntermediateCertificateIssueRequest + ) async throws -> IntermediateCertificateLease { + issueCount += 1 + lastRequest = request + if let error { + throw error + } + return lease + } +} diff --git a/Packages/LookInsideHostCore/Package.swift b/Packages/LookInsideHostCore/Package.swift new file mode 100644 index 00000000..8ba13b33 --- /dev/null +++ b/Packages/LookInsideHostCore/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.0 + +// Host building blocks that do not need AppKit or the LookinCore model: +// the multi-subscriber AsyncStream broadcaster and the pure decisions of the +// Connection layer (response frame accounting, Server version check, license +// handshake gate and retry policy). The LookInside app target links the +// library; `swift test` runs the tests without building the app. + +import PackageDescription + +let package = Package( + name: "LookInsideHostCore", + platforms: [.macOS(.v14)], + products: [ + .library(name: "LookInsideHostCore", targets: ["LookInsideHostCore"]), + ], + targets: [ + .target(name: "LookInsideHostCore"), + .testTarget( + name: "LookInsideHostCoreTests", + dependencies: ["LookInsideHostCore"] + ), + ], + swiftLanguageModes: [.v6] +) diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AppShellPolicies.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AppShellPolicies.swift new file mode 100644 index 00000000..d2dc5efb --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AppShellPolicies.swift @@ -0,0 +1,183 @@ +// +// AppShellPolicies.swift +// LookInsideHostCore +// +// Pure decisions of the Host's app shell: the per-app expansion state +// stored in user defaults, the Server version comparison and the SwiftUI +// selection hand-over after a display-mode reload. The app target wraps +// them (LKPreferenceManager, LKVersionComparer, LKStaticWindowController); +// keeping them here lets `swift test` cover them without the app. +// + +import Foundation + +/// How the per-app hierarchy expansion state is kept in user defaults. +/// +/// Each app's state lives under `ExpansionState.`; the bundle +/// ids are ordered most recent first under `ExpansionStateBundleLRU`, and +/// only the 20 most recent keep their state. +public enum ExpansionStatePolicy { + public static let lruKey = "ExpansionStateBundleLRU" + public static let stateKeyPrefix = "ExpansionState." + public static let capacity = 20 + + public static func stateKey(forBundleIdentifier bundleIdentifier: String) -> String { + stateKeyPrefix + bundleIdentifier + } + + /// The LRU list with `bundleIdentifier` first. Earlier entries for the + /// same id, and malformed entries (not strings, or empty) left by a + /// damaged plist, are dropped. + public static func lru(movingToFront bundleIdentifier: String, in storedLRU: Any?) -> [String] { + var result = [bundleIdentifier] + if let entries = storedLRU as? [Any] { + for entry in entries { + guard let identifier = entry as? String, !identifier.isEmpty, identifier != bundleIdentifier else { + continue + } + result.append(identifier) + } + } + return result + } + + /// Splits an LRU list into the entries that stay and the ones past the + /// capacity, whose state is removed. + public static func evicting(_ lru: [String], capacity: Int = capacity) -> (kept: [String], evicted: [String]) { + guard lru.count > capacity else { + return (lru, []) + } + return (Array(lru[.. Bool { + guard let entries = storedLRU as? [Any] else { + return false + } + let contains = entries.contains { ($0 as? String) == bundleIdentifier } + guard contains else { + return false + } + return (entries.first as? String) != bundleIdentifier + } + + /// Reads a stored expansion state: path → expanded. + /// + /// The current format is a dictionary of path to boolean; entries with + /// an empty or non-string key or a non-number value are skipped. The + /// legacy format, an array of the expanded paths, reads as all of them + /// expanded. Anything else reads as empty. + public static func decodeState(_ stored: Any?) -> [String: Bool] { + if let dictionary = stored as? NSDictionary { + var result: [String: Bool] = [:] + for (key, value) in dictionary { + guard let path = key as? String, !path.isEmpty, let number = value as? NSNumber else { + continue + } + result[path] = number.boolValue + } + return result + } + if let array = stored as? NSArray { + var result: [String: Bool] = [:] + for entry in array { + guard let path = entry as? String, !path.isEmpty else { + continue + } + result[path] = true + } + return result + } + return [:] + } +} + +/// The Server version comparison used to gate features on old Servers. +public enum ServerVersionComparison { + /// `"1.2.7"` → 10207: the first three dot-separated components, each + /// read like `-[NSString integerValue]`, weighted 10000, 100 and 1. + /// An empty string reads as 0. + public static func numericVersion(_ version: String) -> Int { + guard !version.isEmpty else { + return 0 + } + let components = version.components(separatedBy: ".") + var result = 0 + for (position, component) in components.prefix(3).enumerated() { + let weight = [10000, 100, 1][position] + result += (component as NSString).integerValue * weight + } + return result + } + + /// Whether `realVersion` is at least `expectedVersion`; nil when either + /// reads as 0 (unparseable), which callers treat as unsupported. + public static func satisfies(expectedVersion: String, realVersion: String) -> Bool? { + let expected = numericVersion(expectedVersion) + let real = numericVersion(realVersion) + guard expected != 0, real != 0 else { + return nil + } + return real >= expected + } +} + +/// Keeps a SwiftUI selection across a display-mode reload. SwiftUI display +/// item ids look like `swiftui::`; compact mode +/// folds some items away, so a selected id may be gone after the reload. +public enum SwiftUISelectionMigration { + public static let idPrefix = "swiftui:" + + public enum Outcome: Equatable, Sendable { + /// The prior id is still in the tree, at this index of the list. + case exact(Int) + /// The prior id is gone; this index holds the SwiftUI item with + /// the smallest pre-order index after the prior one. + case migrated(Int) + case none + } + + public static func isSwiftUIID(_ identifier: String?) -> Bool { + identifier?.hasPrefix(idPrefix) ?? false + } + + /// The last `:`-separated component as an integer, or nil when there is + /// no colon or the tail is not an integer (surrounding whitespace is + /// allowed, as `NSScanner` allows it). + public static func preOrderIndex(of identifier: String) -> Int? { + guard let lastColon = identifier.range(of: ":", options: .backwards) else { + return nil + } + let scanner = Scanner(string: String(identifier[lastColon.upperBound...])) + guard let value = scanner.scanInt(), scanner.isAtEnd else { + return nil + } + return value + } + + /// Where the selection goes in the reloaded list `identifiers` (the + /// display item ids in display order; nil for items without one). + public static func target(priorIdentifier: String, in identifiers: [String?]) -> Outcome { + guard !priorIdentifier.isEmpty else { + return .none + } + if let index = identifiers.firstIndex(where: { $0 == priorIdentifier }) { + return .exact(index) + } + guard let priorIndex = preOrderIndex(of: priorIdentifier), priorIndex >= 0 else { + return .none + } + var best: (listIndex: Int, preOrder: Int)? + for (listIndex, identifier) in identifiers.enumerated() { + guard let identifier, isSwiftUIID(identifier), let candidate = preOrderIndex(of: identifier) else { + continue + } + if candidate > priorIndex, candidate < (best?.preOrder ?? Int.max) { + best = (listIndex, candidate) + } + } + return best.map { .migrated($0.listIndex) } ?? .none + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AsyncBroadcaster.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AsyncBroadcaster.swift new file mode 100644 index 00000000..d48a78c9 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/AsyncBroadcaster.swift @@ -0,0 +1,118 @@ +import Foundation + +/// Sends every value to all current subscribers. Each `subscribe` call +/// returns a new `AsyncStream` with its own buffer. +/// +/// - A subscriber receives only the values yielded after it subscribed. +/// There is no replay. +/// - Values from one producer arrive in the order they were yielded. +/// - When a subscriber's task is cancelled, or it stops iterating and drops +/// the stream, it is removed. Later values are not buffered for it. +/// - `finish()` ends every stream. A subscriber that arrives after that gets +/// a stream that is already finished. Releasing the broadcaster finishes +/// the streams in the same way. +/// - The broadcaster keeps no strong reference from a stream back to itself, +/// so a subscriber that is still listening does not keep it alive. +/// +/// Safe to use from any thread. `Element` does not have to be `Sendable`: +/// the Host sends AppKit-confined objects (such as connection channels) and +/// produces and consumes them on the main actor. +public final class AsyncBroadcaster: @unchecked Sendable { + public typealias BufferingPolicy = AsyncStream.Continuation.BufferingPolicy + + private let lock = NSLock() + /// Serialises `yield` so values from concurrent producers reach every + /// subscriber in one order. Never held together with a termination + /// handler: those take only `lock`. + private let deliveryLock = NSLock() + private var continuations: [Int: AsyncStream.Continuation] = [:] + private var nextSubscriberID = 0 + private var isFinished = false + private let defaultBufferingPolicy: BufferingPolicy + + /// - Parameter bufferingPolicy: how many values each subscriber keeps + /// while it is not iterating. Unbounded unless a subscriber asks for + /// another policy. + public init(bufferingPolicy: BufferingPolicy = .unbounded) { + defaultBufferingPolicy = bufferingPolicy + } + + deinit { + for continuation in continuations.values { + continuation.finish() + } + } + + /// The number of subscribers whose streams are still open. + public var subscriberCount: Int { + lock.withLock { continuations.count } + } + + /// Returns a stream of the values yielded from now on. + /// + /// - Parameter bufferingPolicy: overrides the broadcaster's policy for + /// this subscriber. + public func subscribe(bufferingPolicy: BufferingPolicy? = nil) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream( + of: Element.self, + bufferingPolicy: bufferingPolicy ?? defaultBufferingPolicy + ) + let subscriberID: Int? = lock.withLock { + guard !isFinished else { return nil } + let subscriberID = nextSubscriberID + nextSubscriberID += 1 + continuations[subscriberID] = continuation + return subscriberID + } + guard let subscriberID else { + continuation.finish() + return stream + } + // Set outside the lock: the handler takes it, and it may run at once + // on this thread if the stream is already gone. + continuation.onTermination = { [weak self] _ in + self?.removeSubscriber(subscriberID) + } + return stream + } + + /// Sends `value` to every current subscriber. Does nothing after + /// `finish()`. + public func yield(_ value: Element) { + // Each subscriber gets the same value. `Element` is not required to + // be Sendable (see the type's note), so the compiler is told not to + // track it; the Host yields and consumes on the main actor. + nonisolated(unsafe) let shared = value + // Deliver outside `lock`: yielding to a stream whose consumer was + // cancelled can run its termination handler on this thread, and that + // handler takes `lock` (a deadlock seen in testing). `deliveryLock` + // keeps concurrent producers in one order instead. + deliveryLock.withLock { + let current = lock.withLock { Array(continuations.values) } + for continuation in current { + continuation.yield(shared) + } + } + } + + /// Ends every subscriber's stream and every later subscription. + public func finish() { + let finishing: [AsyncStream.Continuation] = lock.withLock { + isFinished = true + let finishing = Array(continuations.values) + continuations.removeAll() + return finishing + } + // Outside the lock: finishing runs each stream's termination handler, + // which takes the lock again. + for continuation in finishing { + continuation.finish() + } + } + + private func removeSubscriber(_ subscriberID: Int) { + lock.withLock { + _ = continuations.removeValue(forKey: subscriberID) + } + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ClientDisplayText.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ClientDisplayText.swift new file mode 100644 index 00000000..bb2d19c6 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ClientDisplayText.swift @@ -0,0 +1,239 @@ +// The text decisions behind the Host's client-side extensions on LookinCore +// model classes (LookinDisplayItem, LookinObject, LookinAutoLayoutConstraint) +// and on NSColor / NSString: how a class name, a constraint or a colour is +// written in the inspector, and how SwiftUI identifiers are read back out of +// attribute strings. The app-side extensions only gather the model values +// and call these, so the rules are covered here without building the app. + +import Foundation + +public enum ClientDisplayText {} + +// MARK: - Class names + +public extension ClientDisplayText { + /// Drops the module prefix from a demangled class name + /// ("AppKit._NSCoreHostingView" becomes + /// "_NSCoreHostingView"). Only dots before the + /// first '<' count, so generic arguments keep their own prefixes. + static func removingModulePrefix(_ name: String) -> String { + let ns = name as NSString + let angleBracket = ns.range(of: "<").location + let prefixPortion = angleBracket != NSNotFound ? ns.substring(to: angleBracket) : name + let lastDot = (prefixPortion as NSString).range(of: ".", options: .backwards).location + if lastDot != NSNotFound { + return ns.substring(from: lastDot + 1) + } + return name + } + + /// Whether a class chain entry names the type itself once its module + /// prefix is gone ("SwiftUI.HostingView" matches "HostingView"). + static func classChainEntry(_ entry: String, matches className: String) -> Bool { + entry.components(separatedBy: ".").last == className + } + + private static let swiftUISupportMarkers = [ + "SwiftUI", + "HostingView", + "UIHosting", + "NSHosting", + "ViewRendererHost", + "PlatformViewHost", + "_UIHosting", + "_NSHosting", + ] + + /// Whether a class name (or a row title / subtitle) belongs to SwiftUI's + /// hosting machinery. + static func looksLikeSwiftUISupport(_ className: String?) -> Bool { + guard let className, !className.isEmpty else { return false } + return swiftUISupportMarkers.contains { className.contains($0) } + } +} + +// MARK: - SwiftUI identifiers in attribute strings + +public extension ClientDisplayText { + /// Every token of `string` that reads as a SwiftUI display-list ID: a + /// decimal or 0x-prefixed hex number in 1.. [UInt64] { + guard !string.isEmpty else { return [] } + var result: [UInt64] = [] + let separators = CharacterSet.alphanumerics.inverted + for rawToken in string.components(separatedBy: separators) where !rawToken.isEmpty { + let token = rawToken.lowercased() + var value: UInt64 = 0 + if token.hasPrefix("0x") { + let scanner = Scanner(string: String(token.dropFirst(2))) + guard scanner.scanHexInt64(&value), scanner.isAtEnd else { continue } + } else { + let scanner = Scanner(string: token) + guard scanner.scanUnsignedLongLong(&value), scanner.isAtEnd else { continue } + } + if value == 0 || value >= UInt64(UInt32.max) { + continue + } + if !result.contains(value) { + result.append(value) + } + } + return result + } + + /// The first "0x…" hex address in an object description, lowercased + /// ("" gives "0x6000ab"), or nil when there is none. + static func memoryAddress(inObjectDescription description: String) -> String? { + let ns = description as NSString + guard ns.length > 0 else { return nil } + let prefixRange = ns.range(of: "0x", options: .caseInsensitive) + guard prefixRange.location != NSNotFound else { return nil } + let start = prefixRange.location + var index = NSMaxRange(prefixRange) + let hexDigits = CharacterSet(charactersIn: "0123456789abcdefABCDEF") + while index < ns.length { + guard let scalar = Unicode.Scalar(ns.character(at: index)), hexDigits.contains(scalar) else { break } + index += 1 + } + guard index > NSMaxRange(prefixRange) else { return nil } + return ns.substring(with: NSRange(location: start, length: index - start)).lowercased() + } +} + +// MARK: - Auto Layout constraints + +public extension ClientDisplayText { + /// The name of an iOS `NSLayoutAttribute` raw value. iOS and macOS give + /// the same attributes different values; constraints are described with + /// the iOS numbering, and 32...37 are the autoresizing-mask attributes + /// Xcode's view debugger shows. Unknown values return nil. + static func layoutAttributeName(_ attribute: Int) -> String? { + switch attribute { + // Some apps really do have these; Reveal and Xcode show the same. + case 0: "notAnAttribute" + case 1: "left" + case 2: "right" + case 3: "top" + case 4: "bottom" + case 5: "leading" + case 6: "trailing" + case 7: "width" + case 8: "height" + case 9: "centerX" + case 10: "centerY" + case 11: "lastBaseline" + case 12: "firstBaseline" + case 13: "leftMargin" + case 14: "rightMargin" + case 15: "topMargin" + case 16: "bottomMargin" + case 17: "leadingMargin" + case 18: "trailingMargin" + case 19: "centerXWithinMargins" + case 20: "centerYWithinMargins" + case 32: "minX" + case 33: "minY" + case 34: "midX" + case 35: "midY" + case 36: "maxX" + case 37: "maxY" + default: nil + } + } + + /// "<=", "=" or ">=" for an `NSLayoutRelation` raw value, nil otherwise. + static func layoutRelationSymbol(_ relation: Int) -> String? { + switch relation { + case -1: "<=" + case 0: "=" + case 1: ">=" + default: nil + } + } + + /// The spelled-out name of an `NSLayoutRelation` raw value, nil otherwise. + static func layoutRelationName(_ relation: Int) -> String? { + switch relation { + case -1: "LessThanOrEqual" + case 0: "Equal" + case 1: "GreaterThanOrEqual" + default: nil + } + } +} + +// MARK: - Colours + +public extension ClientDisplayText { + /// "(15, 17, 19)" when opaque, "(15, 17, 19, 0.50)" otherwise, from sRGB + /// components in 0...1. + static func rgbaString(red: Double, green: Double, blue: Double, alpha: Double) -> String { + if alpha >= 1 { + return String(format: "(%.0f, %.0f, %.0f)", red * 255, green * 255, blue * 255) + } + return String(format: "(%.0f, %.0f, %.0f, %.2f)", red * 255, green * 255, blue * 255, alpha) + } + + /// "#0f1113" when opaque, "#0f111380" otherwise, from sRGB components in + /// 0...1. Each channel is truncated, not rounded, to 0...255. + static func hexString(red: Double, green: Double, blue: Double, alpha: Double) -> String { + func channel(_ value: Double) -> String { + let hex = hexDigits(value.isFinite ? Int(value * 255) : 0) + return hex.count < 2 ? "0" + hex : hex + } + if alpha >= 1 { + return "#" + channel(red) + channel(green) + channel(blue) + } + return "#" + channel(red) + channel(green) + channel(blue) + channel(alpha) + } + + /// Base-16 digits built one remainder at a time, as the original + /// formatter did, so out-of-gamut (negative) channels keep its spelling. + private static func hexDigits(_ integer: Int) -> String { + var integer = integer + var result = "" + for _ in 0 ..< 9 { + let remainder = integer % 16 + integer /= 16 + let letter = remainder >= 10 ? String(UnicodeScalar(UInt8(87 + remainder))) : String(remainder) + result = letter + result + if integer == 0 { + break + } + } + return result + } +} + +// MARK: - Preview screenshots + +public extension ClientDisplayText { + /// Whether an expanded node should show its group screenshot in the + /// preview instead of its solo one: the solo screenshot is missing, or it + /// is nearly empty (under 2% visible pixels) while the group screenshot + /// clearly draws something (over 5%, and over four times the solo share). + /// Ratios are nil when the screenshot itself is missing. + static func prefersGroupScreenshot(soloVisibleRatio: () -> Double?, groupVisibleRatio: () -> Double?, hasGroupScreenshot: Bool) -> Bool { + guard hasGroupScreenshot else { return false } + guard let solo = soloVisibleRatio() else { return true } + if solo >= 0.02 { + return false + } + let group = groupVisibleRatio() ?? 0 + return group > max(0.05, solo * 4.0) + } + + /// The share of pixels with alpha above 12 in a premultiplied RGBA + /// buffer (4 bytes per pixel). + static func visiblePixelRatio(rgbaPixels: [UInt8]) -> Double { + let pixelCount = rgbaPixels.count / 4 + guard pixelCount > 0 else { return 0 } + var visible = 0 + for index in 0 ..< pixelCount where rgbaPixels[index * 4 + 3] > 12 { + visible += 1 + } + return Double(visible) / Double(pixelCount) + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ConnectionPolicies.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ConnectionPolicies.swift new file mode 100644 index 00000000..4cb46a8b --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ConnectionPolicies.swift @@ -0,0 +1,189 @@ +import Foundation + +/// Counts the frames of one response. The Server can split a response into +/// several frames; each says how many items it carries (`currentDataCount`) +/// and how many the whole response has (`dataTotalCount`, 0 for a response +/// that is never split). +public struct ResponseFrameProgress: Equatable, Sendable { + /// Items received so far across the frames of this response. + public private(set) var receivedDataCount = 0 + + public init() {} + + /// `true` before the first frame of a split response was counted. + public var isAtFirstFrame: Bool { + receivedDataCount == 0 + } + + /// Counts one frame and returns `true` when the response is complete. + public mutating func record(currentDataCount: Int, dataTotalCount: Int) -> Bool { + guard dataTotalCount > 0 else { + return true + } + receivedDataCount += currentDataCount + return receivedDataCount >= dataTotalCount + } +} + +/// Whether the Host can talk to a Server that reported `serverVersion` in +/// its ping response. +public enum ServerVersionCompatibility: Equatable, Sendable { + case compatible + /// The Server must be updated. + case serverTooOld + /// The Host must be updated. + case serverTooNew + + /// Version values of old internal LookinServer builds, treated as too old. + public static let legacyInternalVersions: Set = [-1, 100] + + public init(serverVersion: Int, supported: ClosedRange) { + if Self.legacyInternalVersions.contains(serverVersion) { + self = .serverTooOld + } else if serverVersion > supported.upperBound { + self = .serverTooNew + } else if serverVersion < supported.lowerBound { + self = .serverTooOld + } else { + self = .compatible + } + } +} + +/// App infos the Host already holds are reused for this long; the Server +/// then answers with `shouldUseCache` instead of resending icons and +/// screenshots. +public enum AppInfoCachePolicy { + public static let maximumAge: TimeInterval = 8 + + public static func isFresh(cachedTimestamp: TimeInterval, now: TimeInterval) -> Bool { + now - cachedTimestamp <= maximumAge + } +} + +/// The 220 LicenseChallenge payload: a 32-byte nonce and the Server +/// instance identifier. Other keys are ignored, so a Server may add keys +/// without breaking older Hosts. +public struct LicenseChallenge: Equatable, Sendable { + public static let nonceLength = 32 + + public let nonce: Data + public let serverInstanceID: String + + /// Why a challenge payload was rejected, with the lengths for the log. + public struct Malformed: Error, Equatable, Sendable { + public let nonceLength: Int + public let serverInstanceIDLength: Int + } + + /// Reads the challenge from the decoded payload (an `NSDictionary`). + public static func parse(_ payload: Any?) -> Result { + let dictionary = payload as? [AnyHashable: Any] + let nonce = dictionary?["nonce"] as? Data + let serverInstanceID = dictionary?["server_instance_id"] as? String + guard let nonce, nonce.count == nonceLength, let serverInstanceID, !serverInstanceID.isEmpty else { + return .failure(Malformed( + nonceLength: nonce?.count ?? 0, + serverInstanceIDLength: serverInstanceID?.utf16.count ?? 0 + )) + } + return .success(LicenseChallenge(nonce: nonce, serverInstanceID: serverInstanceID)) + } +} + +/// When a license handshake asks the Server for a fresh challenge. +/// +/// The Server accepts a 221 only within 120 s of issuing the 220. Getting the +/// signature can take longer: the key use waits behind other uses of the +/// key, then possibly on a login-keychain prompt. Each handshake retries at +/// most once, and the retry itself is an automatic use of the license key +/// that the signing policy must allow. +public enum LicenseHandshakeRetryPolicy { + /// A signature obtained this long after the 220 arrived (queue time + /// included, since the challenge ages either way) is not sent. + public static let slowSigningInterval: TimeInterval = 100 + + /// A 221 rejected after the key itself took at least this long (the + /// key use's own time, without queue time) is retried: the delay most + /// likely came from a keychain prompt. + public static let retryAfterRejectedSigningInterval: TimeInterval = 30 + + /// `true` when the signature is too old to send and a fresh 220 is + /// requested instead. + public static func shouldRequestFreshChallenge(challengeAge: TimeInterval, allowsRetry: Bool) -> Bool { + allowsRetry && challengeAge >= slowSigningInterval + } + + /// `true` when a rejected 221 is retried with a fresh 220. + public static func shouldRetryAfterRejection(keyUseDuration: TimeInterval, allowsRetry: Bool) -> Bool { + allowsRetry && keyUseDuration >= retryAfterRejectedSigningInterval + } +} + +/// The license handshake state of one channel: whether it is licensed, +/// whether a 220/221 exchange is running, and who waits for it. +/// +/// Not thread-safe; the Host keeps it on the main actor. +public final class LicenseHandshakeGate { + /// What the caller of `admit` does next. + public enum Admission: Equatable, Sendable { + /// The completion already ran with this result. + case settled(verified: Bool) + /// A handshake is running; the completion runs when it ends. + case joined + /// No handshake is running. Ask the signing policy, then call + /// `markStarted()` and run one, or `finish(verified: false)`. + case mayStart + } + + public private(set) var isVerified = false + public private(set) var isInFlight = false + private var waiters: [(Bool) -> Void] = [] + + public init() {} + + /// Registers `completion` for the channel's handshake. + /// + /// - Parameters: + /// - force: run a new handshake even when the channel is licensed. + /// - isActivated: whether this Mac's license is active; without it the + /// completion runs at once with `false`. + public func admit(force: Bool, isActivated: Bool, completion: ((Bool) -> Void)?) -> Admission { + if isVerified, !force { + completion?(true) + return .settled(verified: true) + } + if !isActivated { + completion?(false) + return .settled(verified: false) + } + if let completion { + waiters.append(completion) + } + return isInFlight ? .joined : .mayStart + } + + public func markStarted() { + isInFlight = true + } + + /// Ends the running handshake and runs every waiting completion once. + /// A failed handshake leaves an earlier success in place. + public func finish(verified: Bool) { + isInFlight = false + if verified { + isVerified = true + } + let completions = waiters + waiters = [] + for completion in completions { + completion(verified) + } + } + + /// The license is no longer active: the next request needs a new + /// handshake. + public func revokeVerification() { + isVerified = false + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ConsoleHistory.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ConsoleHistory.swift new file mode 100644 index 00000000..439d229d --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ConsoleHistory.swift @@ -0,0 +1,46 @@ +// The Console's pure rules: which input it refuses before asking the app, +// and the short list of objects that recent calls returned. + +/// Why the Console refuses an input without sending it. +public enum ConsoleInputCheck: Equatable, Sendable { + case accepted + /// Nothing was typed. + case empty + /// The input names a method with arguments (contains ":"). + case hasArguments + /// The input is a key path or dot syntax (contains "."). + case unsupportedSyntax + + public init(_ text: String) { + if text.isEmpty { + self = .empty + } else if text.contains(":") { + self = .hasArguments + } else if text.contains(".") { + self = .unsupportedSyntax + } else { + self = .accepted + } + } +} + +/// Most-recent-first list of returned objects, without duplicates (by id), +/// capped at `maxCount`. +public struct ConsoleRecentList { + public let maxCount: Int + public private(set) var entries: [(id: UInt, element: Element)] = [] + + public init(maxCount: Int = 5) { + self.maxCount = maxCount + } + + /// Puts `element` first, dropping an older entry with the same id and the + /// oldest entry when the list is over `maxCount`. + public mutating func insert(_ element: Element, id: UInt) { + entries.removeAll { $0.id == id } + entries.insert((id, element), at: 0) + if entries.count > maxCount { + entries.removeLast() + } + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ExportNaming.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ExportNaming.swift new file mode 100644 index 00000000..230825b7 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ExportNaming.swift @@ -0,0 +1,46 @@ +// The `.lookin` export's naming and image-quality choices, shared by the save +// panel accessory and the exporter. + +import Foundation + +public enum ExportNaming { + /// The image-quality choices the save panel offers, as scale factors. + public static let compressionOptions: [Double] = [0.1, 0.3, 0.5, 0.75, 1] + + /// The menu title of a quality choice: the percentage as `NSNumber` + /// prints it, then "%" (`@"%@%%"` with `@(value * 100)`). + public static func compressionTitle(_ compression: Double) -> String { + "\(NSNumber(value: compression * 100))%" + } + + /// The index of the first choice within 0.05 of `compression`, or nil. + public static func compressionIndex(for compression: Double) -> Int? { + compressionOptions.firstIndex { abs(compression - $0) < 0.05 } + } + + /// `_ios_.lookin`. The major version is + /// `osDescription` up to its first dot; a missing value prints as "(null)", + /// as the Objective-C format did. + public static func fileName( + appName: String?, + osDescription: String?, + date: Date, + timeZone: TimeZone = .current, + locale: Locale = .current + ) -> String { + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.locale = locale + formatter.dateFormat = "MMddHHmm" + let time = formatter.string(from: date) + let osMajor = osDescription.map { description in + description.firstIndex(of: ".").map { String(description[..<$0]) } ?? description + } + return "\(appName ?? "(null)")_ios\(osMajor ?? "(null)")_\(time).lookin" + } + + /// The size line of the save panel accessory, in megabytes (10^6 bytes). + public static func megabytes(_ byteCount: UInt) -> Double { + Double(byteCount) / 1000.0 / 1000.0 + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/HierarchyOutline.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/HierarchyOutline.swift new file mode 100644 index 00000000..85b2cee8 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/HierarchyOutline.swift @@ -0,0 +1,228 @@ +import Foundation + +// The hierarchy outline's decisions that do not need the LookinCore model: +// structural path identifiers, keyboard navigation, the filter's visibility +// pass and the color alias catalog. The Host's `LKHierarchyDataSource` +// adapts `LookinDisplayItem` to these through closures. + +/// A structural identifier of a node, stable across reloads of the same +/// hierarchy, used to keep and persist expand/collapse state. +/// +/// Format: `"/:/:/..."`, +/// root first. +public enum HierarchyPathIdentifier { + /// Returns nil when `rootItems` is empty, when a node of the chain has no + /// class name (or an empty one), or when the chain does not lead to one + /// of `rootItems`. Nodes are compared by identity. + public static func make( + for item: Node, + rootItems: [Node], + parent: (Node) -> Node?, + children: (Node) -> [Node], + className: (Node) -> String? + ) -> String? { + guard !rootItems.isEmpty else { + return nil + } + // Walk up to the root, recording the segments leaf first. + var segments: [String] = [] + var cursor = item + while let parentNode = parent(cursor) { + guard let siblingIndex = children(parentNode).firstIndex(where: { $0 === cursor }) else { + return nil + } + guard let name = className(cursor), !name.isEmpty else { + return nil + } + segments.append("\(name):\(siblingIndex)") + cursor = parentNode + } + guard let rootIndex = rootItems.firstIndex(where: { $0 === cursor }) else { + return nil + } + return ([String(rootIndex)] + segments.reversed()).joined(separator: "/") + } +} + +/// What an arrow key does in the hierarchy outline. +public enum HierarchyKeyNavigation { + public enum Key: UInt16 { + case left = 123 + case right = 124 + case down = 125 + case up = 126 + } + + public enum Action: Equatable, Sendable { + /// Select the row. + case select(row: Int) + /// Collapse the selected row. + case collapse(row: Int) + /// Collapse the row (the selected row's parent) and select it. + case collapseAndSelect(row: Int) + /// Expand the selected row. + case expand(row: Int) + } + + /// One visible row as the outline sees it. + public struct Row: Equatable, Sendable { + public var isExpandable: Bool + public var isExpanded: Bool + /// The visible row of the node's parent; nil without a parent or + /// when the parent is not visible. + public var parentRow: Int? + public var isInHiddenHierarchy: Bool + + public init(isExpandable: Bool, isExpanded: Bool, parentRow: Int?, isInHiddenHierarchy: Bool) { + self.isExpandable = isExpandable + self.isExpanded = isExpanded + self.parentRow = parentRow + self.isInHiddenHierarchy = isInHiddenHierarchy + } + } + + /// Returns nil when the key is not handled: no visible selection, an + /// unknown key, or nowhere to go. + /// + /// - Parameter row: the visible row at an index; called only for indexes + /// in `0 ..< rowCount`. + public static func action( + keyCode: UInt16, + selectedRow: Int?, + rowCount: Int, + row: (Int) -> Row + ) -> Action? { + guard let selectedRow, (0 ..< rowCount).contains(selectedRow), let key = Key(rawValue: keyCode) else { + return nil + } + let current = row(selectedRow) + switch key { + case .down: + return selectedRow + 1 < rowCount ? .select(row: selectedRow + 1) : nil + case .up: + return selectedRow > 0 ? .select(row: selectedRow - 1) : nil + case .left: + if current.isExpandable, current.isExpanded { + return .collapse(row: selectedRow) + } + if let parentRow = current.parentRow { + return .collapseAndSelect(row: parentRow) + } + return nil + case .right: + if current.isExpandable, !current.isExpanded { + return .expand(row: selectedRow) + } + for next in (selectedRow + 1) ..< rowCount where !row(next).isInHiddenHierarchy { + return .select(row: next) + } + return nil + } + } +} + +/// The hierarchy filter's visibility pass. +public enum HierarchySearchVisibility { + /// Shows every match with its ancestors (expanded) and its subtree + /// (collapsed, so the user can still open it). + /// + /// Matches are visited in `flatItems` order and their expansion writes + /// apply in that order, so a later match can expand a node an earlier + /// match collapsed as part of its subtree. + /// + /// - Returns: the nodes to show, in `flatItems` order. + public static func apply( + flatItems: [Node], + matches: (Node) -> Bool, + ancestors: (Node) -> [Node], + selfAndDescendants: (Node) -> [Node], + setExpanded: (Node, Bool) -> Void, + didMatch: (Node) -> Void + ) -> [Node] { + var shown = Set() + for item in flatItems where matches(item) { + didMatch(item) + for ancestor in ancestors(item) { + setExpanded(ancestor, true) + shown.insert(ObjectIdentifier(ancestor)) + } + for node in selfAndDescendants(item) { + setExpanded(node, false) + shown.insert(ObjectIdentifier(node)) + } + } + return flatItems.filter { shown.contains(ObjectIdentifier($0)) } + } +} + +/// The color aliases a hierarchy declares, for the dashboard's color menu +/// and for naming a color. +/// +/// An alias entry is either a color (`alias -> color`) or a titled group of +/// them (`title -> [alias -> color]`); both kinds can be mixed. +public struct ColorAliasCatalog { + public enum Value { + case color(Color) + case group([(alias: String, color: Color)]) + /// Anything else, which is ignored. + case unsupported + } + + public enum Entry { + case color(title: String, color: Color) + /// A group's items are sorted by title, ignoring case. + case section(title: String, items: [(title: String, color: Color)]) + + public var title: String { + switch self { + case let .color(title, _), let .section(title, _): + return title + } + } + } + + /// Alias names by color key, in the order the aliases were declared. + public private(set) var aliasesByColorKey: [String: [String]] = [:] + /// Single colors first, then groups; each sorted by title, ignoring case. + /// Empty groups are left out. + public private(set) var entries: [Entry] = [] + + /// - Parameter colorKey: the key two equal colors share, or nil when the + /// color cannot be described (it is still listed, just not named). + public init(aliases: [(key: String, value: Value)], colorKey: (Color) -> String?) { + var colors: [Entry] = [] + var sections: [Entry] = [] + for (key, value) in aliases { + switch value { + case let .color(color): + record(alias: key, color: color, colorKey: colorKey) + colors.append(.color(title: key, color: color)) + case let .group(members): + for member in members { + record(alias: member.alias, color: member.color, colorKey: colorKey) + } + if !members.isEmpty { + let items = members + .map { (title: $0.alias, color: $0.color) } + .sorted { Self.precedes($0.title, $1.title) } + sections.append(.section(title: key, items: items)) + } + case .unsupported: + continue + } + } + entries = colors.sorted { Self.precedes($0.title, $1.title) } + + sections.sorted { Self.precedes($0.title, $1.title) } + } + + private mutating func record(alias: String, color: Color, colorKey: (Color) -> String?) { + guard let key = colorKey(color) else { + return + } + aliasesByColorKey[key, default: []].append(alias) + } + + private static func precedes(_ lhs: String, _ rhs: String) -> Bool { + lhs.caseInsensitiveCompare(rhs) == .orderedAscending + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/LicenseHandshakeFlow.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/LicenseHandshakeFlow.swift new file mode 100644 index 00000000..76caca7a --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/LicenseHandshakeFlow.swift @@ -0,0 +1,241 @@ +import Foundation + +/// The answer to a 220 or 221 request on the channel. +public enum LicenseExchangeResult { + /// The Server answered. `error` is the response attachment's error and + /// `payload` its data. + case response(error: NSError?, payload: Any?) + /// The request failed on the channel (timeout, disconnect, ...). + case transportFailure(NSError) +} + +/// What signing the challenge produced. +public struct LicenseSigning: Sendable { + public var signature: Data? + public var intermediateCertificateDER: Data? + public var udid: String? + /// How long the key took in its turn (including a keychain prompt), + /// without the wait behind other uses of the key. + public var keyUseDuration: TimeInterval + /// Set when signing threw. + public var failureDescription: String? + public var failed: Bool + /// When the signature was ready, on the environment's clock. + public var finishedAt: TimeInterval + + public init( + signature: Data? = nil, + intermediateCertificateDER: Data? = nil, + udid: String? = nil, + keyUseDuration: TimeInterval = 0, + failureDescription: String? = nil, + failed: Bool = false, + finishedAt: TimeInterval + ) { + self.signature = signature + self.intermediateCertificateDER = intermediateCertificateDER + self.udid = udid + self.keyUseDuration = keyUseDuration + self.failureDescription = failureDescription + self.failed = failed + self.finishedAt = finishedAt + } +} + +/// The 221 LicenseVerify request. +public struct LicenseVerifyRequest { + public let challenge: LicenseChallenge + /// The 220's own payload, whose nonce and server instance id objects go + /// back to the Server unchanged. + public let challengePayload: Any? + public let signature: Data + public let intermediateCertificateDER: Data + /// Empty when the runtime gave none. + public let udid: String +} + +/// Why a handshake ended without a license. +public enum LicenseHandshakeFailure: Error { + /// The 220 response carried an error. + case challengeRejected(NSError) + case malformedChallenge(LicenseChallenge.Malformed) + case challengeTransport(NSError) + /// Signing failed or returned no signature or certificate. + case signingFailed(detail: String?) + /// A retry was due, but the channel is gone or the signing policy holds + /// it back. + case retryHeldBack + /// The Server rejected the 221. + case verifyRejected(NSError) + case verifyTransport(NSError) +} + +/// What a license handshake needs from the Host, for one channel. +/// +/// Every call and completion happens on the main actor; `sign` may do its +/// work elsewhere as long as its completion comes back there. +@MainActor +public protocol LicenseHandshakeEnvironment: AnyObject { + var isChannelConnected: Bool { get } + /// The current time; only differences are used. + func now() -> TimeInterval + /// Asks the activation runtime's signing policy whether a handshake may + /// start on this channel now. + func shouldStartHandshake() -> Bool + /// Tells the signing policy that the Server refused this channel's + /// handshake, so it is not repeated. + func noteHandshakeFailed() + func sendChallenge(completion: @escaping (LicenseExchangeResult) -> Void) + func sendVerify(_ request: LicenseVerifyRequest, completion: @escaping (LicenseExchangeResult) -> Void) + /// Signs the challenge with this Mac's license key. + func sign(_ challenge: LicenseChallenge, completion: @escaping (LicenseSigning) -> Void) + /// The release the Server reported in a well-formed 220 (both fields nil + /// for a 0.2.9 or older Server). Drives the upgrade hint only. + func noteServerRelease(_ release: ServerRelease) + func log(_ message: String) +} + +public extension LicenseHandshakeEnvironment { + func noteServerRelease(_: ServerRelease) {} +} + +/// One license handshake: 220 LicenseChallenge, signature, 221 +/// LicenseVerify, with at most one retry when the signing was slow (see +/// `LicenseHandshakeRetryPolicy`). The caller asks the signing policy +/// before the first attempt; a retry asks again. +@MainActor +public enum LicenseHandshakeFlow { + /// Runs the handshake. `completion` gets `nil` when the Server accepted + /// the signature. + public static func run( + in environment: LicenseHandshakeEnvironment, + channelDescription: String, + allowsRetry: Bool = true, + completion: @escaping (LicenseHandshakeFailure?) -> Void + ) { + environment.log("LookInside - License: starting handshake on channel \(channelDescription) (sending 220 LicenseChallenge).") + environment.sendChallenge { result in + switch result { + case let .transportFailure(error): + environment.log("LookInside - License: 220 challenge transport error: \(error.localizedDescription)") + completion(.challengeTransport(error)) + case let .response(error?, _): + environment.log("LookInside - License: 220 challenge errored: \(error.localizedDescription)") + environment.noteHandshakeFailed() + completion(.challengeRejected(error)) + case let .response(nil, payload): + switch LicenseChallenge.parse(payload) { + case let .failure(malformed): + environment.log( + "LookInside - License: 220 challenge payload malformed (nonce_len=\(malformed.nonceLength), server_instance_id_len=\(malformed.serverInstanceIDLength))." + ) + environment.noteHandshakeFailed() + completion(.malformedChallenge(malformed)) + case let .success(challenge): + let release = ServerRelease.parse(payload) + environment.log( + "LookInside - License: 220 Server release \(release.version ?? "") (build \(release.build.map(String.init) ?? ""))." + ) + environment.noteServerRelease(release) + environment.log( + "LookInside - License: 220 challenge OK (server_instance_id=\(challenge.serverInstanceID)); requesting signature from the activation runtime." + ) + sign( + challenge, + payload: payload, + in: environment, + channelDescription: channelDescription, + allowsRetry: allowsRetry, + completion: completion + ) + } + } + } + } + + private static func sign( + _ challenge: LicenseChallenge, + payload: Any?, + in environment: LicenseHandshakeEnvironment, + channelDescription: String, + allowsRetry: Bool, + completion: @escaping (LicenseHandshakeFailure?) -> Void + ) { + let challengeReceivedAt = environment.now() + environment.sign(challenge) { signing in + // A failed or refused signing is recorded by the activation + // runtime, which then holds every automatic signing back, so it + // is never retried here. + guard !signing.failed, + let signature = signing.signature, !signature.isEmpty, + let certificate = signing.intermediateCertificateDER, !certificate.isEmpty + else { + let detail = signing.failureDescription ?? "License signing failed." + environment.log("LookInside - License: sign_challenge failed: \(detail)") + completion(.signingFailed(detail: signing.failureDescription)) + return + } + let challengeAge = signing.finishedAt - challengeReceivedAt + if LicenseHandshakeRetryPolicy.shouldRequestFreshChallenge(challengeAge: challengeAge, allowsRetry: allowsRetry) { + environment.log( + "LookInside - License: the signature arrived \(seconds(challengeAge))s after the challenge (key use \(seconds(signing.keyUseDuration))s), past its lifetime; requesting a fresh 220." + ) + retry(in: environment, channelDescription: channelDescription, completion: completion) + return + } + let udid = signing.udid ?? "" + environment.log( + "LookInside - License: signature obtained (sig=\(signature.count)b, intermediate=\(certificate.count)b, udid=\(udid.isEmpty ? "" : udid)); sending 221 LicenseVerify." + ) + let request = LicenseVerifyRequest( + challenge: challenge, + challengePayload: payload, + signature: signature, + intermediateCertificateDER: certificate, + udid: udid + ) + environment.sendVerify(request) { result in + switch result { + case let .transportFailure(error): + environment.log("LookInside - License: 221 verify transport error: \(error.localizedDescription)") + completion(.verifyTransport(error)) + case let .response(error?, _): + environment.log("LookInside - License: 221 verify rejected by server: \(error.localizedDescription)") + if LicenseHandshakeRetryPolicy.shouldRetryAfterRejection(keyUseDuration: signing.keyUseDuration, allowsRetry: allowsRetry) { + environment.log( + "LookInside - License: signing took \(seconds(signing.keyUseDuration))s before the rejection; retrying once with a fresh 220." + ) + retry(in: environment, channelDescription: channelDescription, completion: completion) + return + } + environment.noteHandshakeFailed() + completion(.verifyRejected(error)) + case .response(nil, _): + environment.log("LookInside - License: 221 verify accepted; channel \(channelDescription) marked licensed.") + completion(nil) + } + } + } + } + + /// The single retry. It is an automatic use of the license key like the + /// first attempt: the signing policy decides whether it may start (it + /// may not after a refused prompt, for example). After a one-time Allow + /// it raises another keychain prompt; only Always Allow makes it silent. + private static func retry( + in environment: LicenseHandshakeEnvironment, + channelDescription: String, + completion: @escaping (LicenseHandshakeFailure?) -> Void + ) { + guard environment.isChannelConnected, environment.shouldStartHandshake() else { + environment.log("LookInside - License: the signing policy holds the retry back on channel \(channelDescription).") + completion(.retryHeldBack) + return + } + run(in: environment, channelDescription: channelDescription, allowsRetry: false, completion: completion) + } + + private nonisolated static func seconds(_ interval: TimeInterval) -> String { + String(format: "%.0f", interval) + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/MeasureGuides.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/MeasureGuides.swift new file mode 100644 index 00000000..71e39499 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/MeasureGuides.swift @@ -0,0 +1,198 @@ +// The geometry of the Measure panel: how the two frames are scaled into the +// panel and which distance lines are drawn between them. +// +// "Main" is the selected item, "refer" the hovered one. Lines are computed on +// the scaled frames; the numbers they show come from the original frames. + +import CoreGraphics + +public enum MeasureGuides { + public struct HorizontalLine: Equatable, Sendable { + public var startX: CGFloat + public var endX: CGFloat + public var y: CGFloat + public var value: CGFloat + + public init(startX: CGFloat, endX: CGFloat, y: CGFloat, value: CGFloat) { + self.startX = startX + self.endX = endX + self.y = y + self.value = value + } + } + + public struct VerticalLine: Equatable, Sendable { + public var startY: CGFloat + public var endY: CGFloat + public var x: CGFloat + public var value: CGFloat + + public init(startY: CGFloat, endY: CGFloat, x: CGFloat, value: CGFloat) { + self.startY = startY + self.endY = endY + self.x = x + self.value = value + } + } + + /// How the two frames overlap. The panel dims the frame that contains the + /// other, or both when they only partly overlap. + public enum Overlap: Equatable, Sendable { + case mainContainsRefer + case referContainsMain + case partial + case disjoint + + public init(main: CGRect, refer: CGRect) { + if main.contains(refer) { + self = .mainContainsRefer + } else if refer.contains(main) { + self = .referContainsMain + } else if main.intersects(refer) { + self = .partial + } else { + self = .disjoint + } + } + + /// The image alphas for (main, refer). + public var alphas: (main: CGFloat, refer: CGFloat) { + switch self { + case .mainContainsRefer: (0.2, 1) + case .referContainsMain: (1, 0.2) + case .partial: (0.2, 0.2) + case .disjoint: (1, 1) + } + } + } + + /// The width and height of the union of the two frames' extents. + public static func extent(_ a: CGRect, _ b: CGRect) -> CGSize { + CGSize(width: max(a.maxX, b.maxX) - min(a.minX, b.minX), + height: max(a.maxY, b.maxY) - min(a.minY, b.minY)) + } + + /// The factor that fits both frames into `maxWidth` x `maxHeight`; 1 when + /// the frames have no extent. + public static func scaleFactor(main: CGRect, refer: CGRect, maxWidth: CGFloat, maxHeight: CGFloat) -> CGFloat { + let size = extent(main, refer) + let factor = max(size.width / maxWidth, size.height / maxHeight) + return factor > 0 ? factor : 1 + } + + /// Both frames divided by `factor`, then moved together so their union starts at (0, 0). + public static func scaledFrames(main: CGRect, refer: CGRect, factor: CGFloat) -> (main: CGRect, refer: CGRect) { + let factor = factor == 0 ? 1 : factor + func scaled(_ rect: CGRect) -> CGRect { + CGRect(x: rect.minX / factor, y: rect.minY / factor, width: rect.width / factor, height: rect.height / factor) + } + let scaledMain = scaled(main) + let scaledRefer = scaled(refer) + let minX = min(scaledMain.minX, scaledRefer.minX) + let minY = min(scaledMain.minY, scaledRefer.minY) + return (scaledMain.offsetBy(dx: -minX, dy: -minY), scaledRefer.offsetBy(dx: -minX, dy: -minY)) + } + + private enum Order { + case bigger, same, smaller + + init(_ a: CGFloat, _ b: CGFloat) { + if abs(a - b) < 0.00001 { + self = .same + } else { + self = a > b ? .bigger : .smaller + } + } + } + + /// The horizontal distance lines between the scaled frames `a` (main) and + /// `b` (refer), labelled with distances between `originalA` and `originalB`. + public static func horizontalLines(a: CGRect, b: CGRect, originalA: CGRect, originalB: CGRect) -> [HorizontalLine] { + var lines: [HorizontalLine] = [] + func add(_ startX: CGFloat, _ endX: CGFloat, _ y: CGFloat, _ value: CGFloat) { + lines.append(HorizontalLine(startX: startX, endX: endX, y: y, value: value)) + } + switch Order(a.minX, b.minX) { + case .smaller: + if Order(a.maxX, b.minX) == .smaller { + // A's right edge to B's left edge. + add(a.maxX, b.minX, a.midY, originalB.minX - originalA.maxX) + } else { + switch Order(a.maxX, b.maxX) { + case .smaller: + add(a.maxX, b.maxX, a.midY, originalB.maxX - originalA.maxX) + case .same: + break + case .bigger: + add(b.maxX, a.maxX, b.midY, originalA.maxX - originalB.maxX) + add(a.minX, b.minX, b.midY, originalB.minX - originalA.minX) + } + } + case .same: + switch Order(a.maxX, b.maxX) { + case .smaller: + add(a.maxX, b.maxX, a.midY, originalB.maxX - originalA.maxX) + case .same: + break + case .bigger: + add(b.maxX, a.maxX, b.midY, originalA.maxX - originalB.maxX) + } + case .bigger: + if Order(a.minX, b.maxX) == .bigger { + // A's left edge to B's right edge. + add(a.minX, b.maxX, a.midY, originalA.minX - originalB.maxX) + } else { + add(b.minX, a.minX, a.midY, originalA.minX - originalB.minX) + if Order(a.maxX, b.maxX) == .smaller { + add(a.maxX, b.maxX, a.midY, originalB.maxX - originalA.maxX) + } + } + } + return lines + } + + /// The vertical counterpart of `horizontalLines`. + public static func verticalLines(a: CGRect, b: CGRect, originalA: CGRect, originalB: CGRect) -> [VerticalLine] { + var lines: [VerticalLine] = [] + func add(_ startY: CGFloat, _ endY: CGFloat, _ x: CGFloat, _ value: CGFloat) { + lines.append(VerticalLine(startY: startY, endY: endY, x: x, value: value)) + } + switch Order(a.minY, b.minY) { + case .smaller: + if Order(a.maxY, b.minY) == .smaller { + // A's bottom edge to B's top edge. + add(a.maxY, b.minY, a.midX, originalB.minY - originalA.maxY) + } else { + switch Order(a.maxY, b.maxY) { + case .smaller: + add(a.maxY, b.maxY, a.midX, originalB.maxY - originalA.maxY) + case .same: + break + case .bigger: + add(b.maxY, a.maxY, b.midX, originalA.maxY - originalB.maxY) + add(a.minY, b.minY, b.midX, originalB.minY - originalA.minY) + } + } + case .same: + switch Order(a.maxY, b.maxY) { + case .smaller: + add(a.maxY, b.maxY, a.midX, originalB.maxY - originalA.maxY) + case .same: + break + case .bigger: + add(b.maxY, a.maxY, b.midX, originalA.maxY - originalB.maxY) + } + case .bigger: + if Order(a.minY, b.maxY) == .bigger { + // A's top edge to B's bottom edge. + add(b.maxY, a.minY, a.midX, originalA.minY - originalB.maxY) + } else { + add(b.minY, a.minY, a.midX, originalA.minY - originalB.minY) + if Order(a.maxY, b.maxY) == .smaller { + add(a.maxY, b.maxY, a.midX, originalB.maxY - originalA.maxY) + } + } + } + return lines + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreviewGeometry.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreviewGeometry.swift new file mode 100644 index 00000000..a9b830ee --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/PreviewGeometry.swift @@ -0,0 +1,95 @@ +import Foundation + +/// The numeric rules of the 3D preview (`LKPreviewView`): rotation +/// normalisation, the camera focal length, and how layer planes are spaced +/// along the z axis. +public enum PreviewGeometry { + /// Maps an angle in radians into [-π, π): values at or below -π move up + /// by 2π, then values at or above π move down by 2π (so exactly π comes + /// back as -π). + public static func equivalentAngle(_ angle: Double) -> Double { + var value = angle + while value <= -Double.pi { + value += Double.pi * 2 + } + while value >= Double.pi { + value -= Double.pi * 2 + } + return value + } + + /// The camera focal length for a preview scale in 0...1: 20 at the + /// smallest, 750 at the largest, growing with the square of the scale. + public static func focalLength(forScale scale: Double) -> Double { + 20 + scale * scale * 730 + } + + /// Clamps a z interspace setting into 0...1. + public static func clampedZInterspace(_ value: Double) -> Double { + min(max(value, 0), 1) + } + + /// The distance between two adjacent z indexes: a hair in 2D, and + /// 0.1...0.8 driven by the z interspace setting in 3D. + public static func layerInterspace(is3D: Bool, zInterspace: Double) -> Double { + is3D ? 0.1 + zInterspace * 0.7 : 0.01 + } + + /// The z position of each plane, in order, for the planes' preview z + /// indexes. + /// + /// The indexes are shifted so the middle one sits at 0 (rotation then + /// pivots around the middle of the stack). Planes that share a z index + /// are each moved 0.0001 further forward than the previous one, so + /// coplanar planes do not z-fight. + public static func zPositions(zIndexes: [Int], interspace: Double) -> [Double] { + let maxZIndex = zIndexes.reduce(0) { max($0, $1) } + let offset = Int((Double(maxZIndex) * 0.5).rounded()) + var countByZIndex: [Int: Int] = [:] + return zIndexes.map { zIndex in + let adjusted = zIndex - offset + let count = countByZIndex[adjusted, default: 0] + countByZIndex[adjusted] = count + 1 + return Double(adjusted) * interspace + Double(count) * 0.0001 + } + } +} + +/// How `LKStaticAsyncUpdateManager` splits detail tasks into the packages +/// it sends: a package is closed before a task that would take its +/// screenshot area over `maxArea`, or once it holds `maxCount` tasks. +public enum DetailTaskPackaging { + public static let maxArea: Double = 2_000_000 + public static let maxCount = 100 + + /// The index ranges of the packages, for the tasks' screenshot areas + /// (width × height), in order. + /// + /// The running area is kept as an unsigned integer, truncated after + /// each task, as the original Objective-C did. + public static func packageRanges( + areas: [Double], + maxArea: Double = maxArea, + maxCount: Int = maxCount + ) -> [Range] { + var ranges: [Range] = [] + var start = 0 + var totalArea: UInt = 0 + for (index, area) in areas.enumerated() { + let count = index - start + if Double(totalArea) + area > maxArea || count >= maxCount { + if count > 0 { + totalArea = 0 + ranges.append(start ..< index) + start = index + } + } + let sum = Double(totalArea) + area + totalArea = sum > 0 ? UInt(sum) : 0 + } + if start < areas.count { + ranges.append(start ..< areas.count) + } + return ranges + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ServerUpgradeHint.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ServerUpgradeHint.swift new file mode 100644 index 00000000..96543e3e --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/ServerUpgradeHint.swift @@ -0,0 +1,110 @@ +import Foundation + +/// The release a Server reports in its 220 LicenseChallenge reply. +/// +/// Servers newer than 0.2.9 add two optional keys to the reply: +/// `lookinside_server_version` (the marketing version, "dev" in an +/// unreleased build) and `lookinside_server_build` (an integer). A 0.2.9 +/// Server sends neither. Only the nonce and the server instance id are +/// required for the handshake; these keys only drive the upgrade hint, and a +/// modified Server can omit or forge them, so they are never a security +/// boundary. +public struct ServerRelease: Equatable, Sendable { + public static let versionKey = "lookinside_server_version" + public static let buildKey = "lookinside_server_build" + + /// The newest release that does not report its version. + public static let lastReleaseWithoutVersion = SemanticVersion(major: 0, minor: 2, patch: 9) + + public let version: String? + public let build: Int? + + public init(version: String?, build: Int?) { + self.version = version + self.build = build + } + + /// Reads the two keys from the decoded 220 payload (an `NSDictionary`). + /// A key with a value of the wrong type counts as missing. + public static func parse(_ payload: Any?) -> ServerRelease { + let dictionary = payload as? [AnyHashable: Any] + let version = dictionary?[versionKey] as? String + let build = (dictionary?[buildKey] as? NSNumber)?.intValue + return ServerRelease(version: version, build: build) + } + + /// Whether the Host suggests that the app's integrator upgrade the + /// Server: when a key is missing (a 0.2.9 or older Server), or when the + /// version is a SemVer at or below 0.2.9. A version that is not SemVer, + /// such as "dev" from an unreleased build, gets no hint. + public var suggestsUpgrade: Bool { + guard let version, build != nil else { + return true + } + guard let semanticVersion = SemanticVersion(version) else { + return false + } + return semanticVersion <= Self.lastReleaseWithoutVersion + } +} + +/// `MAJOR.MINOR.PATCH`, with an optional pre-release and build suffix that +/// the comparison ignores. +public struct SemanticVersion: Comparable, Sendable { + public let major: Int + public let minor: Int + public let patch: Int + + public init(major: Int, minor: Int, patch: Int) { + self.major = major + self.minor = minor + self.patch = patch + } + + /// Parses `1.2.3`, `1.2.3-rc.1` or `1.2.3+42`; nil for anything else. + public init?(_ string: String) { + var core = Substring(string) + if let suffixStart = core.firstIndex(where: { $0 == "-" || $0 == "+" }) { + let suffix = core[core.index(after: suffixStart)...] + guard !suffix.isEmpty, suffix.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "-" || $0 == "+") }) else { + return nil + } + core = core[.. Bool { + (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch) + } +} + +/// Shows the upgrade hint at most once per inspected app for the life of the +/// Host process. +/// +/// Not thread-safe; the Host keeps it on the main actor. +public final class ServerUpgradeHintLedger { + private var hintedApps = Set() + + public init() {} + + /// `true` the first time it is asked about `appKey` with a release that + /// suggests an upgrade; the hint is then recorded as shown. + public func shouldShowHint(for release: ServerRelease, appKey: String) -> Bool { + guard release.suggestsUpgrade else { + return false + } + return hintedApps.insert(appKey).inserted + } +} diff --git a/Packages/LookInsideHostCore/Sources/LookInsideHostCore/StringScore.swift b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/StringScore.swift new file mode 100644 index 00000000..21d0bcc9 --- /dev/null +++ b/Packages/LookInsideHostCore/Sources/LookInsideHostCore/StringScore.swift @@ -0,0 +1,129 @@ +// Fuzzy match score of an abbreviation against a string, ported from the +// Host's NSString+Score category (reference: http://jsfiddle.net/JrLVD/). +// The Host ranks Dashboard search suggestions with it. The walk runs over +// UTF-16 units through NSString, exactly as the original did, so scores +// stay identical for non-ASCII input. + +import Foundation + +public enum StringScore { + public struct Options: OptionSet, Sendable { + public let rawValue: UInt + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + public static let none = Options(rawValue: 1 << 0) + /// Divides by the string's length instead, so shorter words win. + public static let favorSmallerWords = Options(rawValue: 1 << 1) + public static let reducedLongStringPenalty = Options(rawValue: 1 << 2) + } + + /// Letters and spaces survive; everything else is dropped before scoring. + private static let invalidCharacters: CharacterSet = { + var valid = CharacterSet.lowercaseLetters + valid.formUnion(.uppercaseLetters) + valid.insert(charactersIn: " ") + return valid.inverted + }() + + private static func decomposed(_ string: String) -> NSString { + (string as NSString).decomposedStringWithCanonicalMapping + .components(separatedBy: invalidCharacters) + .joined() as NSString + } + + /// Scores `abbreviation` against `string`: 1 for an exact match, 0 when + /// a character is missing and `fuzziness` is nil, otherwise a weighted + /// mix of matched characters, same-case, start-of-word and acronym + /// bonuses. + public static func score(_ string: String, against abbreviation: String, fuzziness: Double? = nil, options: Options = .none) -> Double { + var remaining = decomposed(string) + let other = decomposed(abbreviation) + + if remaining.isEqual(to: other as String) { + return 1 + } + if other.length == 0 { + return 0 + } + + var totalCharacterScore: Double = 0 + let otherLength = other.length + let stringLength = remaining.length + var startOfStringBonus = false + var fuzzies: Double = 1 + let otherUpper = other.uppercased as NSString + let otherLower = other.lowercased as NSString + // The original read the fuzziness through -[NSNumber floatValue]. + let fuzzinessValue = fuzziness.map { Double(Float($0)) } ?? 0 + let space = (" " as NSString).character(at: 0) + + for index in 0 ..< otherLength { + var characterScore = 0.1 + var indexInString = NSNotFound + let range = NSRange(location: index, length: 1) + let character = other.character(at: index) + let lowerLocation = remaining.range(of: otherLower.substring(with: range)).location + let upperLocation = remaining.range(of: otherUpper.substring(with: range)).location + + if lowerLocation == NSNotFound, upperLocation == NSNotFound { + guard fuzziness != nil else { return 0 } + fuzzies += 1 - fuzzinessValue + } else if lowerLocation != NSNotFound, upperLocation != NSNotFound { + indexInString = min(lowerLocation, upperLocation) + } else { + indexInString = lowerLocation != NSNotFound ? lowerLocation : upperLocation + } + + // Same-case bonus. + if indexInString != NSNotFound, remaining.character(at: indexInString) == character { + characterScore += 0.1 + } + + if indexInString == 0 { + // Matching the first character of what is left of the string. + characterScore += 0.6 + if index == 0 { + startOfStringBonus = true + } + } else if indexInString != NSNotFound { + // Acronym bonus: the first letter of a word counts as if two + // perfect matches came before it. + if remaining.character(at: indexInString - 1) == space { + characterScore += 0.8 + } + } + + // Matching is sequential: drop everything up to the match. + if indexInString != NSNotFound { + remaining = remaining.substring(from: indexInString + 1) as NSString + } + + totalCharacterScore += characterScore + } + + if options.contains(.favorSmallerWords) { + return totalCharacterScore / Double(stringLength) + } + + let otherStringScore = totalCharacterScore / Double(otherLength) + var finalScore: Double + if options.contains(.reducedLongStringPenalty) { + // Integer division, as in the original (which gave 0 on arm64 + // for an empty string instead of trapping). + let percentageOfMatchedString = stringLength == 0 ? 0 : Double(otherLength / stringLength) + let wordScore = otherStringScore * percentageOfMatchedString + finalScore = (wordScore + otherStringScore) / 2 + } else { + finalScore = ((otherStringScore * (Double(otherLength) / Double(stringLength))) + otherStringScore) / 2 + } + + finalScore /= fuzzies + + if startOfStringBonus, finalScore + 0.15 < 1 { + finalScore += 0.15 + } + return finalScore + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AppShellPoliciesTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AppShellPoliciesTests.swift new file mode 100644 index 00000000..08c0b83e --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AppShellPoliciesTests.swift @@ -0,0 +1,100 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +struct ExpansionStatePolicyTests { + @Test func keysKeepTheirStoredNames() { + #expect(ExpansionStatePolicy.lruKey == "ExpansionStateBundleLRU") + #expect(ExpansionStatePolicy.stateKey(forBundleIdentifier: "com.example.app") == "ExpansionState.com.example.app") + #expect(ExpansionStatePolicy.capacity == 20) + } + + @Test func movingToFrontDropsDuplicatesAndMalformedEntries() { + let stored: [Any] = ["a", "b", 3, "", "c", "b"] + #expect(ExpansionStatePolicy.lru(movingToFront: "b", in: stored) == ["b", "a", "c"]) + #expect(ExpansionStatePolicy.lru(movingToFront: "x", in: nil) == ["x"]) + #expect(ExpansionStatePolicy.lru(movingToFront: "x", in: "not an array") == ["x"]) + } + + @Test func evictionKeepsTheMostRecentEntries() { + let lru = (0 ..< 22).map { "app\($0)" } + let (kept, evicted) = ExpansionStatePolicy.evicting(lru) + #expect(kept.count == 20) + #expect(kept.first == "app0") + #expect(evicted == ["app20", "app21"]) + #expect(ExpansionStatePolicy.evicting(["a"]).evicted.isEmpty) + } + + @Test func bumpOnlyMovesARecordedIdThatIsNotFirst() { + #expect(ExpansionStatePolicy.shouldBump("b", in: ["a", "b"])) + #expect(!ExpansionStatePolicy.shouldBump("a", in: ["a", "b"])) + #expect(!ExpansionStatePolicy.shouldBump("c", in: ["a", "b"])) + #expect(!ExpansionStatePolicy.shouldBump("a", in: nil)) + } + + @Test func decodesTheDictionaryFormat() { + let stored: NSDictionary = ["a/b": NSNumber(value: true), "a/c": NSNumber(value: false), "": NSNumber(value: true), "bad": "x"] + #expect(ExpansionStatePolicy.decodeState(stored) == ["a/b": true, "a/c": false]) + } + + @Test func decodesTheLegacyArrayFormatAsExpanded() { + let stored: NSArray = ["a/b", "", NSNumber(value: 1), "a/c"] + #expect(ExpansionStatePolicy.decodeState(stored) == ["a/b": true, "a/c": true]) + #expect(ExpansionStatePolicy.decodeState(nil).isEmpty) + #expect(ExpansionStatePolicy.decodeState("x").isEmpty) + } + + @Test func roundTripsThroughUserDefaults() throws { + let suite = "LookInsideHostCoreTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(["p": true, "q": false], forKey: "state") + defaults.set(["p", "q"], forKey: "legacy") + #expect(ExpansionStatePolicy.decodeState(defaults.object(forKey: "state")) == ["p": true, "q": false]) + #expect(ExpansionStatePolicy.decodeState(defaults.object(forKey: "legacy")) == ["p": true, "q": true]) + } +} + +struct ServerVersionComparisonTests { + @Test func numericVersionWeighsThreeComponents() { + #expect(ServerVersionComparison.numericVersion("1.2.7") == 10207) + #expect(ServerVersionComparison.numericVersion("1.2") == 10200) + #expect(ServerVersionComparison.numericVersion("1.2.7.9") == 10207) + #expect(ServerVersionComparison.numericVersion("0.2.9") == 209) + #expect(ServerVersionComparison.numericVersion("2a.1") == 20100) + #expect(ServerVersionComparison.numericVersion("") == 0) + } + + @Test func comparesExpectedAgainstReal() { + #expect(ServerVersionComparison.satisfies(expectedVersion: "1.2.7", realVersion: "1.2.7") == true) + #expect(ServerVersionComparison.satisfies(expectedVersion: "1.2.7", realVersion: "1.3") == true) + #expect(ServerVersionComparison.satisfies(expectedVersion: "1.2.7", realVersion: "1.2.6") == false) + #expect(ServerVersionComparison.satisfies(expectedVersion: "1.2.7", realVersion: "") == nil) + } +} + +struct SwiftUISelectionMigrationTests { + @Test func parsesThePreOrderIndex() { + #expect(SwiftUISelectionMigration.preOrderIndex(of: "swiftui:abc:12") == 12) + #expect(SwiftUISelectionMigration.preOrderIndex(of: "swiftui:abc:12x") == nil) + #expect(SwiftUISelectionMigration.preOrderIndex(of: "swiftui") == nil) + #expect(SwiftUISelectionMigration.preOrderIndex(of: "swiftui:abc:") == nil) + } + + @Test func keepsAnIdThatSurvived() { + let ids: [String?] = [nil, "swiftui:h:1", "swiftui:h:4"] + #expect(SwiftUISelectionMigration.target(priorIdentifier: "swiftui:h:4", in: ids) == .exact(2)) + } + + @Test func movesToTheNextSurvivingItem() { + let ids: [String?] = ["swiftui:h:9", "view:1", "swiftui:h:5", "swiftui:h:2"] + #expect(SwiftUISelectionMigration.target(priorIdentifier: "swiftui:h:3", in: ids) == .migrated(2)) + } + + @Test func givesUpWithoutALaterItem() { + let ids: [String?] = ["swiftui:h:1", "swiftui:h:2"] + #expect(SwiftUISelectionMigration.target(priorIdentifier: "swiftui:h:3", in: ids) == .none) + #expect(SwiftUISelectionMigration.target(priorIdentifier: "", in: ids) == .none) + #expect(SwiftUISelectionMigration.target(priorIdentifier: "swiftui:h:x", in: ids) == .none) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AsyncBroadcasterTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AsyncBroadcasterTests.swift new file mode 100644 index 00000000..81da35cf --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/AsyncBroadcasterTests.swift @@ -0,0 +1,237 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +/// A deadlock in the broadcaster hangs a test instead of failing it; the limit +/// turns that into a failure within a minute. +@Suite(.timeLimit(.minutes(1))) +struct AsyncBroadcasterTests { + /// Waits until `condition` holds, polling, or fails after `timeout`. + private func waitUntil( + timeout: Duration = .seconds(5), + _ condition: () -> Bool, + sourceLocation: SourceLocation = #_sourceLocation + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !condition() { + if clock.now >= deadline { + Issue.record("condition did not become true in time", sourceLocation: sourceLocation) + return + } + try await Task.sleep(for: .milliseconds(5)) + } + } + + private func collect(_ stream: AsyncStream) async -> [Element] { + var values: [Element] = [] + for await value in stream { + values.append(value) + } + return values + } + + @Test func everySubscriberReceivesEveryValueInOrder() async { + let broadcaster = AsyncBroadcaster() + let first = broadcaster.subscribe() + let second = broadcaster.subscribe() + #expect(broadcaster.subscriberCount == 2) + + for value in 1 ... 5 { + broadcaster.yield(value) + } + broadcaster.finish() + + #expect(await collect(first) == [1, 2, 3, 4, 5]) + #expect(await collect(second) == [1, 2, 3, 4, 5]) + } + + @Test func lateSubscriberReceivesOnlyLaterValues() async { + let broadcaster = AsyncBroadcaster() + let early = broadcaster.subscribe() + broadcaster.yield("before") + let late = broadcaster.subscribe() + broadcaster.yield("after") + broadcaster.finish() + + #expect(await collect(early) == ["before", "after"]) + #expect(await collect(late) == ["after"]) + } + + @Test func yieldWithoutSubscribersIsDropped() async { + let broadcaster = AsyncBroadcaster() + broadcaster.yield(1) + let stream = broadcaster.subscribe() + broadcaster.yield(2) + broadcaster.finish() + #expect(await collect(stream) == [2]) + } + + @Test func cancellingTheConsumerRemovesItsSubscription() async throws { + let broadcaster = AsyncBroadcaster() + let stream = broadcaster.subscribe() + let survivor = broadcaster.subscribe() + let received = LockedBox<[Int]>([]) + + let consumer = Task { + for await value in stream { + received.mutate { $0.append(value) } + } + } + broadcaster.yield(1) + try await waitUntil { received.value == [1] } + + consumer.cancel() + await consumer.value + try await waitUntil { broadcaster.subscriberCount == 1 } + + broadcaster.yield(2) + broadcaster.finish() + #expect(received.value == [1]) + #expect(await collect(survivor) == [1, 2]) + } + + @Test func droppingAStreamWithoutIteratingRemovesItsSubscription() async throws { + let broadcaster = AsyncBroadcaster() + do { + _ = broadcaster.subscribe() + } + try await waitUntil { broadcaster.subscriberCount == 0 } + } + + @Test func finishEndsEveryStreamAndLaterSubscriptions() async { + let broadcaster = AsyncBroadcaster() + let stream = broadcaster.subscribe() + let consumer = Task { await collect(stream) } + + broadcaster.yield(7) + broadcaster.finish() + #expect(await consumer.value == [7]) + #expect(broadcaster.subscriberCount == 0) + + // After finish: nothing is delivered and new streams end at once. + broadcaster.yield(8) + let afterFinish = broadcaster.subscribe() + #expect(await collect(afterFinish).isEmpty) + #expect(broadcaster.subscriberCount == 0) + } + + @Test func finishIsIdempotent() async { + let broadcaster = AsyncBroadcaster() + let stream = broadcaster.subscribe() + broadcaster.finish() + broadcaster.finish() + #expect(await collect(stream).isEmpty) + } + + @Test func bufferingPolicyLimitsWhatAnIdleSubscriberKeeps() async { + let broadcaster = AsyncBroadcaster(bufferingPolicy: .bufferingNewest(2)) + let newest = broadcaster.subscribe() + let oldest = broadcaster.subscribe(bufferingPolicy: .bufferingOldest(1)) + let unbounded = broadcaster.subscribe(bufferingPolicy: .unbounded) + + for value in 1 ... 4 { + broadcaster.yield(value) + } + broadcaster.finish() + + #expect(await collect(newest) == [3, 4]) + #expect(await collect(oldest) == [1]) + #expect(await collect(unbounded) == [1, 2, 3, 4]) + } + + @Test func aListeningSubscriberDoesNotKeepTheBroadcasterAlive() async { + weak var weakBroadcaster: AsyncBroadcaster? + let stream: AsyncStream + do { + let broadcaster = AsyncBroadcaster() + weakBroadcaster = broadcaster + stream = broadcaster.subscribe() + broadcaster.yield(1) + } + #expect(weakBroadcaster == nil) + // Releasing the broadcaster finished the stream after the buffered value. + #expect(await collect(stream) == [1]) + } + + @Test func aSubscriberThatHoldsTheBroadcasterIsReleasedAfterFinish() async { + final class Owner: @unchecked Sendable { + let broadcaster = AsyncBroadcaster() + var consumer: Task? + } + weak var weakOwner: Owner? + do { + let owner = Owner() + weakOwner = owner + let stream = owner.broadcaster.subscribe() + // The consumer captures the owner weakly, as the Host's + // subscribers do; the stream itself holds nothing back. + owner.consumer = Task { [weak owner] in + for await _ in stream { + _ = owner + } + } + owner.broadcaster.finish() + await owner.consumer?.value + } + #expect(weakOwner == nil) + } + + @Test func concurrentProducersAndSubscribersDoNotLoseOrDuplicateValues() async { + let broadcaster = AsyncBroadcaster() + let streams = (0 ..< 8).map { _ in broadcaster.subscribe() } + let consumers = streams.map { stream in + Task { await collect(stream) } + } + + await withTaskGroup(of: Void.self) { group in + for producer in 0 ..< 4 { + group.addTask { + for index in 0 ..< 250 { + broadcaster.yield(producer * 1000 + index) + } + } + } + // Subscribing and cancelling at the same time must not disturb + // the others. + group.addTask { + for _ in 0 ..< 100 { + let transient = broadcaster.subscribe() + let task = Task { for await _ in transient {} } + task.cancel() + } + } + } + broadcaster.finish() + + let expected = Set((0 ..< 4).flatMap { producer in (0 ..< 250).map { producer * 1000 + $0 } }) + for consumer in consumers { + let values = await consumer.value + #expect(values.count == 1000) + #expect(Set(values) == expected) + // Each producer's own values stay in order. + for producer in 0 ..< 4 { + let own = values.filter { $0 / 1000 == producer } + #expect(own == own.sorted()) + } + } + } +} + +/// A value shared between a test and the tasks it starts. +final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + + init(_ value: Value) { + stored = value + } + + var value: Value { + lock.withLock { stored } + } + + func mutate(_ body: (inout Value) -> Void) { + lock.withLock { body(&stored) } + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ClientDisplayTextTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ClientDisplayTextTests.swift new file mode 100644 index 00000000..1ea7c560 --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ClientDisplayTextTests.swift @@ -0,0 +1,127 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +/// Expected values were produced by the Objective-C categories these +/// functions replace (NSString+Score, NSColor+LookinClient, +/// LookinDisplayItem+LookinClient), so a difference is a behaviour change. +@Suite("Client display text") +struct ClientDisplayTextTests { + struct ScoreCase: Sendable, CustomTestStringConvertible { + let string: String + let abbreviation: String + let plain: Double + let fuzzy: Double + let favorSmaller: Double + let reducedPenalty: Double + var testDescription: String { + "\(string) / \(abbreviation)" + } + } + + static let scoreCases: [ScoreCase] = [ + .init(string: "LKHierarchyView", abbreviation: "hv", plain: 0.056666666666666671, fuzzy: 0.056666666666666671, favorSmaller: 0.013333333333333334, reducedPenalty: 0.050000000000000003), + .init(string: "backgroundColor", abbreviation: "bgc", plain: 0.37, fuzzy: 0.37, favorSmaller: 0.073333333333333334, reducedPenalty: 0.33333333333333337), + .init(string: "Hello World", abbreviation: "hw", plain: 0.6227272727272728, fuzzy: 0.6227272727272728, favorSmaller: 0.14545454545454548, reducedPenalty: 0.55000000000000004), + .init(string: "hello world", abbreviation: "Hello", plain: 0.71727272727272717, fuzzy: 0.71727272727272717, favorSmaller: 0.3545454545454545, reducedPenalty: 0.53999999999999992), + .init(string: "frame", abbreviation: "frame", plain: 1, fuzzy: 1, favorSmaller: 1, reducedPenalty: 1), + .init(string: "frame", abbreviation: "", plain: 0, fuzzy: 0, favorSmaller: 0, reducedPenalty: 0), + .init(string: "alpha", abbreviation: "xyz", plain: 0, fuzzy: 0.032000000000000008, favorSmaller: 0.060000000000000012, reducedPenalty: 0.016129032444135213), + .init(string: "Café Crème", abbreviation: "cc", plain: 0.63, fuzzy: 0.63, favorSmaller: 0.16, reducedPenalty: 0.55000000000000004), + .init(string: "setNeedsLayout", abbreviation: "layout", plain: 0.48809523809523803, fuzzy: 0.48809523809523803, favorSmaller: 0.29285714285714282, reducedPenalty: 0.34166666666666662), + .init(string: "isHidden", abbreviation: "hid", plain: 0.38958333333333339, fuzzy: 0.38958333333333339, favorSmaller: 0.21250000000000002, reducedPenalty: 0.28333333333333338), + .init(string: "abc", abbreviation: "ABC", plain: 0.84999999999999987, fuzzy: 0.84999999999999987, favorSmaller: 0.69999999999999984, reducedPenalty: 0.84999999999999987), + ] + + @Test("String score matches the Objective-C category", arguments: scoreCases) + func stringScore(_ testCase: ScoreCase) { + func close(_ lhs: Double, _ rhs: Double) -> Bool { + abs(lhs - rhs) < 1e-12 + } + #expect(close(StringScore.score(testCase.string, against: testCase.abbreviation), testCase.plain)) + #expect(close(StringScore.score(testCase.string, against: testCase.abbreviation, fuzziness: 0.5), testCase.fuzzy)) + #expect(close(StringScore.score(testCase.string, against: testCase.abbreviation, fuzziness: 0.5, options: .favorSmallerWords), testCase.favorSmaller)) + #expect(close(StringScore.score(testCase.string, against: testCase.abbreviation, fuzziness: 0.3, options: .reducedLongStringPenalty), testCase.reducedPenalty)) + } + + @Test("Colours are written as RGB tuples and lowercase hex") + func colourText() { + #expect(ClientDisplayText.rgbaString(red: 15 / 255.0, green: 17 / 255.0, blue: 19 / 255.0, alpha: 1) == "(15, 17, 19)") + #expect(ClientDisplayText.hexString(red: 15 / 255.0, green: 17 / 255.0, blue: 19 / 255.0, alpha: 1) == "#0f1113") + #expect(ClientDisplayText.rgbaString(red: 1, green: 0.5, blue: 0, alpha: 0.5) == "(255, 128, 0, 0.50)") + #expect(ClientDisplayText.hexString(red: 1, green: 0.5, blue: 0, alpha: 0.5) == "#ff7f007f") + #expect(ClientDisplayText.rgbaString(red: 0.1, green: 0.2, blue: 0.3, alpha: 0.01) == "(26, 51, 76, 0.01)") + #expect(ClientDisplayText.hexString(red: 0.1, green: 0.2, blue: 0.3, alpha: 0.01) == "#19334c02") + } + + @Test("Module prefixes are dropped outside generic arguments only") + func modulePrefix() { + #expect(ClientDisplayText.removingModulePrefix("AppKit._NSCoreHostingView") == "_NSCoreHostingView") + #expect(ClientDisplayText.removingModulePrefix("MyApp.Outer.Inner") == "Inner") + #expect(ClientDisplayText.removingModulePrefix("UIView") == "UIView") + #expect(ClientDisplayText.removingModulePrefix("Array") == "Array") + } + + @Test("Class chain entries match without their module prefix") + func classChainMatch() { + #expect(ClientDisplayText.classChainEntry("SwiftUI.HostingView", matches: "HostingView")) + #expect(ClientDisplayText.classChainEntry("UIView", matches: "UIView")) + #expect(!ClientDisplayText.classChainEntry("UIViewController", matches: "UIView")) + } + + @Test("SwiftUI hosting classes are recognised by name") + func swiftUISupportNames() { + #expect(ClientDisplayText.looksLikeSwiftUISupport("_UIHostingView")) + #expect(ClientDisplayText.looksLikeSwiftUISupport("SwiftUI.PlatformViewHost")) + #expect(!ClientDisplayText.looksLikeSwiftUISupport("UILabel")) + #expect(!ClientDisplayText.looksLikeSwiftUISupport("")) + #expect(!ClientDisplayText.looksLikeSwiftUISupport(nil)) + } + + @Test("Display-list IDs are read from decimal and hex tokens") + func displayListIDs() { + #expect(ClientDisplayText.swiftUIDisplayListIDs(in: "12, 0x1F; 12 abc 0 4294967295 4294967294") == [12, 31, 4_294_967_294]) + #expect(ClientDisplayText.swiftUIDisplayListIDs(in: "0xZZ 1e3 -7") == [7]) + #expect(ClientDisplayText.swiftUIDisplayListIDs(in: "").isEmpty) + } + + @Test("The first hex address is taken out of an object description") + func memoryAddresses() { + #expect(ClientDisplayText.memoryAddress(inObjectDescription: "") == "0x6000abcd12") + #expect(ClientDisplayText.memoryAddress(inObjectDescription: "0X1f") == "0x1f") + #expect(ClientDisplayText.memoryAddress(inObjectDescription: "") == nil) + #expect(ClientDisplayText.memoryAddress(inObjectDescription: "no address") == nil) + #expect(ClientDisplayText.memoryAddress(inObjectDescription: "") == nil) + } + + @Test("Constraint attributes and relations use the iOS numbering") + func constraintText() { + #expect(ClientDisplayText.layoutAttributeName(0) == "notAnAttribute") + #expect(ClientDisplayText.layoutAttributeName(5) == "leading") + #expect(ClientDisplayText.layoutAttributeName(20) == "centerYWithinMargins") + #expect(ClientDisplayText.layoutAttributeName(37) == "maxY") + #expect(ClientDisplayText.layoutAttributeName(21) == nil) + #expect(ClientDisplayText.layoutRelationSymbol(-1) == "<=") + #expect(ClientDisplayText.layoutRelationSymbol(1) == ">=") + #expect(ClientDisplayText.layoutRelationSymbol(2) == nil) + #expect(ClientDisplayText.layoutRelationName(0) == "Equal") + #expect(ClientDisplayText.layoutRelationName(-1) == "LessThanOrEqual") + } + + @Test("Group screenshots replace nearly empty solo screenshots") + func groupScreenshotFallback() { + #expect(!ClientDisplayText.prefersGroupScreenshot(soloVisibleRatio: { nil }, groupVisibleRatio: { 1 }, hasGroupScreenshot: false)) + #expect(ClientDisplayText.prefersGroupScreenshot(soloVisibleRatio: { nil }, groupVisibleRatio: { 0 }, hasGroupScreenshot: true)) + #expect(!ClientDisplayText.prefersGroupScreenshot(soloVisibleRatio: { 0.02 }, groupVisibleRatio: { 1 }, hasGroupScreenshot: true)) + #expect(ClientDisplayText.prefersGroupScreenshot(soloVisibleRatio: { 0.01 }, groupVisibleRatio: { 0.06 }, hasGroupScreenshot: true)) + #expect(!ClientDisplayText.prefersGroupScreenshot(soloVisibleRatio: { 0.015 }, groupVisibleRatio: { 0.059 }, hasGroupScreenshot: true)) + #expect(!ClientDisplayText.prefersGroupScreenshot(soloVisibleRatio: { 0 }, groupVisibleRatio: { 0.05 }, hasGroupScreenshot: true)) + } + + @Test("Visible pixels are those with alpha above 12") + func visiblePixelRatio() { + let pixels: [UInt8] = [0, 0, 0, 13, 0, 0, 0, 12, 255, 255, 255, 255, 0, 0, 0, 0] + #expect(ClientDisplayText.visiblePixelRatio(rgbaPixels: pixels) == 0.5) + #expect(ClientDisplayText.visiblePixelRatio(rgbaPixels: []) == 0) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ConnectionPoliciesTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ConnectionPoliciesTests.swift new file mode 100644 index 00000000..00507e8b --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ConnectionPoliciesTests.swift @@ -0,0 +1,243 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +struct ResponseFrameProgressTests { + @Test func unsplitResponseCompletesWithItsOnlyFrame() { + var progress = ResponseFrameProgress() + #expect(progress.isAtFirstFrame) + let complete = progress.record(currentDataCount: 0, dataTotalCount: 0) + #expect(complete) + #expect(progress.receivedDataCount == 0) + } + + @Test func splitResponseCompletesWhenEveryItemArrived() { + var progress = ResponseFrameProgress() + var completions: [Bool] = [] + completions.append(progress.record(currentDataCount: 3, dataTotalCount: 10)) + #expect(!progress.isAtFirstFrame) + completions.append(progress.record(currentDataCount: 3, dataTotalCount: 10)) + completions.append(progress.record(currentDataCount: 4, dataTotalCount: 10)) + #expect(completions == [false, false, true]) + #expect(progress.receivedDataCount == 10) + } + + @Test func overcountedResponseStillCompletes() { + var progress = ResponseFrameProgress() + let first = progress.record(currentDataCount: 1, dataTotalCount: 2) + let second = progress.record(currentDataCount: 5, dataTotalCount: 2) + #expect(!first) + #expect(second) + } +} + +struct ServerVersionCompatibilityTests { + @Test(arguments: [ + (8, ServerVersionCompatibility.compatible), + (9, .compatible), + (7, .serverTooOld), + (0, .serverTooOld), + (10, .serverTooNew), + // Old internal builds report these and are always too old, even + // 100, which is above the supported range. + (-1, .serverTooOld), + (100, .serverTooOld), + ]) + func classifiesAgainstTheSupportedRange(version: Int, expected: ServerVersionCompatibility) { + #expect(ServerVersionCompatibility(serverVersion: version, supported: 8 ... 9) == expected) + } +} + +struct AppInfoCachePolicyTests { + @Test func appInfoIsReusedForEightSeconds() { + #expect(AppInfoCachePolicy.isFresh(cachedTimestamp: 100, now: 100)) + #expect(AppInfoCachePolicy.isFresh(cachedTimestamp: 100, now: 108)) + #expect(!AppInfoCachePolicy.isFresh(cachedTimestamp: 100, now: 108.01)) + } +} + +struct LicenseChallengeTests { + private let nonce = Data(repeating: 0xAB, count: 32) + + @Test func parsesTheNonceAndServerInstanceID() throws { + let payload: NSDictionary = [ + "nonce": nonce as NSData, + "server_instance_id": "server-1" as NSString, + "issued_at": NSNumber(value: 1), + "ttl": NSNumber(value: 120), + ] + let challenge = try LicenseChallenge.parse(payload).get() + #expect(challenge.nonce == nonce) + #expect(challenge.serverInstanceID == "server-1") + } + + @Test func ignoresKeysItDoesNotKnow() throws { + let payload: NSDictionary = [ + "nonce": nonce as NSData, + "server_instance_id": "server-1" as NSString, + "lookinside_server_version": "1.0" as NSString, + NSNumber(value: 7): "non-string key" as NSString, + ] + #expect(try LicenseChallenge.parse(payload).get().serverInstanceID == "server-1") + } + + @Test func rejectsAShortNonce() { + let payload: NSDictionary = [ + "nonce": Data(count: 31) as NSData, + "server_instance_id": "server-1" as NSString, + ] + #expect(throws: LicenseChallenge.Malformed(nonceLength: 31, serverInstanceIDLength: 8)) { + try LicenseChallenge.parse(payload).get() + } + } + + @Test func rejectsAMissingOrEmptyServerInstanceID() { + let empty: NSDictionary = ["nonce": nonce as NSData, "server_instance_id": "" as NSString] + #expect(throws: LicenseChallenge.Malformed(nonceLength: 32, serverInstanceIDLength: 0)) { + try LicenseChallenge.parse(empty).get() + } + let missing: NSDictionary = ["nonce": nonce as NSData] + #expect(throws: LicenseChallenge.Malformed(nonceLength: 32, serverInstanceIDLength: 0)) { + try LicenseChallenge.parse(missing).get() + } + } + + @Test func rejectsValuesOfTheWrongType() { + let payload: NSDictionary = ["nonce": "not data" as NSString, "server_instance_id": NSNumber(value: 1)] + #expect(throws: LicenseChallenge.Malformed(nonceLength: 0, serverInstanceIDLength: 0)) { + try LicenseChallenge.parse(payload).get() + } + } + + @Test func rejectsAPayloadThatIsNotADictionary() { + #expect(throws: LicenseChallenge.Malformed(nonceLength: 0, serverInstanceIDLength: 0)) { + try LicenseChallenge.parse(nil).get() + } + #expect(throws: LicenseChallenge.Malformed(nonceLength: 0, serverInstanceIDLength: 0)) { + try LicenseChallenge.parse(["nonce"] as NSArray).get() + } + } +} + +struct LicenseHandshakeRetryPolicyTests { + @Test func aSignatureOlderThanOneHundredSecondsAsksForAFreshChallenge() { + #expect(!LicenseHandshakeRetryPolicy.shouldRequestFreshChallenge(challengeAge: 99.9, allowsRetry: true)) + #expect(LicenseHandshakeRetryPolicy.shouldRequestFreshChallenge(challengeAge: 100, allowsRetry: true)) + // The retry itself never retries again. + #expect(!LicenseHandshakeRetryPolicy.shouldRequestFreshChallenge(challengeAge: 500, allowsRetry: false)) + } + + @Test func aRejectionAfterASlowKeyUseRetriesOnce() { + #expect(!LicenseHandshakeRetryPolicy.shouldRetryAfterRejection(keyUseDuration: 29.9, allowsRetry: true)) + #expect(LicenseHandshakeRetryPolicy.shouldRetryAfterRejection(keyUseDuration: 30, allowsRetry: true)) + #expect(!LicenseHandshakeRetryPolicy.shouldRetryAfterRejection(keyUseDuration: 60, allowsRetry: false)) + } +} + +struct LicenseHandshakeGateTests { + private final class Results { + var values: [Bool] = [] + func record(_ verified: Bool) { + values.append(verified) + } + } + + @Test func withoutActivationTheRequestRunsUnlicensedAtOnce() { + let gate = LicenseHandshakeGate() + let results = Results() + #expect(gate.admit(force: false, isActivated: false, completion: results.record) == .settled(verified: false)) + #expect(results.values == [false]) + #expect(!gate.isInFlight) + } + + @Test func callersWaitingOnOneHandshakeAllGetItsResult() { + let gate = LicenseHandshakeGate() + let results = Results() + #expect(gate.admit(force: false, isActivated: true, completion: results.record) == .mayStart) + gate.markStarted() + #expect(gate.admit(force: false, isActivated: true, completion: results.record) == .joined) + #expect(gate.admit(force: false, isActivated: true, completion: nil) == .joined) + #expect(results.values.isEmpty) + + gate.finish(verified: true) + #expect(results.values == [true, true]) + #expect(gate.isVerified) + #expect(!gate.isInFlight) + + // Licensed: later requests go straight through. + #expect(gate.admit(force: false, isActivated: true, completion: results.record) == .settled(verified: true)) + #expect(results.values == [true, true, true]) + } + + @Test func aLicensedChannelSkipsTheActivationCheck() { + // Matches the Objective-C order: a verified channel is not re-checked + // against the activation state until it is revoked. + let gate = LicenseHandshakeGate() + _ = gate.admit(force: false, isActivated: true, completion: nil) + gate.markStarted() + gate.finish(verified: true) + #expect(gate.admit(force: false, isActivated: false, completion: nil) == .settled(verified: true)) + } + + @Test func aFailedHandshakeLeavesTheChannelUnlicensedAndAllowsAnother() { + let gate = LicenseHandshakeGate() + let results = Results() + _ = gate.admit(force: false, isActivated: true, completion: results.record) + gate.markStarted() + gate.finish(verified: false) + #expect(results.values == [false]) + #expect(!gate.isVerified) + #expect(gate.admit(force: false, isActivated: true, completion: results.record) == .mayStart) + } + + @Test func aSigningPolicyRefusalFinishesWithoutStarting() { + let gate = LicenseHandshakeGate() + let results = Results() + #expect(gate.admit(force: false, isActivated: true, completion: results.record) == .mayStart) + // The caller's signing policy says no: finish without markStarted. + gate.finish(verified: false) + #expect(results.values == [false]) + #expect(!gate.isInFlight) + } + + @Test func forceRunsAgainOnALicensedChannelAndKeepsTheEarlierSuccess() { + let gate = LicenseHandshakeGate() + _ = gate.admit(force: false, isActivated: true, completion: nil) + gate.markStarted() + gate.finish(verified: true) + + let results = Results() + #expect(gate.admit(force: true, isActivated: true, completion: results.record) == .mayStart) + gate.markStarted() + gate.finish(verified: false) + #expect(results.values == [false]) + #expect(gate.isVerified) + } + + @Test func revokingVerificationRequiresANewHandshake() { + let gate = LicenseHandshakeGate() + _ = gate.admit(force: false, isActivated: true, completion: nil) + gate.markStarted() + gate.finish(verified: true) + gate.revokeVerification() + #expect(gate.admit(force: false, isActivated: true, completion: nil) == .mayStart) + } + + @Test func aCompletionThatStartsAnotherRequestJoinsAFreshWaitList() { + let gate = LicenseHandshakeGate() + var nested: LicenseHandshakeGate.Admission? + var nestedResult: Bool? + _ = gate.admit(force: false, isActivated: true) { _ in + nested = gate.admit(force: false, isActivated: true) { nestedResult = $0 } + } + gate.markStarted() + gate.finish(verified: false) + // The nested request found no handshake running and was not run by + // the finished one. + #expect(nested == .mayStart) + #expect(nestedResult == nil) + gate.markStarted() + gate.finish(verified: true) + #expect(nestedResult == true) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/HierarchyOutlineTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/HierarchyOutlineTests.swift new file mode 100644 index 00000000..891fe4ff --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/HierarchyOutlineTests.swift @@ -0,0 +1,248 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +/// A stand-in for LookinDisplayItem: a class name, a parent and children. +private final class Node { + let className: String? + weak var parent: Node? + var children: [Node] = [] + var isExpanded = true + var isHidden = false + + init(_ className: String?, _ children: [Node] = []) { + self.className = className + self.children = children + for child in children { + child.parent = self + } + } + + var ancestors: [Node] { + var result: [Node] = [] + var cursor = parent + while let node = cursor { + result.append(node) + cursor = node.parent + } + return result + } + + var selfAndDescendants: [Node] { + [self] + children.flatMap(\.selfAndDescendants) + } +} + +private func path(_ node: Node, roots: [Node]) -> String? { + HierarchyPathIdentifier.make( + for: node, + rootItems: roots, + parent: { $0.parent }, + children: { $0.children }, + className: { $0.className } + ) +} + +struct HierarchyPathIdentifierTests { + @Test func describesTheChainFromRootToLeaf() { + let label = Node("UILabel") + let button = Node("UIButton") + let content = Node("UIView", [button, label]) + let window = Node("UIWindow", [content]) + let otherWindow = Node("UIWindow") + let roots = [otherWindow, window] + + #expect(path(window, roots: roots) == "1") + #expect(path(content, roots: roots) == "1/UIView:0") + #expect(path(label, roots: roots) == "1/UIView:0/UILabel:1") + } + + @Test func rootsAreNamedByIndexOnlySoTheirClassMayBeMissing() { + let child = Node("UIView") + let scene = Node(nil, [child]) + #expect(path(scene, roots: [scene]) == "0") + #expect(path(child, roots: [scene]) == "0/UIView:0") + } + + @Test func failsWithoutAClassNameBelowTheRoot() { + let custom = Node(nil) + let empty = Node("") + let window = Node("UIWindow", [custom, empty]) + #expect(path(custom, roots: [window]) == nil) + #expect(path(empty, roots: [window]) == nil) + } + + @Test func failsWhenTheChainDoesNotReachARoot() { + let child = Node("UIView") + let window = Node("UIWindow", [child]) + withExtendedLifetime(window) { + #expect(path(child, roots: []) == nil) + #expect(path(child, roots: [Node("UIWindow")]) == nil) + } + } + + @Test func failsWhenTheParentDoesNotListTheNode() { + let window = Node("UIWindow") + let orphan = Node("UIView") + orphan.parent = window + #expect(path(orphan, roots: [window]) == nil) + } + + @Test func comparesNodesByIdentity() { + let first = Node("UIView") + let second = Node("UIView") + let window = Node("UIWindow", [first, second]) + #expect(path(second, roots: [window]) == "0/UIView:1") + } +} + +struct HierarchyKeyNavigationTests { + private typealias Row = HierarchyKeyNavigation.Row + + private static let leaf = Row(isExpandable: false, isExpanded: false, parentRow: 0, isInHiddenHierarchy: false) + + private func action(_ key: HierarchyKeyNavigation.Key, selected: Int?, rows: [Row]) -> HierarchyKeyNavigation.Action? { + HierarchyKeyNavigation.action(keyCode: key.rawValue, selectedRow: selected, rowCount: rows.count) { rows[$0] } + } + + @Test func upAndDownMoveWithinTheRows() { + let rows = [Self.leaf, Self.leaf, Self.leaf] + #expect(action(.down, selected: 0, rows: rows) == .select(row: 1)) + #expect(action(.down, selected: 2, rows: rows) == nil) + #expect(action(.up, selected: 1, rows: rows) == .select(row: 0)) + #expect(action(.up, selected: 0, rows: rows) == nil) + } + + @Test func nothingIsHandledWithoutAVisibleSelection() { + #expect(action(.down, selected: nil, rows: [Self.leaf]) == nil) + #expect(action(.down, selected: 3, rows: [Self.leaf]) == nil) + #expect(HierarchyKeyNavigation.action(keyCode: 36, selectedRow: 0, rowCount: 1) { _ in Self.leaf } == nil) + } + + @Test func leftCollapsesAnExpandedRowThenGoesToTheParent() { + let parent = Row(isExpandable: true, isExpanded: true, parentRow: nil, isInHiddenHierarchy: false) + let expandedChild = Row(isExpandable: true, isExpanded: true, parentRow: 0, isInHiddenHierarchy: false) + let collapsedChild = Row(isExpandable: true, isExpanded: false, parentRow: 0, isInHiddenHierarchy: false) + #expect(action(.left, selected: 1, rows: [parent, expandedChild]) == .collapse(row: 1)) + #expect(action(.left, selected: 1, rows: [parent, collapsedChild]) == .collapseAndSelect(row: 0)) + #expect(action(.left, selected: 1, rows: [parent, Self.leaf]) == .collapseAndSelect(row: 0)) + } + + @Test func leftAtARootDoesNothing() { + let root = Row(isExpandable: false, isExpanded: false, parentRow: nil, isInHiddenHierarchy: false) + #expect(action(.left, selected: 0, rows: [root]) == nil) + } + + @Test func rightExpandsACollapsedRowElseSelectsTheNextShownRow() { + let collapsed = Row(isExpandable: true, isExpanded: false, parentRow: nil, isInHiddenHierarchy: false) + let expanded = Row(isExpandable: true, isExpanded: true, parentRow: nil, isInHiddenHierarchy: false) + let hidden = Row(isExpandable: false, isExpanded: false, parentRow: 0, isInHiddenHierarchy: true) + #expect(action(.right, selected: 0, rows: [collapsed, Self.leaf]) == .expand(row: 0)) + #expect(action(.right, selected: 0, rows: [expanded, hidden, Self.leaf]) == .select(row: 2)) + #expect(action(.right, selected: 0, rows: [expanded, hidden]) == nil) + #expect(action(.right, selected: 1, rows: [expanded, Self.leaf]) == nil) + } +} + +struct HierarchySearchVisibilityTests { + @Test func showsMatchesWithExpandedAncestorsAndCollapsedSubtrees() { + let grandchild = Node("UIImageView") + let match = Node("UILabel", [grandchild]) + let sibling = Node("UIButton") + let content = Node("UIView", [match, sibling]) + let window = Node("UIWindow", [content]) + let flat = window.selfAndDescendants + content.isExpanded = false + + var matched: [Node] = [] + let shown = HierarchySearchVisibility.apply( + flatItems: flat, + matches: { $0.className == "UILabel" }, + ancestors: { $0.ancestors }, + selfAndDescendants: { $0.selfAndDescendants }, + setExpanded: { $0.isExpanded = $1 }, + didMatch: { matched.append($0) } + ) + + #expect(shown.map(\.className) == ["UIWindow", "UIView", "UILabel", "UIImageView"]) + #expect(matched.map(\.className) == ["UILabel"]) + #expect(window.isExpanded && content.isExpanded) + #expect(!match.isExpanded && !grandchild.isExpanded) + } + + @Test func aLaterMatchReopensANodeAnEarlierMatchCollapsed() { + let inner = Node("UILabel") + let outer = Node("UILabelContainer", [inner]) + let window = Node("UIWindow", [outer]) + + let shown = HierarchySearchVisibility.apply( + flatItems: window.selfAndDescendants, + matches: { $0.className?.hasPrefix("UILabel") == true }, + ancestors: { $0.ancestors }, + selfAndDescendants: { $0.selfAndDescendants }, + setExpanded: { $0.isExpanded = $1 }, + didMatch: { _ in } + ) + + #expect(shown.count == 3) + // `outer` was collapsed as its own match, then expanded as the + // ancestor of `inner`. + #expect(outer.isExpanded) + #expect(!inner.isExpanded) + } + + @Test func noMatchShowsNothing() { + let window = Node("UIWindow", [Node("UIView")]) + let shown = HierarchySearchVisibility.apply( + flatItems: window.selfAndDescendants, + matches: { _ in false }, + ancestors: { $0.ancestors }, + selfAndDescendants: { $0.selfAndDescendants }, + setExpanded: { $0.isExpanded = $1 }, + didMatch: { _ in } + ) + #expect(shown.isEmpty) + #expect(window.isExpanded) + } +} + +struct ColorAliasCatalogTests { + private typealias Catalog = ColorAliasCatalog + + @Test func namesColorsByKeyInDeclarationOrder() { + let catalog = Catalog( + aliases: [ + (key: "MainWhite", value: .color("white")), + (key: "Theme", value: .group([(alias: "ThemeWhite", color: "white"), (alias: "ThemeRed", color: "red")])), + (key: "Broken", value: .unsupported), + ], + colorKey: { $0 } + ) + #expect(catalog.aliasesByColorKey == ["white": ["MainWhite", "ThemeWhite"], "red": ["ThemeRed"]]) + } + + @Test func listsColorsBeforeGroupsSortedIgnoringCase() { + let catalog = Catalog( + aliases: [ + (key: "zeta", value: .color("z")), + (key: "Groups B", value: .group([(alias: "b2", color: "x"), (alias: "B1", color: "y")])), + (key: "Alpha", value: .color("a")), + (key: "groups a", value: .group([(alias: "only", color: "o")])), + (key: "Empty", value: .group([])), + ], + colorKey: { $0 } + ) + #expect(catalog.entries.map(\.title) == ["Alpha", "zeta", "groups a", "Groups B"]) + guard case let .section(_, items) = catalog.entries[3] else { + Issue.record("expected a section") + return + } + #expect(items.map(\.title) == ["B1", "b2"]) + } + + @Test func colorsWithoutAKeyAreListedButNotNamed() { + let catalog = Catalog(aliases: [(key: "Odd", value: .color("?"))], colorKey: { _ in nil }) + #expect(catalog.aliasesByColorKey.isEmpty) + #expect(catalog.entries.map(\.title) == ["Odd"]) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/LicenseHandshakeFlowTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/LicenseHandshakeFlowTests.swift new file mode 100644 index 00000000..bcb3d8ce --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/LicenseHandshakeFlowTests.swift @@ -0,0 +1,348 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +/// A scripted channel, signing policy and license key. Every answer is +/// given synchronously, in the order the test queued it. +@MainActor +private final class FakeEnvironment: LicenseHandshakeEnvironment { + var isChannelConnected = true + var clock: TimeInterval = 1000 + var policyAnswers: [Bool] = [] + var challengeAnswers: [LicenseExchangeResult] = [] + var verifyAnswers: [LicenseExchangeResult] = [] + /// Each signing takes `duration` on the clock and returns `signing`. + var signings: [(duration: TimeInterval, signing: LicenseSigning)] = [] + + private(set) var policyQuestions = 0 + private(set) var failuresNoted = 0 + private(set) var challengesSent = 0 + private(set) var signedChallenges: [LicenseChallenge] = [] + private(set) var verifyRequests: [LicenseVerifyRequest] = [] + private(set) var logs: [String] = [] + private(set) var serverReleases: [ServerRelease] = [] + + func now() -> TimeInterval { + clock + } + + func shouldStartHandshake() -> Bool { + policyQuestions += 1 + return policyAnswers.isEmpty ? true : policyAnswers.removeFirst() + } + + func noteHandshakeFailed() { + failuresNoted += 1 + } + + func sendChallenge(completion: @escaping (LicenseExchangeResult) -> Void) { + challengesSent += 1 + completion(challengeAnswers.removeFirst()) + } + + func sendVerify(_ request: LicenseVerifyRequest, completion: @escaping (LicenseExchangeResult) -> Void) { + verifyRequests.append(request) + completion(verifyAnswers.removeFirst()) + } + + func sign(_ challenge: LicenseChallenge, completion: @escaping (LicenseSigning) -> Void) { + signedChallenges.append(challenge) + let next = signings.removeFirst() + clock += next.duration + var signing = next.signing + signing.finishedAt = clock + completion(signing) + } + + func noteServerRelease(_ release: ServerRelease) { + serverReleases.append(release) + } + + func log(_ message: String) { + logs.append(message) + } +} + +@MainActor +struct LicenseHandshakeFlowTests { + private static let nonce = Data((0 ..< 32).map { UInt8($0) }) + + private static func challengePayload(serverID: String = "server-1") -> NSDictionary { + [ + "nonce": nonce as NSData, + "server_instance_id": serverID as NSString, + "issued_at": NSNumber(value: 1), + "ttl": NSNumber(value: 120), + ] + } + + private static let challengeOK = LicenseExchangeResult.response(error: nil, payload: challengePayload()) + private static let accepted = LicenseExchangeResult.response(error: nil, payload: nil) + + private static func serverError(_ code: Int) -> NSError { + NSError(domain: "LookinError", code: code) + } + + private static func signed(keyUse: TimeInterval = 0.2, udid: String? = "UDID-1") -> LicenseSigning { + LicenseSigning( + signature: Data(repeating: 1, count: 256), + intermediateCertificateDER: Data(repeating: 2, count: 900), + udid: udid, + keyUseDuration: keyUse, + finishedAt: 0 + ) + } + + /// Runs the flow and returns its outcome; the fake answers synchronously. + private func run(_ environment: FakeEnvironment) -> LicenseHandshakeFailure?? { + var outcome: LicenseHandshakeFailure?? + LicenseHandshakeFlow.run(in: environment, channelDescription: "0x1") { outcome = .some($0) } + return outcome + } + + @Test func acceptedSignatureLicensesTheChannel() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK] + environment.signings = [(1, Self.signed())] + environment.verifyAnswers = [Self.accepted] + + let outcome = try #require(run(environment)) + #expect(outcome == nil) + #expect(environment.signedChallenges == [LicenseChallenge(nonce: Self.nonce, serverInstanceID: "server-1")]) + let request = try #require(environment.verifyRequests.first) + #expect(request.signature.count == 256) + #expect(request.intermediateCertificateDER.count == 900) + #expect(request.udid == "UDID-1") + // The 220's own payload goes back for its nonce and server id objects. + #expect((request.challengePayload as? NSDictionary) == Self.challengePayload()) + #expect(environment.failuresNoted == 0) + // The first attempt is cleared by the caller; the flow does not ask. + #expect(environment.policyQuestions == 0) + #expect(environment.logs.last == "LookInside - License: 221 verify accepted; channel 0x1 marked licensed.") + } + + /// A released 0.2.9 Server sends no version keys; the handshake runs + /// as before and the 221 carries nothing extra. + @Test func legacyChallengeWithoutReleaseKeysIsHandled() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK] + environment.signings = [(1, Self.signed())] + environment.verifyAnswers = [Self.accepted] + + let outcome = try #require(run(environment)) + #expect(outcome == nil) + #expect(environment.serverReleases == [ServerRelease(version: nil, build: nil)]) + #expect(environment.serverReleases.first?.suggestsUpgrade == true) + #expect(environment.logs.contains("LookInside - License: 220 Server release (build ).")) + } + + @Test func challengeReleaseKeysAreReportedAndIgnoredByTheHandshake() throws { + let environment = FakeEnvironment() + let payload = NSMutableDictionary(dictionary: Self.challengePayload()) + payload["lookinside_server_version"] = "dev" as NSString + payload["lookinside_server_build"] = NSNumber(value: 0) + environment.challengeAnswers = [.response(error: nil, payload: payload)] + environment.signings = [(1, Self.signed())] + environment.verifyAnswers = [Self.accepted] + + let outcome = try #require(run(environment)) + #expect(outcome == nil) + #expect(environment.signedChallenges == [LicenseChallenge(nonce: Self.nonce, serverInstanceID: "server-1")]) + #expect(environment.serverReleases == [ServerRelease(version: "dev", build: 0)]) + #expect(environment.serverReleases.first?.suggestsUpgrade == false) + } + + @Test func missingUDIDIsSentEmpty() { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK] + environment.signings = [(1, Self.signed(udid: nil))] + environment.verifyAnswers = [Self.accepted] + _ = run(environment) + #expect(environment.verifyRequests.first?.udid == "") + #expect(environment.logs.contains { $0.contains("udid=") }) + } + + @Test func challengeErrorIsRememberedForTheChannel() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [.response(error: Self.serverError(-408), payload: nil)] + + let outcome = try #require(run(environment)) + guard case let .challengeRejected(error)? = outcome else { + Issue.record("unexpected outcome \(String(describing: outcome))") + return + } + #expect(error.code == -408) + #expect(environment.failuresNoted == 1) + #expect(environment.signedChallenges.isEmpty) + } + + @Test func malformedChallengeIsRememberedForTheChannel() throws { + let environment = FakeEnvironment() + let shortNonce: NSDictionary = ["nonce": Data(count: 16) as NSData, "server_instance_id": "s" as NSString] + environment.challengeAnswers = [.response(error: nil, payload: shortNonce)] + + let outcome = try #require(run(environment)) + guard case let .malformedChallenge(malformed)? = outcome else { + Issue.record("unexpected outcome \(String(describing: outcome))") + return + } + #expect(malformed == LicenseChallenge.Malformed(nonceLength: 16, serverInstanceIDLength: 1)) + #expect(environment.failuresNoted == 1) + #expect(environment.logs.contains("LookInside - License: 220 challenge payload malformed (nonce_len=16, server_instance_id_len=1).")) + } + + @Test func transportErrorsAreNotRemembered() throws { + let challengeFailure = FakeEnvironment() + challengeFailure.challengeAnswers = [.transportFailure(Self.serverError(-405))] + let first = try #require(run(challengeFailure)) + guard case .challengeTransport? = first else { + Issue.record("unexpected outcome \(String(describing: first))") + return + } + #expect(challengeFailure.failuresNoted == 0) + + let verifyFailure = FakeEnvironment() + verifyFailure.challengeAnswers = [Self.challengeOK] + verifyFailure.signings = [(1, Self.signed())] + verifyFailure.verifyAnswers = [.transportFailure(Self.serverError(-403))] + let second = try #require(run(verifyFailure)) + guard case .verifyTransport? = second else { + Issue.record("unexpected outcome \(String(describing: second))") + return + } + #expect(verifyFailure.failuresNoted == 0) + } + + @Test(arguments: [ + LicenseSigning(failureDescription: "keychain refused", failed: true, finishedAt: 0), + LicenseSigning(signature: Data(), intermediateCertificateDER: Data([1]), finishedAt: 0), + LicenseSigning(signature: Data([1]), intermediateCertificateDER: nil, finishedAt: 0), + ]) + func failedSigningEndsTheHandshakeWithoutRetry(signing: LicenseSigning) throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK] + // Slow as well: a failed signing is never retried. + environment.signings = [(500, signing)] + + let outcome = try #require(run(environment)) + guard case let .signingFailed(detail)? = outcome else { + Issue.record("unexpected outcome \(String(describing: outcome))") + return + } + #expect(detail == signing.failureDescription) + #expect(environment.challengesSent == 1) + #expect(environment.verifyRequests.isEmpty) + // The activation runtime records signing failures itself. + #expect(environment.failuresNoted == 0) + } + + @Test func slowSignatureAsksForAFreshChallengeOnce() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK, .response(error: nil, payload: Self.challengePayload(serverID: "server-2"))] + // Both signings are slow; only the first one retries. + environment.signings = [(100, Self.signed(keyUse: 90)), (150, Self.signed(keyUse: 140))] + environment.verifyAnswers = [Self.accepted] + + let outcome = try #require(run(environment)) + #expect(outcome == nil) + #expect(environment.challengesSent == 2) + #expect(environment.policyQuestions == 1) + #expect(environment.verifyRequests.map(\.challenge.serverInstanceID) == ["server-2"]) + #expect(environment.logs.contains( + "LookInside - License: the signature arrived 100s after the challenge (key use 90s), past its lifetime; requesting a fresh 220." + )) + } + + @Test func signatureJustUnderTheLimitIsSent() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK] + environment.signings = [(99.5, Self.signed())] + environment.verifyAnswers = [Self.accepted] + #expect(try #require(run(environment)) == nil) + #expect(environment.challengesSent == 1) + } + + @Test func retryHeldBackByTheSigningPolicy() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK] + environment.signings = [(120, Self.signed())] + environment.policyAnswers = [false] + + let outcome = try #require(run(environment)) + guard case .retryHeldBack? = outcome else { + Issue.record("unexpected outcome \(String(describing: outcome))") + return + } + #expect(environment.challengesSent == 1) + #expect(environment.verifyRequests.isEmpty) + #expect(environment.logs.last == "LookInside - License: the signing policy holds the retry back on channel 0x1.") + } + + @Test func retryHeldBackWhenTheChannelIsGone() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK] + environment.signings = [(120, Self.signed())] + environment.isChannelConnected = false + + let outcome = try #require(run(environment)) + guard case .retryHeldBack? = outcome else { + Issue.record("unexpected outcome \(String(describing: outcome))") + return + } + // The policy is not asked for a disconnected channel. + #expect(environment.policyQuestions == 0) + } + + @Test func rejectionAfterASlowKeyUseRetriesOnceThenIsRemembered() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK, Self.challengeOK] + environment.signings = [(31, Self.signed(keyUse: 30)), (40, Self.signed(keyUse: 40))] + environment.verifyAnswers = [ + .response(error: Self.serverError(-408), payload: nil), + .response(error: Self.serverError(-408), payload: nil), + ] + + let outcome = try #require(run(environment)) + guard case let .verifyRejected(error)? = outcome else { + Issue.record("unexpected outcome \(String(describing: outcome))") + return + } + #expect(error.code == -408) + #expect(environment.challengesSent == 2) + #expect(environment.verifyRequests.count == 2) + #expect(environment.policyQuestions == 1) + #expect(environment.failuresNoted == 1) + } + + @Test func rejectionAfterAFastKeyUseIsRememberedAtOnce() throws { + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK] + environment.signings = [(2, Self.signed(keyUse: 29.9))] + environment.verifyAnswers = [.response(error: Self.serverError(-408), payload: nil)] + + let outcome = try #require(run(environment)) + guard case .verifyRejected? = outcome else { + Issue.record("unexpected outcome \(String(describing: outcome))") + return + } + #expect(environment.challengesSent == 1) + #expect(environment.failuresNoted == 1) + } + + @Test func slowSigningAndRejectionShareTheSingleRetry() throws { + // The first signature is too old and retried; the retry's verify is + // rejected after a slow key use, but there is no second retry. + let environment = FakeEnvironment() + environment.challengeAnswers = [Self.challengeOK, Self.challengeOK] + environment.signings = [(130, Self.signed(keyUse: 120)), (60, Self.signed(keyUse: 60))] + environment.verifyAnswers = [.response(error: Self.serverError(-408), payload: nil)] + + let outcome = try #require(run(environment)) + guard case .verifyRejected? = outcome else { + Issue.record("unexpected outcome \(String(describing: outcome))") + return + } + #expect(environment.challengesSent == 2) + #expect(environment.failuresNoted == 1) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PeripheralLogicTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PeripheralLogicTests.swift new file mode 100644 index 00000000..6baf0559 --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PeripheralLogicTests.swift @@ -0,0 +1,140 @@ +import CoreGraphics +import Foundation +@testable import LookInsideHostCore +import Testing + +struct ExportNamingTests { + @Test func compressionTitlesMatchTheObjectiveCFormat() { + #expect(ExportNaming.compressionOptions.map(ExportNaming.compressionTitle) == ["10%", "30%", "50%", "75%", "100%"]) + } + + @Test(arguments: [ + (0.1, 0), (0.3, 1), (0.5, 2), (0.75, 3), (1.0, 4), + (0.34, 1), (0.26, 1), (0.2, nil), (0.0, nil), + ] as [(Double, Int?)]) + func compressionIndexMatchesWithinTolerance(value: Double, index: Int?) { + #expect(ExportNaming.compressionIndex(for: value) == index) + } + + @Test func fileNameUsesAppMajorOSVersionAndTime() { + let date = Date(timeIntervalSince1970: 1_700_000_000) // 2023-11-14 22:13:20 UTC + let name = ExportNaming.fileName(appName: "Demo", osDescription: "17.2.1", date: date, + timeZone: TimeZone(identifier: "UTC")!, + locale: Locale(identifier: "en_US_POSIX")) + #expect(name == "Demo_ios17_11142213.lookin") + } + + @Test func fileNamePrintsMissingValuesAsNull() { + let date = Date(timeIntervalSince1970: 0) + let name = ExportNaming.fileName(appName: nil, osDescription: nil, date: date, + timeZone: TimeZone(identifier: "UTC")!, + locale: Locale(identifier: "en_US_POSIX")) + #expect(name == "(null)_ios(null)_01010000.lookin") + let noDot = ExportNaming.fileName(appName: "A", osDescription: "26", date: date, + timeZone: TimeZone(identifier: "UTC")!, + locale: Locale(identifier: "en_US_POSIX")) + #expect(noDot == "A_ios26_01010000.lookin") + } + + @Test func megabytesAreDecimal() { + #expect(ExportNaming.megabytes(46_200_000) == 46.2) + } +} + +struct ConsoleHistoryTests { + @Test(arguments: [ + ("", ConsoleInputCheck.empty), + ("setFrame:", .hasArguments), + ("layer.frame", .unsupportedSyntax), + ("a.b:", .hasArguments), + ("description", .accepted), + ]) + func inputCheck(text: String, expected: ConsoleInputCheck) { + #expect(ConsoleInputCheck(text) == expected) + } + + @Test func recentListIsNewestFirstWithoutDuplicatesAndCapped() { + var list = ConsoleRecentList(maxCount: 3) + list.insert("a", id: 1) + list.insert("b", id: 2) + list.insert("c", id: 3) + list.insert("a2", id: 1) + #expect(list.entries.map(\.element) == ["a2", "c", "b"]) + list.insert("d", id: 4) + #expect(list.entries.map(\.element) == ["d", "a2", "c"]) + #expect(list.entries.map(\.id) == [4, 1, 3]) + } + + @Test func recentListDefaultsToFiveEntries() { + var list = ConsoleRecentList() + for id in 1 ... 7 { + list.insert(Int(id), id: UInt(id)) + } + #expect(list.entries.map(\.element) == [7, 6, 5, 4, 3]) + } +} + +struct MeasureGuidesTests { + @Test func scaleFactorFitsTheLargerSide() { + let main = CGRect(x: 0, y: 0, width: 400, height: 100) + let refer = CGRect(x: 0, y: 300, width: 100, height: 100) + // Extent 400 x 400 into 200 x 100: the height decides. + #expect(MeasureGuides.scaleFactor(main: main, refer: refer, maxWidth: 200, maxHeight: 100) == 4) + #expect(MeasureGuides.scaleFactor(main: .zero, refer: .zero, maxWidth: 200, maxHeight: 100) == 1) + } + + @Test func scaledFramesStartAtTheOrigin() { + let frames = MeasureGuides.scaledFrames(main: CGRect(x: 20, y: 40, width: 20, height: 20), + refer: CGRect(x: 60, y: 20, width: 40, height: 40), + factor: 2) + #expect(frames.main == CGRect(x: 0, y: 10, width: 10, height: 10)) + #expect(frames.refer == CGRect(x: 20, y: 0, width: 20, height: 20)) + } + + @Test func overlapDimsTheOuterFrame() { + let outer = CGRect(x: 0, y: 0, width: 100, height: 100) + let inner = CGRect(x: 10, y: 10, width: 10, height: 10) + #expect(MeasureGuides.Overlap(main: outer, refer: inner) == .mainContainsRefer) + #expect(MeasureGuides.Overlap(main: inner, refer: outer) == .referContainsMain) + #expect(MeasureGuides.Overlap(main: outer, refer: CGRect(x: 90, y: 90, width: 20, height: 20)) == .partial) + #expect(MeasureGuides.Overlap(main: inner, refer: CGRect(x: 50, y: 50, width: 5, height: 5)) == .disjoint) + #expect(MeasureGuides.Overlap.partial.alphas == (0.2, 0.2)) + } + + @Test func separatedFramesGetOneLineEachWay() { + let a = CGRect(x: 0, y: 0, width: 10, height: 10) + let b = CGRect(x: 30, y: 50, width: 10, height: 10) + let originalA = CGRect(x: 0, y: 0, width: 20, height: 20) + let originalB = CGRect(x: 60, y: 100, width: 20, height: 20) + #expect(MeasureGuides.horizontalLines(a: a, b: b, originalA: originalA, originalB: originalB) + == [.init(startX: 10, endX: 30, y: 5, value: 40)]) + #expect(MeasureGuides.verticalLines(a: a, b: b, originalA: originalA, originalB: originalB) + == [.init(startY: 10, endY: 50, x: 5, value: 80)]) + // Swapped: B is left of and above A. + #expect(MeasureGuides.horizontalLines(a: b, b: a, originalA: originalB, originalB: originalA) + == [.init(startX: 30, endX: 10, y: 55, value: 40)]) + #expect(MeasureGuides.verticalLines(a: b, b: a, originalA: originalB, originalB: originalA) + == [.init(startY: 10, endY: 50, x: 35, value: 80)]) + } + + @Test func containedFrameGetsLinesToEveryEdge() { + let outer = CGRect(x: 0, y: 0, width: 100, height: 100) + let inner = CGRect(x: 10, y: 20, width: 30, height: 40) + let horizontal = MeasureGuides.horizontalLines(a: outer, b: inner, originalA: outer, originalB: inner) + #expect(horizontal == [ + .init(startX: 40, endX: 100, y: 40, value: 60), + .init(startX: 0, endX: 10, y: 40, value: 10), + ]) + let vertical = MeasureGuides.verticalLines(a: inner, b: outer, originalA: inner, originalB: outer) + #expect(vertical == [ + .init(startY: 0, endY: 20, x: 25, value: 20), + .init(startY: 60, endY: 100, x: 25, value: 40), + ]) + } + + @Test func alignedEdgesDrawNothing() { + let rect = CGRect(x: 0, y: 0, width: 10, height: 10) + #expect(MeasureGuides.horizontalLines(a: rect, b: rect, originalA: rect, originalB: rect).isEmpty) + #expect(MeasureGuides.verticalLines(a: rect, b: rect, originalA: rect, originalB: rect).isEmpty) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreviewGeometryTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreviewGeometryTests.swift new file mode 100644 index 00000000..eede5773 --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/PreviewGeometryTests.swift @@ -0,0 +1,68 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +struct PreviewGeometryTests { + @Test func equivalentAngleStaysInsideOneTurn() { + #expect(PreviewGeometry.equivalentAngle(0.6) == 0.6) + #expect(abs(PreviewGeometry.equivalentAngle(.pi) - -Double.pi) < 1e-12) + #expect(abs(PreviewGeometry.equivalentAngle(-Double.pi) - -Double.pi) < 1e-12) + #expect(abs(PreviewGeometry.equivalentAngle(3 * .pi + 0.5) - (-Double.pi + 0.5)) < 1e-12) + #expect(abs(PreviewGeometry.equivalentAngle(-5 * .pi - 0.25) - (Double.pi - 0.25)) < 1e-12) + } + + @Test func focalLengthSpansTwentyToSevenFifty() { + #expect(PreviewGeometry.focalLength(forScale: 0) == 20) + #expect(PreviewGeometry.focalLength(forScale: 1) == 750) + #expect(PreviewGeometry.focalLength(forScale: 0.5) == 20 + 0.25 * 730) + } + + @Test func interspaceDependsOnDimension() { + #expect(PreviewGeometry.layerInterspace(is3D: false, zInterspace: 1) == 0.01) + #expect(PreviewGeometry.layerInterspace(is3D: true, zInterspace: 0) == 0.1) + #expect(abs(PreviewGeometry.layerInterspace(is3D: true, zInterspace: 1) - 0.8) < 1e-12) + #expect(PreviewGeometry.clampedZInterspace(-1) == 0) + #expect(PreviewGeometry.clampedZInterspace(2) == 1) + #expect(PreviewGeometry.clampedZInterspace(0.3) == 0.3) + } + + @Test func zPositionsCentreTheStackAndSeparateCoplanarPlanes() { + // max 3 → offset round(1.5) = 2. + let positions = PreviewGeometry.zPositions(zIndexes: [0, 1, 1, 3, 1], interspace: 0.5) + let expected = [-1.0, -0.5, -0.5 + 0.0001, 0.5, -0.5 + 0.0002] + #expect(positions.count == expected.count) + for (actual, wanted) in zip(positions, expected) { + #expect(abs(actual - wanted) < 1e-12) + } + } + + @Test func zPositionsOfASinglePlaneUseOffsetRoundedAwayFromZero() { + // max 1 → offset round(0.5) = 1, as C's round() does. + #expect(PreviewGeometry.zPositions(zIndexes: [1, 0], interspace: 1) == [0, -1]) + #expect(PreviewGeometry.zPositions(zIndexes: [], interspace: 1) == []) + } +} + +struct DetailTaskPackagingTests { + @Test func packagesCloseBeforeTheAreaLimit() { + let ranges = DetailTaskPackaging.packageRanges(areas: [1_000_000, 900_000, 200_000, 50]) + #expect(ranges == [0 ..< 2, 2 ..< 4]) + } + + @Test func aTaskLargerThanTheLimitGetsItsOwnPackage() { + let ranges = DetailTaskPackaging.packageRanges(areas: [3_000_000, 10, 3_000_000]) + #expect(ranges == [0 ..< 1, 1 ..< 2, 2 ..< 3]) + } + + @Test func packagesHoldAtMostTheTaskLimit() { + let ranges = DetailTaskPackaging.packageRanges(areas: Array(repeating: 1, count: 250)) + #expect(ranges == [0 ..< 100, 100 ..< 200, 200 ..< 250]) + } + + @Test func runningAreaIsTruncatedLikeAnUnsignedInteger() { + // 0.6 + 0.6 truncates to 0 then 0 each time, so 1_999_999.5 still fits. + let ranges = DetailTaskPackaging.packageRanges(areas: [0.6, 0.6, 1_999_999.5], maxArea: 2_000_000) + #expect(ranges == [0 ..< 3]) + #expect(DetailTaskPackaging.packageRanges(areas: []) == []) + } +} diff --git a/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ServerUpgradeHintTests.swift b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ServerUpgradeHintTests.swift new file mode 100644 index 00000000..ea2aafae --- /dev/null +++ b/Packages/LookInsideHostCore/Tests/LookInsideHostCoreTests/ServerUpgradeHintTests.swift @@ -0,0 +1,81 @@ +import Foundation +@testable import LookInsideHostCore +import Testing + +struct ServerUpgradeHintTests { + private static let nonce = Data((0 ..< 32).map { UInt8($0) }) + + /// The 220 reply of a released 0.2.9 Server: no version keys. + private static var legacyPayload: NSDictionary { + [ + "nonce": nonce as NSData, + "server_instance_id": "server-1" as NSString, + "issued_at": NSNumber(value: 1), + "ttl": NSNumber(value: 120), + ] + } + + private static func payload(version: String?, build: Int?) -> NSDictionary { + let dictionary = NSMutableDictionary(dictionary: legacyPayload) + dictionary["lookinside_server_version"] = version.map { $0 as NSString } + dictionary["lookinside_server_build"] = build.map { NSNumber(value: $0) } + return dictionary + } + + @Test func legacyChallengeStillParses() throws { + let challenge = try LicenseChallenge.parse(Self.legacyPayload).get() + #expect(challenge == LicenseChallenge(nonce: Self.nonce, serverInstanceID: "server-1")) + #expect(ServerRelease.parse(Self.legacyPayload) == ServerRelease(version: nil, build: nil)) + } + + @Test func newChallengeParsesTheSameAndCarriesTheRelease() throws { + let payload = Self.payload(version: "0.3.0", build: 3000) + let challenge = try LicenseChallenge.parse(payload).get() + #expect(challenge == LicenseChallenge(nonce: Self.nonce, serverInstanceID: "server-1")) + #expect(ServerRelease.parse(payload) == ServerRelease(version: "0.3.0", build: 3000)) + } + + @Test(arguments: [ + // A key is missing: a 0.2.9 or older Server. + (nil, nil, true), + ("0.3.0", nil, true), + (nil, 3000, true), + // SemVer at or below 0.2.9. + ("0.2.9", 2009, true), + ("0.2.0", 2000, true), + ("0.1.12", 1012, true), + ("0.2.9-rc.1", 2009, true), + // Newer releases. + ("0.2.10", 2010, false), + ("0.3.0", 3000, false), + ("1.0.0+5", 1_000_000, false), + // Not SemVer: an unreleased build, or something unexpected. + ("dev", 0, false), + ("0.2", 0, false), + ("v0.2.9", 0, false), + ("0.2.9.1", 0, false), + ("", 0, false), + ] as [(String?, Int?, Bool)]) + func upgradeDecision(version: String?, build: Int?, expected: Bool) { + #expect(ServerRelease(version: version, build: build).suggestsUpgrade == expected) + } + + @Test func wrongValueTypesCountAsMissing() { + let dictionary = NSMutableDictionary(dictionary: Self.legacyPayload) + dictionary["lookinside_server_version"] = NSNumber(value: 3) + dictionary["lookinside_server_build"] = "3000" as NSString + let release = ServerRelease.parse(dictionary) + #expect(release == ServerRelease(version: nil, build: nil)) + #expect(release.suggestsUpgrade) + } + + @Test func ledgerShowsTheHintOncePerApp() { + let ledger = ServerUpgradeHintLedger() + let old = ServerRelease(version: nil, build: nil) + let current = ServerRelease(version: "0.3.0", build: 3000) + #expect(!ledger.shouldShowHint(for: current, appKey: "com.example.a")) + #expect(ledger.shouldShowHint(for: old, appKey: "com.example.a")) + #expect(!ledger.shouldShowHint(for: old, appKey: "com.example.a")) + #expect(ledger.shouldShowHint(for: old, appKey: "com.example.b")) + } +} diff --git a/README.md b/README.md index 495cb201..c42f0895 100644 --- a/README.md +++ b/README.md @@ -11,17 +11,11 @@ LookInside continues the work of [Lookin](https://lookin.work/), the original iO ## Use it -1. Download LookInside from the [Releases page](https://github.com/LookInsideApp/LookInside/releases). +1. Download LookInside from the [Releases page](https://github.com/LookInsideApp/LookInside/releases). It runs on macOS 14 or later. 2. Add the server package to the app you want to inspect. 3. Run your app. 4. Open LookInside on your Mac and pick the running app. -## Use it with AI agents - -LookInside 2.3.11 and later include a local MCP server for Codex, Claude, Cursor, Windsurf, VS Code, and other MCP clients. Agents can inspect hierarchies, attributes, screenshots, and UI changes through the app you already opened in LookInside. - -See the [MCP configuration and usage guide](docs/mcp.md). - ## What you can inspect - UIKit, AppKit, and SwiftUI view trees @@ -29,6 +23,12 @@ See the [MCP configuration and usage guide](docs/mcp.md). - SwiftUI modifiers and layout details - Live property changes while your app is running +## Activation + +SwiftUI inspection needs LookInside Pro, as a trial or a license. Activation runs inside LookInside itself; it no longer installs a separate helper app. The activation code is part of this repository. + +If you activated an earlier version, your activation carries over. The first time the new version uses your existing activation key, macOS asks for your login keychain password. Choose **Always Allow** so it does not ask again. Going back to an earlier version keeps working too. + ## Add the server package Use [LookInside-Release](https://github.com/LookInsideApp/LookInside-Release) with Swift Package Manager or CocoaPods. diff --git a/Resources/Licenses/Peertalk.txt b/Resources/Licenses/Peertalk.txt deleted file mode 100644 index 849a0247..00000000 --- a/Resources/Licenses/Peertalk.txt +++ /dev/null @@ -1,34 +0,0 @@ -Peertalk -======== - -Source: -- https://github.com/rsms/peertalk - -Local usage: -- Vendored and adapted in `Sources/LookinCore/Peertalk` -- Mirrored for the app build in `LookInside/DerivedSource/LookinCore/Peertalk` -- Legacy shared copy retained in `Sources/LookinServer/Shared/Peertalk` - -Notes: -- Local files are prefixed with `Lookin_` and integrated into the LookInside codebase. -- The upstream project is distributed under the MIT License. - -Copyright (c) 2012 Rasmus Andersson - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/Resources/Licenses/ReactiveObjC.md b/Resources/Licenses/ReactiveObjC.md deleted file mode 100644 index 49bc3746..00000000 --- a/Resources/Licenses/ReactiveObjC.md +++ /dev/null @@ -1,19 +0,0 @@ -**Copyright (c) 2012 - 2016, GitHub, Inc.** -**All rights reserved.** - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Resources/Licenses/ShortCocoa.md b/Resources/Licenses/ShortCocoa.md deleted file mode 100644 index a68f3d36..00000000 --- a/Resources/Licenses/ShortCocoa.md +++ /dev/null @@ -1,20 +0,0 @@ -## ShortCocoa - -Source: - -- Upstream Lookin client source tree: https://github.com/CocoaUIInspector/Lookin/tree/Develop/LookinClient/ShortCocoa - -Local usage: - -- Vendored in [`LookInside/ShortCocoa`](../../LookInside/ShortCocoa) - -License basis: - -- `ShortCocoa` is distributed in the upstream `CocoaUIInspector/Lookin` repository as part of that repository's GPL-3.0-licensed source tree. -- LookInside inherits that GPL-3.0 distribution basis for the vendored `ShortCocoa` files included from upstream Lookin. -- See the repository-level GPL-3.0 text in [`LICENSE`](../../LICENSE). - -Provenance note: - -- The original file headers in `ShortCocoa` identify authorship by `hughkli`. -- This repository preserves those headers and distributes the files under the same GPL-3.0 repository license used by upstream Lookin and this fork. diff --git a/Scripts/ViewHierarchyProbe/main.swift b/Scripts/ViewHierarchyProbe/main.swift index 04e642e0..2d25e6e9 100644 --- a/Scripts/ViewHierarchyProbe/main.swift +++ b/Scripts/ViewHierarchyProbe/main.swift @@ -30,24 +30,30 @@ func inflatingGzipIfNeeded(_ payload: Data) -> Data? { cursor += 2 + extraLength } if flags & 0x08 != 0 { - while cursor < bytes.count, bytes[cursor] != 0 { cursor += 1 } + while cursor < bytes.count, bytes[cursor] != 0 { + cursor += 1 + } cursor += 1 } if flags & 0x10 != 0 { - while cursor < bytes.count, bytes[cursor] != 0 { cursor += 1 } + while cursor < bytes.count, bytes[cursor] != 0 { + cursor += 1 + } cursor += 1 } - if flags & 0x02 != 0 { cursor += 2 } + if flags & 0x02 != 0 { + cursor += 2 + } guard cursor < bytes.count - 8 else { return nil } - let deflateBytes = Array(bytes[cursor..<(bytes.count - 8)]) + let deflateBytes = Array(bytes[cursor ..< (bytes.count - 8)]) let trailer = bytes.suffix(4) let declaredSize = trailer.enumerated().reduce(0) { partial, element in partial | (Int(element.element) << (8 * element.offset)) } var capacity = max(declaredSize, 64 * 1024) - for _ in 0..<6 { + for _ in 0 ..< 6 { var destination = [UInt8](repeating: 0, count: capacity) let writtenCount = deflateBytes.withUnsafeBufferPointer { source in destination.withUnsafeMutableBufferPointer { target in @@ -58,10 +64,10 @@ func inflatingGzipIfNeeded(_ payload: Data) -> Data? { ) } } - if writtenCount > 0 && writtenCount < capacity { - return Data(destination[0.. 0, writtenCount < capacity { + return Data(destination[0 ..< writtenCount]) } - if writtenCount == capacity && capacity == declaredSize { + if writtenCount == capacity, capacity == declaredSize { return Data(destination) } capacity *= 2 @@ -166,10 +172,14 @@ func suppressingPoisoningLayers(under layer: CALayer, perform body: () - candidate.isHidden = true hiddenLayers.append(candidate) } - for sublayer in candidate.sublayers ?? [] { collect(sublayer) } + for sublayer in candidate.sublayers ?? [] { + collect(sublayer) + } } collect(layer) - defer { for hidden in hiddenLayers { hidden.isHidden = false } } + defer { for hidden in hiddenLayers { + hidden.isHidden = false + } } return body() } @@ -212,17 +222,23 @@ func measuringOpaqueCoverage(of layer: CALayer, includingSublayers: Bool) -> Dou func gatheringStatistics(for layer: CALayer, into statistics: inout RecoveryStatistics) { statistics.layerCount += 1 statistics.classHistogram[String(describing: type(of: layer)), default: 0] += 1 - if layer.contents != nil { statistics.layersWithContents += 1 } + if layer.contents != nil { + statistics.layersWithContents += 1 + } let groupCoverage = measuringOpaqueCoverage(of: layer, includingSublayers: true) if groupCoverage < 0 { statistics.layersWithZeroArea += 1 } else { - if groupCoverage > 0 { statistics.layersWithNonEmptyGroupRender += 1 } + if groupCoverage > 0 { + statistics.layersWithNonEmptyGroupRender += 1 + } let suppressedCoverage = suppressingPoisoningLayers(under: layer) { measuringOpaqueCoverage(of: layer, includingSublayers: true) } - if suppressedCoverage > 0 { statistics.layersWithNonEmptyGroupRenderWithSuppression += 1 } + if suppressedCoverage > 0 { + statistics.layersWithNonEmptyGroupRenderWithSuppression += 1 + } if measuringOpaqueCoverage(of: layer, includingSublayers: false) > 0 { statistics.layersWithNonEmptySoloRender += 1 } @@ -238,10 +254,10 @@ func writingPreviewImage(of layer: CALayer, to fileURL: URL) { let height = Int(layer.bounds.height.rounded()) guard width > 0, height > 0, let context = CGContext( - data: nil, width: width, height: height, - bitsPerComponent: 8, bytesPerRow: width * 4, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + data: nil, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return } context.translateBy(x: -layer.bounds.origin.x, y: -layer.bounds.origin.y) @@ -313,7 +329,7 @@ for bundlePath in arguments.dropFirst() { let renderableDenominator = max(totals.layerCount - totals.layersWithZeroArea, 1) print(" TOTAL archives decoded: \(successfulArchiveCount)/\(archives.count)") print(" TOTAL layers: \(totals.layerCount) " - + "(zero-area \(totals.layersWithZeroArea), renderable \(renderableDenominator))") + + "(zero-area \(totals.layersWithZeroArea), renderable \(renderableDenominator))") print(" layers carrying contents: \(totals.layersWithContents)") print(String(format: " group render non-empty: %d (%.1f%% of renderable)", totals.layersWithNonEmptyGroupRender, diff --git a/Scripts/build-and-run-debug.sh b/Scripts/build-and-run-debug.sh index 2065482d..9ee70711 100755 --- a/Scripts/build-and-run-debug.sh +++ b/Scripts/build-and-run-debug.sh @@ -4,22 +4,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -if [[ "${LOOKINSIDE_SKIP_DEBUG_AUTH:-0}" == "1" ]]; then - echo "Skipping debug auth server prep (LOOKINSIDE_SKIP_DEBUG_AUTH=1)" -elif "$SCRIPT_DIR/prepare-debug-auth-server.sh"; then - echo "Prepared debug auth server." -else - status=$? - if [[ "${LOOKINSIDE_REQUIRE_DEBUG_AUTH:-0}" == "1" ]]; then - echo "fatal: debug auth server prep failed and LOOKINSIDE_REQUIRE_DEBUG_AUTH=1" >&2 - exit "$status" - fi - echo "warning: debug auth server prep failed (status=$status); continuing host build" >&2 - echo " set LOOKINSIDE_REQUIRE_DEBUG_AUTH=1 to fail hard, or LOOKINSIDE_SKIP_DEBUG_AUTH=1 to skip." >&2 -fi - pkill -x LookInside 2>/dev/null || true -pkill -x lookinside-auth-server 2>/dev/null || true cd "$PROJECT_DIR" rm -rf build/derived diff --git a/Scripts/build-release-from-tag.sh b/Scripts/build-release-from-tag.sh index 2d079832..d7ccf934 100755 --- a/Scripts/build-release-from-tag.sh +++ b/Scripts/build-release-from-tag.sh @@ -456,19 +456,6 @@ verify_injector_bundle_layout() { fail "Injector binary SMAuthorizedClients does not authorize $HOST_BUNDLE_IDENTIFIER for team $INJECTOR_AUTHORIZED_TEAM_ID" } -# The MCP server rides the host's release so it can never drift out of step -# with the MCPBridge protocol the host speaks. A release that quietly shipped -# without it would present as "the MCP integration disappeared in this -# version" to every user who configured their agent against the bundled path. -verify_mcp_cli_bundle_layout() { - local app_path="$1" - local mcp_binary="$app_path/Contents/Resources/lookinside-mcp" - - [[ -f "$mcp_binary" ]] || fail "MCP server binary is missing: $mcp_binary" - [[ -x "$mcp_binary" ]] || fail "MCP server binary is not executable: $mcp_binary" - is_mach_o_file "$mcp_binary" || fail "MCP server binary is not Mach-O: $mcp_binary" -} - package_cli() { local cli_binary="$1" local cli_zip="$2" @@ -562,9 +549,6 @@ sign_app_bundle() { log "Verifying bundled injector daemon" verify_injector_bundle_layout "$app_path" - log "Verifying bundled MCP server" - verify_mcp_cli_bundle_layout "$app_path" - remove_legacy_code_resources "$app_path" log "Signing nested app code" diff --git a/Scripts/check-host-release-clean.sh b/Scripts/check-host-release-clean.sh new file mode 100755 index 00000000..5f0f0a28 --- /dev/null +++ b/Scripts/check-host-release-clean.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# +# Fails when a LookInside app build still carries DEBUG-only activation +# overrides, the DEBUG end-to-end dump, or a private key, or (in release +# mode) ships without the Sparkle updater configured. +# +# Usage: +# Scripts/check-host-release-clean.sh [--release-strict | --local-build] ... +# +# DEBUG builds of the activation package compile ActivationDebugOverrides, +# which reads LOOKINSIDE_ACTIVATION_API_BASE_URL (a different activation +# service) and LOOKINSIDE_ACTIVATION_TEST_ROOT_ID / +# LOOKINSIDE_ACTIVATION_TEST_ROOT_PUBLIC_KEY_PEM (an extra trusted root) from +# the environment, and the end-to-end dump (DebugE2E/: LKDebugE2EDump, +# LookinSnapshotNormalizer) reads LOOKINSIDE_DEBUG_E2E_*, and the UI +# snapshots (DebugE2E/: LKDebugUISnapshots, LKDebugWindowImage) read +# LOOKINSIDE_DEBUG_UI_SNAPSHOT_*. A Release build +# compiles them out; this check proves it on the shipped bundle. +# +# A path fails when any of the following holds: +# - a file in it contains ActivationDebugOverrides, any +# LOOKINSIDE_ACTIVATION_*, LOOKINSIDE_DEBUG_E2E_* or +# LOOKINSIDE_DEBUG_UI_SNAPSHOT_* string, the LKDebugE2EDump, +# LKDebugUISnapshots, LKDebugWindowImage or LookinSnapshotNormalizer +# name, or a LookinServer +# license test-hook +# marker (test_setOverrideRootCertificateDER, sLKSTestOverrideRoot, +# LOOKINSIDE_ENABLE_LICENSE_TEST_HOOKS); every byte of every file is +# searched, so all architecture slices of the app binary, its frameworks, +# helpers and resources are covered; +# - the demangled symbol table of a Mach-O file names ActivationDebugOverrides +# (Swift mangling can split the name, so the raw search alone is not enough); +# - Scripts/scan-private-keys.sh finds a private key; +# - release mode only: it holds no app bundle, or an outermost app bundle's +# Contents/Info.plist has an empty or missing SUPublicEDKey or SUFeedURL. +# Only the release workflows inject SPARKLE_PUBLIC_ED_KEY; a build without +# it silently skips the updater (LKAppMenuManager), so a release must +# never ship one. +# Release mode (--release-strict) is the default. --local-build skips only +# the Sparkle check, for local Release builds that have no updater key. +# A .zip or .tar.gz is unpacked into a temporary directory under +# CHECK_HOST_RELEASE_SCRATCH (default: $TMPDIR). +# +# Exit status: 0 clean, 1 an override, key or missing updater setting was +# found, 2 on a usage error or when a path holds no Mach-O file to check. + +set -uo pipefail + +usage() { + sed -n '2,41p' "$0" >&2 + exit 2 +} + +CHECK_SPARKLE=1 +while [ "$#" -gt 0 ]; do + case "$1" in + --release-strict) CHECK_SPARKLE=1 ;; + --local-build) CHECK_SPARKLE=0 ;; + -h | --help) usage ;; + --) + shift + break + ;; + -*) + echo "Unknown option: $1" >&2 + usage + ;; + *) break ;; + esac + shift +done +[ "$#" -gt 0 ] || usage + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCANNER="$SCRIPT_DIR/scan-private-keys.sh" +[ -f "$SCANNER" ] || { + echo "Missing $SCANNER" >&2 + exit 2 +} + +SCRATCH_PARENT="${CHECK_HOST_RELEASE_SCRATCH:-${TMPDIR:-/tmp}}" +SCRATCH_PARENT="${SCRATCH_PARENT%/}" +mkdir -p "$SCRATCH_PARENT" || exit 2 +WORK_DIR="$(mktemp -d "$SCRATCH_PARENT/check-host-release-clean.XXXXXX")" || exit 2 +trap 'rm -rf "$WORK_DIR"' EXIT + +MARKERS_RE='ActivationDebugOverrides|LOOKINSIDE_ACTIVATION_[A-Z0-9_]+|LOOKINSIDE_DEBUG_E2E_[A-Z0-9_]+|LOOKINSIDE_DEBUG_UI_SNAPSHOT_[A-Z0-9_]+|LKDebugE2EDump|LKDebugUISnapshots|LKDebugWindowImage|LookinSnapshotNormalizer|test_setOverrideRootCertificateDER|sLKSTestOverrideRoot|LOOKINSIDE_ENABLE_LICENSE_TEST_HOOKS' +SYMBOL_RE='ActivationDebugOverrides|LKDebugE2EDump|LKDebugUISnapshots|LKDebugWindowImage|LookinSnapshotNormalizer' + +findings=0 +usage_errors=0 + +report() { + echo "FAIL: $1" + findings=$((findings + 1)) +} + +demangle() { + if xcrun --find swift-demangle >/dev/null 2>&1; then + xcrun swift-demangle + else + cat + fi +} + +check_tree() { + local root="$1" label="$2" + local machos=0 file relative hits + + while IFS= read -r -d '' file; do + relative="${file#"$root"/}" + [ "$relative" = "$file" ] && relative="$(basename "$file")" + + hits="$(LC_ALL=C grep -a -o -E "$MARKERS_RE" "$file" 2>/dev/null | sort -u | tr '\n' ' ')" + if [ -n "$hits" ]; then + report "$label!$relative: contains ${hits% }" + fi + + case "$(file -b "$file" 2>/dev/null)" in + *Mach-O*) ;; + *) continue ;; + esac + machos=$((machos + 1)) + hits="$(nm -a "$file" 2>/dev/null | demangle | grep -E -o "[^ ]*${SYMBOL_RE}[^ ]*" | sort -u | head -n 5 | tr '\n' ' ')" + if [ -n "$hits" ]; then + report "$label!$relative: symbols name ${hits% }" + fi + done < <(find "$root" -type f -print0) + + if [ "$machos" -eq 0 ]; then + echo "ERROR: $label holds no Mach-O file to check" >&2 + usage_errors=$((usage_errors + 1)) + return + fi + echo "checked $machos Mach-O file(s) in $label" + + if ! bash "$SCANNER" "$root"; then + report "$label: private key scan failed" + fi + + if [ "$CHECK_SPARKLE" -eq 1 ]; then + check_sparkle "$root" "$label" + fi +} + +# The Info.plist of every outermost app bundle under root (root itself when it +# is one); apps nested inside another bundle, such as Sparkle's Updater.app, +# are skipped. +outermost_app_plists() { + local root="$1" plist relative + if [ -f "$root/Contents/Info.plist" ] && [ "${root%.app}" != "$root" ]; then + echo "$root/Contents/Info.plist" + return + fi + while IFS= read -r -d '' plist; do + relative="${plist#"$root"/}" + relative="${relative%/Contents/Info.plist}" + case "$relative" in + *.app/*) continue ;; + esac + echo "$plist" + done < <(find "$root" -path '*.app/Contents/Info.plist' -type f -print0) +} + +check_sparkle() { + local root="$1" label="$2" + local apps=0 plist relative key value + while IFS= read -r plist; do + apps=$((apps + 1)) + relative="${plist#"$root"/}" + [ "$relative" = "$plist" ] && relative="$(basename "$root")/Contents/Info.plist" + for key in SUPublicEDKey SUFeedURL; do + value="$(plutil -extract "$key" raw -o - "$plist" 2>/dev/null)" + value="${value//[[:space:]]/}" + if [ -z "$value" ] || [ "${value#\$(}" != "$value" ]; then + report "$label!$relative: $key is empty or missing (release builds must ship the Sparkle updater)" + fi + done + done < <(outermost_app_plists "$root") + if [ "$apps" -eq 0 ]; then + report "$label: no app bundle to check the Sparkle settings of" + return + fi + echo "checked Sparkle settings of $apps app bundle(s) in $label" +} + +index=0 +for path in "$@"; do + index=$((index + 1)) + if [ -d "$path" ]; then + check_tree "${path%/}" "$path" + continue + fi + if [ ! -f "$path" ]; then + echo "ERROR: $path does not exist" >&2 + usage_errors=$((usage_errors + 1)) + continue + fi + unpacked="$WORK_DIR/$index" + mkdir -p "$unpacked" + case "$path" in + *.zip) ditto -x -k "$path" "$unpacked" ;; + *.tar.gz | *.tgz) tar -xzf "$path" -C "$unpacked" ;; + *) + echo "ERROR: $path is not a directory, .zip or .tar.gz" >&2 + usage_errors=$((usage_errors + 1)) + continue + ;; + esac || { + report "$path: cannot unpack" + continue + } + check_tree "$unpacked" "$path" +done + +if [ "$findings" -gt 0 ]; then + echo "host release check failed: $findings finding(s)" + exit 1 +fi +if [ "$usage_errors" -gt 0 ]; then + exit 2 +fi +echo "host release check passed: $*" diff --git a/Scripts/package-lookinserver.sh b/Scripts/package-lookinserver.sh deleted file mode 100755 index 82365590..00000000 --- a/Scripts/package-lookinserver.sh +++ /dev/null @@ -1,163 +0,0 @@ -#!/bin/zsh - -set -euo pipefail - -cd "$(dirname "$0")/.." - -PROJECT_DIR="$PWD" -WORKSPACE_PATH="LookInside.xcworkspace" -CONFIGURATION="${CONFIGURATION:-Release}" -SKIP_BUILD="${SKIP_BUILD:-0}" -MIN_IOS_VERSION="${MIN_IOS_VERSION:-12.0}" -USE_XCBEAUTIFY="${USE_XCBEAUTIFY:-1}" -RUNTIME_VARIANT="${RUNTIME_VARIANT:-standard}" - -case "$RUNTIME_VARIANT" in -standard | dynamic) - DEFAULT_SCHEME="LookinServerDynamic" - DEFAULT_FRAMEWORK_NAME="LookinServer" - DEFAULT_PRODUCT_NAME="LookinServerDynamic" - DEFAULT_OUTPUT_ROOT="$PROJECT_DIR/build/lookinserver" - DEFAULT_DERIVED_ROOT="/tmp/LookinServerDynamicDD" - COPY_RUNTIME_HEADERS=1 - SWIFT_HEADER_TARGET="LookinServerSwift" - ;; -injected) - DEFAULT_SCHEME="LookinServerInjected" - DEFAULT_FRAMEWORK_NAME="LookinServerInjected" - DEFAULT_PRODUCT_NAME="LookinServerInjected" - DEFAULT_OUTPUT_ROOT="$PROJECT_DIR/build/lookinserver-injected" - DEFAULT_DERIVED_ROOT="/tmp/LookinServerInjectedDD" - COPY_RUNTIME_HEADERS=0 - SWIFT_HEADER_TARGET="" - ;; -*) - echo "Unsupported RUNTIME_VARIANT: $RUNTIME_VARIANT" >&2 - exit 1 - ;; -esac - -SCHEME="${SCHEME:-$DEFAULT_SCHEME}" -FRAMEWORK_NAME="${FRAMEWORK_NAME:-$DEFAULT_FRAMEWORK_NAME}" -PRODUCT_NAME="${PRODUCT_NAME:-$DEFAULT_PRODUCT_NAME}" -OUTPUT_ROOT="${OUTPUT_ROOT:-$DEFAULT_OUTPUT_ROOT}" -DERIVED_ROOT="${DERIVED_ROOT:-$DEFAULT_DERIVED_ROOT}" - -copy_public_headers() { - local headers_dir="$1" - - mkdir -p "$headers_dir" - - while IFS= read -r -d '' header; do - cp "$header" "$headers_dir/$(basename "$header")" - done < <( - find \ - "$PROJECT_DIR/Sources/LookinServer/Shared" \ - "$PROJECT_DIR/Sources/LookinServer/Server" \ - "$PROJECT_DIR/Sources/LookinServerBase" \ - -name '*.h' -print0 - ) -} - -write_modulemap() { - local framework_dir="$1" - - mkdir -p "$framework_dir/Modules" - cat >"$framework_dir/Modules/module.modulemap" <<'EOF' -framework module LookinServer { - umbrella "../Headers" - export * - module * { export * } -} -EOF -} - -run_xcodebuild() { - if [ "$USE_XCBEAUTIFY" = "1" ] && command -v xcbeautify >/dev/null 2>&1; then - xcodebuild "$@" 2>&1 | xcbeautify - else - xcodebuild "$@" - fi -} - -stage_framework_bundle() { - local sdk="$1" - local derived_dir="$2" - local release_dir="$derived_dir/Build/Products/$CONFIGURATION-$sdk" - local source_framework="$release_dir/PackageFrameworks/$PRODUCT_NAME.framework" - local staged_root="$OUTPUT_ROOT/$sdk" - local staged_framework="$staged_root/$FRAMEWORK_NAME.framework" - local staged_binary="$staged_framework/$FRAMEWORK_NAME" - local staged_dylib="$staged_root/$FRAMEWORK_NAME.dylib" - local swift_header - - rm -rf "$staged_framework" - mkdir -p "$staged_root" - cp -R "$source_framework" "$staged_framework" - - if [ "$PRODUCT_NAME" != "$FRAMEWORK_NAME" ]; then - mv "$staged_framework/$PRODUCT_NAME" "$staged_binary" - else - staged_binary="$staged_framework/$PRODUCT_NAME" - fi - - /usr/libexec/PlistBuddy \ - -c "Set :CFBundleExecutable $FRAMEWORK_NAME" \ - -c "Set :CFBundleName $FRAMEWORK_NAME" \ - -c "Set :CFBundleIdentifier lookinside.$FRAMEWORK_NAME" \ - "$staged_framework/Info.plist" - - if [ "$COPY_RUNTIME_HEADERS" = "1" ]; then - copy_public_headers "$staged_framework/Headers" - - swift_header="$derived_dir/Build/Intermediates.noindex/LookInside.build/$CONFIGURATION-$sdk/$SWIFT_HEADER_TARGET.build/Objects-normal" - if [ "$sdk" = "iphoneos" ]; then - swift_header="$swift_header/arm64/$SWIFT_HEADER_TARGET-Swift.h" - else - swift_header="$swift_header/arm64/$SWIFT_HEADER_TARGET-Swift.h" - fi - cp "$swift_header" "$staged_framework/Headers/$FRAMEWORK_NAME-Swift.h" - - write_modulemap "$staged_framework" - fi - - install_name_tool -id "@rpath/$FRAMEWORK_NAME.framework/$FRAMEWORK_NAME" "$staged_binary" - - cp "$staged_binary" "$staged_dylib" - install_name_tool -id "@rpath/$FRAMEWORK_NAME.dylib" "$staged_dylib" -} - -build_sdk() { - local sdk="$1" - local derived_dir="$DERIVED_ROOT/$sdk" - - run_xcodebuild \ - -workspace "$WORKSPACE_PATH" \ - -scheme "$SCHEME" \ - -configuration "$CONFIGURATION" \ - -sdk "$sdk" \ - -derivedDataPath "$derived_dir" \ - CODE_SIGNING_ALLOWED=NO \ - SKIP_INSTALL=NO \ - BUILD_LIBRARY_FOR_DISTRIBUTION=YES \ - IPHONEOS_DEPLOYMENT_TARGET="$MIN_IOS_VERSION" \ - build -} - -mkdir -p "$OUTPUT_ROOT" - -if [ "$SKIP_BUILD" != "1" ]; then - build_sdk iphoneos - build_sdk iphonesimulator -fi - -stage_framework_bundle iphoneos "$DERIVED_ROOT/iphoneos" -stage_framework_bundle iphonesimulator "$DERIVED_ROOT/iphonesimulator" - -rm -rf "$OUTPUT_ROOT/$FRAMEWORK_NAME.xcframework" -run_xcodebuild -create-xcframework \ - -framework "$OUTPUT_ROOT/iphoneos/$FRAMEWORK_NAME.framework" \ - -framework "$OUTPUT_ROOT/iphonesimulator/$FRAMEWORK_NAME.framework" \ - -output "$OUTPUT_ROOT/$FRAMEWORK_NAME.xcframework" - -echo "Packaged artifacts under $OUTPUT_ROOT" diff --git a/Scripts/prepare-debug-auth-server.sh b/Scripts/prepare-debug-auth-server.sh deleted file mode 100755 index c6b45991..00000000 --- a/Scripts/prepare-debug-auth-server.sh +++ /dev/null @@ -1,117 +0,0 @@ -#!/bin/zsh -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -MONOREPO_DIR="$(cd "$PROJECT_DIR/.." && pwd)" -AUTH_REPO="$MONOREPO_DIR/LookInside-Auth" -WORKSPACE="$AUTH_REPO/LookInsideAuthenticator.xcworkspace" - -DEBUG_ROOT="/tmp/lookinside-auth-server-debug" -DERIVED_DATA="$DEBUG_ROOT/derived-data" -LOG="$DEBUG_ROOT/xcodebuild.log" -DEST_DIR="$DEBUG_ROOT/current" -DEST_APP="$DEST_DIR/lookinside-auth-server.app" - -if [ ! -d "$AUTH_REPO" ]; then - echo "warning: LookInside-Auth repo not found at $AUTH_REPO" >&2 - echo " (private/source-available; public contributors can ignore)" >&2 - exit 1 -fi - -mkdir -p "$DEBUG_ROOT" "$DEST_DIR" -rm -rf "$DERIVED_DATA" "$DEST_APP" - -# Refuse to build a project that no longer matches its Tuist manifests. -# -# The workspace and xcodeproj are gitignored build artifacts, so testing only -# for their presence means a checkout that already has them keeps building a -# stale project after Project.swift changes -- and does so silently, since the -# only symptom is whatever the stale project happens to get wrong. Raising a -# package version requirement is the case that bites: the generated project -# still carries the old requirement, SwiftPM keeps honouring the old pin, and -# the build fails inside a dependency far away from the edit. -# -# This reports the problem rather than regenerating in place, and that is not a -# matter of taste. LookInsideAuthenticator.xcodeproj is a member of the -# monorepo's LookInside.xcworkspace. Rewriting a workspace member mid-build -# makes Xcode reload the workspace and cancel the build in progress, which kills -# this script before it can finish -- and since nothing was recorded, the next -# build repeats it. The result is a build that cancels itself forever. -# Generating is only safe when the workspace does not exist yet, because nothing -# can have it open. -# -# Staleness is decided by modification time rather than a stamp file, so that -# regenerating by hand is enough to clear it -- no bookkeeping this script has -# to be alive to perform. -PROJECT_FILE="$AUTH_REPO/LookInsideAuthenticator.xcodeproj/project.pbxproj" - -newest_manifest_mtime=0 -newest_manifest_file="" -for manifest_candidate in \ - "$AUTH_REPO/Project.swift" \ - "$AUTH_REPO/Workspace.swift" \ - "$AUTH_REPO/Tuist.swift" \ - "$AUTH_REPO/Tuist/Package.swift" \ - "$AUTH_REPO/mise.toml" -do - if [ -f "$manifest_candidate" ]; then - candidate_mtime="$(stat -f %m "$manifest_candidate")" - if [ "$candidate_mtime" -gt "$newest_manifest_mtime" ]; then - newest_manifest_mtime="$candidate_mtime" - newest_manifest_file="$manifest_candidate" - fi - fi -done - -if [ ! -d "$WORKSPACE" ] || [ ! -f "$PROJECT_FILE" ]; then - # Prefer mise (matches internal toolchain pinning); fall back to bare tuist. - if command -v mise >/dev/null 2>&1; then - (cd "$AUTH_REPO" && mise exec -- tuist generate --no-open) - elif command -v tuist >/dev/null 2>&1; then - (cd "$AUTH_REPO" && tuist generate --no-open) - else - echo "warning: neither 'mise' nor 'tuist' is on PATH; cannot generate workspace" >&2 - exit 1 - fi -elif [ "$newest_manifest_mtime" -gt "$(stat -f %m "$PROJECT_FILE")" ]; then - echo "error: $newest_manifest_file is newer than the generated LookInsideAuthenticator.xcodeproj," >&2 - echo " so this build would use a stale project. Regenerate it, then build again:" >&2 - echo " (cd $AUTH_REPO && mise exec -- tuist generate --no-open)" >&2 - echo " Not done automatically: that project belongs to LookInside.xcworkspace, and" >&2 - echo " rewriting it mid-build makes Xcode cancel the build. See docs/monorepo/workflow.md." >&2 - exit 1 -fi - -set +e -xcodebuild \ - -workspace "$WORKSPACE" \ - -scheme LookInsideAuthServer \ - -configuration Debug \ - -destination 'platform=macOS' \ - -derivedDataPath "$DERIVED_DATA" \ - -skipMacroValidation \ - -skipPackagePluginValidation \ - CODE_SIGNING_ALLOWED=NO \ - CODE_SIGNING_REQUIRED=NO \ - CODE_SIGN_IDENTITY= \ - ONLY_ACTIVE_ARCH=YES \ - build >"$LOG" 2>&1 -build_status=$? -set -e - -if [ "$build_status" -ne 0 ]; then - echo "LookInsideAuthServer Debug build failed. Log: $LOG" >&2 - tail -80 "$LOG" >&2 || true - exit "$build_status" -fi - -built_app="$(find "$DERIVED_DATA/Build/Products" -type d -name 'lookinside-auth-server.app' | head -1)" -if [ -z "$built_app" ]; then - echo "Built lookinside-auth-server.app was not found under $DERIVED_DATA/Build/Products" >&2 - exit 1 -fi - -cp -R "$built_app" "$DEST_APP" -chmod +x "$DEST_APP/Contents/MacOS/lookinside-auth-server" -echo "Prepared debug auth server at $DEST_APP" diff --git a/Scripts/prepare-mcp-cli.sh b/Scripts/prepare-mcp-cli.sh deleted file mode 100755 index 8d6b7219..00000000 --- a/Scripts/prepare-mcp-cli.sh +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/zsh -set -euo pipefail - -# Builds the lookinside-mcp stdio server out of the sibling LookInside-MCP -# repo (when present) and embeds the binary into the LookInside.app bundle -# currently being built. -# -# Layout written: -# $BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH/Resources/lookinside-mcp -# -# Resources/ rather than MacOS/, because the host never launches this binary. -# MCP clients (Claude Desktop, Claude Code, Codex CLI, …) spawn it themselves -# and speak Model Context Protocol to it over stdio; the host only ever sees -# it as one more connection on its Unix socket. Shipping it inside the bundle -# is a distribution convenience -- it inherits the host's notarization and -# rides the same Sparkle channel, so the binary can never drift out of step -# with the MCPBridge protocol version the host speaks. -# -# GPL boundary: lookinside-mcp is proprietary and stays a separate process. It -# is never linked into the GPL host -- process isolation is the license -# boundary, and placing an independent executable in the bundle is -# aggregation, not linking. See docs/monorepo/mcp-design.md §0.2-0.3. -# -# Signing is deliberately not done here. The release flow's `sign_nested_code` -# walks every file under Contents/, detects Mach-O binaries and signs each one -# before sealing the outer bundle, so this binary is picked up automatically. -# -# Public/contributor builds without the sibling repo are tolerated: the script -# warns and exits 0, exactly like prepare-injector-daemon.sh. Those builds -# simply ship without the MCP server; nothing else about the app changes. - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -MONOREPO_DIR="$(cd "$PROJECT_DIR/.." && pwd)" -MCP_REPO="$MONOREPO_DIR/LookInside-MCP" - -if [ ! -d "$MCP_REPO" ]; then - if [ "${CONFIGURATION:-}" = "Release" ]; then - echo "error: LookInside-MCP repo not found at $MCP_REPO" >&2 - echo " Release builds must embed the MCP server binary." >&2 - exit 1 - fi - echo "warning: LookInside-MCP repo not found at $MCP_REPO" >&2 - echo " (private/source-available; public contributors can ignore)" >&2 - exit 0 -fi - -if [ -z "${CONFIGURATION:-}" ]; then - echo "error: CONFIGURATION is unset (not running under Xcode?)" >&2 - exit 1 -fi - -if [ -z "${BUILT_PRODUCTS_DIR:-}" ] || [ -z "${CONTENTS_FOLDER_PATH:-}" ]; then - echo "error: BUILT_PRODUCTS_DIR / CONTENTS_FOLDER_PATH are unset (not running under Xcode?)" >&2 - exit 1 -fi - -case "$CONFIGURATION" in - Debug) - swift_configuration="debug" - ;; - Release) - swift_configuration="release" - ;; - *) - echo "warning: unknown CONFIGURATION=$CONFIGURATION, skipping MCP CLI embed" >&2 - exit 0 - ;; -esac - -# A scratch path of our own, never the package's default .build. A developer -# very likely has `swift build` running in this same package from a terminal, -# and SwiftPM takes an exclusive lock on its scratch directory -- sharing one -# would make the two builds block each other. -SCRATCH_PATH="$MCP_REPO/build/host-derived" -LOG="$SCRATCH_PATH/swift-build-$swift_configuration.log" - -mkdir -p "$SCRATCH_PATH" - -set +e -swift build \ - --package-path "$MCP_REPO" \ - --scratch-path "$SCRATCH_PATH" \ - --configuration "$swift_configuration" \ - --product lookinside-mcp >"$LOG" 2>&1 -build_status=$? -set -e - -if [ "$build_status" -ne 0 ]; then - echo "lookinside-mcp $swift_configuration build failed. Log: $LOG" >&2 - tail -80 "$LOG" >&2 || true - exit "$build_status" -fi - -BIN_PATH="$(swift build \ - --package-path "$MCP_REPO" \ - --scratch-path "$SCRATCH_PATH" \ - --configuration "$swift_configuration" \ - --show-bin-path 2>/dev/null)" - -built_binary="$BIN_PATH/lookinside-mcp" -if [ ! -x "$built_binary" ]; then - echo "error: lookinside-mcp binary not found at $built_binary" >&2 - exit 1 -fi - -APP_CONTENTS_DIR="$BUILT_PRODUCTS_DIR/$CONTENTS_FOLDER_PATH" -DEST_DIR="$APP_CONTENTS_DIR/Resources" -mkdir -p "$DEST_DIR" - -cp -f "$built_binary" "$DEST_DIR/lookinside-mcp" -chmod +x "$DEST_DIR/lookinside-mcp" - -echo "Embedded lookinside-mcp ($swift_configuration) into $DEST_DIR" diff --git a/Scripts/run-gitleaks.sh b/Scripts/run-gitleaks.sh new file mode 100755 index 00000000..8ecb0c45 --- /dev/null +++ b/Scripts/run-gitleaks.sh @@ -0,0 +1,139 @@ +#!/bin/bash +# Scans a Git repository's history for secrets with a pinned gitleaks release. +# +# Usage: bash Scripts/run-gitleaks.sh [--config ] [] +# +# The release archive is downloaded from GitHub and checked against the +# SHA-256 pinned below before it runs. Findings are redacted in the output. +# Check out the repository with full history (fetch-depth: 0) to scan every +# commit. +# +# Where the rules come from: --config, or by default the .gitleaks.toml at +# the root of the repository that holds this script (gitleaks' built-in rules +# when there is none). A .gitleaks.toml or GITLEAKS_CONFIG* setting from the +# scanned repository is never read, gitleaks runs on the repository's Git +# directory so a .gitleaksignore in its working tree is not read, and inline +# gitleaks:allow comments are ignored. +# +# That keeps the scanned repository from relaxing the scan only when this +# script and its config come from a separate, trusted checkout, as in the +# monorepo's internal-snapshot workflow. When a repository runs its own copy +# in its own CI, the script and the default config are that repository's own +# committed files, so a commit can change the rules it is scanned with. +# Exemptions belong in the trusted .gitleaks.toml, matched as exactly as +# possible (commit and path). +# +# Exit status: 0 clean, 1 secrets found, other values on setup errors. +# +# This script is kept identical in every repository that publishes artifacts. + +set -euo pipefail + +GITLEAKS_VERSION="8.30.1" + +usage() { + sed -n '2,28p' "$0" >&2 + exit 2 +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +config="" +repository="" +while [[ $# -gt 0 ]]; do + case "$1" in + --config) + [[ $# -ge 2 && -n "$2" ]] || usage + config="$2" + shift 2 + ;; + -h | --help) usage ;; + -*) + echo "Unknown option: $1" >&2 + usage + ;; + *) + [[ -z "$repository" ]] || usage + repository="$1" + shift + ;; + esac +done +repository="${repository:-.}" + +[[ -d "$repository" ]] || { + echo "Repository directory not found: $repository" >&2 + exit 2 +} +git_dir="$(git -C "$repository" rev-parse --absolute-git-dir)" || { + echo "Not a Git repository: $repository" >&2 + exit 2 +} + +if [[ -n "$config" ]]; then + [[ -f "$config" ]] || { + echo "gitleaks config not found: $config" >&2 + exit 2 + } +elif [[ -f "$script_dir/../.gitleaks.toml" ]]; then + config="$script_dir/../.gitleaks.toml" +fi + +case "$(uname -s)-$(uname -m)" in +Darwin-arm64) + platform="darwin_arm64" + checksum="b40ab0ae55c505963e365f271a8d3846efbc170aa17f2607f13df610a9aeb6a5" + ;; +Darwin-x86_64) + platform="darwin_x64" + checksum="dfe101a4db2255fc85120ac7f3d25e4342c3c20cf749f2c20a18081af1952709" + ;; +Linux-x86_64) + platform="linux_x64" + checksum="551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + ;; +Linux-aarch64 | Linux-arm64) + platform="linux_arm64" + checksum="e4a487ee7ccd7d3a7f7ec08657610aa3606637dab924210b3aee62570fb4b080" + ;; +*) + echo "No pinned gitleaks build for $(uname -s)-$(uname -m)." >&2 + exit 2 + ;; +esac + +work_dir="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/gitleaks.XXXXXX")" +trap 'rm -rf "$work_dir"' EXIT + +if [[ -z "$config" ]]; then + config="$work_dir/default.gitleaks.toml" + printf '[extend]\nuseDefault = true\n' >"$config" +fi +mkdir "$work_dir/no-ignore-file" + +archive="$work_dir/gitleaks.tar.gz" +curl -fsSL --retry 3 -o "$archive" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_${platform}.tar.gz" + +if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$archive" | awk '{print $1}')" +else + actual="$(shasum -a 256 "$archive" | awk '{print $1}')" +fi +[[ "$actual" == "$checksum" ]] || { + echo "gitleaks archive checksum mismatch: expected $checksum, got $actual" >&2 + exit 2 +} + +tar -xzf "$archive" -C "$work_dir" gitleaks +"$work_dir/gitleaks" version +echo "gitleaks config: $config" + +env -u GITLEAKS_CONFIG -u GITLEAKS_CONFIG_TOML \ + "$work_dir/gitleaks" git \ + --no-banner \ + --redact \ + --exit-code 1 \ + --config "$config" \ + --gitleaks-ignore-path "$work_dir/no-ignore-file" \ + --ignore-gitleaks-allow \ + "$git_dir" diff --git a/Scripts/scan-private-keys.sh b/Scripts/scan-private-keys.sh new file mode 100755 index 00000000..a35879bc --- /dev/null +++ b/Scripts/scan-private-keys.sh @@ -0,0 +1,367 @@ +#!/bin/bash +# Fails when a PEM private key is found in the given files or directories. +# +# Usage: bash Scripts/scan-private-keys.sh [ ...] +# +# Directories are walked recursively, so .app, .xcframework and .xcarchive +# bundles are covered. Zip files (including .ipa) and tar files (plain, gzip, +# bzip2, xz) are opened and their members scanned, nested archives included; +# standalone gzip, bzip2 and xz files are decompressed and scanned. Detection +# is by content, not by file name. An archive that cannot be fully read (for +# example an encrypted zip member) fails the scan, because it cannot be shown +# to be clean. +# +# A hit is a PEM private-key header (PRIVATE KEY, RSA/EC/DSA/ENCRYPTED/OPENSSH +# PRIVATE KEY, PGP PRIVATE KEY BLOCK) followed by key data, also when the +# line breaks are written as \n escapes and slashes as \/ inside a string +# literal. Key data without a header is a hit too: a raw DER key (PKCS#8, +# encrypted PKCS#8, PKCS#1 RSA, SEC1 EC) or an OpenSSH key body anywhere in a +# file, or the same encoded as base64 (one line or wrapped, plain or escaped). +# Each candidate is parsed as DER, so certificates and public keys pass. Code +# that only names a header, such as a PEM parser, does not count. Matches are +# reported by location and key type only; key material is never printed. +# +# Exit status: 0 clean, 1 private key found or archive unreadable, 2 usage. +# +# This script is kept identical in every repository that publishes artifacts. + +set -euo pipefail + +if [[ $# -eq 0 || "$1" == "-h" || "$1" == "--help" ]]; then + sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//' >&2 + exit 2 +fi + +exec python3 - "$@" <<'PY' +import base64 +import binascii +import bz2 +import gzip +import io +import lzma +import os +import re +import stat +import sys +import tarfile +import zipfile + +MAX_DEPTH = 8 + +PEM_PRIVATE_KEY = re.compile( + rb"-----BEGIN ((?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?)-----" + # Optional RFC 1421 headers (Proc-Type, DEK-Info) or PGP armor headers. + rb"(?:(?:\s|\\[rn])+[A-Za-z][A-Za-z0-9-]*:[^\r\n\\]*)*" + # Real key data follows the header; JSON may escape "/" as "\/". + rb"(?:\s|\\[rn])+(?:[A-Za-z0-9+/=]|\\/){40}" +) + +# Where a raw DER private key can start: a SEQUENCE holding version 0 or 1 +# followed by an AlgorithmIdentifier (PKCS#8), an INTEGER (PKCS#1) or an +# OCTET STRING (SEC1), or a SEQUENCE whose first element is an encryption +# AlgorithmIdentifier (encrypted PKCS#8). Candidates are confirmed by parsing. +DER_CANDIDATE = re.compile( + rb"\x30(?:\x83...|\x82..|\x81.|[\x00-\x7f])" + rb"(?:\x02\x01[\x00\x01][\x02\x04\x30]|\x30(?:\x81.|[\x00-\x7f])\x06)", + re.DOTALL, +) + +# The OpenSSH key body: magic, then the cipher name as an SSH string. +OPENSSH_KEY_MAGIC = b"openssh-key-" + b"v1\x00" +OPENSSH_KEY = re.compile( + re.escape(OPENSSH_KEY_MAGIC) + rb"\x00\x00\x00[\x03-\x20][a-z0-9]", re.DOTALL +) + +# A base64 run that may encode a key: DER keys start with 0x30 ("M" followed +# by "A".."P"), the OpenSSH body with its magic ("b3Bl"). The run may wrap +# with real (indented) or escaped line breaks, and JSON may escape "/" as "\/". +BASE64_START = re.compile(rb"(?:(? end: + return None + tag = data[position] + first = data[position + 1] + position += 2 + if first < 0x80: + length = first + elif 0x81 <= first <= 0x83: + size = first - 0x80 + if position + size > end: + return None + length = int.from_bytes(data[position : position + size], "big") + position += size + # DER uses the shortest length form. + if length < 0x80 or length < (1 << (8 * (size - 1))): + return None + else: + return None + if position + length > end: + return None + return tag, position, position + length + + +def read_children(data, start, end, limit=12): + children = [] + position = start + while position < end: + element = read_tlv(data, position, end) + if element is None or len(children) == limit: + return None + children.append(element) + position = element[2] + return children + + +def algorithm_oid(data, algorithm): + tag, start, end = algorithm + if tag != 0x30: + return None + children = read_children(data, start, end) + if not children or children[0][0] != 0x06: + return None + return data[children[0][1] : children[0][2]] + + +def classify_der(data, offset): + """Names the private-key structure that starts at offset, or None.""" + outer = read_tlv(data, offset, len(data)) + if outer is None or outer[0] != 0x30: + return None + children = read_children(data, outer[1], outer[2]) + if not children or len(children) < 2: + return None + first, second = children[0], children[1] + content = lambda element: data[element[1] : element[2]] + + if first[0] == 0x02 and content(first) in (b"\x00", b"\x01"): + version = content(first)[0] + if ( + second[0] == 0x30 + and len(children) >= 3 + and children[2][0] == 0x04 + and children[2][2] > children[2][1] + ): + name = PKCS8_ALGORITHMS.get(algorithm_oid(data, second)) + if name: + return f"PKCS#8 {name} private key" + if ( + version == 0 + and len(children) == 9 + and all(element[0] == 0x02 for element in children) + ): + return "PKCS#1 RSA private key" + if ( + version == 1 + and second[0] == 0x04 + and 16 <= second[2] - second[1] <= 66 + and len(children) >= 3 + and all(element[0] in (0xA0, 0xA1) for element in children[2:]) + ): + return "SEC1 EC private key" + return None + + if first[0] == 0x30 and len(children) == 2 and second[0] == 0x04: + oid = algorithm_oid(data, first) + if oid and oid.startswith(ENCRYPTION_ALGORITHM_PREFIXES): + return "encrypted PKCS#8 private key" + return None + + +def classify_key_bytes(data): + if len(data) < MIN_KEY_BYTES: + return None + if data.startswith(OPENSSH_KEY_MAGIC): + return "OpenSSH private key" if OPENSSH_KEY.match(data) else None + return classify_der(data, 0) + + +def decode_base64_run(run): + text = BASE64_SEPARATORS.sub(b"", run).split(b"=", 1)[0] + # A lone trailing character cannot be decoded; anything after "=" is not + # part of this value. + if len(text) % 4 == 1: + text = text[:-1] + try: + return base64.b64decode(text + b"=" * (-len(text) % 4), validate=True) + except (binascii.Error, ValueError): + return b"" + + +def report_hits(data, location): + seen = set() + + def add(description): + if description not in seen: + seen.add(description) + hits.append(f"{location}: {description}") + + for match in PEM_PRIVATE_KEY.finditer(data): + add("BEGIN " + match.group(1).decode("ascii")) + for match in DER_CANDIDATE.finditer(data): + description = classify_der(data, match.start()) + if description: + add(f"{description} (DER)") + for match in OPENSSH_KEY.finditer(data): + add("OpenSSH private key (binary)") + for match in BASE64_START.finditer(data): + run = BASE64_RUN.match(data, match.start(), match.start() + MAX_BASE64_RUN) + description = classify_key_bytes(decode_base64_run(run.group(0))) + if description: + add(f"{description} (base64)") + + +def scan_bytes(data, location, depth): + report_hits(data, location) + if depth >= MAX_DEPTH: + errors.append(f"{location}: archive nesting deeper than {MAX_DEPTH}") + return + scan_container(io.BytesIO(data), data[:6], location, depth) + + +def scan_container(stream, magic, location, depth): + if magic.startswith(b"PK\x03\x04") or magic.startswith(b"PK\x05\x06"): + scan_zip(stream, location, depth) + return + stream.seek(0) + if tarfile.is_tarfile(stream): + stream.seek(0) + scan_tar(stream, location, depth) + return + for prefix, opener in ( + (b"\x1f\x8b", gzip.GzipFile), + (b"BZh", bz2.BZ2File), + (b"\xfd7zXZ\x00", lzma.LZMAFile), + ): + if magic.startswith(prefix): + stream.seek(0) + try: + with opener(fileobj=stream) as decompressed: + data = decompressed.read() + except Exception as error: + errors.append(f"{location}: cannot decompress ({error})") + return + scan_bytes(data, f"{location}!", depth + 1) + return + + +def scan_zip(stream, location, depth): + try: + archive = zipfile.ZipFile(stream) + except zipfile.BadZipFile as error: + errors.append(f"{location}: unreadable zip ({error})") + return + with archive: + for info in archive.infolist(): + if info.is_dir(): + continue + member = f"{location}!{info.filename}" + try: + data = archive.read(info) + except Exception as error: + errors.append(f"{member}: cannot read zip member ({error})") + continue + scan_bytes(data, member, depth + 1) + + +def scan_tar(stream, location, depth): + try: + archive = tarfile.open(fileobj=stream, mode="r:*") + except tarfile.TarError as error: + errors.append(f"{location}: unreadable tar ({error})") + return + with archive: + try: + for info in archive: + if not info.isfile(): + continue + member = f"{location}!{info.name}" + extracted = archive.extractfile(info) + if extracted is None: + continue + with extracted: + data = extracted.read() + scan_bytes(data, member, depth + 1) + except (tarfile.TarError, EOFError, OSError, lzma.LZMAError) as error: + errors.append(f"{location}: unreadable tar ({error})") + + +def scan_file(path): + try: + with open(path, "rb") as handle: + magic = handle.read(6) + handle.seek(0) + data = handle.read() + except OSError as error: + errors.append(f"{path}: cannot read ({error})") + return + report_hits(data, path) + scan_container(io.BytesIO(data), magic, path, 0) + + +def scan_path(root): + try: + mode = os.lstat(root).st_mode + except OSError as error: + errors.append(f"{root}: cannot stat ({error})") + return + if stat.S_ISREG(mode): + scan_file(root) + return + if not stat.S_ISDIR(mode): + # Symlinks are not followed: bundle symlinks point inside the bundle. + return + def listing_failed(error): + errors.append(f"{error.filename}: cannot list ({error.strerror})") + + for directory, _subdirectories, files in os.walk(root, onerror=listing_failed): + for name in sorted(files): + path = os.path.join(directory, name) + if stat.S_ISREG(os.lstat(path).st_mode): + scan_file(path) + + +for argument in sys.argv[1:]: + scan_path(argument) + +for line in hits: + print(f"private key found: {line}", file=sys.stderr) +for line in errors: + print(f"scan error: {line}", file=sys.stderr) + +if hits or errors: + print( + f"private key scan failed: {len(hits)} key(s), {len(errors)} unreadable item(s)", + file=sys.stderr, + ) + sys.exit(1) + +print(f"private key scan passed: {' '.join(sys.argv[1:])}") +PY diff --git a/Scripts/sync-derived-source.sh b/Scripts/sync-derived-source.sh index 6582e39b..74d3b9df 100755 --- a/Scripts/sync-derived-source.sh +++ b/Scripts/sync-derived-source.sh @@ -7,7 +7,7 @@ DERIVED_ROOT="$PROJECT_DIR/LookInside/DerivedSource" mkdir -p "$DERIVED_ROOT" -for name in LookinCore LookinServerBase; do +for name in LookinCore LookinCoreImpl LookinServerBase; do mkdir -p "$DERIVED_ROOT/$name" rsync -a --delete "$SOURCE_ROOT/$name/" "$DERIVED_ROOT/$name/" done diff --git a/Scripts/test-dashboard.sh b/Scripts/test-dashboard.sh new file mode 100755 index 00000000..87a2e806 --- /dev/null +++ b/Scripts/test-dashboard.sh @@ -0,0 +1,52 @@ +#!/bin/sh +set -eu + +# Host-side tests for the Dashboard's payload and display rules. +# +# The modification payloads, the edited values, the enum lists and the JSON +# attribute tree are kept in Foundation-only files that depend on the +# LookinCore model alone, so they compile here with LookinCore into a +# standalone binary, without building or launching the app. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMPDIR="${TMPDIR:-/tmp}/lookinside-dashboard-tests.$$" +mkdir -p "$TMPDIR" +trap 'rm -rf "$TMPDIR"' EXIT + +SOURCE_DIR="$ROOT/LookInside/Dashboard" +TEST_DIR="$ROOT/Tests/Dashboard" +LOOKIN_CORE_DIR="$ROOT/Sources/LookinCore" +# LookinDisplayItem pulls one header from the server base sources. +LOOKIN_SERVER_BASE_DIR="$ROOT/Sources/LookinServerBase" +# The Swift `@objc @implementation` of the LookinCore model classes. +LOOKIN_CORE_IMPL_DIR="$ROOT/Sources/LookinCoreImpl" +DEPLOYMENT_TARGET=14.0 + +mkdir -p "$TMPDIR/lookin-core" +for source_file in "$LOOKIN_CORE_DIR"/*.m "$LOOKIN_CORE_DIR"/Shim/*.m "$LOOKIN_SERVER_BASE_DIR"/*.m; do + [ -e "$source_file" ] || continue + xcrun clang -c -fobjc-arc -w -DSHOULD_COMPILE_LOOKIN_SERVER=1 \ + -mmacosx-version-min="$DEPLOYMENT_TARGET" \ + -I "$LOOKIN_CORE_DIR" -I "$LOOKIN_CORE_DIR/include" \ + -I "$LOOKIN_SERVER_BASE_DIR" \ + "$source_file" -o "$TMPDIR/lookin-core/$(basename "$source_file" .m).o" +done + +swiftc -parse-as-library \ + -target "$(uname -m)-apple-macos$DEPLOYMENT_TARGET" \ + -import-objc-header "$TEST_DIR/LKDashboardTests-Bridging-Header.h" \ + -Xcc -DSHOULD_COMPILE_LOOKIN_SERVER=1 \ + -D SHOULD_COMPILE_LOOKIN_SERVER -D LOOKIN_CORE_STANDALONE \ + -Xcc -I"$LOOKIN_CORE_DIR" -Xcc -I"$LOOKIN_CORE_DIR/include" \ + -Xcc -I"$LOOKIN_SERVER_BASE_DIR" \ + "$SOURCE_DIR/LKDashboardModification.swift" \ + "$SOURCE_DIR/LKEnumListRegistry.swift" \ + "$SOURCE_DIR/AttributeView/JSON/LKJSONAttributeItem.swift" \ + $(find "$LOOKIN_CORE_IMPL_DIR" -name '*.swift' | sort) \ + "$TEST_DIR/LKDashboardTests.swift" \ + "$TMPDIR"/lookin-core/*.o \ + -framework AppKit -framework QuartzCore \ + -o "$TMPDIR/dashboard-test" +"$TMPDIR/dashboard-test" "$TEST_DIR/Fixtures" + +echo "Dashboard tests passed" diff --git a/Scripts/test-gesture-models.sh b/Scripts/test-gesture-models.sh index b6bf0bfa..ba5ceb0d 100755 --- a/Scripts/test-gesture-models.sh +++ b/Scripts/test-gesture-models.sh @@ -4,7 +4,7 @@ set -eu ROOT="$(cd "$(dirname "$0")/.." && pwd)" GESTURE_TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/lookinside-gesture-models.XXXXXX")" trap 'rm -rf "$GESTURE_TEST_DIR"' EXIT -swiftc -parse-as-library -import-objc-header "$ROOT/Sources/LookinCore/LookinHitTargetSize.h" \ +swiftc -parse-as-library -import-objc-header "$ROOT/Sources/LookinCore/include/LookinHitTargetSize.h" \ "$ROOT/LookInside/GestureDebug/LKGestureCaptureModels.swift" \ "$ROOT/LookInside/GestureDebug/LKSuggestions.swift" \ "$ROOT/Tests/GestureDebug/LKGestureCaptureModelsTests.swift" -o "$GESTURE_TEST_DIR/gesture-models" diff --git a/Scripts/test-launch-toolbar-read.sh b/Scripts/test-launch-toolbar-read.sh new file mode 100755 index 00000000..60645549 --- /dev/null +++ b/Scripts/test-launch-toolbar-read.sh @@ -0,0 +1,82 @@ +#!/bin/sh +set -eu + +# Host-side tests for the Launch, Toolbar and Read modules. +# +# The logic under test lives in Foundation-only Swift files (the .lookin +# archive coding, the reader's screenshot lookup, the apps popover copy, the +# toolbar identifiers and steppers). They are compiled with the test file and +# the LookinCore objects into a standalone binary, the same way +# test-xcode-viewhierarchy.sh covers the Xcode capture converter. +# +# --make-fixture rebuilds Tests/Read/Fixtures/legacy-host.lookin with the +# Objective-C generator instead. The committed fixture was made from the +# encoding call of the Objective-C reader; do not regenerate it from the +# Swift code under test. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# A fresh directory per run: objects left over from an earlier layout (an +# Objective-C model file that is Swift now) would otherwise link twice. +WORK="${TMPDIR:-/tmp}/lookinside-launch-toolbar-read-tests.$$" +mkdir -p "$WORK/lookin-core" +trap 'rm -rf "$WORK"' EXIT + +SOURCE_DIR="$ROOT/LookInside" +TEST_DIR="$ROOT/Tests/Read" +LOOKIN_CORE_DIR="$ROOT/Sources/LookinCore" +LOOKIN_SERVER_BASE_DIR="$ROOT/Sources/LookinServerBase" +# The Swift `@objc @implementation` of the LookinCore model classes. +LOOKIN_CORE_IMPL_DIR="$ROOT/Sources/LookinCoreImpl" +DEPLOYMENT_TARGET=14.0 + +for source_file in "$LOOKIN_CORE_DIR"/*.m "$LOOKIN_CORE_DIR"/Shim/*.m "$LOOKIN_SERVER_BASE_DIR"/*.m; do + [ -e "$source_file" ] || continue + xcrun clang -c -fobjc-arc -w -DSHOULD_COMPILE_LOOKIN_SERVER=1 \ + -mmacosx-version-min="$DEPLOYMENT_TARGET" \ + -I "$LOOKIN_CORE_DIR" -I "$LOOKIN_CORE_DIR/include" \ + -I "$LOOKIN_SERVER_BASE_DIR" \ + "$source_file" -o "$WORK/lookin-core/$(basename "$source_file" .m).o" +done + +if [ "${1:-}" = "--make-fixture" ]; then + xcrun clang -c -fobjc-arc -DSHOULD_COMPILE_LOOKIN_SERVER=1 \ + -mmacosx-version-min="$DEPLOYMENT_TARGET" \ + -I "$LOOKIN_CORE_DIR" -I "$LOOKIN_CORE_DIR/include" \ + -I "$LOOKIN_SERVER_BASE_DIR" \ + "$TEST_DIR/LookinArchiveFixtureGenerator.m" -o "$WORK/fixture-generator.o" + # The model classes are Swift: swiftc links them (and the Swift runtime) + # with the Objective-C generator, whose main() is the entry point. + swiftc -parse-as-library \ + -target "$(uname -m)-apple-macos$DEPLOYMENT_TARGET" \ + -import-objc-header "$TEST_DIR/LaunchToolbarReadTests-Bridging-Header.h" \ + -Xcc -DSHOULD_COMPILE_LOOKIN_SERVER=1 \ + -D SHOULD_COMPILE_LOOKIN_SERVER -D LOOKIN_CORE_STANDALONE \ + -Xcc -I"$LOOKIN_CORE_DIR" -Xcc -I"$LOOKIN_CORE_DIR/include" \ + -Xcc -I"$LOOKIN_SERVER_BASE_DIR" \ + $(find "$LOOKIN_CORE_IMPL_DIR" -name '*.swift' | sort) \ + "$WORK/fixture-generator.o" "$WORK"/lookin-core/*.o \ + -framework AppKit -framework QuartzCore \ + -o "$WORK/fixture-generator" + "$WORK/fixture-generator" "$TEST_DIR/Fixtures/legacy-host.lookin" + exit 0 +fi + +swiftc -parse-as-library \ + -target "$(uname -m)-apple-macos$DEPLOYMENT_TARGET" \ + -import-objc-header "$TEST_DIR/LaunchToolbarReadTests-Bridging-Header.h" \ + -Xcc -DSHOULD_COMPILE_LOOKIN_SERVER=1 \ + -D SHOULD_COMPILE_LOOKIN_SERVER -D LOOKIN_CORE_STANDALONE \ + -Xcc -I"$LOOKIN_CORE_DIR" -Xcc -I"$LOOKIN_CORE_DIR/include" \ + -Xcc -I"$LOOKIN_SERVER_BASE_DIR" \ + "$SOURCE_DIR/Read/LookinArchiveCoding.swift" \ + "$SOURCE_DIR/Read/LKReadScreenshotLookup.swift" \ + "$SOURCE_DIR/Toolbar/LKToolbarRules.swift" \ + "$SOURCE_DIR/Launch/LKLaunchRules.swift" \ + $(find "$LOOKIN_CORE_IMPL_DIR" -name '*.swift' | sort) \ + "$TEST_DIR/LaunchToolbarReadTests.swift" \ + "$WORK"/lookin-core/*.o \ + -framework AppKit -framework QuartzCore \ + -o "$WORK/launch-toolbar-read-test" +"$WORK/launch-toolbar-read-test" "$TEST_DIR/Fixtures/legacy-host.lookin" + +echo "Launch / Toolbar / Read tests passed" diff --git a/Scripts/test-mcp-bridge.sh b/Scripts/test-mcp-bridge.sh deleted file mode 100755 index 01cc7c8e..00000000 --- a/Scripts/test-mcp-bridge.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/bin/sh -set -eu - -# Host-side MCPBridge tests. -# -# Same shape as test-security-gates.sh: compile the unit under test -# together with its test file into a standalone binary and run it. Only -# units that depend on Darwin / Foundation alone can be covered this -# way -- the routing services pull in AppKit, NSDocumentController and -# the ObjC bridge, so they are exercised through the end-to-end smoke -# test in docs/monorepo/mcp-smoke-test.md instead. - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TMPDIR="${TMPDIR:-/tmp}/lookinside-mcp-bridge-tests.$$" -mkdir -p "$TMPDIR" -trap 'rm -rf "$TMPDIR"' EXIT - -LISTEN_SOCKET="$ROOT/LookInside/MCPBridge/LKMCPBridgeListenSocket.swift" -LISTEN_SOCKET_TEST="$ROOT/Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift" - -swiftc -parse-as-library "$LISTEN_SOCKET" "$LISTEN_SOCKET_TEST" -o "$TMPDIR/listen-socket-test" -"$TMPDIR/listen-socket-test" - -SEARCH_QUERY="$ROOT/LookInside/MCPBridge/LKMCPBridgeSearchQuery.swift" -SEARCH_QUERY_TEST="$ROOT/Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift" - -swiftc -parse-as-library "$SEARCH_QUERY" "$SEARCH_QUERY_TEST" -o "$TMPDIR/search-query-test" -"$TMPDIR/search-query-test" - -# The identifier list needs LKMCPBridgeFrame.swift alongside it for -# LKMCPBridgeJSONValue; that file is Foundation-only, so it compiles here. -FRAME="$ROOT/LookInside/MCPBridge/LKMCPBridgeFrame.swift" -OBJECT_IDENTIFIER_LIST="$ROOT/LookInside/MCPBridge/LKMCPBridgeObjectIdentifierList.swift" -OBJECT_IDENTIFIER_LIST_TEST="$ROOT/Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift" - -swiftc -parse-as-library "$FRAME" "$OBJECT_IDENTIFIER_LIST" "$OBJECT_IDENTIFIER_LIST_TEST" -o "$TMPDIR/object-identifier-list-test" -"$TMPDIR/object-identifier-list-test" - -# Catch-clause ordering guard. -# -# `catch let error as NSError` matches *every* Swift error -- they all bridge -# to NSError -- so above the RACBridgeError clauses it swallows them and makes -# them dead code, silently degrading precise error codes into internalError. -# The routes cannot be unit-tested here (AppKit + the ObjC bridge come along), -# so this is a textual guard on a mistake that is easy to repeat every time a -# new route is added by copying an existing one. -ordering_failures=0 -for service in "$ROOT"/LookInside/MCPBridge/LKMCPBridge*Service.swift; do - nserror_line=$(grep -n "catch let error as NSError" "$service" | head -1 | cut -d: -f1) - racbridge_line=$(grep -n "catch RACBridgeError" "$service" | head -1 | cut -d: -f1) - if [ -n "$nserror_line" ] && [ -n "$racbridge_line" ] && [ "$nserror_line" -lt "$racbridge_line" ]; then - echo "FAIL: $(basename "$service"): 'catch let error as NSError' on line $nserror_line precedes 'catch RACBridgeError' on line $racbridge_line, which makes the RACBridgeError clauses unreachable" >&2 - ordering_failures=$((ordering_failures + 1)) - fi -done -if [ "$ordering_failures" -ne 0 ]; then - exit 1 -fi -echo "MCPBridge catch ordering guard passed" - -echo "MCPBridge tests passed" diff --git a/Scripts/test-release-workflow.sh b/Scripts/test-release-workflow.sh index 78ac1fb0..82ca75f6 100755 --- a/Scripts/test-release-workflow.sh +++ b/Scripts/test-release-workflow.sh @@ -29,17 +29,24 @@ assert_contains 'run-name: ${{ github.event.client_payload.release_id }}' assert_contains 'SOURCE_REF="${{ github.event.client_payload.source_ref }}"' assert_contains 'BUILD_NUMBER="${{ github.event.client_payload.build_number }}"' assert_contains 'INJECTOR_REF="${{ github.event.client_payload.injector_ref }}"' -assert_contains 'MCP_REF="${{ github.event.client_payload.mcp_ref }}"' assert_contains 'RELEASE_REF="${{ github.event.client_payload.release_ref }}"' assert_contains 'ref: ${{ needs.prepare.outputs.source_ref }}' assert_contains 'checkout --detach "${{ needs.prepare.outputs.injector_ref }}"' -assert_contains 'checkout --detach "${{ needs.prepare.outputs.mcp_ref }}"' +assert_absent 'LookInside-MCP' assert_contains 'token: ${{ secrets.LOOKINSIDE_WEB_RELEASE_TOKEN }}' assert_contains 'target_commitish: ${{ needs.prepare.outputs.source_ref }}' assert_contains 'if existing_target="$(gh api' -assert_contains 'ref: ${{ needs.prepare.outputs.release_ref }}' -assert_contains 'sync-auth-server-assets-to-web.sh' -assert_contains 'public/downloads/auth-server' +assert_contains 'sparse-checkout: sparkle' +assert_contains 'bash Scripts/run-gitleaks.sh .' +assert_contains 'bash Scripts/scan-private-keys.sh' +assert_contains 'bash app/Scripts/scan-private-keys.sh dist' +assert_contains 'bash app/Scripts/scan-private-keys.sh web/public' +assert_contains 'bash Scripts/check-host-release-clean.sh --release-strict' +assert_absent 'check-host-release-clean.sh --local-build' +assert_absent 'sync-auth-server-assets-to-web.sh' +assert_absent 'public/downloads/auth-server' +assert_absent 'lookinside-auth-server' +assert_absent 'release-tools' assert_contains 'Using parent-resolved Xcode version ${VERSION} (${BUILD_NUMBER}).' assert_contains 'without leading zeroes' assert_absent "workflow_dispatch:" @@ -47,6 +54,14 @@ assert_absent "tags:" assert_absent '2>/dev/null || true' assert_absent 'APPCAST_URL="https://lookinside-app.com/appcast.xml"' +for workflow in "$root_dir"/.github/workflows/*.yml; do + if grep -F -- 'SECRETS_SOURCE_REPO' "$workflow" >/dev/null && + ! grep -F -- 'sparse-checkout: sparkle' "$workflow" >/dev/null; then + echo "$(basename "$workflow") checks out the secrets repository without sparse-checkout: sparkle" >&2 + exit 1 + fi +done + work_dir="$(mktemp -d "${TMPDIR:-/tmp}/lookinside-host-release-test.XXXXXX")" trap 'rm -rf "$work_dir"' EXIT OUTPUT_PATH="$work_dir/release.xcconfig" bash "$xcconfig_writer" --version 2.3.13 --build-number 46 >/dev/null diff --git a/Scripts/test-scan-private-keys.sh b/Scripts/test-scan-private-keys.sh new file mode 100755 index 00000000..fdaa4cfb --- /dev/null +++ b/Scripts/test-scan-private-keys.sh @@ -0,0 +1,261 @@ +#!/bin/bash +# Tests scan-private-keys.sh with throwaway keys generated for this run only. +# +# This script is kept identical in every repository that publishes artifacts. + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +scanner="$script_dir/scan-private-keys.sh" + +work_dir="$(mktemp -d "${TMPDIR:-/tmp}/scan-private-keys-test.XXXXXX")" +trap 'rm -rf "$work_dir"' EXIT +umask 077 + +keys="$work_dir/keys" +mkdir -p "$keys" +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$keys/pkcs8.pem" 2>/dev/null +openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out "$keys/ec-pkcs8.pem" 2>/dev/null +openssl pkey -in "$keys/ec-pkcs8.pem" -traditional -out "$keys/ec.pem" 2>/dev/null +openssl pkcs8 -topk8 -in "$keys/pkcs8.pem" -passout pass:throwaway -out "$keys/encrypted.pem" 2>/dev/null +openssl pkey -in "$keys/pkcs8.pem" -pubout -out "$keys/public.pem" 2>/dev/null +openssl req -new -x509 -key "$keys/pkcs8.pem" -subj "/CN=scan-test" -days 1 -out "$keys/cert.pem" 2>/dev/null +ssh-keygen -q -t ed25519 -N "" -C scan-test -f "$keys/openssh" >/dev/null +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "$keys/rsa4096.pem" 2>/dev/null +openssl genpkey -algorithm ED25519 -out "$keys/ed25519.pem" 2>/dev/null +# DER forms of the same keys, as an app or a script could embed them. +openssl pkcs8 -topk8 -nocrypt -in "$keys/pkcs8.pem" -outform DER -out "$keys/pkcs8.der" 2>/dev/null +openssl pkey -in "$keys/pkcs8.pem" -traditional -outform DER -out "$keys/pkcs1.der" 2>/dev/null +openssl pkcs8 -topk8 -nocrypt -in "$keys/ec-pkcs8.pem" -outform DER -out "$keys/ec-pkcs8.der" 2>/dev/null +openssl pkey -in "$keys/ec-pkcs8.pem" -traditional -outform DER -out "$keys/ec.der" 2>/dev/null +openssl pkey -in "$keys/ed25519.pem" -outform DER -out "$keys/ed25519.der" 2>/dev/null +openssl pkcs8 -topk8 -nocrypt -in "$keys/rsa4096.pem" -outform DER -out "$keys/rsa4096.der" 2>/dev/null +openssl pkcs8 -topk8 -in "$keys/pkcs8.pem" -passout pass:throwaway -outform DER -out "$keys/encrypted.der" 2>/dev/null +openssl pkey -in "$keys/pkcs8.pem" -pubout -outform DER -out "$keys/public.der" 2>/dev/null +openssl x509 -in "$keys/cert.pem" -outform DER -out "$keys/cert.der" 2>/dev/null + +# Base64 helpers: the body of a PEM file, a DER file on one line, and "/" +# escaped as "\/" the way JSON encoders may write it. +pem_body() { grep -v -- '-----' "$1" | tr -d '\n'; } +der_base64() { openssl base64 -A -in "$1"; } +json_slashes() { sed 's#/#\\/#g'; } + +failures=0 + +expect_clean() { + local label="$1" + shift + if ! bash "$scanner" "$@" >/dev/null 2>"$work_dir/stderr"; then + echo "FAIL ($label): clean input was rejected" >&2 + cat "$work_dir/stderr" >&2 + failures=$((failures + 1)) + return + fi + echo "ok - $label" +} + +expect_hit() { + local label="$1" + local needle="$2" + shift 2 + if bash "$scanner" "$@" >/dev/null 2>"$work_dir/stderr"; then + echo "FAIL ($label): private key was not detected" >&2 + failures=$((failures + 1)) + return + fi + if ! grep -F -- "$needle" "$work_dir/stderr" >/dev/null; then + echo "FAIL ($label): report does not name $needle" >&2 + cat "$work_dir/stderr" >&2 + failures=$((failures + 1)) + return + fi + if grep -E '[A-Za-z0-9+/]{40}' "$work_dir/stderr" >/dev/null; then + echo "FAIL ($label): report leaks key material" >&2 + failures=$((failures + 1)) + return + fi + echo "ok - $label" +} + +make_zip() { + local output="$1" + local source="$2" + (cd "$(dirname "$source")" && python3 -m zipfile -c "$output" "$(basename "$source")") +} + +# A clean product: a bundle with a binary, a public key, a certificate, and +# source that only names PEM headers. +clean="$work_dir/clean" +mkdir -p "$clean/Clean.app/Contents/MacOS" "$clean/Clean.app/Contents/Resources" +head -c 65536 /dev/urandom >"$clean/Clean.app/Contents/MacOS/Clean" +cp "$keys/public.pem" "$keys/cert.pem" "$clean/Clean.app/Contents/Resources/" +cat >"$clean/pem.js" <<'JS' +const header = "-----BEGIN " + type + "-----"; +if (line === "-----BEGIN RSA PRIVATE KEY-----") { parsePrivateKey(); } +const label = "-----BEGIN PRIVATE KEY-----\n"; +JS +# DER and base64 certificates and public keys are not private keys, and a +# key's well-known base64 prefix alone (as in documentation) is not a key. +cp "$keys/public.der" "$keys/cert.der" "$clean/Clean.app/Contents/Resources/" +{ + head -c 4096 /dev/urandom + cat "$keys/cert.der" "$keys/public.der" + head -c 4096 /dev/urandom +} >"$clean/Clean.app/Contents/MacOS/Helper" +printf '{"certificate":"%s","publicKey":"%s"}\n' \ + "$(der_base64 "$keys/cert.der" | json_slashes)" \ + "$(der_base64 "$keys/public.der" | json_slashes)" >"$clean/Clean.app/Contents/Resources/keys.json" +printf 'RSA PKCS#8 keys start with %s, PKCS#1 keys with %s.\n' \ + "$(der_base64 "$keys/pkcs8.der" | cut -c1-48)" \ + "$(der_base64 "$keys/pkcs1.der" | cut -c1-24)" >"$clean/notes.md" +ln -s Contents/MacOS/Clean "$clean/Clean.app/link" +make_zip "$work_dir/clean.zip" "$clean/Clean.app" +tar -czf "$work_dir/clean.tar.gz" -C "$clean" Clean.app +expect_clean "clean directory passes" "$clean" +expect_clean "clean zip and tar.gz pass" "$work_dir/clean.zip" "$work_dir/clean.tar.gz" + +# A plain PEM file in a directory. +plain="$work_dir/plain" +mkdir -p "$plain/nested" +cp "$keys/pkcs8.pem" "$plain/nested/server.pem" +expect_hit "plain PKCS#8 key in a directory" "BEGIN PRIVATE KEY" "$plain" + +# A throwaway key inside a zip, under a harmless name. +zipped="$work_dir/zipped" +mkdir -p "$zipped/Payload.app" +cp "$keys/ec.pem" "$zipped/Payload.app/config.bin" +make_zip "$work_dir/app.zip" "$zipped/Payload.app" +expect_hit "EC key inside a zip" "app.zip!Payload.app/config.bin: BEGIN EC PRIVATE KEY" "$work_dir/app.zip" + +# An encrypted key inside an xcframework, zipped, then nested in a tar.gz. +xcf="$work_dir/xcf" +mkdir -p "$xcf/Kit.xcframework/macos-arm64/Kit.framework/Resources" +cp "$keys/encrypted.pem" "$xcf/Kit.xcframework/macos-arm64/Kit.framework/Resources/data" +make_zip "$work_dir/Kit.xcframework.zip" "$xcf/Kit.xcframework" +mkdir -p "$work_dir/outer" +cp "$work_dir/Kit.xcframework.zip" "$work_dir/outer/" +tar -czf "$work_dir/release.tar.gz" -C "$work_dir/outer" Kit.xcframework.zip +expect_hit "encrypted key in an xcframework directory" "BEGIN ENCRYPTED PRIVATE KEY" "$xcf" +expect_hit "encrypted key in a zip nested in a tar.gz" \ + "release.tar.gz!Kit.xcframework.zip!Kit.xcframework/macos-arm64/Kit.framework/Resources/data" \ + "$work_dir/release.tar.gz" + +# An OpenSSH key in a gzip file. +gzip -c "$keys/openssh" >"$work_dir/blob.gz" +expect_hit "OpenSSH key in a gzip file" "BEGIN OPENSSH PRIVATE KEY" "$work_dir/blob.gz" + +# A key embedded in a JavaScript bundle as a string with \n escapes. +bundle="$work_dir/bundle" +mkdir -p "$bundle" +python3 - "$keys/pkcs8.pem" >"$bundle/worker.js" <<'PY' +import json +import sys + +pem = open(sys.argv[1]).read() +print("var a=1;var key=" + json.dumps(pem) + ";export default key;") +PY +expect_hit "escaped key in a JavaScript bundle" "worker.js: BEGIN PRIVATE KEY" "$bundle" + +# A key inside a binary file next to other bytes. +binary="$work_dir/binary" +mkdir -p "$binary" +{ + head -c 4096 /dev/urandom + cat "$keys/pkcs8.pem" + head -c 4096 /dev/urandom +} >"$binary/Host" +expect_hit "key embedded in a binary" "BEGIN PRIVATE KEY" "$binary" + +# Raw DER keys with no PEM header, inside a binary or as a file. +embed_der() { + local der="$1" + local output="$2" + mkdir -p "$(dirname "$output")" + { + head -c 4096 /dev/urandom + cat "$der" + head -c 4096 /dev/urandom + } >"$output" +} +der="$work_dir/der" +embed_der "$keys/pkcs8.der" "$der/pkcs8/Host" +expect_hit "DER PKCS#8 RSA key in a binary" "Host: PKCS#8 RSA private key (DER)" "$der/pkcs8" +embed_der "$keys/pkcs1.der" "$der/pkcs1/Host" +expect_hit "DER PKCS#1 RSA key in a binary" "Host: PKCS#1 RSA private key (DER)" "$der/pkcs1" +embed_der "$keys/ec.der" "$der/sec1/Host" +expect_hit "DER SEC1 EC key in a binary" "Host: SEC1 EC private key (DER)" "$der/sec1" +embed_der "$keys/ec-pkcs8.der" "$der/ec-pkcs8/Host" +expect_hit "DER PKCS#8 EC key in a binary" "Host: PKCS#8 EC private key (DER)" "$der/ec-pkcs8" +embed_der "$keys/ed25519.der" "$der/ed25519/Host" +expect_hit "DER PKCS#8 Ed25519 key in a binary" "Host: PKCS#8 Ed25519 private key (DER)" "$der/ed25519" +embed_der "$keys/encrypted.der" "$der/encrypted/Host" +expect_hit "DER encrypted PKCS#8 key in a binary" "Host: encrypted PKCS#8 private key (DER)" "$der/encrypted" +cp "$keys/rsa4096.der" "$der/key.der" +make_zip "$work_dir/der.zip" "$der/key.der" +expect_hit "DER RSA-4096 key file inside a zip" "der.zip!key.der: PKCS#8 RSA private key (DER)" "$work_dir/der.zip" + +# Base64 keys with no PEM header: one line, wrapped, indented, JSON-escaped. +b64="$work_dir/base64" +mkdir -p "$b64" +printf 'let key = "%s"\n' "$(der_base64 "$keys/rsa4096.der")" >"$b64/Key.swift" +expect_hit "one-line base64 RSA-4096 key in source" "Key.swift: PKCS#8 RSA private key (base64)" "$b64/Key.swift" +printf '{"key":"%s"}\n' "$(der_base64 "$keys/pkcs1.der" | fold -w 64 | json_slashes | awk '{ printf "%s\\n", $0 }')" >"$b64/config.json" +expect_hit "wrapped base64 PKCS#1 key with JSON escapes" "config.json: PKCS#1 RSA private key (base64)" "$b64/config.json" +{ + printf '\n\tKey\n\t\n' + der_base64 "$keys/ec.der" | fold -w 36 | sed 's/^/\t\t/' + printf '\t\n\n' +} >"$b64/Info.plist" +expect_hit "indented base64 SEC1 EC key in a plist" "Info.plist: SEC1 EC private key (base64)" "$b64/Info.plist" +printf 'export const k = "%s";\n' "$(der_base64 "$keys/ed25519.der")" >"$b64/ed.js" +expect_hit "base64 Ed25519 key in a script" "ed.js: PKCS#8 Ed25519 private key (base64)" "$b64/ed.js" +printf 'k=%s\n' "$(der_base64 "$keys/encrypted.der")" >"$b64/encrypted.env" +expect_hit "base64 encrypted PKCS#8 key" "encrypted.env: encrypted PKCS#8 private key (base64)" "$b64/encrypted.env" +printf 'ssh=%s\n' "$(pem_body "$keys/openssh")" >"$b64/ssh.env" +expect_hit "base64 OpenSSH key body" "ssh.env: OpenSSH private key (base64)" "$b64/ssh.env" +mkdir -p "$work_dir/openssh-binary" +{ + head -c 1024 /dev/urandom + pem_body "$keys/openssh" | openssl base64 -d -A + head -c 1024 /dev/urandom +} >"$work_dir/openssh-binary/blob" +expect_hit "binary OpenSSH key body" "blob: OpenSSH private key (binary)" "$work_dir/openssh-binary" + +# A PEM key in JSON with "/" written as "\/" inside the first 40 data +# characters. EC keys are cheap, so generate until one has a "/" there. +first_data_has_slash() { sed -n 2p "$1" | cut -c1-40 | grep -q /; } +for _ in $(seq 1 200); do + openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 2>/dev/null | + openssl pkey -traditional -out "$keys/slash.pem" 2>/dev/null + if first_data_has_slash "$keys/slash.pem"; then + break + fi +done +if first_data_has_slash "$keys/slash.pem"; then + mkdir -p "$work_dir/escaped" + printf '{"key":"%s"}\n' "$(json_slashes <"$keys/slash.pem" | awk '{ printf "%s\\n", $0 }')" >"$work_dir/escaped/keys.json" + expect_hit "JSON-escaped PEM key with \\/" "keys.json: BEGIN EC PRIVATE KEY" "$work_dir/escaped" +else + echo "FAIL (setup): no EC key had a \"/\" in its first 40 data characters" >&2 + failures=$((failures + 1)) +fi + +# One clean and one dirty path: the scan still fails. +expect_hit "any dirty path fails the run" "BEGIN PRIVATE KEY" "$clean" "$plain" + +# A corrupt zip cannot be shown to be clean. +printf 'PK\003\004not really a zip' >"$work_dir/corrupt.zip" +expect_hit "corrupt zip fails closed" "unreadable zip" "$work_dir/corrupt.zip" + +if bash "$scanner" >/dev/null 2>&1; then + echo "FAIL (usage): scanner without paths passed" >&2 + failures=$((failures + 1)) +else + echo "ok - scanner without paths is a usage error" +fi + +if ((failures > 0)); then + echo "scan-private-keys tests failed: $failures" >&2 + exit 1 +fi +echo "scan-private-keys tests passed" diff --git a/Scripts/test-security-gates.sh b/Scripts/test-security-gates.sh index 5184d1ad..00ca31b6 100755 --- a/Scripts/test-security-gates.sh +++ b/Scripts/test-security-gates.sh @@ -6,46 +6,9 @@ TMPDIR="${TMPDIR:-/tmp}/lookinside-security-tests.$$" mkdir -p "$TMPDIR" trap 'rm -rf "$TMPDIR"' EXIT -POLICY="$ROOT/LookInside/SwiftUISupport/LKSwiftUISupportAuthServerPathPolicy.swift" -PATH_POLICY_TEST="$ROOT/Tests/Security/LKSwiftUISupportAuthServerPathPolicyTests.swift" -LOCAL_LICENSE_PROBE="$ROOT/LookInside/SwiftUISupport/LKSwiftUISupportLocalLicenseProbe.swift" -LOCAL_LICENSE_PROBE_TEST="$ROOT/Tests/Security/LKSwiftUISupportLocalLicenseProbeTests.swift" -INSTALLER="$ROOT/LookInside/SwiftUISupport/LKSwiftUISupportInstaller.swift" -LOGGER="$ROOT/LookInside/SwiftUISupport/LKSwiftUISupportLogger.swift" -COMMON_INSTALLER_ARCHIVE="$ROOT/LookInside/Common/Installer/LKInstallerArchiveExtractor.swift" -COMMON_INSTALLER_CANCELLATION="$ROOT/LookInside/Common/Installer/LKInstallerCancellation.swift" -COMMON_INSTALLER_CODE_SIGNATURE="$ROOT/LookInside/Common/Installer/LKInstallerCodeSignature.swift" -COMMON_INSTALLER_DOWNLOADER="$ROOT/LookInside/Common/Installer/LKInstallerDownloader.swift" -COMMON_INSTALLER_FILESYSTEM="$ROOT/LookInside/Common/Installer/LKInstallerFilesystem.swift" -COMMON_INSTALLER_LOGGER="$ROOT/LookInside/Common/Installer/LKInstallerLogger.swift" -COMMON_INSTALLER_PROGRESS="$ROOT/LookInside/Common/Installer/LKInstallerProgressWindowController.swift" -INSTALLER_CANCELLATION_TEST="$ROOT/Tests/Security/LKSwiftUISupportInstallerCancellationTests.swift" INJECTION_START_GATE="$ROOT/LookInside/Injection/LKInjectionStartGate.swift" INJECTION_START_GATE_TEST="$ROOT/Tests/Security/LKInjectionStartGateTests.swift" -swiftc -parse-as-library "$POLICY" "$PATH_POLICY_TEST" -o "$TMPDIR/path-policy-release" -"$TMPDIR/path-policy-release" - -swiftc -D DEBUG -parse-as-library "$POLICY" "$PATH_POLICY_TEST" -o "$TMPDIR/path-policy-debug" -"$TMPDIR/path-policy-debug" - -swiftc -parse-as-library "$LOCAL_LICENSE_PROBE" "$LOCAL_LICENSE_PROBE_TEST" -o "$TMPDIR/local-license-probe-test" -"$TMPDIR/local-license-probe-test" "$TMPDIR" - -swiftc -parse-as-library \ - "$LOGGER" \ - "$COMMON_INSTALLER_LOGGER" \ - "$COMMON_INSTALLER_CANCELLATION" \ - "$COMMON_INSTALLER_ARCHIVE" \ - "$COMMON_INSTALLER_CODE_SIGNATURE" \ - "$COMMON_INSTALLER_DOWNLOADER" \ - "$COMMON_INSTALLER_FILESYSTEM" \ - "$COMMON_INSTALLER_PROGRESS" \ - "$INSTALLER" \ - "$INSTALLER_CANCELLATION_TEST" \ - -o "$TMPDIR/installer-cancellation-test" -"$TMPDIR/installer-cancellation-test" - swiftc -parse-as-library "$INJECTION_START_GATE" "$INJECTION_START_GATE_TEST" -o "$TMPDIR/injection-start-gate-test" "$TMPDIR/injection-start-gate-test" diff --git a/Scripts/test-xcode-viewhierarchy.sh b/Scripts/test-xcode-viewhierarchy.sh index 8fe980e7..48713c1d 100755 --- a/Scripts/test-xcode-viewhierarchy.sh +++ b/Scripts/test-xcode-viewhierarchy.sh @@ -3,11 +3,11 @@ set -eu # Host-side tests for reading Xcode-exported .viewhierarchy documents. # -# Same shape as test-mcp-bridge.sh: compile the unit under test together with -# its test file into a standalone binary and run it. The parsing layer is -# deliberately Foundation-only so it can be covered this way; the pixel -# recovery and conversion layers pull in QuartzCore and the ObjC model and are -# exercised against real captures instead. +# Compile the unit under test together with its test file into a standalone +# binary and run it. The parsing layer is deliberately Foundation-only so it +# can be covered this way; the pixel recovery and conversion layers pull in +# QuartzCore and the ObjC model and are exercised against real captures +# instead. ROOT="$(cd "$(dirname "$0")/.." && pwd)" TMPDIR="${TMPDIR:-/tmp}/lookinside-xcode-viewhierarchy-tests.$$" @@ -61,14 +61,17 @@ swiftc -parse-as-library \ LOOKIN_CORE_DIR="$ROOT/Sources/LookinCore" # LookinDisplayItem pulls one header from the server base sources. LOOKIN_SERVER_BASE_DIR="$ROOT/Sources/LookinServerBase" +# The Swift `@objc @implementation` of the LookinCore model classes. +LOOKIN_CORE_IMPL_DIR="$ROOT/Sources/LookinCoreImpl" # The app's deployment target; against the bare SDK the model's window # capture call is already obsoleted. DEPLOYMENT_TARGET=14.0 mkdir -p "$TMPDIR/lookin-core" -for source_file in "$LOOKIN_CORE_DIR"/*.m "$LOOKIN_CORE_DIR"/Category/*.m "$LOOKIN_SERVER_BASE_DIR"/*.m; do +for source_file in "$LOOKIN_CORE_DIR"/*.m "$LOOKIN_CORE_DIR"/Shim/*.m "$LOOKIN_SERVER_BASE_DIR"/*.m; do + [ -e "$source_file" ] || continue xcrun clang -c -fobjc-arc -w -DSHOULD_COMPILE_LOOKIN_SERVER=1 \ -mmacosx-version-min="$DEPLOYMENT_TARGET" \ - -I "$LOOKIN_CORE_DIR" -I "$LOOKIN_CORE_DIR/include" -I "$LOOKIN_CORE_DIR/Category" \ + -I "$LOOKIN_CORE_DIR" -I "$LOOKIN_CORE_DIR/include" \ -I "$LOOKIN_SERVER_BASE_DIR" \ "$source_file" -o "$TMPDIR/lookin-core/$(basename "$source_file" .m).o" done @@ -76,7 +79,8 @@ swiftc -parse-as-library \ -target "$(uname -m)-apple-macos$DEPLOYMENT_TARGET" \ -import-objc-header "$TEST_DIR/LKXcodeViewHierarchyConverterTests-Bridging-Header.h" \ -Xcc -DSHOULD_COMPILE_LOOKIN_SERVER=1 \ - -Xcc -I"$LOOKIN_CORE_DIR" -Xcc -I"$LOOKIN_CORE_DIR/include" -Xcc -I"$LOOKIN_CORE_DIR/Category" \ + -D SHOULD_COMPILE_LOOKIN_SERVER -D LOOKIN_CORE_STANDALONE \ + -Xcc -I"$LOOKIN_CORE_DIR" -Xcc -I"$LOOKIN_CORE_DIR/include" \ -Xcc -I"$LOOKIN_SERVER_BASE_DIR" \ "$SOURCE_DIR/LKXcodeViewHierarchyGzip.swift" \ "$SOURCE_DIR/LKXcodeViewHierarchyValue.swift" \ @@ -88,6 +92,7 @@ swiftc -parse-as-library \ "$SOURCE_DIR/LKXcodeViewHierarchyAttributeCatalog.swift" \ "$SOURCE_DIR/LKXcodeViewHierarchyAttributes.swift" \ "$SOURCE_DIR/LKXcodeViewHierarchyConverter.swift" \ + $(find "$LOOKIN_CORE_IMPL_DIR" -name '*.swift' | sort) \ "$TEST_DIR/LKXcodeViewHierarchyConverterTests.swift" \ "$TMPDIR"/lookin-core/*.o \ -framework AppKit -framework QuartzCore \ diff --git a/Scripts/test.sh b/Scripts/test.sh index 66c55bab..a6d4a36f 100755 --- a/Scripts/test.sh +++ b/Scripts/test.sh @@ -60,7 +60,6 @@ run_xcode_build() { run_command "sync derived source" bash Scripts/sync-derived-source.sh run_command "gesture capture model tests" bash Scripts/test-gesture-models.sh run_swift_build "swift build" build -run_swift_build "swift build target=LookinServer" build -c debug --target LookinServer run_xcode_build "LookInside" "Debug" echo "[*] all tests passed" diff --git a/Sources/LookinCore/Category/CALayer+Lookin.h b/Sources/LookinCore/Category/CALayer+Lookin.h deleted file mode 100644 index 815459a7..00000000 --- a/Sources/LookinCore/Category/CALayer+Lookin.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// CALayer+Lookin.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import - -@interface CALayer (Lookin) - -- (void)lookin_removeImplicitAnimations; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/CALayer+Lookin.m b/Sources/LookinCore/Category/CALayer+Lookin.m deleted file mode 100644 index e756fc49..00000000 --- a/Sources/LookinCore/Category/CALayer+Lookin.m +++ /dev/null @@ -1,70 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// CALayer+Lookin.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - - - -#import "CALayer+Lookin.h" - -@implementation CALayer (Lookin) - -- (void)lookin_removeImplicitAnimations { - NSMutableDictionary> *actions = @{NSStringFromSelector(@selector(bounds)): [NSNull null], - NSStringFromSelector(@selector(position)): [NSNull null], - NSStringFromSelector(@selector(zPosition)): [NSNull null], - NSStringFromSelector(@selector(anchorPoint)): [NSNull null], - NSStringFromSelector(@selector(anchorPointZ)): [NSNull null], - NSStringFromSelector(@selector(transform)): [NSNull null], - NSStringFromSelector(@selector(sublayerTransform)): [NSNull null], - NSStringFromSelector(@selector(masksToBounds)): [NSNull null], - NSStringFromSelector(@selector(contents)): [NSNull null], - NSStringFromSelector(@selector(contentsRect)): [NSNull null], - NSStringFromSelector(@selector(contentsScale)): [NSNull null], - NSStringFromSelector(@selector(contentsCenter)): [NSNull null], - NSStringFromSelector(@selector(minificationFilterBias)): [NSNull null], - NSStringFromSelector(@selector(backgroundColor)): [NSNull null], - NSStringFromSelector(@selector(cornerRadius)): [NSNull null], - NSStringFromSelector(@selector(borderWidth)): [NSNull null], - NSStringFromSelector(@selector(borderColor)): [NSNull null], - NSStringFromSelector(@selector(opacity)): [NSNull null], - NSStringFromSelector(@selector(compositingFilter)): [NSNull null], - NSStringFromSelector(@selector(filters)): [NSNull null], - NSStringFromSelector(@selector(backgroundFilters)): [NSNull null], - NSStringFromSelector(@selector(shouldRasterize)): [NSNull null], - NSStringFromSelector(@selector(rasterizationScale)): [NSNull null], - NSStringFromSelector(@selector(shadowColor)): [NSNull null], - NSStringFromSelector(@selector(shadowOpacity)): [NSNull null], - NSStringFromSelector(@selector(shadowOffset)): [NSNull null], - NSStringFromSelector(@selector(shadowRadius)): [NSNull null], - NSStringFromSelector(@selector(shadowPath)): [NSNull null]}.mutableCopy; - - if ([self isKindOfClass:[CAShapeLayer class]]) { - [actions addEntriesFromDictionary:@{NSStringFromSelector(@selector(path)): [NSNull null], - NSStringFromSelector(@selector(fillColor)): [NSNull null], - NSStringFromSelector(@selector(strokeColor)): [NSNull null], - NSStringFromSelector(@selector(strokeStart)): [NSNull null], - NSStringFromSelector(@selector(strokeEnd)): [NSNull null], - NSStringFromSelector(@selector(lineWidth)): [NSNull null], - NSStringFromSelector(@selector(miterLimit)): [NSNull null], - NSStringFromSelector(@selector(lineDashPhase)): [NSNull null]}]; - } - - if ([self isKindOfClass:[CAGradientLayer class]]) { - [actions addEntriesFromDictionary:@{NSStringFromSelector(@selector(colors)): [NSNull null], - NSStringFromSelector(@selector(locations)): [NSNull null], - NSStringFromSelector(@selector(startPoint)): [NSNull null], - NSStringFromSelector(@selector(endPoint)): [NSNull null]}]; - } - - self.actions = actions; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/Color+Lookin.h b/Sources/LookinCore/Category/Color+Lookin.h deleted file mode 100644 index 51ddc81c..00000000 --- a/Sources/LookinCore/Category/Color+Lookin.h +++ /dev/null @@ -1,29 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Color+Lookin.h -// LookinShared -// -// Created by 李凯 on 2022/4/2. -// - -#import - -#if TARGET_OS_IPHONE - -#elif TARGET_OS_OSX - -#import - -@interface NSColor (Lookin) - -+ (instancetype)lookin_colorFromRGBAComponents:(NSArray *)components; - -- (NSArray *)lookin_rgbaComponents; - -@end - -#endif - - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/Color+Lookin.m b/Sources/LookinCore/Category/Color+Lookin.m deleted file mode 100644 index eda9e651..00000000 --- a/Sources/LookinCore/Category/Color+Lookin.m +++ /dev/null @@ -1,42 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Color+Lookin.m -// LookinShared -// -// Created by 李凯 on 2022/4/2. -// - -#import "Image+Lookin.h" - -#if TARGET_OS_IPHONE - -#elif TARGET_OS_OSX - -@implementation NSColor (Lookin) - -+ (instancetype)lookin_colorFromRGBAComponents:(NSArray *)components { - if (!components) { - return nil; - } - if (components.count != 4) { - NSAssert(NO, @""); - return nil; - } - NSColor *color = [NSColor colorWithRed:components[0].doubleValue green:components[1].doubleValue blue:components[2].doubleValue alpha:components[3].doubleValue]; - return color; -} - -- (NSArray *)lookin_rgbaComponents { - NSColor *rgbColor = [self colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; - CGFloat r, g, b, a; - [rgbColor getRed:&r green:&g blue:&b alpha:&a]; - NSArray *rgba = @[@(r), @(g), @(b), @(a)]; - return rgba; -} - -@end - -#endif - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/Image+Lookin.h b/Sources/LookinCore/Category/Image+Lookin.h deleted file mode 100644 index b3a1d48b..00000000 --- a/Sources/LookinCore/Category/Image+Lookin.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Image+Lookin.h -// LookinShared -// -// Created by 李凯 on 2022/4/2. -// - -#import - -#if TARGET_OS_IPHONE - -#elif TARGET_OS_OSX - -#import - -@interface NSImage (LookinClient) - -- (NSData *)lookin_data; - -@end - -#endif - - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/Image+Lookin.m b/Sources/LookinCore/Category/Image+Lookin.m deleted file mode 100644 index 4f651687..00000000 --- a/Sources/LookinCore/Category/Image+Lookin.m +++ /dev/null @@ -1,41 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Image+Lookin.m -// LookinShared -// -// Created by 李凯 on 2022/4/2. -// - -#import "Image+Lookin.h" - -#if TARGET_OS_IPHONE - -#elif TARGET_OS_OSX - -@implementation NSImage (LookinClient) - -- (NSData *)lookin_data { - if (self.representations.count == 0) { - return nil; - } - // 优先尝试直接使用 bitmap representations(适用于光栅图像)。 - NSData *data = [NSBitmapImageRep representationOfImageRepsInArray:self.representations usingType:NSBitmapImageFileTypePNG properties:@{}]; - if (data) { - return data; - } - // 兜底方案:渲染到 bitmap context(处理矢量图、SF Symbols 等)。 - CGRect proposedRect = CGRectMake(0, 0, self.size.width, self.size.height); - CGImageRef cgImage = [self CGImageForProposedRect:&proposedRect context:nil hints:nil]; - if (!cgImage) { - return nil; - } - NSBitmapImageRep *bitmapRep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage]; - return [bitmapRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; -} - -@end - -#endif - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSArray+Lookin.h b/Sources/LookinCore/Category/NSArray+Lookin.h deleted file mode 100644 index a66e02a9..00000000 --- a/Sources/LookinCore/Category/NSArray+Lookin.h +++ /dev/null @@ -1,72 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSArray+Lookin.h -// Lookin -// -// Created by Li Kai on 2018/9/3. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import -#import - -@interface NSArray<__covariant ValueType> (Lookin) - -/** - 初始化一个新的 NSArray 并返回,新数组的长度为 count,如果当前数组长度比 count 小则会补充新元素(被补充的元素由 addBlock 返回),如果当前数组长度比 count 大则会舍弃多余的元素,被舍弃的元素会作为参数传入 removeBlock。最终,新数组的所有元素均会作为参数被传入 doBlock。 - */ -- (NSArray *)lookin_resizeWithCount:(NSUInteger)count add:(ValueType (^)(NSUInteger idx))addBlock remove:(void (^)(NSUInteger idx, ValueType obj))removeBlock doNext:(void (^)(NSUInteger idx, ValueType obj))doBlock __attribute__((warn_unused_result)); - -+ (NSArray *)lookin_arrayWithCount:(NSUInteger)count block:(id (^)(NSUInteger idx))block; - -/** - 检查 index 位置是否有元素存在 - */ -- (BOOL)lookin_hasIndex:(NSInteger)index; - -- (NSArray *)lookin_map:(id (^)(NSUInteger idx, ValueType value))block; - -- (NSArray *)lookin_filter:(BOOL (^)( ValueType obj))block; - -- (ValueType)lookin_firstFiltered:(BOOL (^)(ValueType obj))block; - -/// 返回最后一个 block 返回 YES 的元素 -- (ValueType)lookin_lastFiltered:(BOOL (^)(ValueType obj))block; - -- (id)lookin_reduce:(id (^)(id accumulator, NSUInteger idx, ValueType obj))block; - -- (CGFloat)lookin_reduceCGFloat:(CGFloat (^)(CGFloat accumulator, NSUInteger idx, ValueType obj))block initialAccumlator:(CGFloat)initialAccumlator; -- (NSInteger)lookin_reduceInteger:(NSInteger (^)(NSInteger accumulator, NSUInteger idx, ValueType obj))block initialAccumlator:(NSInteger)initialAccumlator; - -- (BOOL)lookin_all:(BOOL (^)(ValueType obj))block; - -- (BOOL)lookin_any:(BOOL (^)(ValueType obj))block; - -- (NSArray *)lookin_arrayByRemovingObject:(ValueType)obj; - -- (NSArray *)lookin_nonredundantArray; - -- (ValueType)lookin_safeObjectAtIndex:(NSInteger)idx; - -/// 字符串长度从短到长,即 length 小的字符串的 idx 更小 -- (NSArray *)lookin_sortedArrayByStringLength; - -@end - -@interface NSMutableArray (Lookin) - -/** - 如果当前数组长度比 count 小则会补充新元素(被补充的元素由 addBlock 返回),如果当前数组长度比 count 大则多余的元素会被作为参数传入 notDequeued。然后从 idx 为 0 算起,前 count 个元素会被作为参数传入 doBlock - */ -- (void)lookin_dequeueWithCount:(NSUInteger)count add:(ValueType (^)(NSUInteger idx))addBlock notDequeued:(void (^)(NSUInteger idx, ValueType obj))notDequeuedBlock doNext:(void (^)(NSUInteger idx, ValueType obj))doBlock; - -- (void)lookin_removeObjectsPassingTest:(BOOL (^)(NSUInteger idx, ValueType obj))block; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSArray+Lookin.m b/Sources/LookinCore/Category/NSArray+Lookin.m deleted file mode 100644 index 6df614b7..00000000 --- a/Sources/LookinCore/Category/NSArray+Lookin.m +++ /dev/null @@ -1,300 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSArray+Lookin.m -// Lookin -// -// Created by Li Kai on 2018/9/3. -// https://lookin.work -// - - - -#import "NSArray+Lookin.h" - -@implementation NSArray (Lookin) - -- (NSArray *)lookin_resizeWithCount:(NSUInteger)count add:(id (^)(NSUInteger idx))addBlock remove:(void (^)(NSUInteger idx, id obj))removeBlock doNext:(void (^)(NSUInteger idx, id obj))doBlock { - NSMutableArray *resultArray = [NSMutableArray arrayWithCapacity:count]; - - for (NSUInteger i = 0; i < count; i++) { - if (self.count > i) { - id obj = [self objectAtIndex:i]; - [resultArray addObject:obj]; - if (doBlock) { - doBlock(i, obj); - } - } else { - if (addBlock) { - id newObj = addBlock(i); - if (newObj) { - [resultArray addObject:newObj]; - if (doBlock) { - doBlock(i, newObj); - } - } else { - NSAssert(NO, @""); - } - } else { - NSAssert(NO, @""); - } - } - } - - if (removeBlock) { - if (self.count > count) { - for (NSUInteger i = count; i < self.count; i++) { - id obj = [self objectAtIndex:i]; - removeBlock(i, obj); - } - } - } - - return [resultArray copy]; -} - -+ (NSArray *)lookin_arrayWithCount:(NSUInteger)count block:(id (^)(NSUInteger idx))block { - NSMutableArray *array = [NSMutableArray arrayWithCapacity:count]; - for (NSUInteger i = 0; i < count; i++) { - id obj = block(i); - if (obj) { - [array addObject:obj]; - } - } - return [array copy]; -} - -- (BOOL)lookin_hasIndex:(NSInteger)index { - if (index == NSNotFound || index < 0) { - return NO; - } - return self.count > index; -} - -- (NSArray *)lookin_map:(id (^)(NSUInteger , id))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:self.count]; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - id newObj = block(idx, obj); - if (newObj) { - [array addObject:newObj]; - } - }]; - return [array copy]; -} - -- (NSArray *)lookin_filter:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - NSMutableArray *mArray = [NSMutableArray array]; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (block(obj)) { - [mArray addObject:obj]; - } - }]; - return [mArray copy]; -} - -- (id)lookin_firstFiltered:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - __block id targetObj = nil; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (block(obj)) { - targetObj = obj; - *stop = YES; - } - }]; - return targetObj; -} - -- (id)lookin_lastFiltered:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - __block id targetObj = nil; - [self enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (block(obj)) { - targetObj = obj; - *stop = YES; - } - }]; - return targetObj; -} - -- (id)lookin_reduce:(id (^)(id accumulator, NSUInteger idx, id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - __block id accumulator = nil; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - accumulator = block(accumulator, idx, obj); - }]; - return accumulator; -} - -- (CGFloat)lookin_reduceCGFloat:(CGFloat (^)(CGFloat accumulator, NSUInteger idx, id obj))block initialAccumlator:(CGFloat)initialAccumlator { - if (!block) { - NSAssert(NO, @""); - return initialAccumlator; - } - - __block CGFloat accumulator = initialAccumlator; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - accumulator = block(accumulator, idx, obj); - }]; - return accumulator; -} - -- (NSInteger)lookin_reduceInteger:(NSInteger (^)(NSInteger, NSUInteger, id))block initialAccumlator:(NSInteger)initialAccumlator { - if (!block) { - NSAssert(NO, @""); - return initialAccumlator; - } - - __block NSInteger accumulator = initialAccumlator; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - accumulator = block(accumulator, idx, obj); - }]; - return accumulator; -} - -- (BOOL)lookin_all:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return NO; - } - __block BOOL allPass = YES; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL boolValue = block(obj); - if (!boolValue) { - allPass = NO; - *stop = YES; - } - }]; - return allPass; -} - -- (BOOL)lookin_any:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return NO; - } - __block BOOL anyPass = NO; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL boolValue = block(obj); - if (boolValue) { - anyPass = YES; - *stop = YES; - } - }]; - return anyPass; -} - -- (NSArray *)lookin_arrayByRemovingObject:(id)obj { - if (!obj || ![self containsObject:obj]) { - return self; - } - NSMutableArray *mutableArray = self.mutableCopy; - [mutableArray removeObject:obj]; - return mutableArray.copy; -} - -- (NSArray *)lookin_nonredundantArray { - NSSet *set = [NSSet setWithArray:self]; - NSArray *newArray = [set allObjects]; - return newArray; -} - -- (id)lookin_safeObjectAtIndex:(NSInteger)idx { - if (idx == NSNotFound || idx < 0) { - return nil; - } - if (self.count <= idx) { - return nil; - } - return [self objectAtIndex:idx]; -} - -- (NSArray *)lookin_sortedArrayByStringLength { - NSArray *sortedArray = [self sortedArrayUsingComparator:^NSComparisonResult(NSString *obj1, NSString *obj2) { - if (obj1.length > obj2.length) { - return NSOrderedDescending; - } else if (obj1.length == obj2.length) { - return NSOrderedSame; - } else { - return NSOrderedAscending; - } - }]; - return sortedArray; -} - -@end - -@implementation NSMutableArray (Lookin) - -- (void)lookin_dequeueWithCount:(NSUInteger)count add:(id (^)(NSUInteger idx))addBlock notDequeued:(void (^)(NSUInteger idx, id obj))notDequeuedBlock doNext:(void (^)(NSUInteger idx, id obj))doBlock { - for (NSUInteger i = 0; i < count; i++) { - if ([self lookin_hasIndex:i]) { - id obj = [self objectAtIndex:i]; - if (doBlock) { - doBlock(i, obj); - } - } else { - if (addBlock) { - id newObj = addBlock(i); - if (newObj) { - [self addObject:newObj]; - if (doBlock) { - doBlock(i, newObj); - } - } else { - NSAssert(NO, @""); - } - } else { - NSAssert(NO, @""); - } - } - } - - if (notDequeuedBlock) { - if (self.count > count) { - for (NSUInteger i = count; i < self.count; i++) { - id obj = [self objectAtIndex:i]; - notDequeuedBlock(i, obj); - } - } - } -} - -- (void)lookin_removeObjectsPassingTest:(BOOL (^)(NSUInteger idx, id obj))block { - if (!block) { - return; - } - NSMutableIndexSet *indexSet = [NSMutableIndexSet indexSet]; - [self enumerateObjectsUsingBlock:^(id _Nonnull currentObj, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL boolValue = block(idx, currentObj); - if (boolValue) { - [indexSet addIndex:idx]; - } - }]; - [self removeObjectsAtIndexes:indexSet]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSObject+Lookin.h b/Sources/LookinCore/Category/NSObject+Lookin.h deleted file mode 100644 index c8839cf1..00000000 --- a/Sources/LookinCore/Category/NSObject+Lookin.h +++ /dev/null @@ -1,108 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSObject+Lookin.h -// Lookin -// -// Created by Li Kai on 2018/12/22. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import -#import "LookinCodingValueType.h" - -@interface NSObject (Lookin) - -#pragma mark - Data Bind - -/** - 给对象绑定上另一个对象以供后续取出使用,如果 object 传入 nil 则会清除该 key 之前绑定的对象 - - @attention 被绑定的对象会被 strong 强引用 - @note 内部是使用 objc_setAssociatedObject / objc_getAssociatedObject 来实现 - - @code - - (UITableViewCell *)cellForIndexPath:(NSIndexPath *)indexPath { - // 1)在这里给 button 绑定上 indexPath 对象 - [cell lookin_bindObject:indexPath forKey:@"indexPath"]; - } - - - (void)didTapButton:(UIButton *)button { - // 2)在这里取出被点击的 button 的 indexPath 对象 - NSIndexPath *indexPathTapped = [button lookin_getBindObjectForKey:@"indexPath"]; - } - @endcode - */ -- (void)lookin_bindObject:(id)object forKey:(NSString *)key; - -/** - 给对象绑定上另一个对象以供后续取出使用,但相比于 lookin_bindObject:forKey:,该方法不会 strong 强引用传入的 object - */ -- (void)lookin_bindObjectWeakly:(id)object forKey:(NSString *)key; - -/** - 取出之前使用 bind 方法绑定的对象 - */ -- (id)lookin_getBindObjectForKey:(NSString *)key; - -/** - 给对象绑定上一个 double 值以供后续取出使用 - */ -- (void)lookin_bindDouble:(double)doubleValue forKey:(NSString *)key; - -/** - 取出之前用 lookin_bindDouble:forKey: 绑定的值 - */ -- (double)lookin_getBindDoubleForKey:(NSString *)key; - -/** - 给对象绑定上一个 BOOL 值以供后续取出使用 - */ -- (void)lookin_bindBOOL:(BOOL)boolValue forKey:(NSString *)key; - -/** - 取出之前用 lookin_bindBOOL:forKey: 绑定的值 - */ -- (BOOL)lookin_getBindBOOLForKey:(NSString *)key; - -/** - 给对象绑定上一个 long 值以供后续取出使用 - */ -- (void)lookin_bindLong:(long)longValue forKey:(NSString *)key; - -/** - 取出之前用 lookin_bindLong:forKey: 绑定的值 - */ -- (long)lookin_getBindLongForKey:(NSString *)key; - -/** - 给对象绑定上一个 CGPoint 值以供后续取出使用 - */ -- (void)lookin_bindPoint:(CGPoint)pointValue forKey:(NSString *)key; - -/** - 取出之前用 lookin_bindPoint:forKey: 绑定的值 - */ -- (CGPoint)lookin_getBindPointForKey:(NSString *)key; - -/** - 移除之前使用 bind 方法绑定的对象 - */ -- (void)lookin_clearBindForKey:(NSString *)key; - -@end - -@interface NSObject (Lookin_Coding) - -/// 会把 NSImage/UIImage 转换为 NSData,把 NSColor/UIColor 转换回 NSNumber 数组(rgba) -- (id)lookin_encodedObjectWithType:(LookinCodingValueType)type; -/// 会把 NSData 转换回 NSImage/UIImage,把 NSNumber 数组(rgba) 转换为 NSColor/UIColor -- (id)lookin_decodedObjectWithType:(LookinCodingValueType)type; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSObject+Lookin.m b/Sources/LookinCore/Category/NSObject+Lookin.m deleted file mode 100644 index 77d3d693..00000000 --- a/Sources/LookinCore/Category/NSObject+Lookin.m +++ /dev/null @@ -1,239 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSObject+Lookin.m -// Lookin -// -// Created by Li Kai on 2018/12/22. -// https://lookin.work -// - -#import "NSObject+Lookin.h" -#import -#import "TargetConditionals.h" -#import "LookinWeakContainer.h" -#import "Image+Lookin.h" - -@implementation NSObject (Lookin) - -#pragma mark - Data Bind - -static char kAssociatedObjectKey_LookinAllBindObjects; -- (NSMutableDictionary *)lookin_allBindObjects { - NSMutableDictionary *dict = objc_getAssociatedObject(self, &kAssociatedObjectKey_LookinAllBindObjects); - if (!dict) { - dict = [NSMutableDictionary dictionary]; - objc_setAssociatedObject(self, &kAssociatedObjectKey_LookinAllBindObjects, dict, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - return dict; -} - -- (void)lookin_bindObject:(id)object forKey:(NSString *)key { - if (!key.length) { - NSAssert(NO, @""); - return; - } - @synchronized (self) { - if (object) { - [[self lookin_allBindObjects] setObject:object forKey:key]; - } else { - [[self lookin_allBindObjects] removeObjectForKey:key]; - } - } -} - -- (id)lookin_getBindObjectForKey:(NSString *)key { - if (!key.length) { - NSAssert(NO, @""); - return nil; - } - @synchronized (self) { - id storedObj = [[self lookin_allBindObjects] objectForKey:key]; - if ([storedObj isKindOfClass:[LookinWeakContainer class]]) { - storedObj = [(LookinWeakContainer *)storedObj object]; - } - return storedObj; - } -} - -- (void)lookin_bindObjectWeakly:(id)object forKey:(NSString *)key { - if (!key.length) { - NSAssert(NO, @""); - return; - } - if (object) { - LookinWeakContainer *container = [[LookinWeakContainer alloc] init]; - container.object = object; - [self lookin_bindObject:container forKey:key]; - } else { - [self lookin_bindObject:nil forKey:key]; - } -} - -- (void)lookin_bindDouble:(double)doubleValue forKey:(NSString *)key { - [self lookin_bindObject:@(doubleValue) forKey:key]; -} - -- (double)lookin_getBindDoubleForKey:(NSString *)key { - id object = [self lookin_getBindObjectForKey:key]; - if ([object isKindOfClass:[NSNumber class]]) { - double doubleValue = [(NSNumber *)object doubleValue]; - return doubleValue; - - } else { - return 0.0; - } -} - -- (void)lookin_bindBOOL:(BOOL)boolValue forKey:(NSString *)key { - [self lookin_bindObject:@(boolValue) forKey:key]; -} - -- (BOOL)lookin_getBindBOOLForKey:(NSString *)key { - id object = [self lookin_getBindObjectForKey:key]; - if ([object isKindOfClass:[NSNumber class]]) { - BOOL boolValue = [(NSNumber *)object boolValue]; - return boolValue; - - } else { - return NO; - } -} - -- (void)lookin_bindLong:(long)longValue forKey:(NSString *)key { - [self lookin_bindObject:@(longValue) forKey:key]; -} - -- (long)lookin_getBindLongForKey:(NSString *)key { - id object = [self lookin_getBindObjectForKey:key]; - if ([object isKindOfClass:[NSNumber class]]) { - long longValue = [(NSNumber *)object longValue]; - return longValue; - - } else { - return 0; - } -} - -- (void)lookin_bindPoint:(CGPoint)pointValue forKey:(NSString *)key { -#if TARGET_OS_IPHONE - [self lookin_bindObject:[NSValue valueWithCGPoint:pointValue] forKey:key]; -#elif TARGET_OS_OSX - NSPoint nsPoint = NSMakePoint(pointValue.x, pointValue.y); - [self lookin_bindObject:[NSValue valueWithPoint:nsPoint] forKey:key]; -#endif -} - -- (CGPoint)lookin_getBindPointForKey:(NSString *)key { - id object = [self lookin_getBindObjectForKey:key]; - if ([object isKindOfClass:[NSValue class]]) { -#if TARGET_OS_IPHONE - CGPoint pointValue = [(NSValue *)object CGPointValue]; -#elif TARGET_OS_OSX - NSPoint nsPointValue = [(NSValue *)object pointValue]; - CGPoint pointValue = CGPointMake(nsPointValue.x, nsPointValue.y); -#endif - return pointValue; - } else { - return CGPointZero; - } -} - -- (void)lookin_clearBindForKey:(NSString *)key { - [self lookin_bindObject:nil forKey:key]; -} - -@end - -@implementation NSObject (Lookin_Coding) - -- (id)lookin_encodedObjectWithType:(LookinCodingValueType)type { - if (type == LookinCodingValueTypeColor) { - if ([self isKindOfClass:[LookinColor class]]) { - CGFloat r, g, b, a; -#if TARGET_OS_IPHONE - CGFloat white; - if ([(UIColor *)self getRed:&r green:&g blue:&b alpha:&a]) { - // valid - } else if ([(UIColor *)self getWhite:&white alpha:&a]) { - r = white; - g = white; - b = white; - } else { - NSAssert(NO, @""); - r = 0; - g = 0; - b = 0; - a = 0; - } -#elif TARGET_OS_OSX - NSColor *color = [((NSColor *)self) colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; - [color getRed:&r green:&g blue:&b alpha:&a]; -#endif - NSArray *rgba = @[@(r), @(g), @(b), @(a)]; - return rgba; - - } else { - NSAssert(NO, @""); - return nil; - } - - } else if (type == LookinCodingValueTypeImage) { -#if TARGET_OS_IPHONE - if ([self isKindOfClass:[UIImage class]]) { - UIImage *image = (UIImage *)self; - return UIImagePNGRepresentation(image); - - } else { - NSAssert(NO, @""); - return nil; - } -#elif TARGET_OS_OSX - if ([self isKindOfClass:[NSImage class]]) { - NSImage *image = (NSImage *)self; - return [image lookin_data]; - - } else { - NSAssert(NO, @""); - return nil; - } -#endif - - } else { - return self; - } -} - -- (id)lookin_decodedObjectWithType:(LookinCodingValueType)type { - if (type == LookinCodingValueTypeColor) { - if ([self isKindOfClass:[NSArray class]]) { - NSArray *rgba = (NSArray *)self; - CGFloat r = [rgba[0] doubleValue]; - CGFloat g = [rgba[1] doubleValue]; - CGFloat b = [rgba[2] doubleValue]; - CGFloat a = [rgba[3] doubleValue]; - LookinColor *color = [LookinColor colorWithRed:r green:g blue:b alpha:a]; - return color; - - } else { - NSAssert(NO, @""); - return nil; - } - - } else if (type == LookinCodingValueTypeImage) { - if ([self isKindOfClass:[NSData class]]) { - LookinImage *image = [[LookinImage alloc] initWithData:(NSData *)self]; - return image; - } else { - NSAssert(NO, @""); - return nil; - } - - } else { - return self; - } -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSSet+Lookin.h b/Sources/LookinCore/Category/NSSet+Lookin.h deleted file mode 100644 index 757042ca..00000000 --- a/Sources/LookinCore/Category/NSSet+Lookin.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSSet+Lookin.h -// Lookin -// -// Created by Li Kai on 2019/1/13. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import "TargetConditionals.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_OSX -#import -#endif - -@interface NSSet<__covariant ValueType> (Lookin) - -- (NSSet *)lookin_map:(id (^)(ValueType obj))block; - -- (ValueType)lookin_firstFiltered:(BOOL (^)(ValueType obj))block; - -- (NSSet *)lookin_filter:(BOOL (^)(ValueType obj))block; - - -/** - 是否有任何一个元素满足某条件 - @note 元素将被依次传入 block 里,如果任何一个 block 返回 YES,则该方法返回 YES。如果所有 block 均返回 NO,则该方法返回 NO。 - */ -- (BOOL)lookin_any:(BOOL (^)(ValueType obj))block; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSSet+Lookin.m b/Sources/LookinCore/Category/NSSet+Lookin.m deleted file mode 100644 index 1ec9edf2..00000000 --- a/Sources/LookinCore/Category/NSSet+Lookin.m +++ /dev/null @@ -1,81 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSSet+Lookin.m -// Lookin -// -// Created by Li Kai on 2019/1/13. -// https://lookin.work -// - - - -#import "NSSet+Lookin.h" - -@implementation NSSet (Lookin) - -- (NSSet *)lookin_map:(id (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - NSMutableSet *newSet = [NSMutableSet setWithCapacity:self.count]; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { - id newObj = block(obj); - if (newObj) { - [newSet addObject:newObj]; - } - }]; - return [newSet copy]; -} - -- (id)lookin_firstFiltered:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - __block id targetObj = nil; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { - if (block(obj)) { - targetObj = obj; - *stop = YES; - } - }]; - return targetObj; -} - -- (NSSet *)lookin_filter:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - NSMutableSet *mSet = [NSMutableSet set]; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { - if (block(obj)) { - [mSet addObject:obj]; - } - }]; - return [mSet copy]; -} - -- (BOOL)lookin_any:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return NO; - } - __block BOOL boolValue = NO; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { - if (block(obj)) { - boolValue = YES; - *stop = YES; - } - }]; - return boolValue; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSString+Lookin.h b/Sources/LookinCore/Category/NSString+Lookin.h deleted file mode 100644 index 15f11717..00000000 --- a/Sources/LookinCore/Category/NSString+Lookin.h +++ /dev/null @@ -1,42 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSString+Lookin.h -// Lookin -// -// Created by Li Kai on 2019/5/11. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import - -@interface NSString (Lookin) - -/** - 把 CGFloat 转成字符串,最多保留 3 位小数,转换后末尾的 0 会被删除 - 如:1.2341 => @"1.234", 2.1002 => @"2.1", 3.000 => @"3" - */ -+ (NSString *)lookin_stringFromDouble:(double)doubleValue decimal:(NSUInteger)decimal; - -+ (NSString *)lookin_stringFromRect:(CGRect)rect; - -+ (NSString *)lookin_stringFromInset:(LookinInsets)insets; - -+ (NSString *)lookin_stringFromSize:(CGSize)size; - -+ (NSString *)lookin_stringFromPoint:(CGPoint)point; - -+ (NSString *)lookin_rgbaStringFromColor:(LookinColor *)color; - -- (NSString *)lookin_safeInitWithUTF8String:(const char *)string; - -/// 把 1.2.3 这种 String 版本号转换成数字,可用于大小比较,如 110205 代表 11.2.5 版本 -- (NSInteger)lookin_numbericOSVersion; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSString+Lookin.m b/Sources/LookinCore/Category/NSString+Lookin.m deleted file mode 100644 index d1ffeeca..00000000 --- a/Sources/LookinCore/Category/NSString+Lookin.m +++ /dev/null @@ -1,116 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSString+Lookin.m -// Lookin -// -// Created by Li Kai on 2019/5/11. -// https://lookin.work -// - - - -#import "NSString+Lookin.h" - -@implementation NSString (Lookin) - -+ (NSString *)lookin_stringFromDouble:(double)doubleValue decimal:(NSUInteger)decimal { - NSString *formatString = [NSString stringWithFormat:@"%%.%@f", @(decimal)]; - NSString *string = [NSString stringWithFormat:formatString, doubleValue]; - for (int i = 0; i < decimal; i++) { - if ([[string substringFromIndex:string.length - 1] isEqualToString:@"0"]) { - string = [string substringToIndex:string.length - 1]; - } - } - if ([[string substringFromIndex:string.length - 1] isEqualToString:@"."]) { - string = [string substringToIndex:string.length - 1]; - } - return string; -} - -+ (NSString *)lookin_stringFromInset:(LookinInsets)insets { - return [NSString stringWithFormat:@"{%@, %@, %@, %@}", - [NSString lookin_stringFromDouble:insets.top decimal:2], - [NSString lookin_stringFromDouble:insets.left decimal:2], - [NSString lookin_stringFromDouble:insets.bottom decimal:2], - [NSString lookin_stringFromDouble:insets.right decimal:2]]; -} - -+ (NSString *)lookin_stringFromSize:(CGSize)size { - return [NSString stringWithFormat:@"{%@, %@}", - [NSString lookin_stringFromDouble:size.width decimal:2], - [NSString lookin_stringFromDouble:size.height decimal:2]]; -} - - -+ (NSString *)lookin_stringFromPoint:(CGPoint)point { - return [NSString stringWithFormat:@"{%@, %@}", - [NSString lookin_stringFromDouble:point.x decimal:2], - [NSString lookin_stringFromDouble:point.y decimal:2]]; -} - -+ (NSString *)lookin_stringFromRect:(CGRect)rect { - return [NSString stringWithFormat:@"{%@, %@, %@, %@}", - [NSString lookin_stringFromDouble:rect.origin.x decimal:2], - [NSString lookin_stringFromDouble:rect.origin.y decimal:2], - [NSString lookin_stringFromDouble:rect.size.width decimal:2], - [NSString lookin_stringFromDouble:rect.size.height decimal:2]]; -} - -+ (NSString *)lookin_rgbaStringFromColor:(LookinColor *)color { - if (!color) { - return @"nil"; - } - -#if TARGET_OS_IPHONE - UIColor *rgbColor = color; -#elif TARGET_OS_OSX - NSColor *rgbColor = [color colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; -#endif - - CGFloat r, g, b, a; - [rgbColor getRed:&r green:&g blue:&b alpha:&a]; - - NSString *colorDesc; - if (a >= 1) { - colorDesc = [NSString stringWithFormat:@"(%.0f, %.0f, %.0f)", r * 255, g * 255, b * 255]; - } else { - colorDesc = [NSString stringWithFormat:@"(%.0f, %.0f, %.0f, %@)", r * 255, g * 255, b * 255, [NSString lookin_stringFromDouble:a decimal:2]]; - - } - - return colorDesc; -} - -- (NSString *)lookin_safeInitWithUTF8String:(const char *)string { - if (NULL != string) { - return [self initWithUTF8String:string]; - } - return nil; -} - -- (NSInteger)lookin_numbericOSVersion { - if (self.length == 0) { - return 0; - } - NSArray *versionArr = [self componentsSeparatedByString:@"."]; - if (versionArr.count == 0) { - return 0; - } - - NSInteger numbericOSVersion = 0; - NSInteger pos = 0; - NSInteger componentCount = MIN(versionArr.count, 3); - - while (pos < componentCount) { - numbericOSVersion += ([[versionArr objectAtIndex:pos] integerValue] * pow(10, (4 - pos * 2))); - pos++; - } - - return numbericOSVersion; -} - - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSValue+Lookin.m b/Sources/LookinCore/Category/NSValue+Lookin.m deleted file mode 100644 index 396ff85c..00000000 --- a/Sources/LookinCore/Category/NSValue+Lookin.m +++ /dev/null @@ -1,111 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSValue+Lookin.m -// LookinServer -// -// Created by JH on 2024/11/5. -// - -#import "NSValue+Lookin.h" - -#if TARGET_OS_OSX -NSString *NSStringFromInsets(NSEdgeInsets insets) { - return [NSString stringWithFormat:@"{%.*g, %.*g, %.*g, %.*g}", 17, insets.top, 17, insets.left, 17, insets.bottom, 17, insets.right]; -} - -NSString *NSStringFromCGAffineTransform(CGAffineTransform transform) { - return [NSString stringWithFormat:@"[%.*g, %.*g, %.*g, %.*g, %.*g, %.*g]", 17, transform.a, 17, transform.b, 17, transform.c, 17, transform.d, 17, transform.tx, 17, transform.ty]; -} - -NSString *NSStringFromCGVector(CGVector vector) { - return [NSString stringWithFormat:@"{%.*g, %.*g}", 17, vector.dx, 17, vector.dy]; -} - -NSString *NSStringFromCGRect(CGRect rect) { - return NSStringFromRect(rect); -} - -NSString *NSStringFromCGPoint(CGPoint point) { - return NSStringFromPoint(point); -} - -NSString *NSStringFromCGSize(CGSize size) { - return NSStringFromSize(size); -} - -NSString *NSStringFromDirectionalEdgeInsets(NSDirectionalEdgeInsets insets) { - return [NSString stringWithFormat:@"{%.*g, %.*g, %.*g, %.*g}", 17, insets.top, 17, insets.leading, 17, insets.bottom, 17, insets.trailing]; -} -#else -NSString *NSStringFromInsets(UIEdgeInsets insets) { - return [NSString stringWithFormat:@"{%.*g, %.*g, %.*g, %.*g}", 17, insets.top, 17, insets.left, 17, insets.bottom, 17, insets.right]; -} -#endif - -@implementation NSValue (Lookin) -#if TARGET_OS_OSX -+ (NSValue *)valueWithCGVector:(CGVector)vector { - return [self valueWithBytes:&vector objCType:@encode(CGVector)]; -} - -+ (NSValue *)valueWithCGRect:(CGRect)rect { - return [self valueWithRect:rect]; -} - -+ (NSValue *)valueWithCGSize:(CGSize)size { - return [self valueWithSize:size]; -} - -+ (NSValue *)valueWithCGPoint:(CGPoint)point { - return [self valueWithPoint:point]; -} - -+ (NSValue *)valueWithCGAffineTransform:(CGAffineTransform)transform { - return [self valueWithBytes:&transform objCType:@encode(CGAffineTransform)]; -} - -- (CGAffineTransform)CGAffineTransformValue { - CGAffineTransform transform; - [self getValue:&transform]; - return transform; -} - -- (CGVector)CGVectorValue { - CGVector vector; - [self getValue:&vector]; - return vector; -} - -- (CGRect)CGRectValue { - return [self rectValue]; -} - -- (CGPoint)CGPointValue { - return [self pointValue]; -} - -- (CGSize)CGSizeValue { - return [self sizeValue]; -} - -+ (NSValue *)valueWithInsets:(NSEdgeInsets)insets { - return [self valueWithEdgeInsets:insets]; -} - -- (NSEdgeInsets)InsetsValue { - return [self edgeInsetsValue]; -} -#else -+ (NSValue *)valueWithInsets:(UIEdgeInsets)insets { - return [self valueWithUIEdgeInsets:insets]; -} - -- (UIEdgeInsets)InsetsValue { - return self.UIEdgeInsetsValue; -} -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LKS_MultiplatformAdapter.h b/Sources/LookinCore/LKS_MultiplatformAdapter.h deleted file mode 100644 index 42342ced..00000000 --- a/Sources/LookinCore/LKS_MultiplatformAdapter.h +++ /dev/null @@ -1,74 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LKS_MultiplatformAdapter.h -// -// -// Created by nixjiang on 2024/3/12. -// - -#import -#if TARGET_OS_IPHONE -#import -#endif - -#if TARGET_OS_OSX -#import -#endif - -#import "LookinDefines.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LKS_MultiplatformAdapter : NSObject - -+ (LookinWindow *)keyWindow; - -+ (NSArray *)allWindows; - -#if TARGET_OS_IPHONE -/// Returns every live UIWindowScene, including internal ones that -/// UIApplication.connectedScenes hides (e.g. _UIKeyboardWindowScene on -/// iOS 17+). Falls back to connectedScenes if the private entry point is -/// unavailable. -+ (NSArray *)allWindowScenes; - -/// Returns every UIWindow attached to the given scene, including ones whose -/// -isInternalWindow returns YES (e.g. UIRemoteKeyboardWindow on iOS 26+, -/// which is filtered out by the public -[UIWindowScene windows] getter). -/// Falls back to scene.windows if the private getter is unavailable. -+ (NSArray *)allWindowsForWindowScene:(UIWindowScene *)scene; -#endif - -+ (CGRect)mainScreenBounds; - -+ (CGFloat)mainScreenScale; - -/// Whether the host device is an iPad — or anything UIKit presents as one. A Mac Catalyst -/// build also answers YES: -[UIDevice model] is "iPad" there. Callers that need to tell a -/// real iPad from a Mac must consult +isMacCatalyst first. -+ (BOOL)isiPad; - -/// Whether the app is a native AppKit Mac app. A Mac Catalyst build answers NO: it runs on -/// macOS but is built against UIKit, so TARGET_OS_OSX is 0 for it. -+ (BOOL)isMac; - -/// Whether the app is a Mac Catalyst build — UIKit source, running on macOS. -/// -/// This is a compile-time verdict rather than a runtime probe, and that is what makes it -/// exact: TARGET_OS_MACCATALYST is set for precisely the Catalyst destination, so the answer -/// is fixed the moment LookinServer is compiled into the target app. -/// -/// UIKit offers nothing comparable. Measured in a Catalyst process on macOS 26.6: -/// -[UIDevice model] and -name are both "iPad", -systemName is "iPadOS", and -/// -userInterfaceIdiom is .pad — every one of them describes an iPad. (-userInterfaceIdiom -/// does become .mac under the "Optimized for Mac" interface setting, which is exactly why it -/// cannot be trusted: the answer would then depend on a per-app UI choice rather than on -/// what the app actually is.) -[NSProcessInfo isMacCatalystApp] is accurate but redundant, -/// and would move a decision that is already settled at compile time into runtime. -+ (BOOL)isMacCatalyst; - -@end - -NS_ASSUME_NONNULL_END - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LKS_MultiplatformAdapter.m b/Sources/LookinCore/LKS_MultiplatformAdapter.m deleted file mode 100644 index 0bc08454..00000000 --- a/Sources/LookinCore/LKS_MultiplatformAdapter.m +++ /dev/null @@ -1,214 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LKS_MultiplatformAdapter.m -// -// -// Created by nixjiang on 2024/3/12. -// - -#import "LKS_MultiplatformAdapter.h" - -#if TARGET_OS_IPHONE -#import -#endif - -#if TARGET_OS_OSX -#import -#endif - -#if TARGET_OS_IPHONE -// Private +[UIScene _scenesIncludingInternal:] signature. Returns every -// UIScene the process has created, including ones UIApplication.connectedScenes -// hides (e.g. _UIKeyboardWindowScene on iOS 17+). Available since iOS 13. -@protocol _LKS_UISceneInternalClassAPI -+ (NSArray *)_scenesIncludingInternal:(BOOL)includeInternal; -@end - -// Private -[UIWindowScene _allWindowsIncludingInternalWindows:onlyVisibleWindows:] -// signature. The public `windows` getter routes through this with -// includeInternal=NO, filtering out every UIWindow whose -isInternalWindow -// returns YES. On iOS 26+ -[UIRemoteKeyboardWindow isInternalWindow] is -// hardcoded to YES, so the soft-keyboard window disappears from the public -// list. Calling with includeInternal=YES bypasses the filter and surfaces it. -@protocol _LKS_UIWindowSceneInternalAPI -- (NSArray *)_allWindowsIncludingInternalWindows:(BOOL)includeInternal - onlyVisibleWindows:(BOOL)onlyVisible; -@end -#endif - -@implementation LKS_MultiplatformAdapter - -+ (BOOL)isiPad { -#if TARGET_OS_IPHONE - static BOOL s_isiPad = NO; - - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSString *nsModel = [UIDevice currentDevice].model; - s_isiPad = [nsModel hasPrefix:@"iPad"]; - }); - - return s_isiPad; -#else - return NO; -#endif -} - -+ (BOOL)isMac { -#if TARGET_OS_OSX - return YES; -#else - return NO; -#endif -} - -+ (BOOL)isMacCatalyst { -#if TARGET_OS_MACCATALYST - return YES; -#else - return NO; -#endif -} - -+ (CGRect)mainScreenBounds { -#if TARGET_OS_VISION || TARGET_OS_MACCATALYST - return [LKS_MultiplatformAdapter getFirstActiveWindowScene].coordinateSpace.bounds; -#elif TARGET_OS_IPHONE - return [UIScreen mainScreen].bounds; -#elif TARGET_OS_OSX - // 这里不能返回屏幕的bounds,因为在macOS上,窗口可以不全屏显示,Lookin的设计是基于窗口的,一般iOS中屏幕的bounds就是窗口的bounds,所以这里直接返回窗口的bounds - CGFloat maxWidth = 0; - CGFloat maxHeight = 0; - CGRect bounds = CGRectZero; - for (NSWindow *window in NSApplication.sharedApplication.windows) { - maxWidth = MAX(maxWidth, window.frame.size.width); - maxHeight = MAX(maxHeight, window.frame.size.height); - } - bounds.size = CGSizeMake(maxWidth, maxHeight); - return bounds; -#else - return CGRectZero; -#endif -} - -+ (CGFloat)mainScreenScale { -#if TARGET_OS_VISION - return 2.f; -#elif TARGET_OS_IPHONE - return [UIScreen mainScreen].scale; -#elif TARGET_OS_OSX - return [NSScreen mainScreen].backingScaleFactor; -#else - return 1.f; -#endif -} - -#if TARGET_OS_VISION || TARGET_OS_MACCATALYST -+ (UIWindowScene *)getFirstActiveWindowScene { - for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { - if (![scene isKindOfClass:UIWindowScene.class]) { - continue; - } - UIWindowScene *windowScene = (UIWindowScene *)scene; - if (windowScene.activationState == UISceneActivationStateForegroundActive) { - return windowScene; - } - } - return nil; -} -#endif - -+ (LookinWindow *)keyWindow { -#if TARGET_OS_VISION - return [self getFirstActiveWindowScene].keyWindow; -#elif TARGET_OS_IPHONE - return [LookinApplication sharedApplication].keyWindow; -#elif TARGET_OS_OSX - return [LookinApplication sharedApplication].keyWindow; -#else - return nil; -#endif -} - - -+ (NSArray *)allWindows { -#if TARGET_OS_IPHONE - NSArray *scenes = [self allWindowScenes]; - if (scenes.count > 0) { - NSMutableArray *windows = [NSMutableArray new]; - for (UIWindowScene *windowScene in scenes) { - for (UIWindow *window in [self allWindowsForWindowScene:windowScene]) { - if (![windows containsObject:window]) { - [windows addObject:window]; - } - } - - // UIModalPresentationFormSheet uses a private window that is - // missing from scene.windows but reachable via scene.keyWindow. - // UIWindowScene.keyWindow is iOS 15+ only; on iOS 13/14 the form - // sheet window is reachable through scene.windows anyway. - if (@available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, *)) { - UIWindow *sceneKeyWindow = windowScene.keyWindow; - if (sceneKeyWindow && ![windows containsObject:sceneKeyWindow]) { - if (![NSStringFromClass(sceneKeyWindow.class) containsString:@"HUD"]) { - [windows addObject:sceneKeyWindow]; - } - } - } - } - if (windows.count > 0) { - return [windows copy]; - } - } -#if TARGET_OS_VISION - // visionOS deprecates -[UIApplication windows] outright; there's no global - // window list to fall back to, so just return empty. - return @[]; -#else - return [[LookinApplication sharedApplication].windows copy]; -#endif -#else - return [[LookinApplication sharedApplication].windows copy]; -#endif -} - -#if TARGET_OS_IPHONE -+ (NSArray *)allWindowScenes { - NSArray *allScenes = nil; - Class<_LKS_UISceneInternalClassAPI> uiSceneClass = (Class<_LKS_UISceneInternalClassAPI>)UIScene.class; - if ([uiSceneClass respondsToSelector:@selector(_scenesIncludingInternal:)]) { - allScenes = [uiSceneClass _scenesIncludingInternal:YES]; - } - if (allScenes.count == 0) { - allScenes = UIApplication.sharedApplication.connectedScenes.allObjects; - } - - NSMutableArray *windowScenes = [NSMutableArray arrayWithCapacity:allScenes.count]; - for (UIScene *scene in allScenes) { - if ([scene isKindOfClass:UIWindowScene.class]) { - [windowScenes addObject:(UIWindowScene *)scene]; - } - } - return [windowScenes copy]; -} - -+ (NSArray *)allWindowsForWindowScene:(UIWindowScene *)scene { - if (!scene) { - return @[]; - } - SEL selector = @selector(_allWindowsIncludingInternalWindows:onlyVisibleWindows:); - if ([scene respondsToSelector:selector]) { - NSArray *windows = [(id<_LKS_UIWindowSceneInternalAPI>)scene - _allWindowsIncludingInternalWindows:YES - onlyVisibleWindows:NO]; - if (windows.count > 0) { - return windows; - } - } - return scene.windows ?: @[]; -} -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAppInfo.h b/Sources/LookinCore/LookinAppInfo.h deleted file mode 100644 index 147356e8..00000000 --- a/Sources/LookinCore/LookinAppInfo.h +++ /dev/null @@ -1,77 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAppInfo.h -// qmuidemo -// -// Created by Li Kai on 2018/11/3. -// Copyright © 2018 QMUI Team. All rights reserved. -// - - - -#import "LookinDefines.h" - -/// 设备类型。该枚举的原始值会经由 wire 协议传给 Lookin 客户端,因此顺序不可调整, -/// 新增的档位只能追加在末尾。上游 Lookin 只认识前三档(0–2),收到更大的值时会落到 -/// 自己的 default 分支(表现为设备图标缺失,而非报错)。 -typedef NS_ENUM(NSInteger, LookinAppInfoDevice) { - LookinAppInfoDeviceSimulator, // 0,模拟器 - LookinAppInfoDeviceIPad, // 1,iPad 真机 - LookinAppInfoDeviceOthers, // 2,应该视为 iPhone 真机 - LookinAppInfoDeviceMac, // 3,使用AppKit的Mac应用(非 Catalyst) - LookinAppInfoDeviceMacCatalyst, // 4,Mac Catalyst 应用:跑在 Mac 上,但界面是 UIKit -}; - -@interface LookinAppInfo : NSObject - -/// 每次启动 app 时都会随机生成一个 appInfoIdentifier 直到 app 被 kill 掉 -@property(nonatomic, assign) NSUInteger appInfoIdentifier; -/// mac 端应该先读取该属性,如果为 YES 则表示应该使用之前保存的旧 appInfo 对象即可 -@property(nonatomic, assign) BOOL shouldUseCache; -/// LookinServer 的版本 -@property(nonatomic, assign) int serverVersion; -/// 类似 "1.1.9",只在 1.2.3 以及之后的 LookinServer 版本里有值 -@property(nonatomic, assign) NSString *serverReadableVersion; -/// 如果 iOS 侧使用了 SPM 或引入了 Swift Subspec,则该属性为 1 -/// 如果 iOS 侧没使用,则该属性为 -1 -/// 如果不知道,则该属性为 0 -@property(nonatomic, assign) int swiftEnabledInLookinServer; -/// Zero for older/unsupported servers; currently 1 for gesture capture. -@property(nonatomic, assign) NSUInteger gestureDebugProtocolVersion; -/// app 的当前截图 -@property(nonatomic, strong) LookinImage *screenshot; -/// 可能为 nil,比如新建的 iOS 空项目 -@property(nonatomic, strong) LookinImage *appIcon; -/// @"微信读书" -@property(nonatomic, copy) NSString *appName; -/// hughkli.lookin -@property(nonatomic, copy) NSString *appBundleIdentifier; -/// @"iPhone X" -@property(nonatomic, copy) NSString *deviceDescription; -/// The hardware model identifier, e.g. @"iPhone16,2", @"iPad14,3", @"Mac15,7". -/// On a simulator this is the *simulated* device's identifier, not the host CPU -/// architecture. Nil whenever the model cannot be determined, and always nil when the -/// peer runs a LookinServer predating this field — receivers must keep a fallback path. -@property(nonatomic, copy) NSString *deviceModelIdentifier; -/// @"12.1" -@property(nonatomic, copy) NSString *osDescription; -/// 返回 os 的主版本号,比如 iOS 12.1 的设备将返回 12,iOS 13.2.1 的设备将返回 13 -@property(nonatomic, assign) NSUInteger osMainVersion; -/// 设备类型 -@property(nonatomic, assign) LookinAppInfoDevice deviceType; -/// 屏幕的宽度 -@property(nonatomic, assign) double screenWidth; -/// 屏幕的高度 -@property(nonatomic, assign) double screenHeight; -/// 是几倍的屏幕 -@property(nonatomic, assign) double screenScale; -@property(nonatomic, assign) NSTimeInterval cachedTimestamp; - -- (BOOL)isEqualToAppInfo:(LookinAppInfo *)info; - -+ (LookinAppInfo *)currentInfoWithScreenshot:(BOOL)hasScreenshot icon:(BOOL)hasIcon localIdentifiers:(NSArray *)localIdentifiers; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAppInfo.m b/Sources/LookinCore/LookinAppInfo.m deleted file mode 100644 index b9de974e..00000000 --- a/Sources/LookinCore/LookinAppInfo.m +++ /dev/null @@ -1,412 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAppInfo.m -// qmuidemo -// -// Created by Li Kai on 2018/11/3. -// Copyright © 2018 QMUI Team. All rights reserved. -// - - - -#import "LookinAppInfo.h" -#import "LKS_MultiplatformAdapter.h" - -#import - -#if TARGET_OS_MACCATALYST -#import -#endif - -static NSString * const CodingKey_AppIcon = @"1"; -static NSString * const CodingKey_Screenshot = @"2"; -static NSString * const CodingKey_DeviceDescription = @"3"; -static NSString * const CodingKey_OsDescription = @"4"; -static NSString * const CodingKey_AppName = @"5"; -static NSString * const CodingKey_ScreenWidth = @"6"; -static NSString * const CodingKey_ScreenHeight = @"7"; -static NSString * const CodingKey_DeviceType = @"8"; - -/// Reads a sysctl entry as a string, or nil when it is unavailable or empty. -static NSString *LookinAppInfoSysctlStringValue(const char *sysctlName) { - size_t valueSize = 0; - if (sysctlbyname(sysctlName, NULL, &valueSize, NULL, 0) != 0 || valueSize == 0) { - return nil; - } - char *valueBuffer = malloc(valueSize); - if (valueBuffer == NULL) { - return nil; - } - NSString *value = nil; - if (sysctlbyname(sysctlName, valueBuffer, &valueSize, NULL, 0) == 0) { - value = [NSString stringWithUTF8String:valueBuffer]; - } - free(valueBuffer); - return value.length ? value : nil; -} - -#if TARGET_OS_MACCATALYST -/// This Mac's user-facing computer name, e.g. @"JH's Mac Studio Ultra". Nil when it cannot -/// be read, which callers must tolerate. -/// -/// Reached through the runtime rather than by calling NSHost directly: NSHost is declared -/// only in the AppKit-flavoured Foundation headers, which a Catalyst target does not see — -/// but the class itself is present and functional in the Foundation a Catalyst process -/// links against, so the lookup succeeds at runtime. -/// -/// Needed because -[UIDevice name] answers the literal string @"iPad" on Catalyst. That -/// names a device family, not this machine, so it identifies nothing to the person reading -/// the host's device label. -static NSString *LookinAppInfoMacHostLocalizedName(void) { - Class hostClass = NSClassFromString(@"NSHost"); - SEL currentHostSelector = NSSelectorFromString(@"currentHost"); - if (![hostClass respondsToSelector:currentHostSelector]) { - return nil; - } - id currentHost = ((id (*)(id, SEL))objc_msgSend)(hostClass, currentHostSelector); - SEL localizedNameSelector = NSSelectorFromString(@"localizedName"); - if (![currentHost respondsToSelector:localizedNameSelector]) { - return nil; - } - id localizedName = ((id (*)(id, SEL))objc_msgSend)(currentHost, localizedNameSelector); - if (![localizedName isKindOfClass:[NSString class]] || [(NSString *)localizedName length] == 0) { - return nil; - } - return localizedName; -} -#endif - -@implementation LookinAppInfo - -- (id)copyWithZone:(NSZone *)zone { - LookinAppInfo *newAppInfo = [[LookinAppInfo allocWithZone:zone] init]; - newAppInfo.appIcon = self.appIcon; - newAppInfo.gestureDebugProtocolVersion = self.gestureDebugProtocolVersion; - newAppInfo.appName = self.appName; - newAppInfo.deviceDescription = self.deviceDescription; - newAppInfo.deviceModelIdentifier = self.deviceModelIdentifier; - newAppInfo.osDescription = self.osDescription; - newAppInfo.osMainVersion = self.osMainVersion; - newAppInfo.deviceType = self.deviceType; - newAppInfo.screenWidth = self.screenWidth; - newAppInfo.screenHeight = self.screenHeight; - newAppInfo.screenScale = self.screenScale; - newAppInfo.appInfoIdentifier = self.appInfoIdentifier; - newAppInfo.cachedTimestamp = self.cachedTimestamp; - return newAppInfo; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - - self.serverVersion = [aDecoder decodeIntForKey:@"serverVersion"]; - self.serverReadableVersion = [aDecoder decodeObjectForKey:@"serverReadableVersion"]; - self.swiftEnabledInLookinServer = [aDecoder decodeIntForKey:@"swiftEnabledInLookinServer"]; - self.gestureDebugProtocolVersion = [aDecoder decodeIntegerForKey:@"gestureDebugProtocolVersion"]; - NSData *screenshotData = [aDecoder decodeObjectForKey:CodingKey_Screenshot]; - self.screenshot = [[LookinImage alloc] initWithData:screenshotData]; - - NSData *appIconData = [aDecoder decodeObjectForKey:CodingKey_AppIcon]; - self.appIcon = [[LookinImage alloc] initWithData:appIconData]; - - self.appName = [aDecoder decodeObjectForKey:CodingKey_AppName]; - self.appBundleIdentifier = [aDecoder decodeObjectForKey:@"appBundleIdentifier"]; - self.deviceDescription = [aDecoder decodeObjectForKey:CodingKey_DeviceDescription]; - self.deviceModelIdentifier = [aDecoder decodeObjectForKey:@"deviceModelIdentifier"]; - self.osDescription = [aDecoder decodeObjectForKey:CodingKey_OsDescription]; - self.osMainVersion = [aDecoder decodeIntegerForKey:@"osMainVersion"]; - self.deviceType = [aDecoder decodeIntegerForKey:CodingKey_DeviceType]; - self.screenWidth = [aDecoder decodeDoubleForKey:CodingKey_ScreenWidth]; - self.screenHeight = [aDecoder decodeDoubleForKey:CodingKey_ScreenHeight]; - self.screenScale = [aDecoder decodeDoubleForKey:@"screenScale"]; - self.appInfoIdentifier = [aDecoder decodeIntegerForKey:@"appInfoIdentifier"]; - self.shouldUseCache = [aDecoder decodeBoolForKey:@"shouldUseCache"]; - } - return self; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInt:self.serverVersion forKey:@"serverVersion"]; - [aCoder encodeObject:self.serverReadableVersion forKey:@"serverReadableVersion"]; - [aCoder encodeInt:self.swiftEnabledInLookinServer forKey:@"swiftEnabledInLookinServer"]; - [aCoder encodeInteger:self.gestureDebugProtocolVersion forKey:@"gestureDebugProtocolVersion"]; - -#if TARGET_OS_IPHONE - NSData *screenshotData = UIImagePNGRepresentation(self.screenshot); - [aCoder encodeObject:screenshotData forKey:CodingKey_Screenshot]; - - NSData *appIconData = UIImagePNGRepresentation(self.appIcon); - [aCoder encodeObject:appIconData forKey:CodingKey_AppIcon]; -#elif TARGET_OS_OSX - NSData *screenshotData = [self.screenshot TIFFRepresentation]; - [aCoder encodeObject:screenshotData forKey:CodingKey_Screenshot]; - - NSData *appIconData = [self.appIcon TIFFRepresentation]; - [aCoder encodeObject:appIconData forKey:CodingKey_AppIcon]; -#endif - - [aCoder encodeObject:self.appName forKey:CodingKey_AppName]; - [aCoder encodeObject:self.appBundleIdentifier forKey:@"appBundleIdentifier"]; - [aCoder encodeObject:self.deviceDescription forKey:CodingKey_DeviceDescription]; - [aCoder encodeObject:self.deviceModelIdentifier forKey:@"deviceModelIdentifier"]; - [aCoder encodeObject:self.osDescription forKey:CodingKey_OsDescription]; - [aCoder encodeInteger:self.osMainVersion forKey:@"osMainVersion"]; - [aCoder encodeInteger:self.deviceType forKey:CodingKey_DeviceType]; - [aCoder encodeDouble:self.screenWidth forKey:CodingKey_ScreenWidth]; - [aCoder encodeDouble:self.screenHeight forKey:CodingKey_ScreenHeight]; - [aCoder encodeDouble:self.screenScale forKey:@"screenScale"]; - [aCoder encodeInteger:self.appInfoIdentifier forKey:@"appInfoIdentifier"]; - [aCoder encodeBool:self.shouldUseCache forKey:@"shouldUseCache"]; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinAppInfo class]]) { - return NO; - } - if ([self isEqualToAppInfo:object]) { - return YES; - } - return NO; -} - -- (NSUInteger)hash { - return self.appName.hash ^ self.deviceDescription.hash ^ self.osDescription.hash ^ self.deviceType; -} - -- (BOOL)isEqualToAppInfo:(LookinAppInfo *)info { - if (!info) { - return NO; - } - if ([self.appName isEqualToString:info.appName] && [self.deviceDescription isEqualToString:info.deviceDescription] && [self.osDescription isEqualToString:info.osDescription] && self.deviceType == info.deviceType) { - return YES; - } - return NO; -} - - -+ (LookinAppInfo *)currentInfoWithScreenshot:(BOOL)hasScreenshot icon:(BOOL)hasIcon localIdentifiers:(NSArray *)localIdentifiers { - NSInteger selfIdentifier = [self getAppInfoIdentifier]; - if ([localIdentifiers containsObject:@(selfIdentifier)]) { - LookinAppInfo *info = [LookinAppInfo new]; - info.appInfoIdentifier = selfIdentifier; - info.shouldUseCache = YES; - return info; - } - - LookinAppInfo *info = [[LookinAppInfo alloc] init]; - if (@available(iOS 16.0, macOS 13.0, tvOS 16.0, *)) { - info.gestureDebugProtocolVersion = NSClassFromString(@"LKS_GestureDebugService") ? 1 : 0; - } - info.serverReadableVersion = LOOKIN_SERVER_READABLE_VERSION; -// Report Swift optimization as enabled whenever the Swift-aware build is -// compiled in. The CocoaPods subspec path defines LOOKIN_SERVER_SWIFT_ENABLED; -// the SwiftPM / XCFramework path defines SPM_LOOKIN_SERVER_ENABLED (which in -// turn imports LookinServerSwift and activates LOOKIN_SERVER_SWIFT_ENABLED_SUCCESSFULLY -// in LKS_TraceManager). Both imply the optimization is active, matching the -// contract documented on -swiftEnabledInLookinServer ("SPM 或 Swift Subspec → 1"). -#if defined(LOOKIN_SERVER_SWIFT_ENABLED) || defined(SPM_LOOKIN_SERVER_ENABLED) - info.swiftEnabledInLookinServer = 1; -#else - info.swiftEnabledInLookinServer = -1; -#endif - info.appInfoIdentifier = selfIdentifier; - info.appName = [self appName]; -#if TARGET_OS_MACCATALYST - // Ask the Mac for its own name. -[UIDevice name] is still consulted as a last resort so - // the field is never empty, but on Catalyst it only ever answers @"iPad". - info.deviceDescription = LookinAppInfoMacHostLocalizedName() ?: [UIDevice currentDevice].name; -#elif TARGET_OS_IPHONE - info.deviceDescription = [UIDevice currentDevice].name; -#elif TARGET_OS_OSX - info.deviceDescription = [NSHost currentHost].localizedName; -#endif - info.deviceModelIdentifier = [self currentDeviceModelIdentifier]; - info.appBundleIdentifier = [[NSBundle mainBundle] bundleIdentifier]; - if ([self isSimulator]) { - info.deviceType = LookinAppInfoDeviceSimulator; - } else if ([LKS_MultiplatformAdapter isMacCatalyst]) { - // Must precede the iPad check: -[UIDevice model] reports "iPad" on Catalyst, so - // +isiPad matches a Catalyst build too and would otherwise claim it first. +isMac - // never fires here either — TARGET_OS_OSX is 0 for the Catalyst destination. - info.deviceType = LookinAppInfoDeviceMacCatalyst; - } else if ([LKS_MultiplatformAdapter isiPad]) { - info.deviceType = LookinAppInfoDeviceIPad; - } else if ([LKS_MultiplatformAdapter isMac]) { - info.deviceType = LookinAppInfoDeviceMac; - } else { - info.deviceType = LookinAppInfoDeviceOthers; - } - -#if TARGET_OS_IPHONE - NSString *systemVersion = [UIDevice currentDevice].systemVersion; -#if TARGET_OS_MACCATALYST - // On Catalyst -[UIDevice systemVersion] answers the *macOS* version (@"26.6" on macOS - // 26.6), not the iOS version Catalyst maps onto. Calling that "iOS 26.6" would be wrong - // twice over — wrong OS, and a version number that OS never shipped. - info.osDescription = [NSString stringWithFormat:@"macCatalyst %@", systemVersion]; -#else - info.osDescription = [NSString stringWithFormat:@"iOS %@", systemVersion]; -#endif - NSString *mainVersionStr = [systemVersion componentsSeparatedByString:@"."].firstObject; - info.osMainVersion = [mainVersionStr integerValue]; -#elif TARGET_OS_OSX - NSOperatingSystemVersion operatingSystemVersion = [NSProcessInfo processInfo].operatingSystemVersion; - if (operatingSystemVersion.patchVersion) { - info.osDescription = [NSString stringWithFormat:@"macOS %ld.%ld.%ld", operatingSystemVersion.majorVersion, operatingSystemVersion.minorVersion, operatingSystemVersion.patchVersion]; - } else { - info.osDescription = [NSString stringWithFormat:@"macOS %ld.%ld", operatingSystemVersion.majorVersion, operatingSystemVersion.minorVersion]; - } - info.osMainVersion = operatingSystemVersion.majorVersion; -#endif - - - CGSize screenSize = [LKS_MultiplatformAdapter mainScreenBounds].size; - info.screenWidth = screenSize.width; - info.screenHeight = screenSize.height; - info.screenScale = [LKS_MultiplatformAdapter mainScreenScale]; - - if (hasScreenshot) { - info.screenshot = [self screenshotImage]; - } - if (hasIcon) { - info.appIcon = [self appIcon]; - } - - return info; -} - -+ (NSString *)appName { - NSDictionary *info = [[NSBundle mainBundle] infoDictionary]; - NSString *displayName = [info objectForKey:@"CFBundleDisplayName"]; - if (displayName.length) return displayName; - NSString *name = [info objectForKey:@"CFBundleName"]; - if (name.length) return name; - return [NSProcessInfo processInfo].processName; -} - -+ (LookinImage *)appIcon { -#if TARGET_OS_TV - return nil; -#elif TARGET_OS_IPHONE - NSString *imageName; - id CFBundleIcons = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleIcons"]; - if ([CFBundleIcons respondsToSelector:@selector(objectForKey:)]) { - id CFBundlePrimaryIcon = [CFBundleIcons objectForKey:@"CFBundlePrimaryIcon"]; - if ([CFBundlePrimaryIcon respondsToSelector:@selector(objectForKey:)]) { - imageName = [[CFBundlePrimaryIcon objectForKey:@"CFBundleIconFiles"] lastObject]; - } else if ([CFBundlePrimaryIcon isKindOfClass:NSString.class]) { - imageName = CFBundlePrimaryIcon; - } - } - if (!imageName.length) { - // 正常情况下拿到的 name 可能比如 “AppIcon60x60”。但某些情况可能为 nil,此时直接 return 否则 [UIImage imageNamed:nil] 可能导致 console 报 "CUICatalog: Invalid asset name supplied: '(null)'" 的错误信息 - return nil; - } - return [UIImage imageNamed:imageName]; -#elif TARGET_OS_OSX - return [[NSApplication sharedApplication] applicationIconImage]; -#endif -} - -+ (LookinImage *)screenshotImage { - LookinWindow *window = [LKS_MultiplatformAdapter keyWindow]; -#if TARGET_OS_OSX - // macOS 上当应用失去焦点或处于后台时 keyWindow 为 nil。 - // 回退到第一个可见窗口(优先 mainWindow,其次任意已排序的窗口)。 - if (!window) { - window = [NSApplication sharedApplication].mainWindow; - } - if (!window) { - window = [NSApplication sharedApplication].windows.firstObject; - } -#endif - if (!window) { - return nil; - } -#if TARGET_OS_IPHONE - CGSize size = window.bounds.size; - if (size.width <= 0 || size.height <= 0) { - // *** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'UIGraphicsBeginImageContext() failed to allocate CGBitampContext: size={0, 0}, scale=3.000000, bitmapInfo=0x2002. Use UIGraphicsImageRenderer to avoid this assert.' - - // https://github.com/hughkli/Lookin/issues/21 - return nil; - } - UIGraphicsBeginImageContextWithOptions(size, YES, 0.4); - [window drawViewHierarchyInRect:window.bounds afterScreenUpdates:YES]; - UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); -#elif TARGET_OS_OSX - if (!window) { - return nil; - } -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - // ScreenCaptureKit replacement is async and prompts for screen recording permission; keep the - // synchronous CGWindowListCreateImage for capturing our own app window thumbnails. - CGImageRef cgImage = CGWindowListCreateImage(CGRectZero, kCGWindowListOptionIncludingWindow, (int)window.windowNumber, kCGWindowImageBoundsIgnoreFraming); -#pragma clang diagnostic pop - if (!cgImage) { - return nil; - } - NSImage *image = [[NSImage alloc] initWithCGImage:cgImage size:window.frame.size]; - CGImageRelease(cgImage); -#endif - - return image; -} - -+ (BOOL)isSimulator { - if (TARGET_OS_SIMULATOR) { - return YES; - } - return NO; -} - -/// The hardware model identifier of the device this process runs on, e.g. @"iPhone16,2". -/// Returns nil when it cannot be determined, which callers must tolerate. -+ (NSString *)currentDeviceModelIdentifier { - static dispatch_once_t onceToken; - static NSString *modelIdentifier = nil; - dispatch_once(&onceToken, ^{ -#if TARGET_OS_SIMULATOR - // hw.machine reports the *host* Mac's architecture ("arm64") inside a simulator, - // which identifies no device at all. The simulated model is only available from - // the environment simctl prepares for the process. - modelIdentifier = [[NSProcessInfo processInfo].environment[@"SIMULATOR_MODEL_IDENTIFIER"] copy]; -#elif TARGET_OS_OSX || TARGET_OS_MACCATALYST - // On macOS hw.machine is the CPU architecture; the Mac model lives in hw.model. - modelIdentifier = LookinAppInfoSysctlStringValue("hw.model"); -#else - modelIdentifier = LookinAppInfoSysctlStringValue("hw.machine"); -#endif - }); - return modelIdentifier; -} - - -+ (NSInteger)getAppInfoIdentifier { - static dispatch_once_t onceToken; - static NSInteger identifier = 0; - dispatch_once(&onceToken,^{ - uint64_t nowMicros = (uint64_t)([[NSDate date] timeIntervalSince1970] * 1000000.0); - uint64_t processID = (uint64_t)NSProcessInfo.processInfo.processIdentifier; - uint64_t randomBits = arc4random(); - identifier = (NSInteger)((nowMicros << 12) ^ (processID << 1) ^ randomBits); - if (identifier <= 0) { - identifier = (NSInteger)(processID ^ (randomBits ?: 1)); - } - }); - return identifier; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttribute.h b/Sources/LookinCore/LookinAttribute.h deleted file mode 100644 index d86f2987..00000000 --- a/Sources/LookinCore/LookinAttribute.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttribute.h -// qmuidemo -// -// Created by Li Kai on 2018/11/17. -// Copyright © 2018 QMUI Team. All rights reserved. -// - -#import "LookinAttrIdentifiers.h" -#import "LookinCodingValueType.h" -#import "LookinAttrType.h" - -@class LookinDisplayItem; - -@interface LookinAttribute : NSObject - -@property(nonatomic, copy) LookinAttrIdentifier identifier; - -/// 只有 Custom Attr 才有该属性 -@property(nonatomic, copy) NSString *displayTitle; - -/// 标识 value 的具体类型(如 double / NSString /...) -@property(nonatomic, assign) LookinAttrType attrType; - -/// 具体的值,需配合 attrType 属性来解析它 -/// 对于 String、Color 等 attyType,该属性可能为 nil -@property(nonatomic, strong) id value; - -/// 额外信息,大部分情况下它是 nil -/// 当 attyType 为 LookinAttrTypeEnumString 时,extraValue 是一个 [String] 且保存了 allEnumCases -@property(nonatomic, strong) id extraValue; - -/// 仅 Custom Attr 可能有该属性 -/// 对于有 retainedSetter 的 Custom Attr,它的 setter 会以 customSetterID 作为 key 被保存到 LKS_CustomAttrSetterManager 里,后续可以通过这个 uniqueID 重新把 setter 从 LKS_CustomAttrSetterManager 里取出来并调用 -@property(nonatomic, copy) NSString *customSetterID; - -#pragma mark - 以下属性不会参与 encode/decode - -/// 标识该 LookinAttribute 对象隶属于哪一个 LookinDisplayItem -@property(nonatomic, weak) LookinDisplayItem *targetDisplayItem; - -- (BOOL)isUserCustom; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttribute.m b/Sources/LookinCore/LookinAttribute.m deleted file mode 100644 index c0625d70..00000000 --- a/Sources/LookinCore/LookinAttribute.m +++ /dev/null @@ -1,64 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttribute.m -// qmuidemo -// -// Created by Li Kai on 2018/11/17. -// Copyright © 2018 QMUI Team. All rights reserved. -// - - - -#import "LookinAttribute.h" -#import "LookinDisplayItem.h" - -@implementation LookinAttribute - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinAttribute *newAttr = [[LookinAttribute allocWithZone:zone] init]; - newAttr.identifier = self.identifier; - newAttr.displayTitle = self.displayTitle; - newAttr.value = self.value; - newAttr.attrType = self.attrType; - newAttr.extraValue = self.extraValue; - newAttr.customSetterID = self.customSetterID; - return newAttr; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.displayTitle forKey:@"displayTitle"]; - [aCoder encodeObject:self.identifier forKey:@"identifier"]; - [aCoder encodeInteger:self.attrType forKey:@"attrType"]; - [aCoder encodeObject:self.value forKey:@"value"]; - [aCoder encodeObject:self.extraValue forKey:@"extraValue"]; - [aCoder encodeObject:self.customSetterID forKey:@"customSetterID"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.displayTitle = [aDecoder decodeObjectForKey:@"displayTitle"]; - self.identifier = [aDecoder decodeObjectForKey:@"identifier"]; - self.attrType = [aDecoder decodeIntegerForKey:@"attrType"]; - self.value = [aDecoder decodeObjectForKey:@"value"]; - self.extraValue = [aDecoder decodeObjectForKey:@"extraValue"]; - self.customSetterID = [aDecoder decodeObjectForKey:@"customSetterID"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (BOOL)isUserCustom { - return [self.identifier isEqualToString:LookinAttr_UserCustom]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttributeModification.h b/Sources/LookinCore/LookinAttributeModification.h deleted file mode 100644 index c9357e0b..00000000 --- a/Sources/LookinCore/LookinAttributeModification.h +++ /dev/null @@ -1,31 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributeModification.h -// Lookin -// -// Created by Li Kai on 2018/11/20. -// https://lookin.work -// - - - -#import -#import "LookinAttrType.h" - -@interface LookinAttributeModification : NSObject - -@property(nonatomic, assign) unsigned long targetOid; - -@property(nonatomic, assign) SEL setterSelector; -@property(nonatomic, assign) SEL getterSelector; - -@property(nonatomic, assign) LookinAttrType attrType; -@property(nonatomic, strong) id value; - -/// 1.0.4 开始加入这个参数 -@property(nonatomic, copy) NSString *clientReadableVersion; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttributeModification.m b/Sources/LookinCore/LookinAttributeModification.m deleted file mode 100644 index 39f16c9a..00000000 --- a/Sources/LookinCore/LookinAttributeModification.m +++ /dev/null @@ -1,40 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributeModification.m -// Lookin -// -// Created by Li Kai on 2018/11/20. -// https://lookin.work -// - -#import "LookinAttributeModification.h" - -@implementation LookinAttributeModification - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:@(self.targetOid) forKey:@"targetOid"]; - [aCoder encodeObject:NSStringFromSelector(self.setterSelector) forKey:@"setterSelector"]; - [aCoder encodeInteger:self.attrType forKey:@"attrType"]; - [aCoder encodeObject:self.value forKey:@"value"]; - [aCoder encodeObject:self.clientReadableVersion forKey:@"clientReadableVersion"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.targetOid = [[aDecoder decodeObjectForKey:@"targetOid"] unsignedLongValue]; - self.setterSelector = NSSelectorFromString([aDecoder decodeObjectForKey:@"setterSelector"]); - self.attrType = [aDecoder decodeIntegerForKey:@"attrType"]; - self.value = [aDecoder decodeObjectForKey:@"value"]; - self.clientReadableVersion = [aDecoder decodeObjectForKey:@"clientReadableVersion"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttributesGroup.h b/Sources/LookinCore/LookinAttributesGroup.h deleted file mode 100644 index aff2985b..00000000 --- a/Sources/LookinCore/LookinAttributesGroup.h +++ /dev/null @@ -1,54 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributesGroup.h -// Lookin -// -// Created by Li Kai on 2018/11/19. -// https://lookin.work -// - - - -#import -#import "LookinAttrIdentifiers.h" - -@class LookinAttributesSection; - -/** - In Lookin, a LookinAttributesGroup instance will be rendered as a property card. - - When isUserCustom is false: two LookinAttributesGroup instances will be regard as equal when they has the same LookinAttrGroupIdentifier. - When isUserCustom is true: two LookinAttributesGroup instances will be regard as equal when they has the same title. - 当 isUserCustom 为 false 时:若两个 attrGroup 有相同的 LookinAttrGroupIdentifier,则 isEqual: 返回 YES - */ -@interface LookinAttributesGroup : NSObject - -/// 只有在 identifier 为 custom 时,才存在该值 -@property(nonatomic, copy) NSString *userCustomTitle; - -@property(nonatomic, copy) LookinAttrGroupIdentifier identifier; - -@property(nonatomic, copy) NSArray *attrSections; - -/// YES if this group represents a SwiftUI attribute card produced by the -/// activation-gated SwiftUI extension. Hosts use this to render an accent -/// indicating paid-feature data. Not part of `isEqual:`/`hash`. -@property(nonatomic, assign) BOOL isSwiftUIGroup; - -/// 如果是 custom 则返回 userCustomTitle,如果不是 custom 则返回 identifier -- (NSString *)uniqueKey; - -- (BOOL)isUserCustom; - -/// Hosts render one card per uniqueKey, so a list carrying the same key twice -/// makes the shared card get laid out at two positions and leaves a blank gap -/// at the first one. Makers must not emit duplicates, but the host dedupes -/// through this as a defense so that already-shipped servers with the bug -/// (0.2.8/0.2.9 duplicated the scene Layout group) still display correctly. -/// Keeps the first occurrence of each uniqueKey; order is preserved. -+ (NSArray *)groupsByKeepingFirstGroupForEachUniqueKey:(NSArray *)groups; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttributesGroup.m b/Sources/LookinCore/LookinAttributesGroup.m deleted file mode 100644 index 6fa38088..00000000 --- a/Sources/LookinCore/LookinAttributesGroup.m +++ /dev/null @@ -1,111 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributesGroup.m -// Lookin -// -// Created by Li Kai on 2018/11/19. -// https://lookin.work -// - - - -#import "LookinAttributesGroup.h" -#import "LookinAttribute.h" -#import "LookinAttributesSection.h" -#import "LookinDashboardBlueprint.h" -#import "NSArray+Lookin.h" - -@implementation LookinAttributesGroup - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinAttributesGroup *newGroup = [[LookinAttributesGroup allocWithZone:zone] init]; - newGroup.userCustomTitle = self.userCustomTitle; - newGroup.identifier = self.identifier; - newGroup.attrSections = [self.attrSections lookin_map:^id(NSUInteger idx, LookinAttributesSection *value) { - return value.copy; - }]; - newGroup.isSwiftUIGroup = self.isSwiftUIGroup; - return newGroup; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.userCustomTitle forKey:@"userCustomTitle"]; - [aCoder encodeObject:self.identifier forKey:@"identifier"]; - [aCoder encodeObject:self.attrSections forKey:@"attrSections"]; - [aCoder encodeBool:self.isSwiftUIGroup forKey:@"isSwiftUIGroup"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.userCustomTitle = [aDecoder decodeObjectForKey:@"userCustomTitle"]; - self.identifier = [aDecoder decodeObjectForKey:@"identifier"]; - self.attrSections = [aDecoder decodeObjectForKey:@"attrSections"]; - self.isSwiftUIGroup = [aDecoder decodeBoolForKey:@"isSwiftUIGroup"]; - } - return self; -} - -- (NSUInteger)hash { - return self.uniqueKey.hash; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinAttributesGroup class]]) { - return NO; - } - LookinAttributesGroup *targetObject = object; - - if (![self.identifier isEqualToString:targetObject.identifier]) { - return false; - } - if ([self.identifier isEqualToString:LookinAttrGroup_UserCustom]) { - BOOL ret = [self.userCustomTitle isEqualToString:targetObject.userCustomTitle]; - return ret; - } else { - return true; - } -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (NSString *)uniqueKey { - if ([self.identifier isEqualToString:LookinAttrGroup_UserCustom]) { - return self.userCustomTitle; - } else { - return self.identifier; - } -} - -- (BOOL)isUserCustom { - return [self.identifier isEqualToString:LookinAttrGroup_UserCustom]; -} - -+ (NSArray *)groupsByKeepingFirstGroupForEachUniqueKey:(NSArray *)groups { - NSMutableSet *seenUniqueKeys = [NSMutableSet set]; - NSMutableArray *deduplicatedGroups = [NSMutableArray array]; - for (LookinAttributesGroup *group in groups) { - NSString *uniqueKey = group.uniqueKey; - if (uniqueKey) { - if ([seenUniqueKeys containsObject:uniqueKey]) { - continue; - } - [seenUniqueKeys addObject:uniqueKey]; - } - [deduplicatedGroups addObject:group]; - } - return deduplicatedGroups; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttributesSection.h b/Sources/LookinCore/LookinAttributesSection.h deleted file mode 100644 index 32ddec8d..00000000 --- a/Sources/LookinCore/LookinAttributesSection.h +++ /dev/null @@ -1,35 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributesSection.h -// Lookin -// -// Created by Li Kai on 2019/3/2. -// https://lookin.work -// - - - -#import -#import "LookinAttrIdentifiers.h" - -@class LookinAttribute; - -typedef NS_ENUM (NSInteger, LookinAttributesSectionStyle) { - LookinAttributesSectionStyleDefault, // 每个 attr 独占一行 - LookinAttributesSectionStyle0, // frame 等卡片使用,前 4 个 attr 每行两个,之后每个 attr 在同一排,每个宽度为 1/4 - LookinAttributesSectionStyle1, // 第一个 attr 在第一排靠左,第二个 attr 在第一排靠右,之后的 attr 每个独占一行 - LookinAttributesSectionStyle2 // 第一排独占一行,剩下的在同一行且均分宽度 -}; - -@interface LookinAttributesSection : NSObject - -@property(nonatomic, copy) LookinAttrSectionIdentifier identifier; - -@property(nonatomic, copy) NSArray *attributes; - -- (BOOL)isUserCustom; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttributesSection.m b/Sources/LookinCore/LookinAttributesSection.m deleted file mode 100644 index 7617c094..00000000 --- a/Sources/LookinCore/LookinAttributesSection.m +++ /dev/null @@ -1,56 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributesSection.m -// Lookin -// -// Created by Li Kai on 2019/3/2. -// https://lookin.work -// - - - -#import "LookinAttributesSection.h" -#import "LookinAttribute.h" - -#import "NSArray+Lookin.h" - -@implementation LookinAttributesSection - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinAttributesSection *newSection = [[LookinAttributesSection allocWithZone:zone] init]; - newSection.identifier = self.identifier; - newSection.attributes = [self.attributes lookin_map:^id(NSUInteger idx, LookinAttribute *value) { - return value.copy; - }]; - return newSection; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.identifier forKey:@"identifier"]; - [aCoder encodeObject:self.attributes forKey:@"attributes"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.identifier = [aDecoder decodeObjectForKey:@"identifier"]; - self.attributes = [aDecoder decodeObjectForKey:@"attributes"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (BOOL)isUserCustom { - return [self.identifier isEqualToString:LookinAttrSec_UserCustom]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAutoLayoutConstraint.h b/Sources/LookinCore/LookinAutoLayoutConstraint.h deleted file mode 100644 index 09d71b4b..00000000 --- a/Sources/LookinCore/LookinAutoLayoutConstraint.h +++ /dev/null @@ -1,65 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAutoLayoutConstraint.h -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - - - -#import "LookinDefines.h" - -@class LookinObject; - -typedef NS_ENUM(NSInteger, LookinConstraintItemType) { - LookinConstraintItemTypeUnknown, - LookinConstraintItemTypeNil, - LookinConstraintItemTypeView, - LookinConstraintItemTypeSelf, - LookinConstraintItemTypeSuper, - LookinConstraintItemTypeLayoutGuide -}; - -typedef NS_ENUM(NSInteger, LookinConstraintEndpoint) { - LookinConstraintEndpointFirst = 0, - LookinConstraintEndpointSecond = 1, -}; - -@interface LookinAutoLayoutConstraint : NSObject - -+ (instancetype)instanceFromNSConstraint:(NSLayoutConstraint *)constraint isEffective:(BOOL)isEffective firstItemType:(LookinConstraintItemType)firstItemType secondItemType:(LookinConstraintItemType)secondItemType; - -@property(nonatomic, assign) BOOL effective; -@property(nonatomic, assign) BOOL active; -@property(nonatomic, assign) BOOL shouldBeArchived; -@property(nonatomic, strong) LookinObject *firstItem; -@property(nonatomic, assign) LookinConstraintItemType firstItemType; -/// iOS 里的 NSLayoutAttribute,注意 iOS 和 macOS 虽然都有 NSLayoutAttribute 但是 value 非常不同,因此这里使用 NSInteger 避免混淆 -@property(nonatomic, assign) NSInteger firstAttribute; -@property(nonatomic, assign) NSLayoutRelation relation; -@property(nonatomic, strong) LookinObject *secondItem; -@property(nonatomic, assign) LookinConstraintItemType secondItemType; -/// iOS 里的 NSLayoutAttribute,注意 iOS 和 macOS 虽然都有 NSLayoutAttribute 但是 value 非常不同,因此这里使用 NSInteger 避免混淆 -@property(nonatomic, assign) NSInteger secondAttribute; -@property(nonatomic, assign) CGFloat multiplier; -@property(nonatomic, assign) CGFloat constant; -@property(nonatomic, assign) CGFloat priority; -@property(nonatomic, copy) NSString *identifier; - -/// The NSLayoutConstraint's own registered oid. The same constraint appears -/// in both endpoints' attribute lists; this is what lets consumers recognize -/// the two copies as one constraint. 0 means the data predates the field. -@property(nonatomic, assign) unsigned long constraintOid; - -/// The item object a jump control for the given endpoint must navigate to: -/// the first endpoint jumps to firstItem, the second endpoint to secondItem. -/// Returns nil when that endpoint has no node in the hierarchy to land on — -/// view and layout guide endpoints are jumpable, everything else is not. -- (LookinObject *)jumpableItemObjectForEndpoint:(LookinConstraintEndpoint)endpoint; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAutoLayoutConstraint.m b/Sources/LookinCore/LookinAutoLayoutConstraint.m deleted file mode 100644 index 23f4f3e0..00000000 --- a/Sources/LookinCore/LookinAutoLayoutConstraint.m +++ /dev/null @@ -1,125 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAutoLayoutConstraint.m -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - - - -#import "LookinAutoLayoutConstraint.h" -#import "LookinObject.h" - -@implementation LookinAutoLayoutConstraint - - -+ (instancetype)instanceFromNSConstraint:(NSLayoutConstraint *)constraint isEffective:(BOOL)isEffective firstItemType:(LookinConstraintItemType)firstItemType secondItemType:(LookinConstraintItemType)secondItemType { - LookinAutoLayoutConstraint *instance = [LookinAutoLayoutConstraint new]; - instance.effective = isEffective; - instance.active = constraint.active; - instance.shouldBeArchived = constraint.shouldBeArchived; - instance.firstItem = [LookinObject instanceWithObject:constraint.firstItem]; - instance.firstItemType = firstItemType; - instance.firstAttribute = constraint.firstAttribute; - instance.relation = constraint.relation; - instance.secondItem = [LookinObject instanceWithObject:constraint.secondItem]; - instance.secondItemType = secondItemType; - instance.secondAttribute = constraint.secondAttribute; - instance.multiplier = constraint.multiplier; - instance.constant = constraint.constant; - instance.priority = constraint.priority; - instance.identifier = constraint.identifier; - - return instance; -} - -- (void)setFirstAttribute:(NSInteger)firstAttribute { - _firstAttribute = firstAttribute; - [self _assertUnknownAttribute:firstAttribute]; -} - -- (void)setSecondAttribute:(NSInteger)secondAttribute { - _secondAttribute = secondAttribute; - [self _assertUnknownAttribute:secondAttribute]; -} - -- (void)_assertUnknownAttribute:(NSInteger)attribute { - // 以下几个 assert 用来帮助发现那些系统私有的定义,正式发布时应该去掉这几个 assert - if (attribute > 20 && attribute < 32) { - NSAssert(NO, nil); - } - if (attribute > 37) { - NSAssert(NO, nil); - } -} - - -#pragma mark - - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeBool:self.effective forKey:@"effective"]; - [aCoder encodeBool:self.active forKey:@"active"]; - [aCoder encodeBool:self.shouldBeArchived forKey:@"shouldBeArchived"]; - [aCoder encodeObject:self.firstItem forKey:@"firstItem"]; - [aCoder encodeInteger:self.firstItemType forKey:@"firstItemType"]; - [aCoder encodeInteger:self.firstAttribute forKey:@"firstAttribute"]; - [aCoder encodeInteger:self.relation forKey:@"relation"]; - [aCoder encodeObject:self.secondItem forKey:@"secondItem"]; - [aCoder encodeInteger:self.secondItemType forKey:@"secondItemType"]; - [aCoder encodeInteger:self.secondAttribute forKey:@"secondAttribute"]; - [aCoder encodeDouble:self.multiplier forKey:@"multiplier"]; - [aCoder encodeDouble:self.constant forKey:@"constant"]; - [aCoder encodeDouble:self.priority forKey:@"priority"]; - [aCoder encodeObject:self.identifier forKey:@"identifier"]; - [aCoder encodeInteger:(NSInteger)self.constraintOid forKey:@"constraintOid"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.effective = [aDecoder decodeBoolForKey:@"effective"]; - self.active = [aDecoder decodeBoolForKey:@"active"]; - self.shouldBeArchived = [aDecoder decodeBoolForKey:@"shouldBeArchived"]; - self.firstItem = [aDecoder decodeObjectForKey:@"firstItem"]; - self.firstItemType = [aDecoder decodeIntegerForKey:@"firstItemType"]; - self.firstAttribute = [aDecoder decodeIntegerForKey:@"firstAttribute"]; - self.relation = [aDecoder decodeIntegerForKey:@"relation"]; - self.secondItem = [aDecoder decodeObjectForKey:@"secondItem"]; - self.secondItemType = [aDecoder decodeIntegerForKey:@"secondItemType"]; - self.secondAttribute = [aDecoder decodeIntegerForKey:@"secondAttribute"]; - self.multiplier = [aDecoder decodeDoubleForKey:@"multiplier"]; - self.constant = [aDecoder decodeDoubleForKey:@"constant"]; - self.priority = [aDecoder decodeDoubleForKey:@"priority"]; - self.identifier = [aDecoder decodeObjectForKey:@"identifier"]; - /// Absent in old archives — decodeIntegerForKey returns 0, which is - /// exactly the "no identity" contract. - self.constraintOid = (unsigned long)[aDecoder decodeIntegerForKey:@"constraintOid"]; - } - return self; -} - -static BOOL LookinConstraintItemTypeIsJumpable(LookinConstraintItemType itemType) { - // View endpoints have always had a node to land on; layout guide - // endpoints gained one when guides entered the hierarchy tree. - return itemType == LookinConstraintItemTypeView || itemType == LookinConstraintItemTypeLayoutGuide; -} - -- (LookinObject *)jumpableItemObjectForEndpoint:(LookinConstraintEndpoint)endpoint { - switch (endpoint) { - case LookinConstraintEndpointFirst: - return LookinConstraintItemTypeIsJumpable(self.firstItemType) ? self.firstItem : nil; - case LookinConstraintEndpointSecond: - return LookinConstraintItemTypeIsJumpable(self.secondItemType) ? self.secondItem : nil; - } - return nil; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinConnectionAttachment.h b/Sources/LookinCore/LookinConnectionAttachment.h deleted file mode 100644 index 54a4b900..00000000 --- a/Sources/LookinCore/LookinConnectionAttachment.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinConnectionAttachment.h -// Lookin -// -// Created by Li Kai on 2019/2/15. -// https://lookin.work -// - - - -#import -#import "LookinCodingValueType.h" - -@interface LookinConnectionAttachment : NSObject - -@property(nonatomic, assign) LookinCodingValueType dataType; - -@property(nonatomic, strong) id data; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinConnectionAttachment.m b/Sources/LookinCore/LookinConnectionAttachment.m deleted file mode 100644 index 99eda1ea..00000000 --- a/Sources/LookinCore/LookinConnectionAttachment.m +++ /dev/null @@ -1,52 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinConnectionAttachment.m -// Lookin -// -// Created by Li Kai on 2019/2/15. -// https://lookin.work -// - - - -#import "LookinConnectionAttachment.h" -#import "LookinDefines.h" -#import "NSObject+Lookin.h" - -static NSString * const Key_Data = @"0"; -static NSString * const Key_DataType = @"1"; - -@interface LookinConnectionAttachment () - -@end - -@implementation LookinConnectionAttachment - -- (instancetype)init { - if (self = [super init]) { - } - return self; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - - [aCoder encodeObject:[self.data lookin_encodedObjectWithType:self.dataType] forKey:Key_Data]; - [aCoder encodeInteger:self.dataType forKey:Key_DataType]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.dataType = [aDecoder decodeIntegerForKey:Key_DataType]; - self.data = [[aDecoder decodeObjectForKey:Key_Data] lookin_decodedObjectWithType:self.dataType]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinConnectionResponseAttachment.h b/Sources/LookinCore/LookinConnectionResponseAttachment.h deleted file mode 100644 index 46bb0e97..00000000 --- a/Sources/LookinCore/LookinConnectionResponseAttachment.h +++ /dev/null @@ -1,36 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinConnectionResponse.h -// Lookin -// -// Created by Li Kai on 2019/1/15. -// https://lookin.work -// - - - -#import -#import "LookinConnectionAttachment.h" - -@interface LookinConnectionResponseAttachment : LookinConnectionAttachment - -+ (instancetype)attachmentWithError:(NSError *)error; - -@property(nonatomic, assign) int lookinServerVersion; - -@property(nonatomic, strong) NSError *error; - -/// 如果为 YES,则表示 app 正处于后台模式,默认为 NO -@property(nonatomic, assign) BOOL appIsInBackground; - -/** - dataTotalCount 为 0 时表示仅有这一个 response,默认为 0 - dataTotalCount 大于 0 时表示可能有多个 response,当所有 response 的 currentDataCount 的总和大于 dataTotalCount 即表示所有 response 已接收完毕 - */ -@property(nonatomic, assign) NSUInteger dataTotalCount; -@property(nonatomic, assign) NSUInteger currentDataCount; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinConnectionResponseAttachment.m b/Sources/LookinCore/LookinConnectionResponseAttachment.m deleted file mode 100644 index 71f62fed..00000000 --- a/Sources/LookinCore/LookinConnectionResponseAttachment.m +++ /dev/null @@ -1,62 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinConnectionResponse.m -// Lookin -// -// Created by Li Kai on 2019/1/15. -// https://lookin.work -// - - - -#import "LookinConnectionResponseAttachment.h" -#import "LookinDefines.h" - -@interface LookinConnectionResponseAttachment () - -@end - -@implementation LookinConnectionResponseAttachment - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [super encodeWithCoder:aCoder]; - [aCoder encodeInt:self.lookinServerVersion forKey:@"lookinServerVersion"]; - [aCoder encodeObject:self.error forKey:@"error"]; - [aCoder encodeObject:@(self.dataTotalCount) forKey:@"dataTotalCount"]; - [aCoder encodeObject:@(self.currentDataCount) forKey:@"currentDataCount"]; - [aCoder encodeBool:self.appIsInBackground forKey:@"appIsInBackground"]; -} - -- (instancetype)init { - if (self = [super init]) { - self.lookinServerVersion = LOOKIN_SERVER_VERSION; - self.dataTotalCount = 0; - } - return self; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super initWithCoder:aDecoder]) { - self.lookinServerVersion = [aDecoder decodeIntForKey:@"lookinServerVersion"]; - self.error = [aDecoder decodeObjectForKey:@"error"]; - self.dataTotalCount = [[aDecoder decodeObjectForKey:@"dataTotalCount"] unsignedIntegerValue]; - self.currentDataCount = [[aDecoder decodeObjectForKey:@"currentDataCount"] unsignedIntegerValue]; - self.appIsInBackground = [aDecoder decodeBoolForKey:@"appIsInBackground"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -+ (instancetype)attachmentWithError:(NSError *)error { - LookinConnectionResponseAttachment *attachment = [LookinConnectionResponseAttachment new]; - attachment.error = error; - return attachment; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinCustomAttrModification.h b/Sources/LookinCore/LookinCustomAttrModification.h deleted file mode 100644 index 1be7926a..00000000 --- a/Sources/LookinCore/LookinCustomAttrModification.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LookinCustomAttrModification.h -// LookinShared -// -// Created by likaimacbookhome on 2023/11/4. -// - -#import -#import "LookinAttrType.h" - -@interface LookinCustomAttrModification : NSObject - -@property(nonatomic, assign) LookinAttrType attrType; -@property(nonatomic, copy) NSString *customSetterID; -@property(nonatomic, strong) id value; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinCustomAttrModification.m b/Sources/LookinCore/LookinCustomAttrModification.m deleted file mode 100644 index 6d490165..00000000 --- a/Sources/LookinCore/LookinCustomAttrModification.m +++ /dev/null @@ -1,34 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LookinCustomAttrModification.m -// LookinShared -// -// Created by likaimacbookhome on 2023/11/4. -// - -#import "LookinCustomAttrModification.h" - -@implementation LookinCustomAttrModification - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInteger:self.attrType forKey:@"attrType"]; - [aCoder encodeObject:self.value forKey:@"value"]; - [aCoder encodeObject:self.customSetterID forKey:@"customSetterID"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.attrType = [aDecoder decodeIntegerForKey:@"attrType"]; - self.value = [aDecoder decodeObjectForKey:@"value"]; - self.customSetterID = [aDecoder decodeObjectForKey:@"customSetterID"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinCustomDisplayItemInfo.h b/Sources/LookinCore/LookinCustomDisplayItemInfo.h deleted file mode 100644 index 28f7aa2f..00000000 --- a/Sources/LookinCore/LookinCustomDisplayItemInfo.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LookinCustomDisplayItemInfo.h -// LookinServer -// -// Created by likai.123 on 2023/11/1. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface LookinCustomDisplayItemInfo : NSObject - -/// 该属性可能有值(CGRect)也可能是 nil(nil 时则表示无图像) -@property(nonatomic, strong, nullable) NSValue *frameInWindow; - -/// Whether frameInWindow holds a finite, reasonable rectangle. -- (BOOL)hasValidFrame; -@property(nonatomic, copy, nullable) NSString *title; -@property(nonatomic, copy, nullable) NSString *subtitle; -@property(nonatomic, copy, nullable) NSString *danceuiSource; - -/// YES if this display item represents a SwiftUI virtual node produced by the -/// activation-gated SwiftUI extension. Hosts use this to render an accent -/// indicating paid-feature data. -@property(nonatomic, assign) BOOL isSwiftUI; - -/// 由 server 端生成的 SwiftUI 节点稳定 ID。仅当 isSwiftUI=YES 时有值, -/// 形如 `swiftui::`。compact / verbose 共享同一编号 -/// 空间, host 用它做 mode 切换时的 selection / scroll restore。详见 spec -/// §ID 稳定性 + §Wire 协议 (spec 显式标注此字段为 `nullable`)。 -@property(nonatomic, copy, nullable) NSString *swiftUIDisplayItemID; - -@end - -NS_ASSUME_NONNULL_END - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinCustomDisplayItemInfo.m b/Sources/LookinCore/LookinCustomDisplayItemInfo.m deleted file mode 100644 index 65b2fe50..00000000 --- a/Sources/LookinCore/LookinCustomDisplayItemInfo.m +++ /dev/null @@ -1,74 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinCustomDisplayItemInfo.m -// LookinServer -// -// Created by likai.123 on 2023/11/1. -// - -#import "LookinCustomDisplayItemInfo.h" -#import "LookinDefines.h" -#import "NSValue+Lookin.h" -#if TARGET_OS_IPHONE -#import -#endif - -@implementation LookinCustomDisplayItemInfo - -- (id)copyWithZone:(NSZone *)zone { - LookinCustomDisplayItemInfo *newInstance = [[LookinCustomDisplayItemInfo allocWithZone:zone] init]; - - if (self.frameInWindow) { -#if TARGET_OS_IPHONE - CGRect rect = [self.frameInWindow CGRectValue]; - newInstance.frameInWindow = [NSValue valueWithCGRect:rect]; -#elif TARGET_OS_OSX - CGRect rect = [self.frameInWindow rectValue]; - newInstance.frameInWindow = [NSValue valueWithRect:rect]; -#endif - } - newInstance.title = self.title; - newInstance.subtitle = self.subtitle; - newInstance.danceuiSource = self.danceuiSource; - newInstance.isSwiftUI = self.isSwiftUI; - newInstance.swiftUIDisplayItemID = self.swiftUIDisplayItemID; - - return newInstance; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.frameInWindow forKey:@"frameInWindow"]; - [aCoder encodeObject:self.title forKey:@"title"]; - [aCoder encodeObject:self.subtitle forKey:@"subtitle"]; - [aCoder encodeObject:self.danceuiSource forKey:@"danceuiSource"]; - [aCoder encodeBool:self.isSwiftUI forKey:@"isSwiftUI"]; - [aCoder encodeObject:self.swiftUIDisplayItemID forKey:@"swiftUIDisplayItemID"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.frameInWindow = [aDecoder decodeObjectOfClass:NSValue.class forKey:@"frameInWindow"]; - self.title = [aDecoder decodeObjectOfClass:NSString.class forKey:@"title"]; - self.subtitle = [aDecoder decodeObjectOfClass:NSString.class forKey:@"subtitle"]; - self.danceuiSource = [aDecoder decodeObjectOfClass:NSString.class forKey:@"danceuiSource"]; - self.isSwiftUI = [aDecoder decodeBoolForKey:@"isSwiftUI"]; - self.swiftUIDisplayItemID = [aDecoder decodeObjectOfClass:NSString.class forKey:@"swiftUIDisplayItemID"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (BOOL)hasValidFrame { - if (!self.frameInWindow) { - return NO; - } - return LookinIsUsableRect([self.frameInWindow CGRectValue]); -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinDashboardBlueprint.h b/Sources/LookinCore/LookinDashboardBlueprint.h deleted file mode 100644 index 6645160f..00000000 --- a/Sources/LookinCore/LookinDashboardBlueprint.h +++ /dev/null @@ -1,93 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDashboardBlueprint.h -// Lookin -// -// Created by Li Kai on 2019/6/5. -// https://lookin.work -// - - - -#import -#import "LookinAttrIdentifiers.h" -#import "LookinAttrType.h" - -/** - 该对象定义了: - - 每一个 Attr 的信息 - - 哪些 GroupID, SectionID, AttrID 是合法的 - - 这些 ID 的父子顺序,比如 LookinAttrGroup_Frame 包含哪些 Section - - 这些 ID 展示顺序(比如哪个 Group 在前、哪个 Group 在后) - */ -@interface LookinDashboardBlueprint : NSObject - -+ (NSArray *)groupIDs; - -+ (NSArray *)sectionIDsForGroupID:(LookinAttrGroupIdentifier)groupID; - -+ (NSArray *)attrIDsForSectionID:(LookinAttrSectionIdentifier)sectionID; - -/// 返回包含目标 attr 的 groupID 和 sectionID -+ (void)getHostGroupID:(inout LookinAttrGroupIdentifier *)groupID sectionID:(inout LookinAttrSectionIdentifier *)sectionID fromAttrID:(LookinAttrIdentifier)attrID; - -/// 返回某个 group 的标题 -+ (NSString *)groupTitleWithGroupID:(LookinAttrGroupIdentifier)groupID; - -/// 返回某个 section 的标题,nil 则表示不显示标题 -+ (NSString *)sectionTitleWithSectionID:(LookinAttrSectionIdentifier)secID; - -/// 当某个 LookinAttribute 确定是 NSObject 类型时,该方法返回它具体是什么对象,比如 UIColor 等 -+ (LookinAttrType)objectAttrTypeWithAttrID:(LookinAttrIdentifier)attrID; - -/// 返回某个 LookinAttribute 代表的属性是哪一个类拥有的,比如 LookinAttrSec_UILabel_TextColor 是 UILabel 才有的 -+ (NSString *)classNameWithAttrID:(LookinAttrIdentifier)attrID; - -/// Which object on a display item an attribute reads from and writes to. The -/// host picks the target oid for a modification with this; the old -/// per-kind boolean queries below remain as wrappers around it. -typedef NS_ENUM(NSInteger, LookinAttrTargetKind) { - LookinAttrTargetKindLayer = 0, - LookinAttrTargetKindView, - LookinAttrTargetKindWindow, - LookinAttrTargetKindCell, // AppKit only; wired up in the NSCell phase -}; - -+ (LookinAttrTargetKind)targetKindForAttrID:(LookinAttrIdentifier)attrID; - -/// Wrapper around targetKindForAttrID: — YES for LookinAttrTargetKindWindow. -+ (BOOL)isWindowPropertyWithAttrID:(LookinAttrIdentifier)attrID; - -/// Wrapper around targetKindForAttrID: — YES for LookinAttrTargetKindView. -/// 一个 attr 要么属于 UIView 要么属于 CALayer,如果它属于 UIView 那么该方法返回 YES -+ (BOOL)isUIViewPropertyWithAttrID:(LookinAttrIdentifier)attrID; - -/// 如果某个 attribute 是 enum,则这里会返回相应的 enum 的名称(如 @"NSTextAlignment"),进而可通过这个名称查询可用的枚举值列表 -+ (NSString *)enumListNameWithAttrID:(LookinAttrIdentifier)attrID; - -/// 如果返回 YES,则说明用户在 Lookin 里修改了该 Attribute 的值后,应该重新拉取和更新相关图层的位置、截图等信息 -+ (BOOL)needPatchAfterModificationWithAttrID:(LookinAttrIdentifier)attrID; - -/// 完整的名字 -+ (NSString *)fullTitleWithAttrID:(LookinAttrIdentifier)attrID; - -/// 在某些 textField 和 checkbox 里会显示这里返回的 title -+ (NSString *)briefTitleWithAttrID:(LookinAttrIdentifier)attrID; - -/// 获取 getter 方法 -+ (SEL)getterWithAttrID:(LookinAttrIdentifier)attrID; - -/// 获取 setter 方法 -+ (SEL)setterWithAttrID:(LookinAttrIdentifier)attrID; - -/// 获取 “hideIfNil” 的值。如果为 YES,则当读取 getter 获取的 value 为 nil 时,Lookin 不会传输该 attr -/// 如果为 NO,则即使 value 为 nil 也会传输(比如 label 的 text 属性,即使它是 nil 我们也要显示,所以它的 hideIfNil 应该为 NO) -+ (BOOL)hideIfNilWithAttrID:(LookinAttrIdentifier)attrID; - -/// 该属性需要的最低的 iOS 版本,比如 safeAreaInsets 从 iOS 11.0 开始出现,则该方法返回 11,如果返回 0 则表示不限制 iOS 版本(注意 Lookin 项目仅支持 iOS 8.0+) -+ (NSInteger)minAvailableOSVersionWithAttrID:(LookinAttrIdentifier)attrID; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinDashboardBlueprint.m b/Sources/LookinCore/LookinDashboardBlueprint.m deleted file mode 100644 index 92c11422..00000000 --- a/Sources/LookinCore/LookinDashboardBlueprint.m +++ /dev/null @@ -1,4952 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDashboardBlueprint.m -// Lookin -// -// Created by Li Kai on 2019/6/5. -// https://lookin.work -// - - - -#import "LookinDashboardBlueprint.h" - -@implementation LookinDashboardBlueprint - -+ (NSArray *)groupIDs { - static NSArray *array; - static dispatch_once_t onceToken; - dispatch_once(&onceToken,^{ - array = @[ - LookinAttrGroup_Class, - LookinAttrGroup_Relation, - LookinAttrGroup_Layout, - LookinAttrGroup_AutoLayout, - LookinAttrGroup_ViewLayer, -#if TARGET_OS_IPHONE - LookinAttrGroup_UIStackView, - LookinAttrGroup_UIVisualEffectView, - LookinAttrGroup_UIImageView, - LookinAttrGroup_UILabel, - LookinAttrGroup_UIControl, - LookinAttrGroup_UIButton, - LookinAttrGroup_UIScrollView, - LookinAttrGroup_UITableView, - LookinAttrGroup_UITextView, - LookinAttrGroup_UITextField, - LookinAttrGroup_UIWindowScene, - LookinAttrGroup_UITraitCollection, -#endif -#if TARGET_OS_OSX - LookinAttrGroup_NSImageView, - LookinAttrGroup_NSControl, - LookinAttrGroup_NSButton, - LookinAttrGroup_NSScrollView, - LookinAttrGroup_NSTableView, - LookinAttrGroup_NSTextView, - LookinAttrGroup_NSTextField, - LookinAttrGroup_NSVisualEffectView, - LookinAttrGroup_NSStackView, - LookinAttrGroup_NSWindow, - LookinAttrGroup_NSSlider, - LookinAttrGroup_NSProgressIndicator, - LookinAttrGroup_NSSegmentedControl, - LookinAttrGroup_NSPopUpButton, - LookinAttrGroup_NSComboBox, - LookinAttrGroup_NSStepper, - LookinAttrGroup_NSColorWell, - LookinAttrGroup_NSSwitch, - LookinAttrGroup_NSDatePicker, - LookinAttrGroup_NSLevelIndicator, - LookinAttrGroup_NSOutlineView, - LookinAttrGroup_NSCollectionView, - LookinAttrGroup_NSBox, - LookinAttrGroup_NSSplitView, - LookinAttrGroup_NSTabView, - LookinAttrGroup_NSGridView, - LookinAttrGroup_NSCell, -#endif - // Platform-neutral: LayoutGuide nodes exist on both platforms - LookinAttrGroup_LayoutGuide, - ]; - }); - return array; -} - -+ (NSArray *)sectionIDsForGroupID:(LookinAttrGroupIdentifier)groupID { - static NSDictionary *> *dict; - static dispatch_once_t onceToken; - dispatch_once(&onceToken,^{ - dict = @{ - LookinAttrGroup_Class: @[LookinAttrSec_Class_Class], - - LookinAttrGroup_Relation: @[LookinAttrSec_Relation_Relation], - - LookinAttrGroup_Layout: @[LookinAttrSec_Layout_Frame, - LookinAttrSec_Layout_Bounds, - LookinAttrSec_Layout_SafeArea, - LookinAttrSec_Layout_Position, - LookinAttrSec_Layout_AnchorPoint, - LookinAttrSec_Layout_CoordinateSpace], - - LookinAttrGroup_AutoLayout: @[LookinAttrSec_AutoLayout_Constraints, - LookinAttrSec_AutoLayout_IntrinsicSize, - LookinAttrSec_AutoLayout_Hugging, - LookinAttrSec_AutoLayout_Resistance], - - LookinAttrGroup_ViewLayer: @[ - LookinAttrSec_ViewLayer_Visibility, - LookinAttrSec_ViewLayer_InterationAndMasks, - LookinAttrSec_ViewLayer_BgColor, - LookinAttrSec_ViewLayer_Border, - LookinAttrSec_ViewLayer_Corner, - LookinAttrSec_ViewLayer_Shadow, - LookinAttrSec_ViewLayer_Tag, -#if TARGET_OS_IPHONE - LookinAttrSec_ViewLayer_ContentMode, - LookinAttrSec_ViewLayer_TintColor, -#endif - ], - -#if TARGET_OS_IPHONE - LookinAttrGroup_UIStackView: @[ - LookinAttrSec_UIStackView_Axis, - LookinAttrSec_UIStackView_Distribution, - LookinAttrSec_UIStackView_Alignment, - LookinAttrSec_UIStackView_Spacing - ], - - LookinAttrGroup_UIVisualEffectView: @[ - LookinAttrSec_UIVisualEffectView_Style, - LookinAttrSec_UIVisualEffectView_QMUIForegroundColor - ], - - LookinAttrGroup_UIImageView: @[LookinAttrSec_UIImageView_Name, - LookinAttrSec_UIImageView_Open], - - LookinAttrGroup_UILabel: @[ - LookinAttrSec_UILabel_Text, - LookinAttrSec_UILabel_Font, - LookinAttrSec_UILabel_NumberOfLines, - LookinAttrSec_UILabel_TextColor, - LookinAttrSec_UILabel_BreakMode, - LookinAttrSec_UILabel_Alignment, - LookinAttrSec_UILabel_CanAdjustFont], - - LookinAttrGroup_UIControl: @[LookinAttrSec_UIControl_EnabledSelected, - LookinAttrSec_UIControl_QMUIOutsideEdge, - LookinAttrSec_UIControl_VerAlignment, - LookinAttrSec_UIControl_HorAlignment], - - LookinAttrGroup_UIButton: @[LookinAttrSec_UIButton_ContentInsets, - LookinAttrSec_UIButton_TitleInsets, - LookinAttrSec_UIButton_ImageInsets], - - LookinAttrGroup_UIScrollView: @[LookinAttrSec_UIScrollView_ContentInset, - LookinAttrSec_UIScrollView_AdjustedInset, - LookinAttrSec_UIScrollView_QMUIInitialInset, - LookinAttrSec_UIScrollView_IndicatorInset, - LookinAttrSec_UIScrollView_Offset, - LookinAttrSec_UIScrollView_ContentSize, - LookinAttrSec_UIScrollView_Behavior, - LookinAttrSec_UIScrollView_ShowsIndicator, - LookinAttrSec_UIScrollView_Bounce, - LookinAttrSec_UIScrollView_ScrollPaging, - LookinAttrSec_UIScrollView_ContentTouches, - LookinAttrSec_UIScrollView_Zoom], - - LookinAttrGroup_UITableView: @[LookinAttrSec_UITableView_Style, - LookinAttrSec_UITableView_SectionsNumber, - LookinAttrSec_UITableView_RowsNumber, - LookinAttrSec_UITableView_SeparatorStyle, - LookinAttrSec_UITableView_SeparatorColor, - LookinAttrSec_UITableView_SeparatorInset], - - LookinAttrGroup_UITextView: @[LookinAttrSec_UITextView_Basic, - LookinAttrSec_UITextView_Text, - LookinAttrSec_UITextView_Font, - LookinAttrSec_UITextView_TextColor, - LookinAttrSec_UITextView_Alignment, - LookinAttrSec_UITextView_ContainerInset], - - LookinAttrGroup_UITextField: @[LookinAttrSec_UITextField_Text, - LookinAttrSec_UITextField_Placeholder, - LookinAttrSec_UITextField_Font, - LookinAttrSec_UITextField_TextColor, - LookinAttrSec_UITextField_Alignment, - LookinAttrSec_UITextField_Clears, - LookinAttrSec_UITextField_CanAdjustFont, - LookinAttrSec_UITextField_ClearButtonMode], - LookinAttrGroup_UIWindowScene: @[ - LookinAttrSec_UIWindowScene_State, - LookinAttrSec_UIWindowScene_Title, - LookinAttrSec_UIWindowScene_Orientation, - LookinAttrSec_UIWindowScene_Geometry, - LookinAttrSec_UIWindowScene_Windows, - LookinAttrSec_UIWindowScene_Screen, - LookinAttrSec_UIWindowScene_StatusBar, - LookinAttrSec_UIWindowScene_SizeRestrictions, - LookinAttrSec_UIWindowScene_WindowingBehaviors, - LookinAttrSec_UIWindowScene_Pointer, - LookinAttrSec_UIWindowScene_Protection, - LookinAttrSec_UIWindowScene_Traits, - LookinAttrSec_UIWindowScene_Session, - LookinAttrSec_UIWindowScene_Configuration, - LookinAttrSec_UIWindowScene_ActivationConditions, - ], - LookinAttrGroup_UITraitCollection: @[ - LookinAttrSec_UITraitCollection_Appearance, - LookinAttrSec_UITraitCollection_SizeClass, - LookinAttrSec_UITraitCollection_Display, - LookinAttrSec_UITraitCollection_Device, - LookinAttrSec_UITraitCollection_Layout, - LookinAttrSec_UITraitCollection_Content, - ], -#endif -#if TARGET_OS_OSX - LookinAttrGroup_NSImageView: @[ - LookinAttrSec_NSImageView_Name, - LookinAttrSec_NSImageView_Open, - LookinAttrSec_NSImageView_Scaling, - LookinAttrSec_NSImageView_Behavior, - LookinAttrSec_NSImageView_ContentTintColor, - ], - - LookinAttrGroup_NSControl: @[ - LookinAttrSec_NSControl_State, - LookinAttrSec_NSControl_ControlSize, - LookinAttrSec_NSControl_Font, - LookinAttrSec_NSControl_Alignment, - LookinAttrSec_NSControl_Misc, - LookinAttrSec_NSControl_StringValue, - LookinAttrSec_NSControl_Value, - ], - - LookinAttrGroup_NSButton: @[ - LookinAttrSec_NSButton_ButtonType, - LookinAttrSec_NSButton_BezelStyle, - LookinAttrSec_NSButton_Title, - LookinAttrSec_NSButton_Bordered, - LookinAttrSec_NSButton_BezelColor, - LookinAttrSec_NSButton_Misc, - ], - - LookinAttrGroup_NSScrollView: @[ - LookinAttrSec_NSScrollView_ContentOffset, - LookinAttrSec_NSScrollView_ContentSize, - LookinAttrSec_NSScrollView_ContentInset, - LookinAttrSec_NSScrollView_BorderType, - LookinAttrSec_NSScrollView_Scroller, - LookinAttrSec_NSScrollView_Ruler, - LookinAttrSec_NSScrollView_LineScroll, - LookinAttrSec_NSScrollView_PageScroll, - LookinAttrSec_NSScrollView_ScrollElasiticity, - LookinAttrSec_NSScrollView_Misc, - LookinAttrSec_NSScrollView_Magnification, - ], - - LookinAttrGroup_NSTableView: @[ - LookinAttrSec_NSTableView_RowHeight, - LookinAttrSec_NSTableView_AutomaticRowHeights, - LookinAttrSec_NSTableView_IntercellSpacing, - LookinAttrSec_NSTableView_Style, - LookinAttrSec_NSTableView_ColumnAutoresizingStyle, - LookinAttrSec_NSTableView_GridStyleMask, - LookinAttrSec_NSTableView_SelectionHighlightStyle, - LookinAttrSec_NSTableView_GridColor, - LookinAttrSec_NSTableView_RowSizeStyle, - LookinAttrSec_NSTableView_NumberOfRows, - LookinAttrSec_NSTableView_NumberOfColumns, - LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors, - LookinAttrSec_NSTableView_AllowsColumnReordering, - LookinAttrSec_NSTableView_AllowsColumnResizing, - LookinAttrSec_NSTableView_AllowsMultipleSelection, - LookinAttrSec_NSTableView_AllowsEmptySelection, - LookinAttrSec_NSTableView_AllowsColumnSelection, - LookinAttrSec_NSTableView_AllowsTypeSelect, - LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle, - LookinAttrSec_NSTableView_Autosave, - LookinAttrSec_NSTableView_FloatsGroupRows, - LookinAttrSec_NSTableView_RowActionsVisible, - LookinAttrSec_NSTableView_UsesStaticContents, - LookinAttrSec_NSTableView_UserInterfaceLayoutDirection, - LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag, - ], - - LookinAttrGroup_NSTextView: @[ - LookinAttrSec_NSTextView_Font, - LookinAttrSec_NSTextView_Basic, - LookinAttrSec_NSTextView_String, - LookinAttrSec_NSTextView_TextColor, - LookinAttrSec_NSTextView_Alignment, - LookinAttrSec_NSTextView_ContainerInset, - LookinAttrSec_NSTextView_BaseWritingDirection, - LookinAttrSec_NSTextView_Size, - LookinAttrSec_NSTextView_Resizable, - ], - - LookinAttrGroup_NSTextField: @[ - LookinAttrSec_NSTextField_BezelStyle, - LookinAttrSec_NSTextField_LineBreakStrategy, - LookinAttrSec_NSTextField_Bordered, - LookinAttrSec_NSTextField_TextColor, - LookinAttrSec_NSTextField_Placeholder, - LookinAttrSec_NSTextField_PreferredMaxLayoutWidth, - ], - - - LookinAttrGroup_NSVisualEffectView: @[ - LookinAttrSec_NSVisualEffectView_Material, - LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle, - LookinAttrSec_NSVisualEffectView_BlendingMode, - LookinAttrSec_NSVisualEffectView_State, - LookinAttrSec_NSVisualEffectView_Emphasized, - ], - - LookinAttrGroup_NSStackView: @[ - LookinAttrSec_NSStackView_Orientation, - LookinAttrSec_NSStackView_EdgeInsets, - LookinAttrSec_NSStackView_DetachesHiddenViews, - LookinAttrSec_NSStackView_Distribution, - LookinAttrSec_NSStackView_Alignment, - LookinAttrSec_NSStackView_Spacing, - ], - - LookinAttrGroup_NSWindow: @[ - LookinAttrSec_NSWindow_Title, - LookinAttrSec_NSWindow_Subtitle, - LookinAttrSec_NSWindow_State, - LookinAttrSec_NSWindow_Style, - LookinAttrSec_NSWindow_CollectionBehavior, - LookinAttrSec_NSWindow_Appearance, - LookinAttrSec_NSWindow_TitleVisibility, - LookinAttrSec_NSWindow_ToolbarStyle, - LookinAttrSec_NSWindow_TitlebarSeparatorStyle, - LookinAttrSec_NSWindow_Behavior, - LookinAttrSec_NSWindow_AnimationBehavior, - LookinAttrSec_NSWindow_Level, - LookinAttrSec_NSWindow_TabbingMode, - LookinAttrSec_NSWindow_Size, - LookinAttrSec_NSWindow_Info, - ], - - LookinAttrGroup_NSSlider: @[ - LookinAttrSec_NSSlider_SliderType, - LookinAttrSec_NSSlider_Range, - LookinAttrSec_NSSlider_TickMark, - LookinAttrSec_NSSlider_Misc, - ], - - LookinAttrGroup_NSProgressIndicator: @[ - LookinAttrSec_NSProgressIndicator_Style, - LookinAttrSec_NSProgressIndicator_Range, - LookinAttrSec_NSProgressIndicator_Misc, - ], - - LookinAttrGroup_NSSegmentedControl: @[ - LookinAttrSec_NSSegmentedControl_SegmentCount, - LookinAttrSec_NSSegmentedControl_Selection, - LookinAttrSec_NSSegmentedControl_Style, - LookinAttrSec_NSSegmentedControl_Colors, - ], - - LookinAttrGroup_NSPopUpButton: @[ - LookinAttrSec_NSPopUpButton_Behavior, - LookinAttrSec_NSPopUpButton_Selection, - LookinAttrSec_NSPopUpButton_Items, - ], - - LookinAttrGroup_NSComboBox: @[ - LookinAttrSec_NSComboBox_Items, - LookinAttrSec_NSComboBox_Misc, - ], - - LookinAttrGroup_NSStepper: @[ - LookinAttrSec_NSStepper_Range, - LookinAttrSec_NSStepper_Misc, - ], - - LookinAttrGroup_NSColorWell: @[ - LookinAttrSec_NSColorWell_Color, - LookinAttrSec_NSColorWell_Misc, - ], - - LookinAttrGroup_NSSwitch: @[ - LookinAttrSec_NSSwitch_State, - ], - - LookinAttrGroup_NSDatePicker: @[ - LookinAttrSec_NSDatePicker_Style, - LookinAttrSec_NSDatePicker_Range, - LookinAttrSec_NSDatePicker_Misc, - ], - - LookinAttrGroup_NSLevelIndicator: @[ - LookinAttrSec_NSLevelIndicator_Style, - LookinAttrSec_NSLevelIndicator_Range, - LookinAttrSec_NSLevelIndicator_TickMark, - ], - - LookinAttrGroup_NSOutlineView: @[ - LookinAttrSec_NSOutlineView_Indentation, - LookinAttrSec_NSOutlineView_Misc, - ], - - LookinAttrGroup_NSCollectionView: @[ - LookinAttrSec_NSCollectionView_Selection, - LookinAttrSec_NSCollectionView_Info, - LookinAttrSec_NSCollectionView_Colors, - ], - - LookinAttrGroup_NSBox: @[ - LookinAttrSec_NSBox_Type, - LookinAttrSec_NSBox_Title, - LookinAttrSec_NSBox_Appearance, - LookinAttrSec_NSBox_Metrics, - ], - - LookinAttrGroup_NSSplitView: @[ - LookinAttrSec_NSSplitView_Orientation, - LookinAttrSec_NSSplitView_Style, - LookinAttrSec_NSSplitView_Misc, - ], - - LookinAttrGroup_NSTabView: @[ - LookinAttrSec_NSTabView_Type, - LookinAttrSec_NSTabView_Misc, - LookinAttrSec_NSTabView_Info, - ], - - LookinAttrGroup_NSGridView: @[ - LookinAttrSec_NSGridView_Dimensions, - LookinAttrSec_NSGridView_Spacing, - LookinAttrSec_NSGridView_Placement, - ], -#endif - LookinAttrGroup_LayoutGuide: @[ - LookinAttrSec_LayoutGuide_Identifier, - LookinAttrSec_LayoutGuide_LayoutFrame, - LookinAttrSec_LayoutGuide_OwningView, - ], - LookinAttrGroup_NSCell: @[ - LookinAttrSec_NSCell_Cell, - LookinAttrSec_NSCell_Content, - LookinAttrSec_NSCell_Behavior, - LookinAttrSec_NSCell_ButtonCell, - LookinAttrSec_NSCell_TextFieldCell, - ], - - }; - }); - return dict[groupID]; -} - -+ (NSArray *)attrIDsForSectionID:(LookinAttrSectionIdentifier)sectionID { - static NSDictionary *> *dict; - static dispatch_once_t onceToken; - dispatch_once(&onceToken,^{ - dict = @{ - LookinAttrSec_Class_Class: @[LookinAttr_Class_Class_Class], - - LookinAttrSec_Relation_Relation: @[LookinAttr_Relation_Relation_Relation], - - LookinAttrSec_Layout_Frame: @[LookinAttr_Layout_Frame_Frame], - LookinAttrSec_Layout_Bounds: @[LookinAttr_Layout_Bounds_Bounds], - LookinAttrSec_Layout_SafeArea: @[LookinAttr_Layout_SafeArea_SafeArea], - LookinAttrSec_Layout_Position: @[LookinAttr_Layout_Position_Position], - LookinAttrSec_Layout_AnchorPoint: @[LookinAttr_Layout_AnchorPoint_AnchorPoint], - LookinAttrSec_Layout_CoordinateSpace: @[LookinAttr_Layout_CoordinateSpace_CoordinateSpace], - - LookinAttrSec_AutoLayout_Hugging: @[LookinAttr_AutoLayout_Hugging_Hor, - LookinAttr_AutoLayout_Hugging_Ver], - LookinAttrSec_AutoLayout_Resistance: @[LookinAttr_AutoLayout_Resistance_Hor, - LookinAttr_AutoLayout_Resistance_Ver], - LookinAttrSec_AutoLayout_Constraints: @[LookinAttr_AutoLayout_Constraints_Constraints], - LookinAttrSec_AutoLayout_IntrinsicSize: @[LookinAttr_AutoLayout_IntrinsicSize_Size], - - LookinAttrSec_ViewLayer_Visibility: @[LookinAttr_ViewLayer_Visibility_Hidden, - LookinAttr_ViewLayer_Visibility_Opacity], - - LookinAttrSec_ViewLayer_InterationAndMasks:@[ -#if TARGET_OS_IPHONE - LookinAttr_ViewLayer_InterationAndMasks_Interaction, -#endif - LookinAttr_ViewLayer_InterationAndMasks_MasksToBounds], - - LookinAttrSec_ViewLayer_Corner: @[LookinAttr_ViewLayer_Corner_Radius], - - LookinAttrSec_ViewLayer_BgColor: @[LookinAttr_ViewLayer_BgColor_BgColor], - - LookinAttrSec_ViewLayer_Border: @[LookinAttr_ViewLayer_Border_Color, - LookinAttr_ViewLayer_Border_Width], - - LookinAttrSec_ViewLayer_Shadow: @[LookinAttr_ViewLayer_Shadow_Color, - LookinAttr_ViewLayer_Shadow_Opacity, - LookinAttr_ViewLayer_Shadow_Radius, - LookinAttr_ViewLayer_Shadow_OffsetW, - LookinAttr_ViewLayer_Shadow_OffsetH], -#if TARGET_OS_IPHONE - - LookinAttrSec_ViewLayer_ContentMode: @[LookinAttr_ViewLayer_ContentMode_Mode], - - LookinAttrSec_ViewLayer_TintColor: @[LookinAttr_ViewLayer_TintColor_Color, - LookinAttr_ViewLayer_TintColor_Mode], -#endif - - LookinAttrSec_ViewLayer_Tag: @[LookinAttr_ViewLayer_Tag_Tag], -#if TARGET_OS_IPHONE - - LookinAttrSec_UIStackView_Axis: @[LookinAttr_UIStackView_Axis_Axis], - - LookinAttrSec_UIStackView_Distribution: @[LookinAttr_UIStackView_Distribution_Distribution], - - LookinAttrSec_UIStackView_Alignment: @[LookinAttr_UIStackView_Alignment_Alignment], - - LookinAttrSec_UIStackView_Spacing: @[LookinAttr_UIStackView_Spacing_Spacing], - - LookinAttrSec_UIVisualEffectView_Style: @[LookinAttr_UIVisualEffectView_Style_Style], - - LookinAttrSec_UIVisualEffectView_QMUIForegroundColor: @[LookinAttr_UIVisualEffectView_QMUIForegroundColor_Color], - - LookinAttrSec_UIImageView_Name: @[LookinAttr_UIImageView_Name_Name], - - LookinAttrSec_UIImageView_Open: @[LookinAttr_UIImageView_Open_Open], - - LookinAttrSec_UILabel_Font: @[LookinAttr_UILabel_Font_Name, - LookinAttr_UILabel_Font_Size], - - LookinAttrSec_UILabel_NumberOfLines: @[LookinAttr_UILabel_NumberOfLines_NumberOfLines], - - LookinAttrSec_UILabel_Text: @[LookinAttr_UILabel_Text_Text], - - LookinAttrSec_UILabel_TextColor: @[LookinAttr_UILabel_TextColor_Color], - - LookinAttrSec_UILabel_BreakMode: @[LookinAttr_UILabel_BreakMode_Mode], - - LookinAttrSec_UILabel_Alignment: @[LookinAttr_UILabel_Alignment_Alignment], - - LookinAttrSec_UILabel_CanAdjustFont: @[LookinAttr_UILabel_CanAdjustFont_CanAdjustFont], - - LookinAttrSec_UIControl_EnabledSelected: @[LookinAttr_UIControl_EnabledSelected_Enabled, - LookinAttr_UIControl_EnabledSelected_Selected], - - LookinAttrSec_UIControl_QMUIOutsideEdge: @[LookinAttr_UIControl_QMUIOutsideEdge_Edge], - - LookinAttrSec_UIControl_VerAlignment: @[LookinAttr_UIControl_VerAlignment_Alignment], - - LookinAttrSec_UIControl_HorAlignment: @[LookinAttr_UIControl_HorAlignment_Alignment], - - LookinAttrSec_UIButton_ContentInsets: @[LookinAttr_UIButton_ContentInsets_Insets], - - LookinAttrSec_UIButton_TitleInsets: @[LookinAttr_UIButton_TitleInsets_Insets], - - LookinAttrSec_UIButton_ImageInsets: @[LookinAttr_UIButton_ImageInsets_Insets], - - LookinAttrSec_UIScrollView_ContentInset: @[LookinAttr_UIScrollView_ContentInset_Inset], - - LookinAttrSec_UIScrollView_AdjustedInset: @[LookinAttr_UIScrollView_AdjustedInset_Inset], - - LookinAttrSec_UIScrollView_QMUIInitialInset: @[LookinAttr_UIScrollView_QMUIInitialInset_Inset], - - LookinAttrSec_UIScrollView_IndicatorInset: @[LookinAttr_UIScrollView_IndicatorInset_Inset], - - LookinAttrSec_UIScrollView_Offset: @[LookinAttr_UIScrollView_Offset_Offset], - - LookinAttrSec_UIScrollView_ContentSize: @[LookinAttr_UIScrollView_ContentSize_Size], - - LookinAttrSec_UIScrollView_Behavior: @[LookinAttr_UIScrollView_Behavior_Behavior], - - LookinAttrSec_UIScrollView_ShowsIndicator: @[LookinAttr_UIScrollView_ShowsIndicator_Hor, - LookinAttr_UIScrollView_ShowsIndicator_Ver], - - LookinAttrSec_UIScrollView_Bounce: @[LookinAttr_UIScrollView_Bounce_Hor, - LookinAttr_UIScrollView_Bounce_Ver], - - LookinAttrSec_UIScrollView_ScrollPaging: @[LookinAttr_UIScrollView_ScrollPaging_ScrollEnabled, - LookinAttr_UIScrollView_ScrollPaging_PagingEnabled], - - LookinAttrSec_UIScrollView_ContentTouches: @[LookinAttr_UIScrollView_ContentTouches_Delay, - LookinAttr_UIScrollView_ContentTouches_CanCancel], - - LookinAttrSec_UIScrollView_Zoom: @[LookinAttr_UIScrollView_Zoom_Bounce, - LookinAttr_UIScrollView_Zoom_Scale, - LookinAttr_UIScrollView_Zoom_MinScale, - LookinAttr_UIScrollView_Zoom_MaxScale], - - LookinAttrSec_UITableView_Style: @[LookinAttr_UITableView_Style_Style], - - LookinAttrSec_UITableView_SectionsNumber: @[LookinAttr_UITableView_SectionsNumber_Number], - - LookinAttrSec_UITableView_RowsNumber: @[LookinAttr_UITableView_RowsNumber_Number], - - LookinAttrSec_UITableView_SeparatorInset: @[LookinAttr_UITableView_SeparatorInset_Inset], - - LookinAttrSec_UITableView_SeparatorColor: @[LookinAttr_UITableView_SeparatorColor_Color], - - LookinAttrSec_UITableView_SeparatorStyle: @[LookinAttr_UITableView_SeparatorStyle_Style], - - LookinAttrSec_UITextView_Basic: @[LookinAttr_UITextView_Basic_Editable, - LookinAttr_UITextView_Basic_Selectable], - - LookinAttrSec_UITextView_Text: @[LookinAttr_UITextView_Text_Text], - - LookinAttrSec_UITextView_Font: @[LookinAttr_UITextView_Font_Name, - LookinAttr_UITextView_Font_Size], - - LookinAttrSec_UITextView_TextColor: @[LookinAttr_UITextView_TextColor_Color], - - LookinAttrSec_UITextView_Alignment: @[LookinAttr_UITextView_Alignment_Alignment], - - LookinAttrSec_UITextView_ContainerInset: @[LookinAttr_UITextView_ContainerInset_Inset], - - LookinAttrSec_UITextField_Text: @[LookinAttr_UITextField_Text_Text], - - LookinAttrSec_UITextField_Placeholder: @[LookinAttr_UITextField_Placeholder_Placeholder], - - LookinAttrSec_UITextField_Font: @[LookinAttr_UITextField_Font_Name, - LookinAttr_UITextField_Font_Size], - - LookinAttrSec_UITextField_TextColor: @[LookinAttr_UITextField_TextColor_Color], - - LookinAttrSec_UITextField_Alignment: @[LookinAttr_UITextField_Alignment_Alignment], - - LookinAttrSec_UITextField_Clears: @[LookinAttr_UITextField_Clears_ClearsOnBeginEditing, - LookinAttr_UITextField_Clears_ClearsOnInsertion], - - LookinAttrSec_UITextField_CanAdjustFont: @[LookinAttr_UITextField_CanAdjustFont_CanAdjustFont, - LookinAttr_UITextField_CanAdjustFont_MinSize], - - LookinAttrSec_UITextField_ClearButtonMode: @[LookinAttr_UITextField_ClearButtonMode_Mode], - LookinAttrSec_UIWindowScene_State: @[ - LookinAttr_UIWindowScene_State_ActivationState, - ], - LookinAttrSec_UIWindowScene_Title: @[ - LookinAttr_UIWindowScene_Title_Title, - LookinAttr_UIWindowScene_Title_Subtitle, - ], - LookinAttrSec_UIWindowScene_Orientation: @[ - LookinAttr_UIWindowScene_Orientation_InterfaceOrientation, - ], - LookinAttrSec_UIWindowScene_Windows: @[ - LookinAttr_UIWindowScene_Windows_WindowCount, - LookinAttr_UIWindowScene_Windows_KeyWindowClassName, - ], - LookinAttrSec_UIWindowScene_Screen: @[ - LookinAttr_UIWindowScene_Screen_ScreenBounds, - LookinAttr_UIWindowScene_Screen_ScreenScale, - ], - LookinAttrSec_UIWindowScene_StatusBar: @[ - LookinAttr_UIWindowScene_StatusBar_StatusBarHidden, - LookinAttr_UIWindowScene_StatusBar_StatusBarStyle, - LookinAttr_UIWindowScene_StatusBar_StatusBarFrame, - ], - LookinAttrSec_UIWindowScene_Traits: @[ - LookinAttr_UIWindowScene_Traits_UserInterfaceStyle, - LookinAttr_UIWindowScene_Traits_HorizontalSizeClass, - LookinAttr_UIWindowScene_Traits_VerticalSizeClass, - LookinAttr_UIWindowScene_Traits_UserInterfaceLevel, - LookinAttr_UIWindowScene_Traits_ActiveAppearance, - LookinAttr_UIWindowScene_Traits_AccessibilityContrast, - LookinAttr_UIWindowScene_Traits_LegibilityWeight, - LookinAttr_UIWindowScene_Traits_DisplayScale, - LookinAttr_UIWindowScene_Traits_DisplayGamut, - LookinAttr_UIWindowScene_Traits_UserInterfaceIdiom, - LookinAttr_UIWindowScene_Traits_LayoutDirection, - LookinAttr_UIWindowScene_Traits_PreferredContentSizeCategory, - LookinAttr_UIWindowScene_Traits_SceneCaptureState, - LookinAttr_UIWindowScene_Traits_ImageDynamicRange, - LookinAttr_UIWindowScene_Traits_TypesettingLanguage, - ], - LookinAttrSec_UIWindowScene_Session: @[ - LookinAttr_UIWindowScene_Session_PersistentIdentifier, - LookinAttr_UIWindowScene_Session_SessionRole, - LookinAttr_UIWindowScene_Session_StateRestorationActivityType, - LookinAttr_UIWindowScene_Session_UserInfo, - ], - LookinAttrSec_UIWindowScene_Configuration: @[ - LookinAttr_UIWindowScene_Configuration_Name, - LookinAttr_UIWindowScene_Configuration_SceneClass, - LookinAttr_UIWindowScene_Configuration_DelegateClass, - LookinAttr_UIWindowScene_Configuration_Storyboard, - ], - LookinAttrSec_UIWindowScene_Geometry: @[ - LookinAttr_UIWindowScene_Geometry_SystemFrame, - LookinAttr_UIWindowScene_Geometry_InterfaceOrientationLocked, - LookinAttr_UIWindowScene_Geometry_InteractivelyResizing, - ], - LookinAttrSec_UIWindowScene_ActivationConditions: @[ - LookinAttr_UIWindowScene_ActivationConditions_CanActivate, - LookinAttr_UIWindowScene_ActivationConditions_PrefersToActivate, - ], - LookinAttrSec_UIWindowScene_SizeRestrictions: @[ - LookinAttr_UIWindowScene_SizeRestrictions_MinimumSize, - LookinAttr_UIWindowScene_SizeRestrictions_MaximumSize, - LookinAttr_UIWindowScene_SizeRestrictions_AllowsFullScreen, - ], - LookinAttrSec_UIWindowScene_WindowingBehaviors: @[ - LookinAttr_UIWindowScene_WindowingBehaviors_Closable, - LookinAttr_UIWindowScene_WindowingBehaviors_Miniaturizable, - LookinAttr_UIWindowScene_WindowingBehaviors_FullScreen, - ], - LookinAttrSec_UIWindowScene_Pointer: @[ - LookinAttr_UIWindowScene_Pointer_Locked, - ], - LookinAttrSec_UIWindowScene_Protection: @[ - LookinAttr_UIWindowScene_Protection_UserAuthenticationEnabled, - ], - // UITraitCollection - LookinAttrSec_UITraitCollection_Appearance: @[ - LookinAttr_UITraitCollection_Appearance_UserInterfaceStyle, - LookinAttr_UITraitCollection_Appearance_UserInterfaceLevel, - LookinAttr_UITraitCollection_Appearance_ActiveAppearance, - LookinAttr_UITraitCollection_Appearance_AccessibilityContrast, - LookinAttr_UITraitCollection_Appearance_LegibilityWeight, - ], - LookinAttrSec_UITraitCollection_SizeClass: @[ - LookinAttr_UITraitCollection_SizeClass_HorizontalSizeClass, - LookinAttr_UITraitCollection_SizeClass_VerticalSizeClass, - ], - LookinAttrSec_UITraitCollection_Display: @[ - LookinAttr_UITraitCollection_Display_DisplayScale, - LookinAttr_UITraitCollection_Display_DisplayGamut, - LookinAttr_UITraitCollection_Display_ImageDynamicRange, - ], - LookinAttrSec_UITraitCollection_Device: @[ - LookinAttr_UITraitCollection_Device_UserInterfaceIdiom, - LookinAttr_UITraitCollection_Device_ForceTouchCapability, - ], - LookinAttrSec_UITraitCollection_Layout: @[ - LookinAttr_UITraitCollection_Layout_LayoutDirection, - ], - LookinAttrSec_UITraitCollection_Content: @[ - LookinAttr_UITraitCollection_Content_PreferredContentSizeCategory, - LookinAttr_UITraitCollection_Content_TypesettingLanguage, - ], -#endif -#if TARGET_OS_OSX - LookinAttrSec_NSImageView_Name:@[ - LookinAttr_NSImageView_Name_Name - ], - LookinAttrSec_NSImageView_Open:@[ - LookinAttr_NSImageView_Open_Open - ], - LookinAttrSec_NSImageView_Scaling: @[ - LookinAttr_NSImageView_Scaling_ImageScaling, - LookinAttr_NSImageView_Scaling_ImageAlignment, - LookinAttr_NSImageView_Scaling_ImageFrameStyle, - ], - LookinAttrSec_NSImageView_Behavior: @[ - LookinAttr_NSImageView_Behavior_Animates, - LookinAttr_NSImageView_Behavior_Editable, - ], - LookinAttrSec_NSImageView_ContentTintColor: @[ - LookinAttr_NSImageView_ContentTintColor_ContentTintColor, - ], - LookinAttrSec_NSControl_State: @[ - LookinAttr_NSControl_State_Enabled, - LookinAttr_NSControl_State_Highlighted, - LookinAttr_NSControl_State_Continuous, - ], - LookinAttrSec_NSControl_ControlSize: @[ - LookinAttr_NSControl_ControlSize_Size - ], - LookinAttrSec_NSControl_Font: @[ - LookinAttr_NSControl_Font_Name, - LookinAttr_NSControl_Font_Size - ], - LookinAttrSec_NSControl_Alignment: @[ - LookinAttr_NSControl_Alignment_Alignment - ], - LookinAttrSec_NSControl_Misc: @[ - LookinAttr_NSControl_Misc_WritingDirection, - LookinAttr_NSControl_Misc_IgnoresMultiClick, - LookinAttr_NSControl_Misc_UsesSingleLineMode, - LookinAttr_NSControl_Misc_AllowsExpansionToolTips, - ], - LookinAttrSec_NSControl_StringValue: @[ - LookinAttr_NSControl_Value_StringValue, - ], - LookinAttrSec_NSControl_Value: @[ - LookinAttr_NSControl_Value_IntValue, - LookinAttr_NSControl_Value_IntegerValue, - LookinAttr_NSControl_Value_FloatValue, - LookinAttr_NSControl_Value_DoubleValue, - ], - - LookinAttrSec_NSButton_ButtonType: @[ - LookinAttr_NSButton_ButtonType_ButtonType - ], - LookinAttrSec_NSButton_Title: @[ - LookinAttr_NSButton_Title_Title, - LookinAttr_NSButton_Title_AlernateTitle, - ], - LookinAttrSec_NSButton_BezelStyle: @[LookinAttr_NSButton_BezelStyle_BezelStyle], - LookinAttrSec_NSButton_Bordered: @[ - LookinAttr_NSButton_Bordered_Bordered, - LookinAttr_NSButton_Transparent_Transparent, - LookinAttr_NSButton_Misc_ShowsBorderOnlyWhileMouseInside, - LookinAttr_NSButton_Misc_SpringLoaded, - LookinAttr_NSButton_Misc_HasDestructiveAction, - ], - LookinAttrSec_NSButton_BezelColor: @[ - LookinAttr_NSButton_BezelColor_BezelColor, - LookinAttr_NSButton_ContentTintColor_ContentTintColor, - ], - LookinAttrSec_NSButton_Misc: @[ - LookinAttr_NSButton_Misc_MaxAcceleratorLevel, - ], - - - - LookinAttrSec_NSScrollView_ContentOffset: @[ - LookinAttr_NSScrollView_ContentOffset_Offset - ], - LookinAttrSec_NSScrollView_ContentSize: @[ - LookinAttr_NSScrollView_ContentSize_Size - ], - LookinAttrSec_NSScrollView_ContentInset: @[ - LookinAttr_NSScrollView_ContentInset_ContentInset, - LookinAttr_NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets - ], - LookinAttrSec_NSScrollView_BorderType: @[ - LookinAttr_NSScrollView_BorderType_BorderType - ], - LookinAttrSec_NSScrollView_Scroller: @[ - LookinAttr_NSScrollView_Scroller_Horizontal, - LookinAttr_NSScrollView_Scroller_Vertical, - LookinAttr_NSScrollView_Scroller_AutohidesScrollers, - LookinAttr_NSScrollView_Scroller_ScrollerStyle, - LookinAttr_NSScrollView_Scroller_ScrollerKnobStyle, - LookinAttr_NSScrollView_Scroller_ScrollerInsets, - ], - LookinAttrSec_NSScrollView_Ruler: @[ - LookinAttr_NSScrollView_Ruler_Horizontal, - LookinAttr_NSScrollView_Ruler_Vertical, - LookinAttr_NSScrollView_Ruler_Visible, - ], - LookinAttrSec_NSScrollView_LineScroll: @[ - LookinAttr_NSScrollView_LineScroll_Horizontal, - LookinAttr_NSScrollView_LineScroll_Vertical, - LookinAttr_NSScrollView_LineScroll_LineScroll, - ], - LookinAttrSec_NSScrollView_PageScroll: @[ - LookinAttr_NSScrollView_PageScroll_Horizontal, - LookinAttr_NSScrollView_PageScroll_Vertical, - LookinAttr_NSScrollView_PageScroll_PageScroll, - ], - LookinAttrSec_NSScrollView_ScrollElasiticity: @[ - LookinAttr_NSScrollView_ScrollElasiticity_Horizontal, - LookinAttr_NSScrollView_ScrollElasiticity_Vertical, - ], - LookinAttrSec_NSScrollView_Misc: @[ - LookinAttr_NSScrollView_Misc_ScrollsDynamically, - LookinAttr_NSScrollView_Misc_UsesPredominantAxisScrolling, - ], - LookinAttrSec_NSScrollView_Magnification: @[ - LookinAttr_NSScrollView_Magnification_AllowsMagnification, - LookinAttr_NSScrollView_Magnification_Magnification, - LookinAttr_NSScrollView_Magnification_Max, - LookinAttr_NSScrollView_Magnification_Min, - ], - - LookinAttrSec_NSTableView_RowHeight: @[ - LookinAttr_NSTableView_RowHeight_RowHeight, - ], - LookinAttrSec_NSTableView_AutomaticRowHeights: @[ - LookinAttr_NSTableView_AutomaticRowHeights_AutomaticRowHeights, - ], - LookinAttrSec_NSTableView_IntercellSpacing: @[ - LookinAttr_NSTableView_IntercellSpacing_IntercellSpacing - ], - LookinAttrSec_NSTableView_Style: @[ - LookinAttr_NSTableView_Style_Style - ], - LookinAttrSec_NSTableView_ColumnAutoresizingStyle: @[ - LookinAttr_NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle - ], - LookinAttrSec_NSTableView_GridStyleMask: @[ - LookinAttr_NSTableView_GridStyleMask_GridStyleMask - ], - LookinAttrSec_NSTableView_SelectionHighlightStyle: @[ - LookinAttr_NSTableView_SelectionHighlightStyle_SelectionHighlightStyle - ], - LookinAttrSec_NSTableView_GridColor: @[ - LookinAttr_NSTableView_GridColor_GridColor - ], - LookinAttrSec_NSTableView_RowSizeStyle: @[ - LookinAttr_NSTableView_RowSizeStyle_RowSizeStyle - ], - LookinAttrSec_NSTableView_NumberOfRows: @[ - LookinAttr_NSTableView_NumberOfRows_NumberOfRows - ], - LookinAttrSec_NSTableView_NumberOfColumns: @[ - LookinAttr_NSTableView_NumberOfColumns_NumberOfColumns - ], - LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors: @[ - LookinAttr_NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors - ], - LookinAttrSec_NSTableView_AllowsColumnReordering: @[ - LookinAttr_NSTableView_AllowsColumnReordering_AllowsColumnReordering - ], - LookinAttrSec_NSTableView_AllowsColumnResizing: @[ - LookinAttr_NSTableView_AllowsColumnResizing_AllowsColumnResizing - ], - LookinAttrSec_NSTableView_AllowsMultipleSelection: @[ - LookinAttr_NSTableView_AllowsMultipleSelection_AllowsMultipleSelection - ], - LookinAttrSec_NSTableView_AllowsEmptySelection: @[ - LookinAttr_NSTableView_AllowsEmptySelection_AllowsEmptySelection - ], - LookinAttrSec_NSTableView_AllowsColumnSelection: @[ - LookinAttr_NSTableView_AllowsColumnSelection_AllowsColumnSelection - ], - LookinAttrSec_NSTableView_AllowsTypeSelect: @[ - LookinAttr_NSTableView_AllowsTypeSelect_AllowsTypeSelect - ], - LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle: @[ - LookinAttr_NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle - ], - LookinAttrSec_NSTableView_Autosave: @[ - LookinAttr_NSTableView_AutosaveName_AutosaveName, - LookinAttr_NSTableView_AutosaveTableColumns_AutosaveTableColumns - ], - LookinAttrSec_NSTableView_FloatsGroupRows: @[ - LookinAttr_NSTableView_FloatsGroupRows_FloatsGroupRows - ], - LookinAttrSec_NSTableView_RowActionsVisible: @[ - LookinAttr_NSTableView_RowActionsVisible_RowActionsVisible - ], - LookinAttrSec_NSTableView_UsesStaticContents: @[ - LookinAttr_NSTableView_UsesStaticContents_UsesStaticContents - ], - LookinAttrSec_NSTableView_UserInterfaceLayoutDirection: @[ - LookinAttr_NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection - ], - LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag: @[ - LookinAttr_NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag - ], - - - - LookinAttrSec_NSTextView_Font: @[ - LookinAttr_NSTextView_Font_Name, - LookinAttr_NSTextView_Font_Size - ], - LookinAttrSec_NSTextView_Basic: @[ - LookinAttr_NSTextView_Basic_Editable, - LookinAttr_NSTextView_Basic_Selectable, - LookinAttr_NSTextView_Basic_RichText, - LookinAttr_NSTextView_Basic_FieldEditor, - LookinAttr_NSTextView_Basic_ImportsGraphics, - ], - LookinAttrSec_NSTextView_String: @[ - LookinAttr_NSTextView_String_String - ], - LookinAttrSec_NSTextView_TextColor: @[ - LookinAttr_NSTextView_TextColor_Color - ], - LookinAttrSec_NSTextView_Alignment: @[ - LookinAttr_NSTextView_Alignment_Alignment - ], - LookinAttrSec_NSTextView_ContainerInset: @[ - LookinAttr_NSTextView_ContainerInset_Inset - ], - LookinAttrSec_NSTextView_BaseWritingDirection: @[ - LookinAttr_NSTextView_BaseWritingDirection_BaseWritingDirection - ], - LookinAttrSec_NSTextView_Size: @[ - LookinAttr_NSTextView_MaxSize_MaxSize, - LookinAttr_NSTextView_MinSize_MinSize, - ], - LookinAttrSec_NSTextView_Resizable: @[ - LookinAttr_NSTextView_Resizable_Horizontal, - LookinAttr_NSTextView_Resizable_Vertical, - ], - - LookinAttrSec_NSTextField_BezelStyle: @[ - LookinAttr_NSTextField_BezelStyle_BezelStyle - ], - LookinAttrSec_NSTextField_Bordered: @[ - LookinAttr_NSTextField_Bordered_Bordered, - LookinAttr_NSTextField_Bezeled_Bezeled, - LookinAttr_NSTextField_Editable_Editable, - LookinAttr_NSTextField_Selectable_Selectable, - LookinAttr_NSTextField_DrawsBackground_DrawsBackground, - LookinAttr_NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation, - LookinAttr_NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes, - LookinAttr_NSTextField_ImportsGraphics_ImportsGraphics, - ], - LookinAttrSec_NSTextField_TextColor: @[ - LookinAttr_NSTextField_TextColor_Color, - LookinAttr_NSTextField_BackgroundColor_Color, - ], - LookinAttrSec_NSTextField_Placeholder: @[ - LookinAttr_NSTextField_Placeholder_Placeholder - ], - LookinAttrSec_NSTextField_LineBreakStrategy: @[ - LookinAttr_NSTextField_LineBreakStrategy_LineBreakStrategy, - ], - LookinAttrSec_NSTextField_PreferredMaxLayoutWidth: @[ - LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth, - LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines, - ], - - - - - LookinAttrSec_NSVisualEffectView_Material: @[ - LookinAttr_NSVisualEffectView_Material_Material - ], - LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle: @[ - LookinAttr_NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle - ], - LookinAttrSec_NSVisualEffectView_BlendingMode: @[ - LookinAttr_NSVisualEffectView_BlendingMode_BlendingMode - ], - LookinAttrSec_NSVisualEffectView_State: @[ - LookinAttr_NSVisualEffectView_State_State - ], - LookinAttrSec_NSVisualEffectView_Emphasized: @[ - LookinAttr_NSVisualEffectView_Emphasized_Emphasized - ], - - - - LookinAttrSec_NSStackView_Orientation:@[ - LookinAttr_NSStackView_Orientation_Orientation - ], - LookinAttrSec_NSStackView_EdgeInsets:@[ - LookinAttr_NSStackView_EdgeInsets_EdgeInsets - ], - LookinAttrSec_NSStackView_DetachesHiddenViews:@[ - LookinAttr_NSStackView_DetachesHiddenViews_DetachesHiddenViews - ], - LookinAttrSec_NSStackView_Distribution:@[ - LookinAttr_NSStackView_Distribution_Distribution - ], - LookinAttrSec_NSStackView_Alignment:@[ - LookinAttr_NSStackView_Alignment_Alignment - ], - LookinAttrSec_NSStackView_Spacing:@[ - LookinAttr_NSStackView_Spacing_Spacing - ], - - LookinAttrSec_NSWindow_Title: @[ - LookinAttr_NSWindow_Title_Title, - ], - LookinAttrSec_NSWindow_Subtitle: @[ - LookinAttr_NSWindow_Title_Subtitle, - ], - LookinAttrSec_NSWindow_State: @[ - LookinAttr_NSWindow_State_KeyWindow, - LookinAttr_NSWindow_State_MainWindow, - LookinAttr_NSWindow_State_Visible, - LookinAttr_NSWindow_State_CanBecomeKeyWindow, - LookinAttr_NSWindow_State_CanBecomeMainWindow, - ], - LookinAttrSec_NSWindow_Style: @[ - LookinAttr_NSWindow_Style_Titled, - LookinAttr_NSWindow_Style_Closable, - LookinAttr_NSWindow_Style_Miniaturizable, - LookinAttr_NSWindow_Style_Resizable, - LookinAttr_NSWindow_Style_UnifiedTitleAndToolbar, - LookinAttr_NSWindow_Style_FullScreen, - LookinAttr_NSWindow_Style_FullSizeContentView, - LookinAttr_NSWindow_Style_UtilityWindow, - LookinAttr_NSWindow_Style_DocModalWindow, - LookinAttr_NSWindow_Style_NonactivatingPanel, - LookinAttr_NSWindow_Style_HUDWindow, - ], - LookinAttrSec_NSWindow_CollectionBehavior: @[ - LookinAttr_NSWindow_CollectionBehavior_CanJoinAllSpaces, - LookinAttr_NSWindow_CollectionBehavior_MoveToActiveSpace, - LookinAttr_NSWindow_CollectionBehavior_ParticipatesInCycle, - LookinAttr_NSWindow_CollectionBehavior_IgnoresCycle, - LookinAttr_NSWindow_CollectionBehavior_FullScreenPrimary, - LookinAttr_NSWindow_CollectionBehavior_FullScreenAuxiliary, - LookinAttr_NSWindow_CollectionBehavior_FullScreenNone, - LookinAttr_NSWindow_CollectionBehavior_FullScreenAllowsTiling, - LookinAttr_NSWindow_CollectionBehavior_FullScreenDisallowsTiling, - ], - LookinAttrSec_NSWindow_Appearance: @[ - LookinAttr_NSWindow_Appearance_TitlebarAppearsTransparent, - LookinAttr_NSWindow_Appearance_BackgroundColor, - LookinAttr_NSWindow_Appearance_AlphaValue, - LookinAttr_NSWindow_Appearance_Opaque, - LookinAttr_NSWindow_Appearance_HasShadow, - ], - LookinAttrSec_NSWindow_TitleVisibility: @[ - LookinAttr_NSWindow_Appearance_TitleVisibility, - ], - LookinAttrSec_NSWindow_ToolbarStyle: @[ - LookinAttr_NSWindow_Appearance_ToolbarStyle, - ], - LookinAttrSec_NSWindow_TitlebarSeparatorStyle: @[ - LookinAttr_NSWindow_Appearance_TitlebarSeparatorStyle, - ], - LookinAttrSec_NSWindow_Behavior: @[ - LookinAttr_NSWindow_Behavior_Movable, - LookinAttr_NSWindow_Behavior_MovableByWindowBackground, - LookinAttr_NSWindow_Behavior_HidesOnDeactivate, - ], - LookinAttrSec_NSWindow_AnimationBehavior: @[ - LookinAttr_NSWindow_Behavior_AnimationBehavior, - ], - LookinAttrSec_NSWindow_Level: @[ - LookinAttr_NSWindow_Behavior_Level, - ], - LookinAttrSec_NSWindow_TabbingMode: @[ - LookinAttr_NSWindow_Behavior_TabbingMode, - ], - LookinAttrSec_NSWindow_Size: @[ - LookinAttr_NSWindow_Size_MinSize, - LookinAttr_NSWindow_Size_MaxSize, - ], - LookinAttrSec_NSWindow_Info: @[ - LookinAttr_NSWindow_Info_WindowNumber, - LookinAttr_NSWindow_Info_BackingScaleFactor, - ], - - LookinAttrSec_NSSlider_SliderType: @[ - LookinAttr_NSSlider_SliderType_SliderType, - ], - LookinAttrSec_NSSlider_Range: @[ - LookinAttr_NSSlider_Range_MinValue, - LookinAttr_NSSlider_Range_MaxValue, - ], - LookinAttrSec_NSSlider_TickMark: @[ - LookinAttr_NSSlider_TickMark_NumberOfTickMarks, - LookinAttr_NSSlider_TickMark_TickMarkPosition, - LookinAttr_NSSlider_TickMark_AllowsTickMarkValuesOnly, - ], - LookinAttrSec_NSSlider_Misc: @[ - LookinAttr_NSSlider_Misc_Vertical, - LookinAttr_NSSlider_Misc_KnobThickness, - LookinAttr_NSSlider_Misc_AltIncrementValue, - LookinAttr_NSSlider_Misc_TrackFillColor, - ], - - LookinAttrSec_NSProgressIndicator_Style: @[ - LookinAttr_NSProgressIndicator_Style_Style, - ], - LookinAttrSec_NSProgressIndicator_Range: @[ - LookinAttr_NSProgressIndicator_Range_MinValue, - LookinAttr_NSProgressIndicator_Range_MaxValue, - LookinAttr_NSProgressIndicator_Range_DoubleValue, - ], - LookinAttrSec_NSProgressIndicator_Misc: @[ - LookinAttr_NSProgressIndicator_Misc_Indeterminate, - LookinAttr_NSProgressIndicator_Misc_Bezeled, - LookinAttr_NSProgressIndicator_Misc_DisplayedWhenStopped, - ], - - LookinAttrSec_NSSegmentedControl_SegmentCount: @[ - LookinAttr_NSSegmentedControl_SegmentCount_SegmentCount, - ], - LookinAttrSec_NSSegmentedControl_Selection: @[ - LookinAttr_NSSegmentedControl_Selection_SelectedSegment, - ], - LookinAttrSec_NSSegmentedControl_Style: @[ - LookinAttr_NSSegmentedControl_Style_SegmentStyle, - LookinAttr_NSSegmentedControl_Style_TrackingMode, - ], - LookinAttrSec_NSSegmentedControl_Colors: @[ - LookinAttr_NSSegmentedControl_Colors_SelectedSegmentBezelColor, - ], - - LookinAttrSec_NSPopUpButton_Behavior: @[ - LookinAttr_NSPopUpButton_Behavior_PullsDown, - LookinAttr_NSPopUpButton_Behavior_AutoenablesItems, - LookinAttr_NSPopUpButton_Behavior_PreferredEdge, - ], - LookinAttrSec_NSPopUpButton_Selection: @[ - LookinAttr_NSPopUpButton_Selection_SelectedTag, - LookinAttr_NSPopUpButton_Selection_IndexOfSelectedItem, - LookinAttr_NSPopUpButton_Selection_TitleOfSelectedItem, - ], - LookinAttrSec_NSPopUpButton_Items: @[ - LookinAttr_NSPopUpButton_Items_NumberOfItems, - ], - - LookinAttrSec_NSComboBox_Items: @[ - LookinAttr_NSComboBox_Items_NumberOfItems, - LookinAttr_NSComboBox_Items_HasVerticalScroller, - LookinAttr_NSComboBox_Items_NumberOfVisibleItems, - LookinAttr_NSComboBox_Items_IntercellSpacing, - LookinAttr_NSComboBox_Items_ItemHeight, - ], - LookinAttrSec_NSComboBox_Misc: @[ - LookinAttr_NSComboBox_Misc_ButtonBordered, - LookinAttr_NSComboBox_Misc_Completes, - LookinAttr_NSComboBox_Misc_UsesDataSource, - ], - - LookinAttrSec_NSStepper_Range: @[ - LookinAttr_NSStepper_Range_MinValue, - LookinAttr_NSStepper_Range_MaxValue, - LookinAttr_NSStepper_Range_Increment, - ], - LookinAttrSec_NSStepper_Misc: @[ - LookinAttr_NSStepper_Misc_ValueWraps, - LookinAttr_NSStepper_Misc_Autorepeat, - ], - - LookinAttrSec_NSColorWell_Color: @[ - LookinAttr_NSColorWell_Color_Color, - ], - LookinAttrSec_NSColorWell_Misc: @[ - LookinAttr_NSColorWell_Misc_Bordered, - LookinAttr_NSColorWell_Misc_Active, - LookinAttr_NSColorWell_Misc_ColorWellStyle, - ], - - LookinAttrSec_NSSwitch_State: @[ - LookinAttr_NSSwitch_State_State, - ], - - LookinAttrSec_NSDatePicker_Style: @[ - LookinAttr_NSDatePicker_Style_DatePickerStyle, - LookinAttr_NSDatePicker_Style_DatePickerMode, - ], - LookinAttrSec_NSDatePicker_Range: @[ - LookinAttr_NSDatePicker_Range_DateValue, - LookinAttr_NSDatePicker_Range_MinDate, - LookinAttr_NSDatePicker_Range_MaxDate, - ], - LookinAttrSec_NSDatePicker_Misc: @[ - LookinAttr_NSDatePicker_Misc_Bordered, - LookinAttr_NSDatePicker_Misc_Bezeled, - LookinAttr_NSDatePicker_Misc_DrawsBackground, - ], - - LookinAttrSec_NSLevelIndicator_Style: @[ - LookinAttr_NSLevelIndicator_Style_Style, - ], - LookinAttrSec_NSLevelIndicator_Range: @[ - LookinAttr_NSLevelIndicator_Range_MinValue, - LookinAttr_NSLevelIndicator_Range_MaxValue, - LookinAttr_NSLevelIndicator_Range_WarningValue, - LookinAttr_NSLevelIndicator_Range_CriticalValue, - ], - LookinAttrSec_NSLevelIndicator_TickMark: @[ - LookinAttr_NSLevelIndicator_TickMark_NumberOfTickMarks, - LookinAttr_NSLevelIndicator_TickMark_NumberOfMajorTickMarks, - ], - - LookinAttrSec_NSOutlineView_Indentation: @[ - LookinAttr_NSOutlineView_Indentation_IndentationPerLevel, - ], - LookinAttrSec_NSOutlineView_Misc: @[ - LookinAttr_NSOutlineView_Misc_AutoresizesOutlineColumn, - LookinAttr_NSOutlineView_Misc_IndentationMarkerFollowsCell, - LookinAttr_NSOutlineView_Misc_AutosaveExpandedItems, - ], - - LookinAttrSec_NSCollectionView_Selection: @[ - LookinAttr_NSCollectionView_Selection_Selectable, - LookinAttr_NSCollectionView_Selection_AllowsMultipleSelection, - LookinAttr_NSCollectionView_Selection_AllowsEmptySelection, - ], - LookinAttrSec_NSCollectionView_Info: @[ - LookinAttr_NSCollectionView_Info_NumberOfSections, - ], - LookinAttrSec_NSCollectionView_Colors: @[ - LookinAttr_NSCollectionView_Colors_BackgroundColors, - ], - - LookinAttrSec_NSBox_Type: @[ - LookinAttr_NSBox_Type_BoxType, - LookinAttr_NSBox_Type_BorderType, - ], - LookinAttrSec_NSBox_Title: @[ - LookinAttr_NSBox_Title_Title, - LookinAttr_NSBox_Title_TitlePosition, - ], - LookinAttrSec_NSBox_Appearance: @[ - LookinAttr_NSBox_Appearance_Transparent, - LookinAttr_NSBox_Appearance_FillColor, - LookinAttr_NSBox_Appearance_BorderColor, - ], - LookinAttrSec_NSBox_Metrics: @[ - LookinAttr_NSBox_Metrics_BorderWidth, - LookinAttr_NSBox_Metrics_CornerRadius, - LookinAttr_NSBox_Metrics_ContentViewMargins, - ], - - LookinAttrSec_NSSplitView_Orientation: @[ - LookinAttr_NSSplitView_Orientation_Vertical, - ], - LookinAttrSec_NSSplitView_Style: @[ - LookinAttr_NSSplitView_Style_DividerStyle, - LookinAttr_NSSplitView_Style_DividerThickness, - ], - LookinAttrSec_NSSplitView_Misc: @[ - LookinAttr_NSSplitView_Misc_ArrangesAllSubviews, - ], - - LookinAttrSec_NSTabView_Type: @[ - LookinAttr_NSTabView_Type_TabViewType, - LookinAttr_NSTabView_Type_TabPosition, - LookinAttr_NSTabView_Type_TabViewBorderType, - ], - LookinAttrSec_NSTabView_Misc: @[ - LookinAttr_NSTabView_Misc_AllowsTruncatedLabels, - LookinAttr_NSTabView_Misc_DrawsBackground, - ], - LookinAttrSec_NSTabView_Info: @[ - LookinAttr_NSTabView_Info_NumberOfTabViewItems, - ], - - LookinAttrSec_NSGridView_Dimensions: @[ - LookinAttr_NSGridView_Dimensions_NumberOfColumns, - LookinAttr_NSGridView_Dimensions_NumberOfRows, - ], - LookinAttrSec_NSGridView_Spacing: @[ - LookinAttr_NSGridView_Spacing_RowSpacing, - LookinAttr_NSGridView_Spacing_ColumnSpacing, - ], - LookinAttrSec_NSGridView_Placement: @[ - LookinAttr_NSGridView_Placement_XPlacement, - LookinAttr_NSGridView_Placement_YPlacement, - ], -#endif - LookinAttrSec_LayoutGuide_Identifier: @[ - LookinAttr_LayoutGuide_Identifier_Identifier, - ], - LookinAttrSec_LayoutGuide_LayoutFrame: @[ - LookinAttr_LayoutGuide_LayoutFrame_LayoutFrame, - ], - LookinAttrSec_LayoutGuide_OwningView: @[ - LookinAttr_LayoutGuide_OwningView_OwningView, - ], - LookinAttrSec_NSCell_Cell: @[ - LookinAttr_NSCell_Cell_Type, - LookinAttr_NSCell_Cell_State, - LookinAttr_NSCell_Cell_Enabled, - LookinAttr_NSCell_Cell_Bordered, - LookinAttr_NSCell_Cell_Bezeled, - LookinAttr_NSCell_Cell_Highlighted, - LookinAttr_NSCell_Cell_Editable, - LookinAttr_NSCell_Cell_Selectable, - LookinAttr_NSCell_Cell_Alignment, - LookinAttr_NSCell_Cell_ControlSize, - ], - LookinAttrSec_NSCell_Content: @[ - LookinAttr_NSCell_Content_Title, - LookinAttr_NSCell_Content_FontName, - LookinAttr_NSCell_Content_FontSize, - LookinAttr_NSCell_Content_LineBreakMode, - LookinAttr_NSCell_Content_Wraps, - ], - LookinAttrSec_NSCell_Behavior: @[ - LookinAttr_NSCell_Behavior_Tag, - LookinAttr_NSCell_Behavior_Continuous, - LookinAttr_NSCell_Behavior_AllowsMixedState, - LookinAttr_NSCell_Behavior_SendsActionOnEndEditing, - ], - LookinAttrSec_NSCell_ButtonCell: @[ - LookinAttr_NSCell_ButtonCell_BezelStyle, - LookinAttr_NSCell_ButtonCell_ImagePosition, - LookinAttr_NSCell_ButtonCell_ShowsBorderOnlyWhileMouseInside, - LookinAttr_NSCell_ButtonCell_KeyEquivalent, - LookinAttr_NSCell_ButtonCell_AlternateTitle, - ], - LookinAttrSec_NSCell_TextFieldCell: @[ - LookinAttr_NSCell_TextFieldCell_Placeholder, - LookinAttr_NSCell_TextFieldCell_DrawsBackground, - LookinAttr_NSCell_TextFieldCell_TextColor, - LookinAttr_NSCell_TextFieldCell_BackgroundColor, - LookinAttr_NSCell_TextFieldCell_BezelStyle, - ], - }; - }); - return dict[sectionID]; -} - -+ (void)getHostGroupID:(inout LookinAttrGroupIdentifier *)groupID_inout sectionID:(inout LookinAttrSectionIdentifier *)sectionID_inout fromAttrID:(LookinAttrIdentifier)targetAttrID { - __block LookinAttrGroupIdentifier targetGroupID = nil; - __block LookinAttrSectionIdentifier targetSecID = nil; - [[self groupIDs] enumerateObjectsUsingBlock:^(LookinAttrGroupIdentifier _Nonnull groupID, NSUInteger idx, BOOL * _Nonnull stop0) { - [[self sectionIDsForGroupID:groupID] enumerateObjectsUsingBlock:^(LookinAttrSectionIdentifier _Nonnull secID, NSUInteger idx, BOOL * _Nonnull stop1) { - [[self attrIDsForSectionID:secID] enumerateObjectsUsingBlock:^(LookinAttrIdentifier _Nonnull attrID, NSUInteger idx, BOOL * _Nonnull stop2) { - if ([attrID isEqualToString:targetAttrID]) { - targetGroupID = groupID; - targetSecID = secID; - *stop0 = YES; - *stop1 = YES; - *stop2 = YES; - } - }]; - }]; - }]; - - if (groupID_inout && targetGroupID) { - *groupID_inout = targetGroupID; - } - if (sectionID_inout && targetSecID) { - *sectionID_inout = targetSecID; - } -} - -+ (NSString *)groupTitleWithGroupID:(LookinAttrGroupIdentifier)groupID { - static dispatch_once_t onceToken; - static NSDictionary *rawInfo = nil; - dispatch_once(&onceToken,^{ - rawInfo = @{ - LookinAttrGroup_Class: @"Class", - LookinAttrGroup_Relation: @"Relation", - LookinAttrGroup_Layout: @"Layout", - LookinAttrGroup_AutoLayout: @"AutoLayout", - // iOS groups (macOS client displays iOS data, so both platforms need all titles) - LookinAttrGroup_UIImageView: @"UIImageView", - LookinAttrGroup_UILabel: @"UILabel", - LookinAttrGroup_UIControl: @"UIControl", - LookinAttrGroup_UIButton: @"UIButton", - LookinAttrGroup_UIScrollView: @"UIScrollView", - LookinAttrGroup_UITableView: @"UITableView", - LookinAttrGroup_UITextView: @"UITextView", - LookinAttrGroup_UITextField: @"UITextField", - LookinAttrGroup_UIVisualEffectView: @"UIVisualEffectView", - LookinAttrGroup_UIStackView: @"UIStackView", - LookinAttrGroup_UIWindowScene: @"UIWindowScene", - LookinAttrGroup_UITraitCollection: @"UITraitCollection", - // macOS groups - LookinAttrGroup_NSImageView: @"NSImageView", - LookinAttrGroup_NSControl: @"NSControl", - LookinAttrGroup_NSButton: @"NSButton", - LookinAttrGroup_NSScrollView: @"NSScrollView", - LookinAttrGroup_NSTableView: @"NSTableView", - LookinAttrGroup_NSTextView: @"NSTextView", - LookinAttrGroup_NSTextField: @"NSTextField", - LookinAttrGroup_NSVisualEffectView: @"NSVisualEffectView", - LookinAttrGroup_NSStackView: @"NSStackView", - LookinAttrGroup_NSWindow: @"NSWindow", - LookinAttrGroup_NSSlider: @"NSSlider", - LookinAttrGroup_NSProgressIndicator: @"NSProgressIndicator", - LookinAttrGroup_NSSegmentedControl: @"NSSegmentedControl", - LookinAttrGroup_NSPopUpButton: @"NSPopUpButton", - LookinAttrGroup_NSComboBox: @"NSComboBox", - LookinAttrGroup_NSStepper: @"NSStepper", - LookinAttrGroup_NSColorWell: @"NSColorWell", - LookinAttrGroup_NSSwitch: @"NSSwitch", - LookinAttrGroup_NSDatePicker: @"NSDatePicker", - LookinAttrGroup_NSLevelIndicator: @"NSLevelIndicator", - LookinAttrGroup_NSOutlineView: @"NSOutlineView", - LookinAttrGroup_NSCollectionView: @"NSCollectionView", - LookinAttrGroup_NSBox: @"NSBox", - LookinAttrGroup_NSSplitView: @"NSSplitView", - LookinAttrGroup_NSTabView: @"NSTabView", - LookinAttrGroup_NSGridView: @"NSGridView", - // Platform-neutral; the host renders it as UILayoutGuide or - // NSLayoutGuide depending on the inspected app's platform. - LookinAttrGroup_LayoutGuide: @"LayoutGuide", - LookinAttrGroup_NSCell: @"NSCell", -#if TARGET_OS_IPHONE - LookinAttrGroup_ViewLayer: @"CALayer / UIView", -#else - LookinAttrGroup_ViewLayer: @"CALayer / NSView", -#endif - }; - }); - NSString *title = rawInfo[groupID]; - NSAssert(title.length, @""); - return title; -} - -+ (NSString *)sectionTitleWithSectionID:(LookinAttrSectionIdentifier)secID { - static dispatch_once_t onceToken; - static NSDictionary *rawInfo = nil; - dispatch_once(&onceToken,^{ - rawInfo = @{ - LookinAttrSec_Layout_Frame: @"Frame", - LookinAttrSec_Layout_Bounds: @"Bounds", - LookinAttrSec_Layout_SafeArea: @"SafeArea", - LookinAttrSec_Layout_Position: @"Position", - LookinAttrSec_Layout_AnchorPoint: @"AnchorPoint", - LookinAttrSec_Layout_CoordinateSpace: @"CoordinateSpace", - LookinAttrSec_AutoLayout_Hugging: @"HuggingPriority", - LookinAttrSec_AutoLayout_Resistance: @"ResistancePriority", - LookinAttrSec_AutoLayout_IntrinsicSize: @"IntrinsicSize", - LookinAttrSec_ViewLayer_Corner: @"CornerRadius", - LookinAttrSec_ViewLayer_BgColor: @"BackgroundColor", - LookinAttrSec_ViewLayer_Border: @"Border", - LookinAttrSec_ViewLayer_Shadow: @"Shadow", - LookinAttrSec_ViewLayer_Tag: @"Tag", - LookinAttrSec_ViewLayer_ContentMode: @"ContentMode", - LookinAttrSec_ViewLayer_TintColor: @"TintColor", - LookinAttrSec_UIStackView_Axis: @"Axis", - LookinAttrSec_UIStackView_Distribution: @"Distribution", - LookinAttrSec_UIStackView_Alignment: @"Alignment", - LookinAttrSec_UIVisualEffectView_Style: @"Style", - LookinAttrSec_UIVisualEffectView_QMUIForegroundColor: @"ForegroundColor", - LookinAttrSec_UIImageView_Name: @"ImageName", - LookinAttrSec_UILabel_TextColor: @"TextColor", - LookinAttrSec_UITextView_TextColor: @"TextColor", - LookinAttrSec_UITextField_TextColor: @"TextColor", - LookinAttrSec_UILabel_BreakMode: @"LineBreakMode", - LookinAttrSec_UILabel_NumberOfLines: @"NumberOfLines", - LookinAttrSec_UILabel_Text: @"Text", - LookinAttrSec_UITextView_Text: @"Text", - LookinAttrSec_UITextField_Text: @"Text", - LookinAttrSec_UITextField_Placeholder: @"Placeholder", - LookinAttrSec_UILabel_Alignment: @"TextAlignment", - LookinAttrSec_UITextView_Alignment: @"TextAlignment", - LookinAttrSec_UITextField_Alignment: @"TextAlignment", - LookinAttrSec_UIControl_HorAlignment: @"HorizontalAlignment", - LookinAttrSec_UIControl_VerAlignment: @"VerticalAlignment", - LookinAttrSec_UIControl_QMUIOutsideEdge: @"QMUI_outsideEdge", - LookinAttrSec_UIButton_ContentInsets: @"ContentInsets", - LookinAttrSec_UIButton_TitleInsets: @"TitleInsets", - LookinAttrSec_UIButton_ImageInsets: @"ImageInsets", - LookinAttrSec_UIScrollView_QMUIInitialInset: @"QMUI_initialContentInset", - LookinAttrSec_UIScrollView_ContentInset: @"ContentInset", - LookinAttrSec_UIScrollView_AdjustedInset: @"AdjustedContentInset", - LookinAttrSec_UIScrollView_IndicatorInset: @"ScrollIndicatorInsets", - LookinAttrSec_UIScrollView_Offset: @"ContentOffset", - LookinAttrSec_UIScrollView_ContentSize: @"ContentSize", - LookinAttrSec_UIScrollView_Behavior: @"InsetAdjustmentBehavior", - LookinAttrSec_UIScrollView_ShowsIndicator: @"ShowsScrollIndicator", - LookinAttrSec_UIScrollView_Bounce: @"AlwaysBounce", - LookinAttrSec_UIScrollView_Zoom: @"Zoom", - LookinAttrSec_UITableView_Style: @"Style", - LookinAttrSec_UITableView_SectionsNumber: @"NumberOfSections", - LookinAttrSec_UITableView_RowsNumber: @"NumberOfRows", - LookinAttrSec_UITableView_SeparatorColor: @"SeparatorColor", - LookinAttrSec_UITableView_SeparatorInset: @"SeparatorInset", - LookinAttrSec_UITableView_SeparatorStyle: @"SeparatorStyle", - LookinAttrSec_UILabel_Font: @"Font", - LookinAttrSec_UITextField_Font: @"Font", - LookinAttrSec_UITextView_Font: @"Font", - LookinAttrSec_UITextView_ContainerInset: @"ContainerInset", - LookinAttrSec_UITextField_ClearButtonMode: @"ClearButtonMode", - LookinAttrSec_NSImageView_Name: @"ImageName", - LookinAttrSec_NSImageView_Open: @"Open", - LookinAttrSec_NSImageView_Scaling: @"Scaling", - LookinAttrSec_NSImageView_Behavior: @"Behavior", - LookinAttrSec_NSImageView_ContentTintColor: @"ContentTintColor", - LookinAttrSec_NSControl_State: @"State", - LookinAttrSec_NSControl_ControlSize: @"ControlSize", - LookinAttrSec_NSControl_Font: @"Font", - LookinAttrSec_NSControl_Alignment: @"Alignment", - LookinAttrSec_NSControl_Misc: @"Misc", - LookinAttrSec_NSControl_StringValue: @"StringValue", - LookinAttrSec_NSControl_Value: @"Value", - LookinAttrSec_NSButton_ButtonType: @"ButtonType", - LookinAttrSec_NSButton_Title: @"Title", - LookinAttrSec_NSButton_BezelStyle: @"BezelStyle", - LookinAttrSec_NSButton_BezelColor: @"Colors", - LookinAttrSec_NSButton_Misc: @"Misc", - LookinAttrSec_NSScrollView_ContentOffset: @"ContentOffset", - LookinAttrSec_NSScrollView_ContentSize: @"ContentSize", - LookinAttrSec_NSScrollView_ContentInset: @"ContentInset", - LookinAttrSec_NSScrollView_BorderType: @"BorderType", - LookinAttrSec_NSScrollView_Scroller: @"Scroller", - LookinAttrSec_NSScrollView_Ruler: @"Ruler", - LookinAttrSec_NSScrollView_LineScroll: @"LineScroll", - LookinAttrSec_NSScrollView_PageScroll: @"PageScroll", - LookinAttrSec_NSScrollView_ScrollElasiticity: @"ScrollElasiticity", - LookinAttrSec_NSScrollView_Misc: @"Misc", - LookinAttrSec_NSScrollView_Magnification: @"Magnification", - LookinAttrSec_NSTableView_RowHeight: @"RowHeight", - LookinAttrSec_NSTableView_AutomaticRowHeights: @"AutomaticRowHeights", - LookinAttrSec_NSTableView_IntercellSpacing: @"IntercellSpacing", - LookinAttrSec_NSTableView_Style: @"Style", - LookinAttrSec_NSTableView_ColumnAutoresizingStyle: @"ColumnAutoresizingStyle", - LookinAttrSec_NSTableView_GridStyleMask: @"GridStyleMask", - LookinAttrSec_NSTableView_SelectionHighlightStyle: @"SelectionHighlightStyle", - LookinAttrSec_NSTableView_GridColor: @"GridColor", - LookinAttrSec_NSTableView_RowSizeStyle: @"RowSizeStyle", - LookinAttrSec_NSTableView_NumberOfRows: @"NumberOfRows", - LookinAttrSec_NSTableView_NumberOfColumns: @"NumberOfColumns", - LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors: @"UseAlternatingRowBackgroundColors", - LookinAttrSec_NSTableView_AllowsColumnReordering: @"AllowsColumnReordering", - LookinAttrSec_NSTableView_AllowsColumnResizing: @"AllowsColumnResizing", - LookinAttrSec_NSTableView_AllowsMultipleSelection: @"AllowsMultipleSelection", - LookinAttrSec_NSTableView_AllowsEmptySelection: @"AllowsEmptySelection", - LookinAttrSec_NSTableView_AllowsColumnSelection: @"AllowsColumnSelection", - LookinAttrSec_NSTableView_AllowsTypeSelect: @"AllowsTypeSelect", - LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle: @"DraggingDestinationFeedbackStyle", - LookinAttrSec_NSTableView_Autosave: @"Autosave", - LookinAttrSec_NSTableView_FloatsGroupRows: @"FloatsGroupRows", - LookinAttrSec_NSTableView_RowActionsVisible: @"RowActionsVisible", - LookinAttrSec_NSTableView_UsesStaticContents: @"UsesStaticContents", - LookinAttrSec_NSTableView_UserInterfaceLayoutDirection: @"UserInterfaceLayoutDirection", - LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag: @"VerticalMotionCanBeginDrag", - LookinAttrSec_NSTextView_Font: @"Font", - LookinAttrSec_NSTextView_Basic: @"Basic", - LookinAttrSec_NSTextView_String: @"String", - LookinAttrSec_NSTextView_TextColor: @"TextColor", - LookinAttrSec_NSTextView_Alignment: @"Alignment", - LookinAttrSec_NSTextView_ContainerInset: @"ContainerInset", - LookinAttrSec_NSTextView_BaseWritingDirection: @"BaseWritingDirection", - LookinAttrSec_NSTextView_Size: @"Size", - LookinAttrSec_NSTextView_Resizable: @"Resizable", - LookinAttrSec_NSTextField_BezelStyle: @"BezelStyle", - LookinAttrSec_NSTextField_LineBreakStrategy: @"LineBreakStrategy", - LookinAttrSec_NSTextField_TextColor: @"Colors", - LookinAttrSec_NSTextField_Placeholder: @"Placeholder", - LookinAttrSec_NSTextField_PreferredMaxLayoutWidth: @"Layout", - LookinAttrSec_NSVisualEffectView_Material: @"Material", - LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle: @"InteriorBackgroundStyle", - LookinAttrSec_NSVisualEffectView_BlendingMode: @"BlendingMode", - LookinAttrSec_NSVisualEffectView_State: @"State", - LookinAttrSec_NSVisualEffectView_Emphasized: @"Emphasized", - LookinAttrSec_NSStackView_Orientation: @"Orientation", - LookinAttrSec_NSStackView_EdgeInsets: @"EdgeInsets", - LookinAttrSec_NSStackView_DetachesHiddenViews: @"DetachesHiddenViews", - LookinAttrSec_NSStackView_Distribution: @"Distribution", - LookinAttrSec_NSStackView_Alignment: @"Alignment", - LookinAttrSec_NSStackView_Spacing: @"Spacing", - LookinAttrSec_NSWindow_Title: @"Title", - LookinAttrSec_NSWindow_Subtitle: @"Subtitle", - LookinAttrSec_NSWindow_State: @"State", - LookinAttrSec_NSWindow_Style: @"StyleMask", - LookinAttrSec_NSWindow_CollectionBehavior: @"CollectionBehavior", - LookinAttrSec_NSWindow_Appearance: @"Appearance", - LookinAttrSec_NSWindow_TitleVisibility: @"TitleVisibility", - LookinAttrSec_NSWindow_ToolbarStyle: @"ToolbarStyle", - LookinAttrSec_NSWindow_TitlebarSeparatorStyle: @"TitlebarSeparatorStyle", - LookinAttrSec_NSWindow_Behavior: @"Behavior", - LookinAttrSec_NSWindow_AnimationBehavior: @"AnimationBehavior", - LookinAttrSec_NSWindow_Level: @"Level", - LookinAttrSec_NSWindow_TabbingMode: @"TabbingMode", - LookinAttrSec_NSWindow_Size: @"Size", - LookinAttrSec_NSWindow_Info: @"Info", - LookinAttrSec_NSSlider_SliderType: @"SliderType", - LookinAttrSec_NSSlider_Range: @"Range", - LookinAttrSec_NSSlider_TickMark: @"TickMark", - LookinAttrSec_NSSlider_Misc: @"Misc", - LookinAttrSec_NSProgressIndicator_Style: @"Style", - LookinAttrSec_NSProgressIndicator_Range: @"Range", - LookinAttrSec_NSProgressIndicator_Misc: @"Misc", - LookinAttrSec_NSSegmentedControl_SegmentCount: @"SegmentCount", - LookinAttrSec_NSSegmentedControl_Selection: @"Selection", - LookinAttrSec_NSSegmentedControl_Style: @"Style", - LookinAttrSec_NSSegmentedControl_Colors: @"Colors", - LookinAttrSec_NSPopUpButton_Behavior: @"Behavior", - LookinAttrSec_NSPopUpButton_Selection: @"Selection", - LookinAttrSec_NSPopUpButton_Items: @"Items", - LookinAttrSec_NSComboBox_Items: @"Items", - LookinAttrSec_NSComboBox_Misc: @"Misc", - LookinAttrSec_NSStepper_Range: @"Range", - LookinAttrSec_NSStepper_Misc: @"Misc", - LookinAttrSec_NSColorWell_Color: @"Color", - LookinAttrSec_NSColorWell_Misc: @"Misc", - LookinAttrSec_NSSwitch_State: @"State", - LookinAttrSec_NSDatePicker_Style: @"Style", - LookinAttrSec_NSDatePicker_Range: @"Range", - LookinAttrSec_NSDatePicker_Misc: @"Misc", - LookinAttrSec_NSLevelIndicator_Style: @"Style", - LookinAttrSec_NSLevelIndicator_Range: @"Range", - LookinAttrSec_NSLevelIndicator_TickMark: @"TickMark", - LookinAttrSec_NSOutlineView_Indentation: @"Indentation", - LookinAttrSec_NSOutlineView_Misc: @"Misc", - LookinAttrSec_NSCollectionView_Selection: @"Selection", - LookinAttrSec_NSCollectionView_Info: @"Info", - LookinAttrSec_NSCollectionView_Colors: @"Colors", - LookinAttrSec_NSBox_Type: @"Type", - LookinAttrSec_NSBox_Title: @"Title", - LookinAttrSec_NSBox_Appearance: @"Appearance", - LookinAttrSec_NSBox_Metrics: @"Metrics", - LookinAttrSec_NSSplitView_Orientation: @"Orientation", - LookinAttrSec_NSSplitView_Style: @"Style", - LookinAttrSec_NSSplitView_Misc: @"Misc", - LookinAttrSec_NSTabView_Type: @"Type", - LookinAttrSec_NSTabView_Misc: @"Misc", - LookinAttrSec_NSTabView_Info: @"Info", - LookinAttrSec_NSGridView_Dimensions: @"Dimensions", - LookinAttrSec_NSGridView_Spacing: @"Spacing", - LookinAttrSec_NSGridView_Placement: @"Placement", - // LayoutGuide - LookinAttrSec_LayoutGuide_Identifier: @"Identifier", - LookinAttrSec_LayoutGuide_LayoutFrame: @"LayoutFrame", - LookinAttrSec_LayoutGuide_OwningView: @"OwningView", - // NSCell - LookinAttrSec_NSCell_Cell: @"Cell", - LookinAttrSec_NSCell_Content: @"Content", - LookinAttrSec_NSCell_Behavior: @"Behavior", - LookinAttrSec_NSCell_ButtonCell: @"NSButtonCell", - LookinAttrSec_NSCell_TextFieldCell: @"NSTextFieldCell", - // UIWindowScene - LookinAttrSec_UIWindowScene_State: @"State", - LookinAttrSec_UIWindowScene_Title: @"Title", - LookinAttrSec_UIWindowScene_Orientation: @"Orientation", - LookinAttrSec_UIWindowScene_Windows: @"Windows", - LookinAttrSec_UIWindowScene_Screen: @"Screen", - LookinAttrSec_UIWindowScene_StatusBar: @"StatusBar", - LookinAttrSec_UIWindowScene_Traits: @"Traits", - LookinAttrSec_UIWindowScene_Session: @"Session", - LookinAttrSec_UIWindowScene_Configuration: @"Configuration", - LookinAttrSec_UIWindowScene_Geometry: @"Geometry", - LookinAttrSec_UIWindowScene_ActivationConditions: @"ActivationConditions", - LookinAttrSec_UIWindowScene_SizeRestrictions: @"SizeRestrictions", - LookinAttrSec_UIWindowScene_WindowingBehaviors: @"WindowingBehaviors", - LookinAttrSec_UIWindowScene_Pointer: @"Pointer", - LookinAttrSec_UIWindowScene_Protection: @"Protection", - // UITraitCollection - LookinAttrSec_UITraitCollection_Appearance: @"Appearance", - LookinAttrSec_UITraitCollection_SizeClass: @"SizeClass", - LookinAttrSec_UITraitCollection_Display: @"Display", - LookinAttrSec_UITraitCollection_Device: @"Device", - LookinAttrSec_UITraitCollection_Layout: @"Layout", - LookinAttrSec_UITraitCollection_Content: @"Content", - }; - }); - return rawInfo[secID]; -} - -/** - className: 必填项,标识该属性是哪一个类拥有的 - - fullTitle: 完整的名字,将作为搜索的 keywords,也会展示在搜索结果中,如果为 nil 则不会被搜索到 - - briefTitle:简略的名字,仅 checkbox 和那种自带标题的 input 才需要这个属性,如果需要该属性但该属性又为空,则会读取 fullTitle - - setterString:用户试图修改属性值时会用到,若该字段为空字符串(即 @“”)则该属性不可修改,若该字段为 nil 则会在 fullTitle 的基础上自动生成(自动改首字母大小写、加前缀后缀,比如 alpha 会被转换为 setAlpha:) - - getterString:必填项,业务中读取属性值时会用到。如果该字段为 nil ,则会在 fullTitle 的基础上自动生成(自动把 fullTitle 的第一个字母改成小写,比如 Alpha 会被转换为 alpha)。如果该字段为空字符串(比如 image_open_open)则属性值会被固定为 nil,attrType 会被指为 LookinAttrTypeCustomObj - - typeIfObj:当某个 LookinAttribute 确定是 NSObject 类型时,该方法返回它具体是什么对象,比如 UIColor、NSString - - enumList:如果某个 attribute 是 enum,则这里标识了相应的 enum 的名称(如 "NSTextAlignment"),业务可通过这个名称进而查询可用的枚举值列表 - - patch:如果为 YES,则用户修改了该 Attribute 的值后,Lookin 会重新拉取和更新相关图层的位置、截图等信息,如果为 nil 则默认是 NO - - hideIfNil:如果为 YES,则当获取的 value 为 nil 时,Lookin 不会传输该 attr。如果为 NO,则即使 value 为 nil 也会传输(比如 label 的 text 属性,即使它是 nil 我们也要显示,所以它的 hideIfNil 应该为 NO)。如果该字段为 nil 则默认是 NO - - osVersion: 该属性需要的最低的 iOS 版本,比如 safeAreaInsets 从 iOS 11.0 开始出现,则该属性应该为 @11,如果为 nil 则表示不限制 iOS 版本 - - */ -+ (NSDictionary *)_infoForAttrID:(LookinAttrIdentifier)attrID { - static NSDictionary *> *dict; - static dispatch_once_t onceToken; - dispatch_once(&onceToken,^{ - dict = @{ - LookinAttr_Class_Class_Class: @{ - @"className": @"CALayer", - @"getterString": @"lks_relatedClassChainList", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj) - }, - - LookinAttr_Relation_Relation_Relation: @{ - @"className": @"CALayer", - @"getterString": @"lks_selfRelation", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - - LookinAttr_Layout_Frame_Frame: @{ - @"className": @"CALayer", - @"fullTitle": @"Frame", - @"patch": @(YES) - }, - LookinAttr_Layout_Bounds_Bounds: @{ - @"className": @"CALayer", - @"fullTitle": @"Bounds", - @"patch": @(YES) - }, - // Attributes that exist on both platforms must appear exactly once - // in this literal, with the className split by preprocessor. Two - // entries under the same key silently drop one platform: the first - // entry wins, so the other platform's className resolves to nil - // and the row vanishes from the dashboard. - LookinAttr_Layout_SafeArea_SafeArea: @{ -#if TARGET_OS_IPHONE - @"className": @"UIView", -#else - @"className": @"NSView", -#endif - @"fullTitle": @"SafeAreaInsets", - @"setterString": @"", - @"osVersion": @(11) - }, - LookinAttr_Layout_Position_Position: @{ - @"className": @"CALayer", - @"fullTitle": @"Position", - @"patch": @(YES) - }, - LookinAttr_Layout_AnchorPoint_AnchorPoint: @{ - @"className": @"CALayer", - @"fullTitle": @"AnchorPoint", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Hugging_Hor: @{ -#if TARGET_OS_IPHONE - @"className": @"UIView", -#else - @"className": @"NSView", -#endif - @"fullTitle": @"ContentHuggingPriority(Horizontal)", - @"getterString": @"lks_horizontalContentHuggingPriority", - @"setterString": @"setLks_horizontalContentHuggingPriority:", - @"briefTitle": @"H", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Hugging_Ver: @{ -#if TARGET_OS_IPHONE - @"className": @"UIView", -#else - @"className": @"NSView", -#endif - @"fullTitle": @"ContentHuggingPriority(Vertical)", - @"getterString": @"lks_verticalContentHuggingPriority", - @"setterString": @"setLks_verticalContentHuggingPriority:", - @"briefTitle": @"V", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Resistance_Hor: @{ -#if TARGET_OS_IPHONE - @"className": @"UIView", -#else - @"className": @"NSView", -#endif - @"fullTitle": @"ContentCompressionResistancePriority(Horizontal)", - @"getterString": @"lks_horizontalContentCompressionResistancePriority", - @"setterString": @"setLks_horizontalContentCompressionResistancePriority:", - @"briefTitle": @"H", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Resistance_Ver: @{ -#if TARGET_OS_IPHONE - @"className": @"UIView", -#else - @"className": @"NSView", -#endif - @"fullTitle": @"ContentCompressionResistancePriority(Vertical)", - @"getterString": @"lks_verticalContentCompressionResistancePriority", - @"setterString": @"setLks_verticalContentCompressionResistancePriority:", - @"briefTitle": @"V", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Constraints_Constraints: @{ -#if TARGET_OS_IPHONE - @"className": @"UIView", -#else - @"className": @"NSView", -#endif - @"getterString": @"lks_constraints", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - LookinAttr_AutoLayout_IntrinsicSize_Size: @{ -#if TARGET_OS_IPHONE - @"className": @"UIView", -#else - @"className": @"NSView", -#endif - @"fullTitle": @"IntrinsicContentSize", - @"setterString": @"" - }, - - LookinAttr_ViewLayer_InterationAndMasks_Interaction: @{ - @"className": @"UIView", - @"fullTitle": @"UserInteractionEnabled", - @"getterString": @"isUserInteractionEnabled", - @"patch": @(NO) - }, - - LookinAttr_ViewLayer_ContentMode_Mode: @{ - @"className": @"UIView", - @"fullTitle": @"ContentMode", - @"enumList": @"UIViewContentMode", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_TintColor_Color: @{ - @"className": @"UIView", - @"fullTitle": @"TintColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_ViewLayer_TintColor_Mode: @{ - @"className": @"UIView", - @"fullTitle": @"TintAdjustmentMode", - @"enumList": @"UIViewTintAdjustmentMode", - @"patch": @(YES) - }, - LookinAttr_LayoutGuide_Identifier_Identifier: @{ -#if TARGET_OS_IPHONE - @"className": @"UILayoutGuide", -#else - @"className": @"NSLayoutGuide", -#endif - @"fullTitle": @"Identifier", - @"getterString": @"identifier", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_LayoutGuide_LayoutFrame_LayoutFrame: @{ -#if TARGET_OS_IPHONE - @"className": @"UILayoutGuide", -#else - @"className": @"NSLayoutGuide", -#endif - @"fullTitle": @"LayoutFrame", - @"getterString": @"lks_layoutFrame", - @"setterString": @"" - }, - LookinAttr_LayoutGuide_OwningView_OwningView: @{ -#if TARGET_OS_IPHONE - @"className": @"UILayoutGuide", -#else - @"className": @"NSLayoutGuide", -#endif - @"fullTitle": @"OwningView", - @"getterString": @"lks_owningViewDescription", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_NSCell_Cell_Type: @{ - @"className": @"NSCell", - @"fullTitle": @"CellType", - @"getterString": @"type", - @"setterString": @"", - @"enumList": @"NSCellType" - }, - LookinAttr_NSCell_Cell_State: @{ - @"className": @"NSCell", - @"fullTitle": @"State", - @"getterString": @"state", - @"setterString": @"", - @"enumList": @"NSControlStateValue" - }, - LookinAttr_NSCell_Cell_Enabled: @{ - @"className": @"NSCell", - @"fullTitle": @"Enabled", - @"getterString": @"isEnabled", - @"setterString": @"" - }, - LookinAttr_NSCell_Cell_Bordered: @{ - @"className": @"NSCell", - @"fullTitle": @"Bordered", - @"getterString": @"isBordered", - @"setterString": @"" - }, - LookinAttr_NSCell_Cell_Bezeled: @{ - @"className": @"NSCell", - @"fullTitle": @"Bezeled", - @"getterString": @"isBezeled", - @"setterString": @"" - }, - LookinAttr_NSCell_Cell_Highlighted: @{ - @"className": @"NSCell", - @"fullTitle": @"Highlighted", - @"getterString": @"isHighlighted", - @"setterString": @"" - }, - LookinAttr_NSCell_Cell_Editable: @{ - @"className": @"NSCell", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"setterString": @"" - }, - LookinAttr_NSCell_Cell_Selectable: @{ - @"className": @"NSCell", - @"fullTitle": @"Selectable", - @"getterString": @"isSelectable", - @"setterString": @"" - }, - LookinAttr_NSCell_Cell_Alignment: @{ - @"className": @"NSCell", - @"fullTitle": @"Alignment", - @"getterString": @"alignment", - @"setterString": @"", - @"enumList": @"NSTextAlignment_AppKit" - }, - LookinAttr_NSCell_Cell_ControlSize: @{ - @"className": @"NSCell", - @"fullTitle": @"ControlSize", - @"getterString": @"controlSize", - @"setterString": @"", - @"enumList": @"NSControlSize" - }, - LookinAttr_NSCell_ButtonCell_BezelStyle: @{ - @"className": @"NSButtonCell", - @"fullTitle": @"BezelStyle", - @"getterString": @"bezelStyle", - @"setterString": @"", - @"enumList": @"NSBezelStyle" - }, - LookinAttr_NSCell_ButtonCell_ImagePosition: @{ - @"className": @"NSButtonCell", - @"fullTitle": @"ImagePosition", - @"getterString": @"imagePosition", - @"setterString": @"", - @"enumList": @"NSCellImagePosition" - }, - LookinAttr_NSCell_ButtonCell_ShowsBorderOnlyWhileMouseInside: @{ - @"className": @"NSButtonCell", - @"fullTitle": @"ShowsBorderOnlyWhileMouseInside", - @"getterString": @"showsBorderOnlyWhileMouseInside", - @"setterString": @"" - }, - LookinAttr_NSCell_TextFieldCell_Placeholder: @{ - @"className": @"NSTextFieldCell", - @"fullTitle": @"Placeholder", - @"getterString": @"placeholderString", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_NSCell_TextFieldCell_DrawsBackground: @{ - @"className": @"NSTextFieldCell", - @"fullTitle": @"DrawsBackground", - @"getterString": @"drawsBackground", - @"setterString": @"" - }, - LookinAttr_NSCell_Content_Title: @{ - @"className": @"NSCell", - @"fullTitle": @"Title", - @"getterString": @"title", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_NSCell_Content_FontName: @{ - @"className": @"NSCell", - @"fullTitle": @"FontName", - @"getterString": @"lks_fontName", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_NSCell_Content_FontSize: @{ - @"className": @"NSCell", - @"fullTitle": @"FontSize", - @"getterString": @"lks_fontSize", - @"setterString": @"" - }, - LookinAttr_NSCell_Content_LineBreakMode: @{ - @"className": @"NSCell", - @"fullTitle": @"LineBreakMode", - @"getterString": @"lineBreakMode", - @"setterString": @"", - @"enumList": @"NSLineBreakMode" - }, - LookinAttr_NSCell_Content_Wraps: @{ - @"className": @"NSCell", - @"fullTitle": @"Wraps", - @"getterString": @"wraps", - @"setterString": @"" - }, - LookinAttr_NSCell_Behavior_Tag: @{ - @"className": @"NSCell", - @"fullTitle": @"Tag", - @"getterString": @"tag", - @"setterString": @"" - }, - LookinAttr_NSCell_Behavior_Continuous: @{ - @"className": @"NSCell", - @"fullTitle": @"Continuous", - @"getterString": @"isContinuous", - @"setterString": @"" - }, - LookinAttr_NSCell_Behavior_AllowsMixedState: @{ - @"className": @"NSCell", - @"fullTitle": @"AllowsMixedState", - @"getterString": @"allowsMixedState", - @"setterString": @"" - }, - LookinAttr_NSCell_Behavior_SendsActionOnEndEditing: @{ - @"className": @"NSCell", - @"fullTitle": @"SendsActionOnEndEditing", - @"getterString": @"sendsActionOnEndEditing", - @"setterString": @"" - }, - LookinAttr_NSCell_ButtonCell_KeyEquivalent: @{ - @"className": @"NSButtonCell", - @"fullTitle": @"KeyEquivalent", - @"getterString": @"keyEquivalent", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_NSCell_ButtonCell_AlternateTitle: @{ - @"className": @"NSButtonCell", - @"fullTitle": @"AlternateTitle", - @"getterString": @"alternateTitle", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_NSCell_TextFieldCell_TextColor: @{ - @"className": @"NSTextFieldCell", - @"fullTitle": @"TextColor", - @"getterString": @"textColor", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"hideIfNil": @(YES) - }, - LookinAttr_NSCell_TextFieldCell_BackgroundColor: @{ - @"className": @"NSTextFieldCell", - @"fullTitle": @"BackgroundColor", - @"getterString": @"backgroundColor", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"hideIfNil": @(YES) - }, - LookinAttr_NSCell_TextFieldCell_BezelStyle: @{ - @"className": @"NSTextFieldCell", - @"fullTitle": @"BezelStyle", - @"getterString": @"bezelStyle", - @"setterString": @"", - @"enumList": @"NSTextFieldBezelStyle" - }, - LookinAttr_ViewLayer_Tag_Tag: @{ -#if TARGET_OS_IPHONE - @"className": @"UIView", -#else - @"className": @"NSView", -#endif - @"fullTitle": @"Tag", - @"briefTitle": @"", - @"patch": @(NO) - }, - LookinAttr_ViewLayer_Visibility_Hidden: @{ - @"className": @"CALayer", - @"fullTitle": @"Hidden", - @"getterString": @"isHidden", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Visibility_Opacity: @{ - @"className": @"CALayer", - @"fullTitle": @"Opacity / Alpha", - @"setterString": @"setOpacity:", - @"getterString": @"opacity", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_InterationAndMasks_MasksToBounds: @{ - @"className": @"CALayer", - @"fullTitle": @"MasksToBounds / ClipsToBounds", - @"briefTitle": @"MasksToBounds", - @"setterString": @"setMasksToBounds:", - @"getterString": @"masksToBounds", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Corner_Radius: @{ - @"className": @"CALayer", - @"fullTitle": @"CornerRadius", - @"briefTitle": @"", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_BgColor_BgColor: @{ - @"className": @"CALayer", - @"fullTitle": @"BackgroundColor", - @"setterString": @"setLks_backgroundColor:", - @"getterString": @"lks_backgroundColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Border_Color: @{ - @"className": @"CALayer", - @"fullTitle": @"BorderColor", - @"setterString": @"setLks_borderColor:", - @"getterString": @"lks_borderColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Border_Width: @{ - @"className": @"CALayer", - @"fullTitle": @"BorderWidth", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_Color: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowColor", - @"setterString": @"setLks_shadowColor:", - @"getterString": @"lks_shadowColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_Opacity: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowOpacity", - @"briefTitle": @"Opacity", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_Radius: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowRadius", - @"briefTitle": @"Radius", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_OffsetW: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowOffsetWidth", - @"briefTitle": @"OffsetW", - @"setterString": @"setLks_shadowOffsetWidth:", - @"getterString": @"lks_shadowOffsetWidth", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_OffsetH: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowOffsetHeight", - @"briefTitle": @"OffsetH", - @"setterString": @"setLks_shadowOffsetHeight:", - @"getterString": @"lks_shadowOffsetHeight", - @"patch": @(YES) - }, - - LookinAttr_UIStackView_Axis_Axis: @{ - @"className": @"UIStackView", - @"fullTitle": @"Axis", - @"enumList": @"UILayoutConstraintAxis", - @"patch": @(YES) - }, - - LookinAttr_UIStackView_Distribution_Distribution: @{ - @"className": @"UIStackView", - @"fullTitle": @"Distribution", - @"enumList": @"UIStackViewDistribution", - @"patch": @(YES) - }, - - LookinAttr_UIStackView_Alignment_Alignment: @{ - @"className": @"UIStackView", - @"fullTitle": @"Alignment", - @"enumList": @"UIStackViewAlignment", - @"patch": @(YES) - }, - - LookinAttr_UIStackView_Spacing_Spacing: @{ - @"className": @"UIStackView", - @"fullTitle": @"Spacing", - @"patch": @(YES) - }, - - LookinAttr_UIVisualEffectView_Style_Style: @{ - @"className": @"UIVisualEffectView", - @"setterString": @"setLks_blurEffectStyleNumber:", - @"getterString": @"lks_blurEffectStyleNumber", - @"enumList": @"UIBlurEffectStyle", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"patch": @(YES), - @"hideIfNil": @(YES) - }, - - LookinAttr_UIVisualEffectView_QMUIForegroundColor_Color: @{ - @"className": @"QMUIVisualEffectView", - @"fullTitle": @"ForegroundColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES), - }, - - LookinAttr_UIImageView_Name_Name: @{ - @"className": @"UIImageView", - @"fullTitle": @"ImageName", - @"setterString": @"", - @"getterString": @"lks_imageSourceName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_UIImageView_Open_Open: @{ - @"className": @"UIImageView", - @"setterString": @"", - @"getterString": @"lks_imageViewOidIfHasImage", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - - LookinAttr_UILabel_Text_Text: @{ - @"className": @"UILabel", - @"fullTitle": @"Text", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_UILabel_NumberOfLines_NumberOfLines: @{ - @"className": @"UILabel", - @"fullTitle": @"NumberOfLines", - @"briefTitle": @"", - @"patch": @(YES) - }, - LookinAttr_UILabel_Font_Size: @{ - @"className": @"UILabel", - @"fullTitle": @"FontSize", - @"briefTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_UILabel_Font_Name: @{ - @"className": @"UILabel", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UILabel_TextColor_Color: @{ - @"className": @"UILabel", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_UILabel_Alignment_Alignment: @{ - @"className": @"UILabel", - @"fullTitle": @"TextAlignment", - @"enumList": @"NSTextAlignment", - @"patch": @(YES) - }, - LookinAttr_UILabel_BreakMode_Mode: @{ - @"className": @"UILabel", - @"fullTitle": @"LineBreakMode", - @"enumList": @"NSLineBreakMode", - @"patch": @(YES) - }, - LookinAttr_UILabel_CanAdjustFont_CanAdjustFont: @{ - @"className": @"UILabel", - @"fullTitle": @"AdjustsFontSizeToFitWidth", - @"patch": @(YES) - }, - - LookinAttr_UIControl_EnabledSelected_Enabled: @{ - @"className": @"UIControl", - @"fullTitle": @"Enabled", - @"getterString": @"isEnabled", - @"patch": @(NO) - }, - LookinAttr_UIControl_EnabledSelected_Selected: @{ - @"className": @"UIControl", - @"fullTitle": @"Selected", - @"getterString": @"isSelected", - @"patch": @(YES) - }, - LookinAttr_UIControl_VerAlignment_Alignment: @{ - @"className": @"UIControl", - @"fullTitle": @"ContentVerticalAlignment", - @"enumList": @"UIControlContentVerticalAlignment", - @"patch": @(YES) - }, - LookinAttr_UIControl_HorAlignment_Alignment: @{ - @"className": @"UIControl", - @"fullTitle": @"ContentHorizontalAlignment", - @"enumList": @"UIControlContentHorizontalAlignment", - @"patch": @(YES) - }, - LookinAttr_UIControl_QMUIOutsideEdge_Edge: @{ - @"className": @"UIControl", - @"fullTitle": @"qmui_outsideEdge" - }, - - LookinAttr_UIButton_ContentInsets_Insets: @{ - @"className": @"UIButton", - @"fullTitle": @"ContentEdgeInsets", - @"patch": @(YES) - }, - LookinAttr_UIButton_TitleInsets_Insets: @{ - @"className": @"UIButton", - @"fullTitle": @"TitleEdgeInsets", - @"patch": @(YES) - }, - LookinAttr_UIButton_ImageInsets_Insets: @{ - @"className": @"UIButton", - @"fullTitle": @"ImageEdgeInsets", - @"patch": @(YES) - }, - - LookinAttr_UIScrollView_Offset_Offset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ContentOffset", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_ContentSize_Size: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ContentSize", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_ContentInset_Inset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ContentInset", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_QMUIInitialInset_Inset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"qmui_initialContentInset", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_AdjustedInset_Inset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"AdjustedContentInset", - @"setterString": @"", - @"osVersion": @(11) - }, - LookinAttr_UIScrollView_Behavior_Behavior: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ContentInsetAdjustmentBehavior", - @"enumList": @"UIScrollViewContentInsetAdjustmentBehavior", - @"patch": @(YES), - @"osVersion": @(11) - }, - LookinAttr_UIScrollView_IndicatorInset_Inset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ScrollIndicatorInsets", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ScrollPaging_ScrollEnabled: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ScrollEnabled", - @"getterString": @"isScrollEnabled", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ScrollPaging_PagingEnabled: @{ - @"className": @"UIScrollView", - @"fullTitle": @"PagingEnabled", - @"getterString": @"isPagingEnabled", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Bounce_Ver: @{ - @"className": @"UIScrollView", - @"fullTitle": @"AlwaysBounceVertical", - @"briefTitle": @"Vertical", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Bounce_Hor: @{ - @"className": @"UIScrollView", - @"fullTitle": @"AlwaysBounceHorizontal", - @"briefTitle": @"Horizontal", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ShowsIndicator_Hor: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ShowsHorizontalScrollIndicator", - @"briefTitle": @"Horizontal", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ShowsIndicator_Ver: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ShowsVerticalScrollIndicator", - @"briefTitle": @"Vertical", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ContentTouches_Delay: @{ - @"className": @"UIScrollView", - @"fullTitle": @"DelaysContentTouches", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ContentTouches_CanCancel: @{ - @"className": @"UIScrollView", - @"fullTitle": @"CanCancelContentTouches", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Zoom_MinScale: @{ - @"className": @"UIScrollView", - @"fullTitle": @"MinimumZoomScale", - @"briefTitle": @"MinScale", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Zoom_MaxScale: @{ - @"className": @"UIScrollView", - @"fullTitle": @"MaximumZoomScale", - @"briefTitle": @"MaxScale", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Zoom_Scale: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ZoomScale", - @"briefTitle": @"Scale", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_Zoom_Bounce: @{ - @"className": @"UIScrollView", - @"fullTitle": @"BouncesZoom", - @"patch": @(NO) - }, - - LookinAttr_UITableView_Style_Style: @{ - @"className": @"UITableView", - @"fullTitle": @"Style", - @"setterString": @"", - @"enumList": @"UITableViewStyle", - @"patch": @(YES) - }, - LookinAttr_UITableView_SectionsNumber_Number: @{ - @"className": @"UITableView", - @"fullTitle": @"NumberOfSections", - @"setterString": @"", - @"patch": @(YES) - }, - LookinAttr_UITableView_RowsNumber_Number: @{ - @"className": @"UITableView", - @"setterString": @"", - @"getterString": @"lks_numberOfRows", - @"typeIfObj": @(LookinAttrTypeCustomObj) - }, - LookinAttr_UITableView_SeparatorInset_Inset: @{ - @"className": @"UITableView", - @"fullTitle": @"SeparatorInset", - @"patch": @(NO) - }, - LookinAttr_UITableView_SeparatorColor_Color: @{ - @"className": @"UITableView", - @"fullTitle": @"SeparatorColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_UITableView_SeparatorStyle_Style: @{ - @"className": @"UITableView", - @"fullTitle": @"SeparatorStyle", - @"enumList": @"UITableViewCellSeparatorStyle", - @"patch": @(YES) - }, - - LookinAttr_UITextView_Text_Text: @{ - @"className": @"UITextView", - @"fullTitle": @"Text", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_UITextView_Font_Name: @{ - @"className": @"UITextView", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UITextView_Font_Size: @{ - @"className": @"UITextView", - @"fullTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_UITextView_Basic_Editable: @{ - @"className": @"UITextView", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"patch": @(NO) - }, - LookinAttr_UITextView_Basic_Selectable: @{ - @"className": @"UITextView", - @"fullTitle": @"Selectable", - @"getterString": @"isSelectable", - @"patch": @(NO) - }, - LookinAttr_UITextView_TextColor_Color: @{ - @"className": @"UITextView", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_UITextView_Alignment_Alignment: @{ - @"className": @"UITextView", - @"fullTitle": @"TextAlignment", - @"enumList": @"NSTextAlignment", - @"patch": @(YES) - }, - LookinAttr_UITextView_ContainerInset_Inset: @{ - @"className": @"UITextView", - @"fullTitle": @"TextContainerInset", - @"patch": @(YES) - }, - - LookinAttr_UITextField_Font_Name: @{ - @"className": @"UITextField", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UITextField_Font_Size: @{ - @"className": @"UITextField", - @"fullTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_UITextField_TextColor_Color: @{ - @"className": @"UITextField", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_UITextField_Alignment_Alignment: @{ - @"className": @"UITextField", - @"fullTitle": @"TextAlignment", - @"enumList": @"NSTextAlignment", - @"patch": @(YES) - }, - LookinAttr_UITextField_Text_Text: @{ - @"className": @"UITextField", - @"fullTitle": @"Text", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_UITextField_Placeholder_Placeholder: @{ - @"className": @"UITextField", - @"fullTitle": @"Placeholder", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_UITextField_Clears_ClearsOnBeginEditing: @{ - @"className": @"UITextField", - @"fullTitle": @"ClearsOnBeginEditing", - @"patch": @(NO) - }, - LookinAttr_UITextField_Clears_ClearsOnInsertion: @{ - @"className": @"UITextField", - @"fullTitle": @"ClearsOnInsertion", - @"patch": @(NO) - }, - LookinAttr_UITextField_CanAdjustFont_CanAdjustFont: @{ - @"className": @"UITextField", - @"fullTitle": @"AdjustsFontSizeToFitWidth", - @"patch": @(YES) - }, - LookinAttr_UITextField_CanAdjustFont_MinSize: @{ - @"className": @"UITextField", - @"fullTitle": @"MinimumFontSize", - @"patch": @(YES) - }, - LookinAttr_UITextField_ClearButtonMode_Mode: @{ - @"className": @"UITextField", - @"fullTitle": @"ClearButtonMode", - @"enumList": @"UITextFieldViewMode", - @"patch": @(NO) - }, - LookinAttr_NSImageView_Name_Name: @{ - @"className": @"NSImageView", - @"fullTitle": @"ImageName", - @"setterString": @"", - @"getterString": @"lks_imageSourceName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_NSImageView_Open_Open: @{ - @"className": @"NSImageView", - @"setterString": @"", - @"getterString": @"lks_imageViewOidIfHasImage", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - LookinAttr_NSImageView_Scaling_ImageScaling: @{ - @"className": @"NSImageView", - @"fullTitle": @"ImageScaling", - @"enumList": @"NSImageScaling", - @"patch": @(YES) - }, - LookinAttr_NSImageView_Scaling_ImageAlignment: @{ - @"className": @"NSImageView", - @"fullTitle": @"ImageAlignment", - @"enumList": @"NSImageAlignment", - @"patch": @(YES) - }, - LookinAttr_NSImageView_Scaling_ImageFrameStyle: @{ - @"className": @"NSImageView", - @"fullTitle": @"ImageFrameStyle", - @"enumList": @"NSImageFrameStyle", - @"patch": @(YES) - }, - LookinAttr_NSImageView_Behavior_Animates: @{ - @"className": @"NSImageView", - @"fullTitle": @"Animates", - @"patch": @(YES) - }, - LookinAttr_NSImageView_Behavior_Editable: @{ - @"className": @"NSImageView", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"patch": @(YES) - }, - LookinAttr_NSImageView_ContentTintColor_ContentTintColor: @{ - @"className": @"NSImageView", - @"fullTitle": @"ContentTintColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSControl_State_Enabled: @{ - @"className": @"NSControl", - @"fullTitle": @"Enabled", - @"getterString": @"isEnabled", - @"patch": @(YES) - }, - LookinAttr_NSControl_State_Highlighted: @{ - @"className": @"NSControl", - @"fullTitle": @"Highlighted", - @"getterString": @"isHighlighted", - @"patch": @(YES) - }, - LookinAttr_NSControl_State_Continuous: @{ - @"className": @"NSControl", - @"fullTitle": @"Continuous", - @"getterString": @"isContinuous", - @"patch": @(NO) - }, - LookinAttr_NSControl_ControlSize_Size: @{ - @"className": @"NSControl", - @"fullTitle": @"ControlSize", - @"enumList": @"NSControlSize", - @"patch": @(YES) - }, - LookinAttr_NSControl_Font_Name: @{ - @"className": @"NSControl", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_NSControl_Font_Size: @{ - @"className": @"NSControl", - @"fullTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_NSControl_Alignment_Alignment: @{ - @"className": @"NSControl", - @"fullTitle": @"Alignment", - @"enumList": @"NSTextAlignment_AppKit", - @"patch": @(YES) - }, - LookinAttr_NSControl_Misc_WritingDirection: @{ - @"className": @"NSControl", - @"fullTitle": @"BaseWritingDirection", - @"enumList": @"NSWritingDirection", - @"patch": @(NO) - }, - LookinAttr_NSControl_Misc_IgnoresMultiClick: @{ - @"className": @"NSControl", - @"fullTitle": @"IgnoresMultiClick", - @"patch": @(NO) - }, - LookinAttr_NSControl_Misc_UsesSingleLineMode: @{ - @"className": @"NSControl", - @"fullTitle": @"UsesSingleLineMode", - @"patch": @(NO) - }, - LookinAttr_NSControl_Misc_AllowsExpansionToolTips: @{ - @"className": @"NSControl", - @"fullTitle": @"AllowsExpansionToolTips", - @"patch": @(NO) - }, - LookinAttr_NSControl_Value_StringValue: @{ - @"className": @"NSControl", - @"fullTitle": @"StringValue", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSControl_Value_IntValue: @{ - @"className": @"NSControl", - @"fullTitle": @"IntValue", - @"patch": @(YES) - }, - LookinAttr_NSControl_Value_IntegerValue: @{ - @"className": @"NSControl", - @"fullTitle": @"IntegerValue", - @"patch": @(YES) - }, - LookinAttr_NSControl_Value_FloatValue: @{ - @"className": @"NSControl", - @"fullTitle": @"FloatValue", - @"patch": @(YES) - }, - LookinAttr_NSControl_Value_DoubleValue: @{ - @"className": @"NSControl", - @"fullTitle": @"DoubleValue", - @"patch": @(YES) - }, - LookinAttr_NSButton_ButtonType_ButtonType: @{ - @"className": @"NSButton", - @"fullTitle": @"ButtonType", - @"getterString": @"lks_buttonType", - @"enumList": @"NSButtonType", - @"patch": @(YES) - }, - LookinAttr_NSButton_Title_Title: @{ - @"className": @"NSButton", - @"fullTitle": @"Title", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSButton_Title_AlernateTitle: @{ - @"className": @"NSButton", - @"fullTitle": @"AlternateTitle", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSButton_BezelStyle_BezelStyle: @{ - @"className": @"NSButton", - @"fullTitle": @"BezelStyle", - @"enumList": @"NSBezelStyle", - @"patch": @(YES) - }, - LookinAttr_NSButton_Bordered_Bordered: @{ - @"className": @"NSButton", - @"fullTitle": @"Bordered", - @"getterString": @"isBordered", - @"patch": @(YES) - }, - LookinAttr_NSButton_Transparent_Transparent: @{ - @"className": @"NSButton", - @"fullTitle": @"Transparent", - @"getterString": @"isTransparent", - @"patch": @(YES) - }, - LookinAttr_NSButton_BezelColor_BezelColor: @{ - @"className": @"NSButton", - @"fullTitle": @"BezelColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSButton_ContentTintColor_ContentTintColor: @{ - @"className": @"NSButton", - @"fullTitle": @"ContentTintColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSButton_Misc_ShowsBorderOnlyWhileMouseInside: @{ - @"className": @"NSButton", - @"fullTitle": @"ShowsBorderOnlyWhileMouseInside", - @"patch": @(YES) - }, - LookinAttr_NSButton_Misc_MaxAcceleratorLevel: @{ - @"className": @"NSButton", - @"fullTitle": @"MaxAcceleratorLevel", - @"patch": @(YES) - }, - LookinAttr_NSButton_Misc_SpringLoaded: @{ - @"className": @"NSButton", - @"fullTitle": @"SpringLoaded", - // Derived getter would be -springLoaded; the property is -isSpringLoaded. - @"getterString": @"isSpringLoaded", - @"patch": @(YES) - }, - LookinAttr_NSButton_Misc_HasDestructiveAction: @{ - @"className": @"NSButton", - @"fullTitle": @"HasDestructiveAction", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ContentOffset_Offset: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ContentOffset", - @"setterString": @"lks_setContentOffset:", - @"getterString": @"lks_contentOffset", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ContentSize_Size: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ContentSize", - @"setterString": @"lks_setContentSize:", - @"getterString": @"lks_contentSize", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ContentInset_ContentInset: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ContentInset", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets: @{ - @"className": @"NSScrollView", - @"fullTitle": @"AutomaticallyAdjustsContentInsets", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_BorderType_BorderType: @{ - @"className": @"NSScrollView", - @"fullTitle": @"BorderType", - @"enumList": @"NSBorderType", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HasHorizontalScroller", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HasVerticalScroller", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_AutohidesScrollers: @{ - @"className": @"NSScrollView", - @"fullTitle": @"AutohidesScrollers", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_ScrollerStyle: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ScrollerStyle", - @"enumList": @"NSScrollerStyle", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_ScrollerKnobStyle: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ScrollerKnobStyle", - @"enumList": @"NSScrollerKnobStyle", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_ScrollerInsets: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ScrollerInsets", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Ruler_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HasHorizontalRuler", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Ruler_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HasVerticalRuler", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Ruler_Visible: @{ - @"className": @"NSScrollView", - @"fullTitle": @"RulersVisible", - @"patch": @(YES) - }, - // briefTitle is display-only and never crosses the wire. These six - // sit three-to-a-row with the label inside the field, so the section - // title carries the meaning ("LineScroll") and the field only marks - // the axis — the same shape as the scroller inset row above. - LookinAttr_NSScrollView_LineScroll_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HorizontalLineScroll", - @"briefTitle": @"H", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_LineScroll_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"VerticalLineScroll", - @"briefTitle": @"V", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_LineScroll_LineScroll: @{ - @"className": @"NSScrollView", - @"fullTitle": @"LineScroll", - @"briefTitle": @"⇄", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_PageScroll_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HorizontalPageScroll", - @"briefTitle": @"H", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_PageScroll_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"VerticalPageScroll", - @"briefTitle": @"V", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_PageScroll_PageScroll: @{ - @"className": @"NSScrollView", - @"fullTitle": @"PageScroll", - @"briefTitle": @"⇄", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ScrollElasiticity_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HorizontalScrollElasticity", - @"enumList": @"NSScrollElasticity", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ScrollElasiticity_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"VerticalScrollElasticity", - @"enumList": @"NSScrollElasticity", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Misc_ScrollsDynamically: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ScrollsDynamically", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Misc_UsesPredominantAxisScrolling: @{ - @"className": @"NSScrollView", - @"fullTitle": @"UsesPredominantAxisScrolling", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Magnification_AllowsMagnification: @{ - @"className": @"NSScrollView", - @"fullTitle": @"AllowsMagnification", - @"patch": @(YES) - }, - // Three to a row under the AllowsMagnification switch; the section - // title already says "Magnification", so the fields only need to - // say which of the three values they are. - LookinAttr_NSScrollView_Magnification_Magnification: @{ - @"className": @"NSScrollView", - @"fullTitle": @"Magnification", - @"briefTitle": @"Current", - @"patch": @(YES) - }, - // Explicit getter/setter: the derived names would be - // -maximunMagnification / -minimumMagnification, and NSScrollView - // spells them -maxMagnification / -minMagnification. Deriving them - // produced selectors nobody responds to, so both rows silently read - // as nil and never appeared in the dashboard at all. - LookinAttr_NSScrollView_Magnification_Max: @{ - @"className": @"NSScrollView", - @"fullTitle": @"MaximumMagnification", - @"briefTitle": @"Max", - @"getterString": @"maxMagnification", - @"setterString": @"setMaxMagnification:", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Magnification_Min: @{ - @"className": @"NSScrollView", - @"fullTitle": @"MinimumMagnification", - @"briefTitle": @"Min", - @"getterString": @"minMagnification", - @"setterString": @"setMinMagnification:", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsColumnReordering_AllowsColumnReordering: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsColumnReordering", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsColumnResizing_AllowsColumnResizing: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsColumnResizing", - @"patch": @(YES) - }, - LookinAttr_NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle: @{ - @"className": @"NSTableView", - @"fullTitle": @"ColumnAutoresizingStyle", - @"enumList": @"NSTableViewColumnAutoresizingStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_GridStyleMask_GridStyleMask: @{ - @"className": @"NSTableView", - @"fullTitle": @"GridStyleMask", - @"enumList": @"NSTableViewGridLineStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_IntercellSpacing_IntercellSpacing: @{ - @"className": @"NSTableView", - @"fullTitle": @"IntercellSpacing", - @"patch": @(YES) - }, - LookinAttr_NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors: @{ - @"className": @"NSTableView", - @"fullTitle": @"UsesAlternatingRowBackgroundColors", - @"patch": @(YES) - }, - LookinAttr_NSTableView_GridColor_GridColor: @{ - @"className": @"NSTableView", - @"fullTitle": @"GridColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSTableView_RowSizeStyle_RowSizeStyle: @{ - @"className": @"NSTableView", - @"fullTitle": @"RowSizeStyle", - @"enumList": @"NSTableViewRowSizeStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_RowHeight_RowHeight: @{ - @"className": @"NSTableView", - @"fullTitle": @"RowHeight", - @"patch": @(YES) - }, - LookinAttr_NSTableView_NumberOfRows_NumberOfRows: @{ - @"className": @"NSTableView", - @"fullTitle": @"NumberOfRows", - @"setterString": @"", - @"patch": @(YES) - }, - LookinAttr_NSTableView_NumberOfColumns_NumberOfColumns: @{ - @"className": @"NSTableView", - @"fullTitle": @"NumberOfColumns", - @"setterString": @"", - @"patch": @(YES) - }, - LookinAttr_NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag: @{ - @"className": @"NSTableView", - @"fullTitle": @"VerticalMotionCanBeginDrag", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsMultipleSelection_AllowsMultipleSelection: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsMultipleSelection", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsEmptySelection_AllowsEmptySelection: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsEmptySelection", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsColumnSelection_AllowsColumnSelection: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsColumnSelection", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsTypeSelect_AllowsTypeSelect: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsTypeSelect", - @"patch": @(YES) - }, - LookinAttr_NSTableView_SelectionHighlightStyle_SelectionHighlightStyle: @{ - @"className": @"NSTableView", - @"fullTitle": @"SelectionHighlightStyle", - @"enumList": @"NSTableViewSelectionHighlightStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle: @{ - @"className": @"NSTableView", - @"fullTitle": @"DraggingDestinationFeedbackStyle", - @"enumList": @"NSTableViewDraggingDestinationFeedbackStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AutomaticRowHeights_AutomaticRowHeights: @{ - @"className": @"NSTableView", - @"fullTitle": @"AutomaticRowHeights", - // NSTableView spells this usesAutomaticRowHeights, so both the - // derived getter and the derived setter miss. - @"getterString": @"usesAutomaticRowHeights", - @"setterString": @"setUsesAutomaticRowHeights:", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AutosaveName_AutosaveName: @{ - @"className": @"NSTableView", - @"fullTitle": @"AutosaveName", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AutosaveTableColumns_AutosaveTableColumns: @{ - @"className": @"NSTableView", - @"fullTitle": @"AutosaveTableColumns", - @"patch": @(YES) - }, - LookinAttr_NSTableView_FloatsGroupRows_FloatsGroupRows: @{ - @"className": @"NSTableView", - @"fullTitle": @"FloatsGroupRows", - @"patch": @(YES) - }, - LookinAttr_NSTableView_RowActionsVisible_RowActionsVisible: @{ - @"className": @"NSTableView", - @"fullTitle": @"RowActionsVisible", - @"patch": @(YES) - }, - LookinAttr_NSTableView_UsesStaticContents_UsesStaticContents: @{ - @"className": @"NSTableView", - @"fullTitle": @"UsesStaticContents", - @"patch": @(YES) - }, - LookinAttr_NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection: @{ - @"className": @"NSTableView", - @"fullTitle": @"UserInterfaceLayoutDirection", - @"enumList": @"NSUserInterfaceLayoutDirection", - @"patch": @(YES) - }, - LookinAttr_NSTableView_Style_Style: @{ - @"className": @"NSTableView", - @"fullTitle": @"Style", - @"enumList": @"NSTableViewStyle", - @"patch": @(YES) - }, - LookinAttr_NSTextView_Font_Name: @{ - @"className": @"NSTextView", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_NSTextView_Font_Size: @{ - @"className": @"NSTextView", - @"fullTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_NSTextView_Basic_Editable: @{ - @"className": @"NSTextView", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Basic_Selectable: @{ - @"className": @"NSTextView", - @"fullTitle": @"Selectable", - @"getterString": @"isSelectable", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Basic_RichText: @{ - @"className": @"NSTextView", - @"fullTitle": @"RichText", - @"getterString": @"isRichText", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Basic_FieldEditor: @{ - @"className": @"NSTextView", - @"fullTitle": @"FieldEditor", - @"getterString": @"isFieldEditor", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Basic_ImportsGraphics: @{ - @"className": @"NSTextView", - @"fullTitle": @"ImportsGraphics", - @"patch": @(NO) - }, - LookinAttr_NSTextView_String_String: @{ - @"className": @"NSTextView", - @"fullTitle": @"String", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSTextView_TextColor_Color: @{ - @"className": @"NSTextView", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSTextView_Alignment_Alignment: @{ - @"className": @"NSTextView", - @"fullTitle": @"Alignment", - @"enumList": @"NSTextAlignment_AppKit", - @"patch": @(YES) - }, - LookinAttr_NSTextView_ContainerInset_Inset: @{ - @"className": @"NSTextView", - @"fullTitle": @"TextContainerInset", - @"patch": @(YES) - }, - LookinAttr_NSTextView_BaseWritingDirection_BaseWritingDirection: @{ - @"className": @"NSTextView", - @"fullTitle": @"BaseWritingDirection", - @"enumList": @"NSWritingDirection", - @"patch": @(NO) - }, - LookinAttr_NSTextView_MaxSize_MaxSize: @{ - @"className": @"NSTextView", - @"fullTitle": @"MaxSize", - @"patch": @(YES) - }, - LookinAttr_NSTextView_MinSize_MinSize: @{ - @"className": @"NSTextView", - @"fullTitle": @"MinSize", - @"patch": @(YES) - }, - LookinAttr_NSTextView_Resizable_Horizontal: @{ - @"className": @"NSTextView", - @"fullTitle": @"HorizontallyResizable", - // Derived getter would be -horizontallyResizable; it is -isHorizontallyResizable. - @"getterString": @"isHorizontallyResizable", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Resizable_Vertical: @{ - @"className": @"NSTextView", - @"fullTitle": @"VerticallyResizable", - // Derived getter would be -verticallyResizable; it is -isVerticallyResizable. - @"getterString": @"isVerticallyResizable", - @"patch": @(NO) - }, - LookinAttr_NSTextField_Bordered_Bordered: @{ - @"className": @"NSTextField", - @"fullTitle": @"Bordered", - @"getterString": @"isBordered", - @"patch": @(NO) - }, - LookinAttr_NSTextField_Bezeled_Bezeled: @{ - @"className": @"NSTextField", - @"fullTitle": @"Bezeled", - @"getterString": @"isBezeled", - @"patch": @(NO) - }, - LookinAttr_NSTextField_Editable_Editable: @{ - @"className": @"NSTextField", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"patch": @(NO) - }, - LookinAttr_NSTextField_Selectable_Selectable: @{ - @"className": @"NSTextField", - @"fullTitle": @"Selectable", - @"getterString": @"isSelectable", - @"patch": @(NO) - }, - LookinAttr_NSTextField_DrawsBackground_DrawsBackground: @{ - @"className": @"NSTextField", - @"fullTitle": @"DrawsBackground", - @"patch": @(YES) - }, - LookinAttr_NSTextField_BezelStyle_BezelStyle: @{ - @"className": @"NSTextField", - @"fullTitle": @"BezelStyle", - @"enumList": @"NSTextFieldBezelStyle", - @"patch": @(YES) - }, - LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth: @{ - @"className": @"NSTextField", - @"fullTitle": @"PreferredMaxLayoutWidth", - @"patch": @(YES) - }, - LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines: @{ - @"className": @"NSTextField", - @"fullTitle": @"MaximumNumberOfLines", - @"patch": @(YES) - }, - LookinAttr_NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation: @{ - @"className": @"NSTextField", - @"fullTitle": @"AllowsDefaultTighteningForTruncation", - @"patch": @(YES) - }, - LookinAttr_NSTextField_LineBreakStrategy_LineBreakStrategy: @{ - @"className": @"NSTextField", - @"fullTitle": @"LineBreakStrategy", - @"enumList": @"NSLineBreakStrategy", - @"patch": @(YES) - }, - LookinAttr_NSTextField_Placeholder_Placeholder: @{ - @"className": @"NSTextField", - @"fullTitle": @"PlaceholderString", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSTextField_TextColor_Color: @{ - @"className": @"NSTextField", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSTextField_BackgroundColor_Color: @{ - @"className": @"NSTextField", - @"fullTitle": @"BackgroundColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes: @{ - @"className": @"NSTextField", - @"fullTitle": @"AllowsEditingTextAttributes", - @"patch": @(YES) - }, - LookinAttr_NSTextField_ImportsGraphics_ImportsGraphics: @{ - @"className": @"NSTextField", - @"fullTitle": @"ImportsGraphics", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_Material_Material: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"Material", - @"enumList": @"NSVisualEffectMaterial", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"InteriorBackgroundStyle", - @"enumList": @"NSBackgroundStyle", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_BlendingMode_BlendingMode: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"BlendingMode", - @"enumList": @"NSVisualEffectBlendingMode", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_State_State: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"State", - @"enumList": @"NSVisualEffectState", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_Emphasized_Emphasized: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"Emphasized", - @"getterString": @"isEmphasized", - @"patch": @(YES) - }, - LookinAttr_NSStackView_Orientation_Orientation: @{ - @"className": @"NSStackView", - @"fullTitle": @"Orientation", - @"enumList": @"NSUserInterfaceLayoutOrientation", - @"patch": @(YES) - }, - LookinAttr_NSStackView_EdgeInsets_EdgeInsets: @{ - @"className": @"NSStackView", - @"fullTitle": @"EdgeInsets", - @"patch": @(YES) - }, - LookinAttr_NSStackView_DetachesHiddenViews_DetachesHiddenViews: @{ - @"className": @"NSStackView", - @"fullTitle": @"DetachesHiddenViews", - @"patch": @(YES) - }, - LookinAttr_NSStackView_Distribution_Distribution: @{ - @"className": @"NSStackView", - @"fullTitle": @"Distribution", - @"enumList": @"NSStackViewDistribution", - @"patch": @(YES) - }, - LookinAttr_NSStackView_Alignment_Alignment: @{ - @"className": @"NSStackView", - @"fullTitle": @"Alignment", - @"enumList": @"NSLayoutAttribute", - @"patch": @(YES) - }, - LookinAttr_NSStackView_Spacing_Spacing: @{ - @"className": @"NSStackView", - @"fullTitle": @"Spacing", - @"patch": @(YES) - }, - - // MARK: - NSWindow - LookinAttr_NSWindow_Title_Title: @{ - @"className": @"NSWindow", - @"fullTitle": @"Title", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_NSWindow_Title_Subtitle: @{ - @"className": @"NSWindow", - @"fullTitle": @"Subtitle", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO), - @"osVersion": @(11) - }, - LookinAttr_NSWindow_State_KeyWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"KeyWindow", - @"getterString": @"isKeyWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_State_MainWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"MainWindow", - @"getterString": @"isMainWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_State_Visible: @{ - @"className": @"NSWindow", - @"fullTitle": @"Visible", - @"getterString": @"isVisible", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_State_CanBecomeKeyWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"CanBecomeKeyWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_State_CanBecomeMainWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"CanBecomeMainWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_Titled: @{ - @"className": @"NSWindow", - @"fullTitle": @"Titled", - @"getterString": @"lks_styleMaskTitled", - @"setterString": @"setLks_styleMaskTitled:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_Closable: @{ - @"className": @"NSWindow", - @"fullTitle": @"Closable", - @"getterString": @"lks_styleMaskClosable", - @"setterString": @"setLks_styleMaskClosable:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_Miniaturizable: @{ - @"className": @"NSWindow", - @"fullTitle": @"Miniaturizable", - @"getterString": @"lks_styleMaskMiniaturizable", - @"setterString": @"setLks_styleMaskMiniaturizable:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_Resizable: @{ - @"className": @"NSWindow", - @"fullTitle": @"Resizable", - @"getterString": @"lks_styleMaskResizable", - @"setterString": @"setLks_styleMaskResizable:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_UnifiedTitleAndToolbar: @{ - @"className": @"NSWindow", - @"fullTitle": @"UnifiedTitleAndToolbar", - @"getterString": @"lks_styleMaskUnifiedTitleAndToolbar", - @"setterString": @"setLks_styleMaskUnifiedTitleAndToolbar:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_FullScreen: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreen", - @"getterString": @"lks_styleMaskFullScreen", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_FullSizeContentView: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullSizeContentView", - @"getterString": @"lks_styleMaskFullSizeContentView", - @"setterString": @"setLks_styleMaskFullSizeContentView:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_UtilityWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"UtilityWindow", - @"getterString": @"lks_styleMaskUtilityWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_DocModalWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"DocModalWindow", - @"getterString": @"lks_styleMaskDocModalWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_NonactivatingPanel: @{ - @"className": @"NSWindow", - @"fullTitle": @"NonactivatingPanel", - @"getterString": @"lks_styleMaskNonactivatingPanel", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_HUDWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"HUDWindow", - @"getterString": @"lks_styleMaskHUDWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_CanJoinAllSpaces: @{ - @"className": @"NSWindow", - @"fullTitle": @"CanJoinAllSpaces", - @"getterString": @"lks_collectionBehaviorCanJoinAllSpaces", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_MoveToActiveSpace: @{ - @"className": @"NSWindow", - @"fullTitle": @"MoveToActiveSpace", - @"getterString": @"lks_collectionBehaviorMoveToActiveSpace", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_ParticipatesInCycle: @{ - @"className": @"NSWindow", - @"fullTitle": @"ParticipatesInCycle", - @"getterString": @"lks_collectionBehaviorParticipatesInCycle", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_IgnoresCycle: @{ - @"className": @"NSWindow", - @"fullTitle": @"IgnoresCycle", - @"getterString": @"lks_collectionBehaviorIgnoresCycle", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenPrimary: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenPrimary", - @"getterString": @"lks_collectionBehaviorFullScreenPrimary", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenAuxiliary: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenAuxiliary", - @"getterString": @"lks_collectionBehaviorFullScreenAuxiliary", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenNone: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenNone", - @"getterString": @"lks_collectionBehaviorFullScreenNone", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenAllowsTiling: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenAllowsTiling", - @"getterString": @"lks_collectionBehaviorFullScreenAllowsTiling", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenDisallowsTiling: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenDisallowsTiling", - @"getterString": @"lks_collectionBehaviorFullScreenDisallowsTiling", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Appearance_TitlebarAppearsTransparent: @{ - @"className": @"NSWindow", - @"fullTitle": @"TitlebarAppearsTransparent", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Appearance_TitleVisibility: @{ - @"className": @"NSWindow", - @"fullTitle": @"TitleVisibility", - @"enumList": @"NSWindowTitleVisibility", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Appearance_ToolbarStyle: @{ - @"className": @"NSWindow", - @"fullTitle": @"ToolbarStyle", - @"enumList": @"NSWindowToolbarStyle", - @"patch": @(YES), - @"osVersion": @(11) - }, - LookinAttr_NSWindow_Appearance_TitlebarSeparatorStyle: @{ - @"className": @"NSWindow", - @"fullTitle": @"TitlebarSeparatorStyle", - @"enumList": @"NSTitlebarSeparatorStyle", - @"patch": @(YES), - @"osVersion": @(11) - }, - LookinAttr_NSWindow_Appearance_BackgroundColor: @{ - @"className": @"NSWindow", - @"fullTitle": @"BackgroundColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSWindow_Appearance_AlphaValue: @{ - @"className": @"NSWindow", - @"fullTitle": @"AlphaValue", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Appearance_Opaque: @{ - @"className": @"NSWindow", - @"fullTitle": @"Opaque", - @"getterString": @"isOpaque", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Appearance_HasShadow: @{ - @"className": @"NSWindow", - @"fullTitle": @"HasShadow", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Behavior_Movable: @{ - @"className": @"NSWindow", - @"fullTitle": @"Movable", - @"getterString": @"isMovable", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Behavior_MovableByWindowBackground: @{ - @"className": @"NSWindow", - @"fullTitle": @"MovableByWindowBackground", - @"getterString": @"isMovableByWindowBackground", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Behavior_AnimationBehavior: @{ - @"className": @"NSWindow", - @"fullTitle": @"AnimationBehavior", - @"enumList": @"NSWindowAnimationBehavior", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Behavior_Level: @{ - @"className": @"NSWindow", - @"fullTitle": @"Level", - @"enumList": @"NSWindowLevel", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Behavior_HidesOnDeactivate: @{ - @"className": @"NSWindow", - @"fullTitle": @"HidesOnDeactivate", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Behavior_TabbingMode: @{ - @"className": @"NSWindow", - @"fullTitle": @"TabbingMode", - @"enumList": @"NSWindowTabbingMode", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Size_MinSize: @{ - @"className": @"NSWindow", - @"fullTitle": @"MinSize", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Size_MaxSize: @{ - @"className": @"NSWindow", - @"fullTitle": @"MaxSize", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Info_WindowNumber: @{ - @"className": @"NSWindow", - @"fullTitle": @"WindowNumber", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Info_BackingScaleFactor: @{ - @"className": @"NSWindow", - @"fullTitle": @"BackingScaleFactor", - @"setterString": @"", - @"patch": @(NO) - }, - - // MARK: - UIWindowScene - // Lives under the Layout group rather than the UIWindowScene group, - // because it is the only geometry a scene exposes — it has no frame. - LookinAttr_Layout_CoordinateSpace_CoordinateSpace: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"CoordinateSpace", - @"getterString": @"lks_coordinateSpaceBounds", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_State_ActivationState: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ActivationState", - @"getterString": @"activationState", - @"setterString": @"", - @"enumList": @"UISceneActivationState", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Title_Title: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"Title", - @"patch": @(YES), - @"typeIfObj": @(LookinAttrTypeNSString) - }, - LookinAttr_UIWindowScene_Title_Subtitle: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"Subtitle", - @"patch": @(YES), - @"typeIfObj": @(LookinAttrTypeNSString), - @"osVersion": @(15) - }, - LookinAttr_UIWindowScene_Orientation_InterfaceOrientation: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"InterfaceOrientation", - @"getterString": @"lks_interfaceOrientation", - @"setterString": @"", - @"enumList": @"UIInterfaceOrientation", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Windows_WindowCount: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"WindowCount", - @"getterString": @"lks_windowCount", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Windows_KeyWindowClassName: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"KeyWindowClassName", - @"getterString": @"lks_keyWindowClassName", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Screen_ScreenBounds: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ScreenBounds", - @"getterString": @"lks_screenBounds", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Screen_ScreenScale: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ScreenScale", - @"getterString": @"lks_screenScale", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_StatusBar_StatusBarHidden: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"StatusBarHidden", - @"getterString": @"lks_statusBarHidden", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_StatusBar_StatusBarStyle: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"StatusBarStyle", - @"getterString": @"lks_statusBarStyle", - @"setterString": @"", - @"enumList": @"UIStatusBarStyle", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_StatusBar_StatusBarFrame: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"StatusBarFrame", - @"getterString": @"lks_statusBarFrame", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Traits_UserInterfaceStyle: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"UserInterfaceStyle", - @"getterString": @"lks_userInterfaceStyle", - @"setterString": @"", - @"enumList": @"UIUserInterfaceStyle", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Traits_HorizontalSizeClass: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"HorizontalSizeClass", - @"getterString": @"lks_horizontalSizeClass", - @"setterString": @"", - @"enumList": @"UIUserInterfaceSizeClass", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Traits_VerticalSizeClass: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"VerticalSizeClass", - @"getterString": @"lks_verticalSizeClass", - @"setterString": @"", - @"enumList": @"UIUserInterfaceSizeClass", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Traits_UserInterfaceLevel: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"UserInterfaceLevel", - @"getterString": @"lks_userInterfaceLevel", - @"setterString": @"", - @"enumList": @"UIUserInterfaceLevel", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_ActiveAppearance: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ActiveAppearance", - @"getterString": @"lks_activeAppearance", - @"setterString": @"", - @"enumList": @"UIUserInterfaceActiveAppearance", - @"patch": @(NO), - @"osVersion": @(14) - }, - LookinAttr_UIWindowScene_Traits_AccessibilityContrast: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"AccessibilityContrast", - @"getterString": @"lks_accessibilityContrast", - @"setterString": @"", - @"enumList": @"UIAccessibilityContrast", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_LegibilityWeight: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"LegibilityWeight", - @"getterString": @"lks_legibilityWeight", - @"setterString": @"", - @"enumList": @"UILegibilityWeight", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_DisplayScale: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"DisplayScale", - @"getterString": @"lks_traitDisplayScale", - @"setterString": @"", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_DisplayGamut: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"DisplayGamut", - @"getterString": @"lks_displayGamut", - @"setterString": @"", - @"enumList": @"UIDisplayGamut", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_UserInterfaceIdiom: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"UserInterfaceIdiom", - @"getterString": @"lks_userInterfaceIdiom", - @"setterString": @"", - @"enumList": @"UIUserInterfaceIdiom", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_LayoutDirection: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"LayoutDirection", - @"getterString": @"lks_layoutDirection", - @"setterString": @"", - @"enumList": @"UITraitEnvironmentLayoutDirection", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_PreferredContentSizeCategory: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"PreferredContentSizeCategory", - @"getterString": @"lks_preferredContentSizeCategory", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_SceneCaptureState: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"SceneCaptureState", - @"getterString": @"lks_sceneCaptureState", - @"setterString": @"", - @"enumList": @"UISceneCaptureState", - @"patch": @(NO), - @"osVersion": @(17) - }, - LookinAttr_UIWindowScene_Traits_ImageDynamicRange: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ImageDynamicRange", - @"getterString": @"lks_imageDynamicRange", - @"setterString": @"", - @"enumList": @"UIImageDynamicRange", - @"patch": @(NO), - @"osVersion": @(17) - }, - LookinAttr_UIWindowScene_Traits_TypesettingLanguage: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"TypesettingLanguage", - @"getterString": @"lks_typesettingLanguage", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(17) - }, - LookinAttr_UIWindowScene_Session_PersistentIdentifier: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"PersistentIdentifier", - @"getterString": @"lks_sessionPersistentIdentifier", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Session_SessionRole: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"SessionRole", - @"getterString": @"lks_sessionRole", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Session_StateRestorationActivityType: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"StateRestorationActivity", - @"getterString": @"lks_sessionStateRestorationActivityType", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Session_UserInfo: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"UserInfo", - @"getterString": @"lks_sessionUserInfoJSONString", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeJson), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - - // UISceneConfiguration — describes which classes and storyboard back - // this scene. DelegateClass is the class declared in the configuration, - // which is not necessarily the class of the delegate instance shown in - // the Relation group; a mismatch between the two is worth noticing. - LookinAttr_UIWindowScene_Configuration_Name: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"Name", - @"getterString": @"lks_configurationName", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Configuration_SceneClass: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"SceneClass", - @"getterString": @"lks_configurationSceneClassName", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Configuration_DelegateClass: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"DelegateClass", - @"getterString": @"lks_configurationDelegateClassName", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Configuration_Storyboard: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"Storyboard", - @"getterString": @"lks_configurationStoryboardDescription", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - - // UIWindowSceneGeometry — every entry returns an object so that it can - // disappear via hideIfNil on platforms and OS versions that lack it. - LookinAttr_UIWindowScene_Geometry_SystemFrame: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"SystemFrame", - @"getterString": @"lks_geometrySystemFrame", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCGRect), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(16) - }, - LookinAttr_UIWindowScene_Geometry_InterfaceOrientationLocked: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"InterfaceOrientationLocked", - @"getterString": @"lks_geometryInterfaceOrientationLocked", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeBOOL), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(26) - }, - LookinAttr_UIWindowScene_Geometry_InteractivelyResizing: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"InteractivelyResizing", - @"getterString": @"lks_geometryInteractivelyResizing", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeBOOL), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(26) - }, - - // UISceneActivationConditions — shown as the predicates' format strings. - LookinAttr_UIWindowScene_ActivationConditions_CanActivate: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"CanActivateForTargetContentIdentifier", - @"briefTitle": @"CanActivate", - @"getterString": @"lks_canActivateForTargetContentIdentifierPredicateFormat", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_ActivationConditions_PrefersToActivate: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"PrefersToActivateForTargetContentIdentifier", - @"briefTitle": @"PrefersToActivate", - @"getterString": @"lks_prefersToActivateForTargetContentIdentifierPredicateFormat", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - - // UISceneSizeRestrictions — nil on iPhone, so the whole section drops out there. - LookinAttr_UIWindowScene_SizeRestrictions_MinimumSize: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"MinimumSize", - @"getterString": @"lks_sizeRestrictionsMinimumSize", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCGSize), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_SizeRestrictions_MaximumSize: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"MaximumSize", - @"getterString": @"lks_sizeRestrictionsMaximumSize", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCGSize), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_SizeRestrictions_AllowsFullScreen: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"AllowsFullScreen", - @"getterString": @"lks_sizeRestrictionsAllowsFullScreen", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeBOOL), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - - // UISceneWindowingBehaviors — nil outside iPad multitasking and Catalyst. - LookinAttr_UIWindowScene_WindowingBehaviors_Closable: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"Closable", - @"getterString": @"lks_windowingBehaviorsClosable", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeBOOL), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(16) - }, - LookinAttr_UIWindowScene_WindowingBehaviors_Miniaturizable: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"Miniaturizable", - @"getterString": @"lks_windowingBehaviorsMiniaturizable", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeBOOL), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(16) - }, - LookinAttr_UIWindowScene_WindowingBehaviors_FullScreen: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"FullScreen", - @"getterString": @"lks_fullScreen", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeBOOL), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - - LookinAttr_UIWindowScene_Pointer_Locked: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"Locked", - @"getterString": @"lks_pointerLocked", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeBOOL), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(14) - }, - - LookinAttr_UIWindowScene_Protection_UserAuthenticationEnabled: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"UserAuthenticationEnabled", - @"getterString": @"lks_systemProtectionUserAuthenticationEnabled", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeBOOL), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(18) - }, - // MARK: - UITraitCollection - LookinAttr_UITraitCollection_Appearance_UserInterfaceStyle: @{ - @"className": @"UIView", - @"fullTitle": @"UserInterfaceStyle", - @"getterString": @"lks_traitCollection_userInterfaceStyle", - @"setterString": @"", - @"enumList": @"UIUserInterfaceStyle", - @"patch": @(NO), - @"osVersion": @(12) - }, - LookinAttr_UITraitCollection_Appearance_UserInterfaceLevel: @{ - @"className": @"UIView", - @"fullTitle": @"UserInterfaceLevel", - @"getterString": @"lks_traitCollection_userInterfaceLevel", - @"setterString": @"", - @"enumList": @"UIUserInterfaceLevel", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UITraitCollection_Appearance_ActiveAppearance: @{ - @"className": @"UIView", - @"fullTitle": @"ActiveAppearance", - @"getterString": @"lks_traitCollection_activeAppearance", - @"setterString": @"", - @"enumList": @"UIUserInterfaceActiveAppearance", - @"patch": @(NO), - @"osVersion": @(14) - }, - LookinAttr_UITraitCollection_Appearance_AccessibilityContrast: @{ - @"className": @"UIView", - @"fullTitle": @"AccessibilityContrast", - @"getterString": @"lks_traitCollection_accessibilityContrast", - @"setterString": @"", - @"enumList": @"UIAccessibilityContrast", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UITraitCollection_Appearance_LegibilityWeight: @{ - @"className": @"UIView", - @"fullTitle": @"LegibilityWeight", - @"getterString": @"lks_traitCollection_legibilityWeight", - @"setterString": @"", - @"enumList": @"UILegibilityWeight", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UITraitCollection_SizeClass_HorizontalSizeClass: @{ - @"className": @"UIView", - @"fullTitle": @"HorizontalSizeClass", - @"getterString": @"lks_traitCollection_horizontalSizeClass", - @"setterString": @"", - @"enumList": @"UIUserInterfaceSizeClass", - @"patch": @(NO) - }, - LookinAttr_UITraitCollection_SizeClass_VerticalSizeClass: @{ - @"className": @"UIView", - @"fullTitle": @"VerticalSizeClass", - @"getterString": @"lks_traitCollection_verticalSizeClass", - @"setterString": @"", - @"enumList": @"UIUserInterfaceSizeClass", - @"patch": @(NO) - }, - LookinAttr_UITraitCollection_Display_DisplayScale: @{ - @"className": @"UIView", - @"fullTitle": @"DisplayScale", - @"getterString": @"lks_traitCollection_displayScale", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UITraitCollection_Display_DisplayGamut: @{ - @"className": @"UIView", - @"fullTitle": @"DisplayGamut", - @"getterString": @"lks_traitCollection_displayGamut", - @"setterString": @"", - @"enumList": @"UIDisplayGamut", - @"patch": @(NO), - @"osVersion": @(10) - }, - LookinAttr_UITraitCollection_Display_ImageDynamicRange: @{ - @"className": @"UIView", - @"fullTitle": @"ImageDynamicRange", - @"getterString": @"lks_traitCollection_imageDynamicRange", - @"setterString": @"", - @"enumList": @"UIImageDynamicRange", - @"patch": @(NO), - @"osVersion": @(17) - }, - LookinAttr_UITraitCollection_Device_UserInterfaceIdiom: @{ - @"className": @"UIView", - @"fullTitle": @"UserInterfaceIdiom", - @"getterString": @"lks_traitCollection_userInterfaceIdiom", - @"setterString": @"", - @"enumList": @"UIUserInterfaceIdiom", - @"patch": @(NO) - }, - LookinAttr_UITraitCollection_Device_ForceTouchCapability: @{ - @"className": @"UIView", - @"fullTitle": @"ForceTouchCapability", - @"getterString": @"lks_traitCollection_forceTouchCapability", - @"setterString": @"", - @"enumList": @"UIForceTouchCapability", - @"patch": @(NO), - @"osVersion": @(9) - }, - LookinAttr_UITraitCollection_Layout_LayoutDirection: @{ - @"className": @"UIView", - @"fullTitle": @"LayoutDirection", - @"getterString": @"lks_traitCollection_layoutDirection", - @"setterString": @"", - @"enumList": @"UITraitEnvironmentLayoutDirection", - @"patch": @(NO), - @"osVersion": @(10) - }, - LookinAttr_UITraitCollection_Content_PreferredContentSizeCategory: @{ - @"className": @"UIView", - @"fullTitle": @"PreferredContentSizeCategory", - @"getterString": @"lks_traitCollection_preferredContentSizeCategory", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO), - @"osVersion": @(10) - }, - LookinAttr_UITraitCollection_Content_TypesettingLanguage: @{ - @"className": @"UIView", - @"fullTitle": @"TypesettingLanguage", - @"getterString": @"lks_traitCollection_typesettingLanguage", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(17) - }, - - // MARK: - NSSlider - LookinAttr_NSSlider_SliderType_SliderType: @{ - @"className": @"NSSlider", - @"fullTitle": @"SliderType", - @"enumList": @"NSSliderType", - @"patch": @(YES) - }, - LookinAttr_NSSlider_Range_MinValue: @{ - @"className": @"NSSlider", - @"fullTitle": @"MinValue", - @"patch": @(YES) - }, - LookinAttr_NSSlider_Range_MaxValue: @{ - @"className": @"NSSlider", - @"fullTitle": @"MaxValue", - @"patch": @(YES) - }, - LookinAttr_NSSlider_TickMark_NumberOfTickMarks: @{ - @"className": @"NSSlider", - @"fullTitle": @"NumberOfTickMarks", - @"patch": @(YES) - }, - LookinAttr_NSSlider_TickMark_TickMarkPosition: @{ - @"className": @"NSSlider", - @"fullTitle": @"TickMarkPosition", - @"enumList": @"NSTickMarkPosition", - @"patch": @(YES) - }, - LookinAttr_NSSlider_TickMark_AllowsTickMarkValuesOnly: @{ - @"className": @"NSSlider", - @"fullTitle": @"AllowsTickMarkValuesOnly", - @"patch": @(YES) - }, - LookinAttr_NSSlider_Misc_Vertical: @{ - @"className": @"NSSlider", - @"fullTitle": @"Vertical", - @"getterString": @"isVertical", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSSlider_Misc_KnobThickness: @{ - @"className": @"NSSlider", - @"fullTitle": @"KnobThickness", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSSlider_Misc_AltIncrementValue: @{ - @"className": @"NSSlider", - @"fullTitle": @"AltIncrementValue", - @"patch": @(YES) - }, - LookinAttr_NSSlider_Misc_TrackFillColor: @{ - @"className": @"NSSlider", - @"fullTitle": @"TrackFillColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES), - @"hideIfNil": @(YES) - }, - - // MARK: - NSProgressIndicator - LookinAttr_NSProgressIndicator_Style_Style: @{ - @"className": @"NSProgressIndicator", - @"fullTitle": @"Style", - @"enumList": @"NSProgressIndicatorStyle", - @"patch": @(YES) - }, - LookinAttr_NSProgressIndicator_Range_MinValue: @{ - @"className": @"NSProgressIndicator", - @"fullTitle": @"MinValue", - @"patch": @(YES) - }, - LookinAttr_NSProgressIndicator_Range_MaxValue: @{ - @"className": @"NSProgressIndicator", - @"fullTitle": @"MaxValue", - @"patch": @(YES) - }, - LookinAttr_NSProgressIndicator_Range_DoubleValue: @{ - @"className": @"NSProgressIndicator", - @"fullTitle": @"DoubleValue", - @"patch": @(YES) - }, - LookinAttr_NSProgressIndicator_Misc_Indeterminate: @{ - @"className": @"NSProgressIndicator", - @"fullTitle": @"Indeterminate", - @"getterString": @"isIndeterminate", - @"patch": @(YES) - }, - LookinAttr_NSProgressIndicator_Misc_Bezeled: @{ - @"className": @"NSProgressIndicator", - @"fullTitle": @"Bezeled", - @"getterString": @"isBezeled", - @"patch": @(YES) - }, - LookinAttr_NSProgressIndicator_Misc_DisplayedWhenStopped: @{ - @"className": @"NSProgressIndicator", - @"fullTitle": @"DisplayedWhenStopped", - @"getterString": @"isDisplayedWhenStopped", - @"patch": @(YES) - }, - - // MARK: - NSSegmentedControl - LookinAttr_NSSegmentedControl_SegmentCount_SegmentCount: @{ - @"className": @"NSSegmentedControl", - @"fullTitle": @"SegmentCount", - @"patch": @(YES) - }, - LookinAttr_NSSegmentedControl_Selection_SelectedSegment: @{ - @"className": @"NSSegmentedControl", - @"fullTitle": @"SelectedSegment", - @"patch": @(YES) - }, - LookinAttr_NSSegmentedControl_Style_SegmentStyle: @{ - @"className": @"NSSegmentedControl", - @"fullTitle": @"SegmentStyle", - @"enumList": @"NSSegmentStyle", - @"patch": @(YES) - }, - LookinAttr_NSSegmentedControl_Style_TrackingMode: @{ - @"className": @"NSSegmentedControl", - @"fullTitle": @"TrackingMode", - @"enumList": @"NSSegmentSwitchTracking", - @"patch": @(YES) - }, - LookinAttr_NSSegmentedControl_Colors_SelectedSegmentBezelColor: @{ - @"className": @"NSSegmentedControl", - @"fullTitle": @"SelectedSegmentBezelColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES), - @"hideIfNil": @(YES) - }, - - // MARK: - NSPopUpButton - LookinAttr_NSPopUpButton_Behavior_PullsDown: @{ - @"className": @"NSPopUpButton", - @"fullTitle": @"PullsDown", - @"patch": @(YES) - }, - LookinAttr_NSPopUpButton_Behavior_AutoenablesItems: @{ - @"className": @"NSPopUpButton", - @"fullTitle": @"AutoenablesItems", - @"patch": @(YES) - }, - LookinAttr_NSPopUpButton_Behavior_PreferredEdge: @{ - @"className": @"NSPopUpButton", - @"fullTitle": @"PreferredEdge", - @"enumList": @"NSRectEdge", - @"patch": @(YES) - }, - LookinAttr_NSPopUpButton_Selection_SelectedTag: @{ - @"className": @"NSPopUpButton", - @"fullTitle": @"SelectedTag", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSPopUpButton_Selection_IndexOfSelectedItem: @{ - @"className": @"NSPopUpButton", - @"fullTitle": @"IndexOfSelectedItem", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSPopUpButton_Selection_TitleOfSelectedItem: @{ - @"className": @"NSPopUpButton", - @"fullTitle": @"TitleOfSelectedItem", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_NSPopUpButton_Items_NumberOfItems: @{ - @"className": @"NSPopUpButton", - @"fullTitle": @"NumberOfItems", - @"setterString": @"", - @"patch": @(NO) - }, - - // MARK: - NSComboBox - LookinAttr_NSComboBox_Items_NumberOfItems: @{ - @"className": @"NSComboBox", - @"fullTitle": @"NumberOfItems", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSComboBox_Items_HasVerticalScroller: @{ - @"className": @"NSComboBox", - @"fullTitle": @"HasVerticalScroller", - @"patch": @(YES) - }, - LookinAttr_NSComboBox_Items_NumberOfVisibleItems: @{ - @"className": @"NSComboBox", - @"fullTitle": @"NumberOfVisibleItems", - @"patch": @(YES) - }, - LookinAttr_NSComboBox_Items_IntercellSpacing: @{ - @"className": @"NSComboBox", - @"fullTitle": @"IntercellSpacing", - @"patch": @(YES) - }, - LookinAttr_NSComboBox_Items_ItemHeight: @{ - @"className": @"NSComboBox", - @"fullTitle": @"ItemHeight", - @"patch": @(YES) - }, - LookinAttr_NSComboBox_Misc_ButtonBordered: @{ - @"className": @"NSComboBox", - @"fullTitle": @"ButtonBordered", - @"getterString": @"isButtonBordered", - @"patch": @(YES) - }, - LookinAttr_NSComboBox_Misc_Completes: @{ - @"className": @"NSComboBox", - @"fullTitle": @"Completes", - @"patch": @(YES) - }, - LookinAttr_NSComboBox_Misc_UsesDataSource: @{ - @"className": @"NSComboBox", - @"fullTitle": @"UsesDataSource", - @"patch": @(NO) - }, - - // MARK: - NSStepper - LookinAttr_NSStepper_Range_MinValue: @{ - @"className": @"NSStepper", - @"fullTitle": @"MinValue", - @"patch": @(YES) - }, - LookinAttr_NSStepper_Range_MaxValue: @{ - @"className": @"NSStepper", - @"fullTitle": @"MaxValue", - @"patch": @(YES) - }, - LookinAttr_NSStepper_Range_Increment: @{ - @"className": @"NSStepper", - @"fullTitle": @"Increment", - @"patch": @(YES) - }, - LookinAttr_NSStepper_Misc_ValueWraps: @{ - @"className": @"NSStepper", - @"fullTitle": @"ValueWraps", - @"patch": @(YES) - }, - LookinAttr_NSStepper_Misc_Autorepeat: @{ - @"className": @"NSStepper", - @"fullTitle": @"Autorepeat", - @"patch": @(YES) - }, - - // MARK: - NSColorWell - LookinAttr_NSColorWell_Color_Color: @{ - @"className": @"NSColorWell", - @"fullTitle": @"Color", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSColorWell_Misc_Bordered: @{ - @"className": @"NSColorWell", - @"fullTitle": @"Bordered", - @"getterString": @"isBordered", - @"patch": @(YES) - }, - LookinAttr_NSColorWell_Misc_Active: @{ - @"className": @"NSColorWell", - @"fullTitle": @"Active", - @"getterString": @"isActive", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSColorWell_Misc_ColorWellStyle: @{ - @"className": @"NSColorWell", - @"fullTitle": @"ColorWellStyle", - @"enumList": @"NSColorWellStyle", - @"patch": @(YES), - @"osVersion": @(13) - }, - - // MARK: - NSSwitch - LookinAttr_NSSwitch_State_State: @{ - @"className": @"NSSwitch", - @"fullTitle": @"State", - @"enumList": @"NSControlStateValue", - @"patch": @(YES), - @"osVersion": @(15) - }, - - // MARK: - NSDatePicker - LookinAttr_NSDatePicker_Style_DatePickerStyle: @{ - @"className": @"NSDatePicker", - @"fullTitle": @"DatePickerStyle", - @"enumList": @"NSDatePickerStyle", - @"patch": @(YES) - }, - LookinAttr_NSDatePicker_Style_DatePickerMode: @{ - @"className": @"NSDatePicker", - @"fullTitle": @"DatePickerMode", - @"enumList": @"NSDatePickerMode", - @"patch": @(YES) - }, - LookinAttr_NSDatePicker_Range_DateValue: @{ - @"className": @"NSDatePicker", - @"fullTitle": @"DateValue", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"patch": @(NO) - }, - LookinAttr_NSDatePicker_Range_MinDate: @{ - @"className": @"NSDatePicker", - @"fullTitle": @"MinDate", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_NSDatePicker_Range_MaxDate: @{ - @"className": @"NSDatePicker", - @"fullTitle": @"MaxDate", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES), - @"patch": @(NO) - }, - LookinAttr_NSDatePicker_Misc_Bordered: @{ - @"className": @"NSDatePicker", - @"fullTitle": @"Bordered", - @"getterString": @"isBordered", - @"patch": @(YES) - }, - LookinAttr_NSDatePicker_Misc_Bezeled: @{ - @"className": @"NSDatePicker", - @"fullTitle": @"Bezeled", - @"getterString": @"isBezeled", - @"patch": @(YES) - }, - LookinAttr_NSDatePicker_Misc_DrawsBackground: @{ - @"className": @"NSDatePicker", - @"fullTitle": @"DrawsBackground", - @"patch": @(YES) - }, - - // MARK: - NSLevelIndicator - LookinAttr_NSLevelIndicator_Style_Style: @{ - @"className": @"NSLevelIndicator", - @"fullTitle": @"LevelIndicatorStyle", - @"enumList": @"NSLevelIndicatorStyle", - @"patch": @(YES) - }, - LookinAttr_NSLevelIndicator_Range_MinValue: @{ - @"className": @"NSLevelIndicator", - @"fullTitle": @"MinValue", - @"patch": @(YES) - }, - LookinAttr_NSLevelIndicator_Range_MaxValue: @{ - @"className": @"NSLevelIndicator", - @"fullTitle": @"MaxValue", - @"patch": @(YES) - }, - LookinAttr_NSLevelIndicator_Range_WarningValue: @{ - @"className": @"NSLevelIndicator", - @"fullTitle": @"WarningValue", - @"patch": @(YES) - }, - LookinAttr_NSLevelIndicator_Range_CriticalValue: @{ - @"className": @"NSLevelIndicator", - @"fullTitle": @"CriticalValue", - @"patch": @(YES) - }, - LookinAttr_NSLevelIndicator_TickMark_NumberOfTickMarks: @{ - @"className": @"NSLevelIndicator", - @"fullTitle": @"NumberOfTickMarks", - @"patch": @(YES) - }, - LookinAttr_NSLevelIndicator_TickMark_NumberOfMajorTickMarks: @{ - @"className": @"NSLevelIndicator", - @"fullTitle": @"NumberOfMajorTickMarks", - @"patch": @(YES) - }, - - // MARK: - NSOutlineView - LookinAttr_NSOutlineView_Indentation_IndentationPerLevel: @{ - @"className": @"NSOutlineView", - @"fullTitle": @"IndentationPerLevel", - @"patch": @(YES) - }, - LookinAttr_NSOutlineView_Misc_AutoresizesOutlineColumn: @{ - @"className": @"NSOutlineView", - @"fullTitle": @"AutoresizesOutlineColumn", - @"patch": @(YES) - }, - LookinAttr_NSOutlineView_Misc_IndentationMarkerFollowsCell: @{ - @"className": @"NSOutlineView", - @"fullTitle": @"IndentationMarkerFollowsCell", - @"patch": @(YES) - }, - LookinAttr_NSOutlineView_Misc_AutosaveExpandedItems: @{ - @"className": @"NSOutlineView", - @"fullTitle": @"AutosaveExpandedItems", - @"patch": @(YES) - }, - - // MARK: - NSCollectionView - LookinAttr_NSCollectionView_Selection_Selectable: @{ - @"className": @"NSCollectionView", - @"fullTitle": @"Selectable", - @"getterString": @"isSelectable", - @"patch": @(YES) - }, - LookinAttr_NSCollectionView_Selection_AllowsMultipleSelection: @{ - @"className": @"NSCollectionView", - @"fullTitle": @"AllowsMultipleSelection", - @"patch": @(YES) - }, - LookinAttr_NSCollectionView_Selection_AllowsEmptySelection: @{ - @"className": @"NSCollectionView", - @"fullTitle": @"AllowsEmptySelection", - @"patch": @(YES) - }, - LookinAttr_NSCollectionView_Info_NumberOfSections: @{ - @"className": @"NSCollectionView", - @"fullTitle": @"NumberOfSections", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSCollectionView_Colors_BackgroundColors: @{ - @"className": @"NSCollectionView", - @"fullTitle": @"BackgroundColors", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"patch": @(NO) - }, - - // MARK: - NSBox - LookinAttr_NSBox_Type_BoxType: @{ - @"className": @"NSBox", - @"fullTitle": @"BoxType", - @"enumList": @"NSBoxType", - @"patch": @(YES) - }, - LookinAttr_NSBox_Type_BorderType: @{ - @"className": @"NSBox", - @"fullTitle": @"BorderType", - @"enumList": @"NSBorderType", - @"patch": @(YES) - }, - LookinAttr_NSBox_Title_Title: @{ - @"className": @"NSBox", - @"fullTitle": @"Title", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSBox_Title_TitlePosition: @{ - @"className": @"NSBox", - @"fullTitle": @"TitlePosition", - @"enumList": @"NSTitlePosition", - @"patch": @(YES) - }, - LookinAttr_NSBox_Appearance_Transparent: @{ - @"className": @"NSBox", - @"fullTitle": @"Transparent", - @"getterString": @"isTransparent", - @"patch": @(YES) - }, - LookinAttr_NSBox_Appearance_FillColor: @{ - @"className": @"NSBox", - @"fullTitle": @"FillColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSBox_Appearance_BorderColor: @{ - @"className": @"NSBox", - @"fullTitle": @"BorderColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSBox_Metrics_BorderWidth: @{ - @"className": @"NSBox", - @"fullTitle": @"BorderWidth", - @"patch": @(YES) - }, - LookinAttr_NSBox_Metrics_CornerRadius: @{ - @"className": @"NSBox", - @"fullTitle": @"CornerRadius", - @"patch": @(YES) - }, - LookinAttr_NSBox_Metrics_ContentViewMargins: @{ - @"className": @"NSBox", - @"fullTitle": @"ContentViewMargins", - @"patch": @(YES) - }, - - // MARK: - NSSplitView - LookinAttr_NSSplitView_Orientation_Vertical: @{ - @"className": @"NSSplitView", - @"fullTitle": @"Vertical", - @"getterString": @"isVertical", - @"patch": @(YES) - }, - LookinAttr_NSSplitView_Style_DividerStyle: @{ - @"className": @"NSSplitView", - @"fullTitle": @"DividerStyle", - @"enumList": @"NSSplitViewDividerStyle", - @"patch": @(YES) - }, - LookinAttr_NSSplitView_Style_DividerThickness: @{ - @"className": @"NSSplitView", - @"fullTitle": @"DividerThickness", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSSplitView_Misc_ArrangesAllSubviews: @{ - @"className": @"NSSplitView", - @"fullTitle": @"ArrangesAllSubviews", - @"patch": @(YES) - }, - - // MARK: - NSTabView - LookinAttr_NSTabView_Type_TabViewType: @{ - @"className": @"NSTabView", - @"fullTitle": @"TabViewType", - @"enumList": @"NSTabViewType", - @"patch": @(YES) - }, - LookinAttr_NSTabView_Type_TabPosition: @{ - @"className": @"NSTabView", - @"fullTitle": @"TabPosition", - @"enumList": @"NSTabPosition", - @"patch": @(YES), - @"osVersion": @(14) - }, - LookinAttr_NSTabView_Type_TabViewBorderType: @{ - @"className": @"NSTabView", - @"fullTitle": @"TabViewBorderType", - @"enumList": @"NSTabViewBorderType", - @"patch": @(YES), - @"osVersion": @(14) - }, - LookinAttr_NSTabView_Misc_AllowsTruncatedLabels: @{ - @"className": @"NSTabView", - @"fullTitle": @"AllowsTruncatedLabels", - @"patch": @(YES) - }, - LookinAttr_NSTabView_Misc_DrawsBackground: @{ - @"className": @"NSTabView", - @"fullTitle": @"DrawsBackground", - @"patch": @(YES) - }, - LookinAttr_NSTabView_Info_NumberOfTabViewItems: @{ - @"className": @"NSTabView", - @"fullTitle": @"NumberOfTabViewItems", - @"setterString": @"", - @"patch": @(NO) - }, - - // MARK: - NSGridView - LookinAttr_NSGridView_Dimensions_NumberOfColumns: @{ - @"className": @"NSGridView", - @"fullTitle": @"NumberOfColumns", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSGridView_Dimensions_NumberOfRows: @{ - @"className": @"NSGridView", - @"fullTitle": @"NumberOfRows", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSGridView_Spacing_RowSpacing: @{ - @"className": @"NSGridView", - @"fullTitle": @"RowSpacing", - @"patch": @(YES) - }, - LookinAttr_NSGridView_Spacing_ColumnSpacing: @{ - @"className": @"NSGridView", - @"fullTitle": @"ColumnSpacing", - @"patch": @(YES) - }, - LookinAttr_NSGridView_Placement_XPlacement: @{ - @"className": @"NSGridView", - @"fullTitle": @"XPlacement", - @"enumList": @"NSGridCellPlacement", - @"patch": @(YES) - }, - LookinAttr_NSGridView_Placement_YPlacement: @{ - @"className": @"NSGridView", - @"fullTitle": @"YPlacement", - @"enumList": @"NSGridCellPlacement", - @"patch": @(YES) - }, - }; - }); - - NSDictionary *targetInfo = dict[attrID]; - return targetInfo; -} - -+ (LookinAttrType)objectAttrTypeWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSNumber *typeIfObj = attrInfo[@"typeIfObj"]; - return [typeIfObj integerValue]; -} - -+ (NSString *)classNameWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *className = attrInfo[@"className"]; - - NSAssert(className.length > 0, @""); - - return className; -} - -+ (LookinAttrTargetKind)targetKindForAttrID:(LookinAttrIdentifier)attrID { - NSString *className = [self classNameWithAttrID:attrID]; - if ([className isEqualToString:@"CALayer"]) { - return LookinAttrTargetKindLayer; - } - if ([className isEqualToString:@"UIWindowScene"] || [className isEqualToString:@"NSWindow"]) { - return LookinAttrTargetKindWindow; - } - if ([className isEqualToString:@"NSCell"] || [className isEqualToString:@"NSButtonCell"] || [className isEqualToString:@"NSTextFieldCell"]) { - return LookinAttrTargetKindCell; - } - return LookinAttrTargetKindView; -} - -+ (BOOL)isWindowPropertyWithAttrID:(LookinAttrIdentifier)attrID { - return [self targetKindForAttrID:attrID] == LookinAttrTargetKindWindow; -} - -+ (BOOL)isUIViewPropertyWithAttrID:(LookinAttrIdentifier)attrID { - return [self targetKindForAttrID:attrID] == LookinAttrTargetKindView; -} - -+ (NSString *)enumListNameWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *name = attrInfo[@"enumList"]; - return name; -} - -+ (BOOL)needPatchAfterModificationWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSNumber *needPatch = attrInfo[@"patch"]; - return [needPatch boolValue]; -} - -+ (NSString *)fullTitleWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *fullTitle = attrInfo[@"fullTitle"]; - return fullTitle; -} - -+ (NSString *)briefTitleWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *briefTitle = attrInfo[@"briefTitle"]; - if (!briefTitle) { - briefTitle = attrInfo[@"fullTitle"]; - } - return briefTitle; -} - -+ (SEL)getterWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *getterString = attrInfo[@"getterString"]; - if (getterString && getterString.length == 0) { - // 空字符串,比如 image_open_open - return nil; - } - if (!getterString) { - NSString *fullTitle = attrInfo[@"fullTitle"]; - NSAssert(fullTitle.length > 0, @""); - - getterString = [NSString stringWithFormat:@"%@%@", [fullTitle substringToIndex:1].lowercaseString, [fullTitle substringFromIndex:1]].copy; - } - return NSSelectorFromString(getterString); -} - -+ (SEL)setterWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *setterString = attrInfo[@"setterString"]; - if ([setterString isEqualToString:@""]) { - // 该属性不可在 Lookin 客户端中被修改 - return nil; - } - if (!setterString) { - NSString *fullTitle = attrInfo[@"fullTitle"]; - NSAssert(fullTitle.length > 0, @""); - - setterString = [NSString stringWithFormat:@"set%@%@:", [fullTitle substringToIndex:1].uppercaseString, [fullTitle substringFromIndex:1]]; - } - return NSSelectorFromString(setterString); -} - -+ (BOOL)hideIfNilWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSNumber *boolValue = attrInfo[@"hideIfNil"]; - return boolValue.boolValue; -} - -+ (NSInteger)minAvailableOSVersionWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSNumber *minVerNum = attrInfo[@"osVersion"]; - NSInteger minVer = [minVerNum integerValue]; - return minVer; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinDisplayItem.h b/Sources/LookinCore/LookinDisplayItem.h deleted file mode 100644 index a9efa722..00000000 --- a/Sources/LookinCore/LookinDisplayItem.h +++ /dev/null @@ -1,307 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayItem.h -// qmuidemo -// -// Created by Li Kai on 2018/11/15. -// Copyright © 2018 QMUI Team. All rights reserved. -// - -#import "TargetConditionals.h" -#import "LookinObject.h" -#import "LookinDefines.h" -#import "LookinCustomDisplayItemInfo.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_OSX -#import -#endif - -@class LookinAttributesGroup, LookinIvarTrace, LookinPreviewItemLayer, LookinEventHandler, LKDisplayItemNode, LookinDisplayItem; - -typedef NS_ENUM(NSUInteger, LookinDisplayItemImageEncodeType) { - LookinDisplayItemImageEncodeTypeNone, // 不进行 encode - LookinDisplayItemImageEncodeTypeNSData, // 转换为 NSData - LookinDisplayItemImageEncodeTypeImage // 使用 NSImage / UIImage 自身的 encode 方法 -}; - -typedef NS_ENUM(NSUInteger, LookinDoNotFetchScreenshotReason) { - // 可以同步截图 - LookinFetchScreenshotPermitted, - // layer 尺寸过大 - LookinDoNotFetchScreenshotForTooLarge, - // 被 LookinServer 的用户配置拒绝 - LookinDoNotFetchScreenshotForUserConfig, - // 节点本身没有像素(layout guide 等)——不发截图任务,但照常进预览画线框 - LookinDoNotFetchScreenshotForNoPixels -}; - - -typedef NS_ENUM(NSUInteger, LookinDisplayItemProperty) { - // 当初次设置 delegate 对象时,会立即以该值触发一次 displayItem:propertyDidChange: - LookinDisplayItemProperty_None, - LookinDisplayItemProperty_FrameToRoot, - LookinDisplayItemProperty_DisplayingInHierarchy, - LookinDisplayItemProperty_InHiddenHierarchy, - LookinDisplayItemProperty_IsExpandable, - LookinDisplayItemProperty_IsExpanded, - LookinDisplayItemProperty_SoloScreenshot, - LookinDisplayItemProperty_GroupScreenshot, - LookinDisplayItemProperty_IsSelected, - LookinDisplayItemProperty_IsHovered, - LookinDisplayItemProperty_AvoidSyncScreenshot, - LookinDisplayItemProperty_InNoPreviewHierarchy, - LookinDisplayItemProperty_IsInSearch, - LookinDisplayItemProperty_HighlightedSearchString, -}; - -@protocol LookinDisplayItemDelegate - -- (void)displayItem:(LookinDisplayItem *)displayItem propertyDidChange:(LookinDisplayItemProperty)property; - -@end - -/// What a hierarchy node *is*. Append-only: the integers travel on the wire -/// and inside .lookin archives, so existing cases must never be renumbered. -typedef NS_ENUM(NSInteger, LookinDisplayItemNodeKind) { - /// Data produced before this field existed (old servers, old archives). - /// Consumers must read resolvedNodeKind, which derives the kind from the - /// legacy object slots in this case. - LookinDisplayItemNodeKindUnspecified = 0, - LookinDisplayItemNodeKindLayer = 1, - LookinDisplayItemNodeKindView = 2, - LookinDisplayItemNodeKindWindow = 3, - LookinDisplayItemNodeKindWindowScene = 4, - /// A customInfo node (including SwiftUI virtual nodes — those are told - /// apart by customInfo.isSwiftUI, not by a separate kind). - LookinDisplayItemNodeKindCustom = 5, - LookinDisplayItemNodeKindLayoutGuide = 6, - /// AppKit-only: an NSControl's cell as a child node of the control - /// (cell-node proposal). Wireframe preview, attributes read from the cell. - LookinDisplayItemNodeKindCell = 7, - /// UIKit-only: the outermost layer of a view whose backing layer UIKit - /// wrapped in an intermediate one (_UIMultiLayer, iOS 26+). The geometry and - /// the group-compositing rules moved onto it while the contents stayed on - /// the view's own backing layer, so it has no pixels of its own — no - /// screenshot is ever fetched for it — but it still gets a preview plane of - /// its own, parallel to the view it wraps, like any other child node - /// (node-pixel-ownership proposal). - LookinDisplayItemNodeKindViewOuterLayer = 8, - /// A view's backing layer, emitted as a child of that view's node when the - /// show-backing-layers toggle is on (backing-layer-toggle proposal). An - /// ordinary pixel-bearing node: with the toggle on, content shows on the - /// layer that really renders it, and the view nodes above render as - /// wireframes. Never emitted while the toggle is off. - LookinDisplayItemNodeKindBackingLayer = 9, -}; - -@interface LookinDisplayItem : NSObject - -/// 当 customInfo 不为 nil 时,意思是该 DisplayItem 为 UserCustom 配置的。此时,Encode 属性中仅 subitems 和 customAttrGroupList 属性有意义,其它几乎所有属性都无意义 -@property(nonatomic, strong) LookinCustomDisplayItemInfo *customInfo; - -/// The node's explicit kind. Unspecified for data from servers or archives -/// that predate the field — never read this raw, read resolvedNodeKind. -@property(nonatomic, assign) LookinDisplayItemNodeKind nodeKind; - -/// The primary object for kinds that do not use the legacy slots -/// (LayoutGuide today). view / layer / window nodes keep using their -/// dedicated slots. -@property(nonatomic, strong) LookinObject *kindObject; - -/// Whether the server judged this node to be created and managed by the -/// system rather than by app code (e.g. safe-area / layout-margins layout -/// guides). Drives the host's visibility toggle for such nodes. -@property(nonatomic, assign) BOOL representsSystemManagedNode; - -/// Retired as of the cell-node proposal: cells are first-class tree nodes now -/// (nodeKind Cell, riding kindObject), so servers no longer assign this. The -/// declaration stays because the key shipped on the wire in 0.2.9 and wire -/// rules are only-add-keys-never-remove. Always nil from 0.2.10 on. -@property(nonatomic, strong) LookinObject *cellObject; - -/// nodeKind, falling back to a derivation from the legacy object slots when -/// the field is Unspecified. All consumers read this, never nodeKind itself. -- (LookinDisplayItemNodeKind)resolvedNodeKind; - -/// Whether this node carries pixels of its own that the host can fetch as a -/// screenshot. Layout guides, cells and a view's outer layer do not: their -/// pixels — when any exist at all — belong to the view that owns them, and -/// fetching a screenshot for them would draw that view's content a second time -/// (the very defect the node-pixel-ownership proposal removes). The host -/// suppresses the screenshot task for any node answering NO. -- (BOOL)isPixelBearing; - -/// Whether this pixelless node merely marks out a region of its super item -/// (layout guides and cells). The preview keeps such a node on its super -/// item's z plane instead of pushing it onto a plane of its own, and draws it -/// only while it is selected or hovered — the way Xcode's view debugger -/// outlines a layout guide inside the view that owns it. A view's outer layer -/// is pixelless but NOT coplanar: it renders as a parallel plane of its own, -/// like any other child node. -- (BOOL)shouldRenderAsCoplanarPreviewOverlay; - -/// Whether any descendant reachable through this node's children carries -/// pixels of its own. A pixelless child that itself contains pixel-bearing -/// nodes counts: with the backing-layer toggle ON, a wrapped view's only child -/// can be its pixelless outer layer holding the pixel-bearing BackingLayer -/// node — expanding that view must still switch it off its group screenshot, -/// or the content draws both on the view's plane and on the layer's. -/// -/// The preview switches an expanded node from its group screenshot to its solo -/// one so the children's pixels are not drawn twice. Pixelless subtrees draw -/// no content of their own, so a node whose children contain no pixels at all -/// has nothing to exclude — and worse, if it is a leaf view its solo -/// screenshot does not exist at all (the server returns nil for a layer with -/// no sublayers), which would blank the node out the moment a layout guide, a -/// cell or an outer layer was attached to it. -- (BOOL)hasPixelBearingSubitems; - -@property(nonatomic, copy) NSArray *subitems; - -@property(nonatomic, assign) BOOL isHidden; - -@property(nonatomic, assign) float alpha; - -@property(nonatomic, assign) CGRect frame; - -@property(nonatomic, assign) CGRect bounds; - -/// 只在 macOS 上有效, iOS 总是为 NO -@property(nonatomic, assign, getter=isFlipped) BOOL flipped; - -/// 不存在 subitems 时,该属性的值为 nil -@property(nonatomic, strong) LookinImage *soloScreenshot; -/// 无论是否存在 subitems,该属性始终存在 -@property(nonatomic, strong) LookinImage *groupScreenshot; - -/// The part of `bounds`, in the node's own coordinate space, that -/// `soloScreenshot` was rendered for. CGRectZero — the default, and what -/// every server sends — means the whole of `bounds`. A producer that renders -/// only the region a node actually draws into (a scroll view's content is -/// mostly empty, and a texture cannot exceed 16384 px on a side) records that -/// region here; the preview then shows the image over that region and leaves -/// the rest of the node transparent. -@property(nonatomic, assign) CGRect soloScreenshotRegion; -/// Same for `groupScreenshot`. -@property(nonatomic, assign) CGRect groupScreenshotRegion; - -@property(nonatomic, strong) LookinObject *windowObject; -@property(nonatomic, strong) LookinObject *viewObject; -@property(nonatomic, strong) LookinObject *layerObject; -@property(nonatomic, strong) LookinObject *hostViewControllerObject; -@property(nonatomic, strong) LookinObject *hostWindowControllerObject; - -/// attrGroups 列表 -@property(nonatomic, copy) NSArray *attributesGroupList; -/// 通过 lookin_customDebugInfos 返回的属性列表 -@property(nonatomic, copy) NSArray *customAttrGroupList; -/// attributesGroupList + customAttrGroupList -- (NSArray *)queryAllAttrGroupList; - -@property(nonatomic, copy) NSArray *eventHandlers; - -// 如果当前 item 代表 UIWindow 且是 keyWindow,则该属性为 YES -@property(nonatomic, assign) BOOL representedAsKeyWindow; - - -/// view 或 layer 的 backgroundColor,利用该属性来提前渲染 node 的背景色,使得用户感觉加载的快一点 -/// 注意有一个缺点是,理论上应该像 screenshot 一样拆成 soloBackgroundColor 和 groupBackgroundColor,这里的 backgroundColor 实际上是 soloBackgroundColor,因此某些场景的显示会有瑕疵 -@property(nonatomic, strong) LookinColor *backgroundColor; - -/// 用户可以在 iOS 项目中添加 Lookin 自定义配置来显式地拒绝传输某些图层的图像,通常是屏蔽一些不重要的 View 以提升刷新速度。如果用户这么配置了,那么这个 shouldCaptureImage 就会被置为 NO -/// 默认为 YES -@property(nonatomic, assign) BOOL shouldCaptureImage; - -/// 用户通过重写 lookin_customDebugInfos 而自定义的该实例的名字 -/// 可能为 nil -@property(nonatomic, copy) NSString *customDisplayTitle; - -/// 为 DanceUI SDK 预留的内部字段,用于文件跳转 -/// 可能为 nil -@property(nonatomic, copy) NSString *danceuiSource; - -#pragma mark - No Encode/Decode - -@property(nonatomic, weak) id previewItemDelegate; -@property(nonatomic, weak) id rowViewDelegate; - -/// 父节点 -@property(nonatomic, weak) LookinDisplayItem *superItem; - -/// 如果存在 viewObject 则返回 viewObject,否则返回 layerObject -- (LookinObject *)displayingObject; - -/// Whether the item carries usable geometry: for custom items the -/// frameInWindow value, for everything else the frame itself, must be a -/// finite, reasonable rectangle. -- (BOOL)hasValidFrameToRoot; - -/// The item's rectangle in the root coordinate space, composed by walking up -/// the ancestor chain (subtracting each ancestor's bounds origin and flipping -/// for flipped ancestors). Returns CGRectZero whenever the item — or any -/// ancestor the walk composes — has no usable geometry, so callers never -/// receive NaN or infinite components. -- (CGRect)calculateFrameToRoot; - -/// 在 hierarchy 中的层级,比如顶层的 UIWindow.indentLevel 为 0,UIWindow 的 subitem 的 indentLevel 为 1 -- (NSInteger)indentLevel; - -/** - 该项是否被展开 - @note 假如自己没有被折叠,但是 superItem 被折叠了,则自己仍然不会被看到,但是 self.isExpanded 值仍然为 NO - @note 如果 item 没有 subitems(也就是 isExpandable 为 NO),则该值没有意义。换句话说,在获取该值之前,必须先判断一下 isExpandable - */ -@property(nonatomic, assign) BOOL isExpanded; - -/// 如果有 subitems,则该属性返回 YES,否则返回 NO -@property(nonatomic, assign, readonly) BOOL isExpandable; - -/** - 是否能在 hierarchy panel 上被看到,假如有任意一层的父级元素的 isExpanded 为 NO,则 displayingInHierarchy 为 NO。如果所有父级元素的 isExpanded 均为 YES,则 displayingInHierarchy 为 YES - */ -@property(nonatomic, assign, readonly) BOOL displayingInHierarchy; - -/** - 如果自身或任意一个上层元素的 isHidden 为 YES 或 alpha 为 0,则该属性返回 YES - */ -@property(nonatomic, assign, readonly) BOOL inHiddenHierarchy; - -@property(nonatomic, assign) LookinDisplayItemImageEncodeType screenshotEncodeType; - -/// 是否获取截图及原因。默认值为 LookinFetchScreenshotPermitted。 -@property(nonatomic, assign) LookinDoNotFetchScreenshotReason doNotFetchScreenshotReason; - -@property(nonatomic, weak) LookinPreviewItemLayer *previewLayer; - -@property(nonatomic, weak) LKDisplayItemNode *previewNode; - -/// 如果该值为 YES,则该 item 及所有子 item 均不会在 preview 中被显示出来,只能在 hierarchy 中选择。默认为 NO -@property(nonatomic, assign) BOOL noPreview; - -/// 如果自身或某个上级元素的 noPreview 值为 YES,则该方法返回 YES -/// 注意:当 userCustom 为 YES 时,该属性也可能返回 YES -@property(nonatomic, assign, readonly) BOOL inNoPreviewHierarchy; - -/// 当小于 0 时表示未被设置 -@property(nonatomic, assign) NSInteger previewZIndex; - -@property(nonatomic, assign) BOOL preferToBeCollapsed; - -- (void)notifySelectionChangeToDelegates; -- (void)notifyHoverChangeToDelegates; - -/// 根据 subItems 属性将 items 打平为一维数组 -+ (NSArray *)flatItemsFromHierarchicalItems:(NSArray *)items; - -@property(nonatomic, assign) BOOL hasDeterminedExpansion; - -/// 设置当前是否处于搜索状态 -@property(nonatomic, assign) BOOL isInSearch; -/// 因为搜索而应该被高亮的字符串 -@property(nonatomic, copy) NSString *highlightedSearchString; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinDisplayItem.m b/Sources/LookinCore/LookinDisplayItem.m deleted file mode 100644 index ea75a4ac..00000000 --- a/Sources/LookinCore/LookinDisplayItem.m +++ /dev/null @@ -1,595 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayItem.m -// qmuidemo -// -// Created by Li Kai on 2018/11/15. -// Copyright © 2018 QMUI Team. All rights reserved. -// - -#import "LookinDisplayItem.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LookinAttribute.h" -#import "LookinEventHandler.h" -#import "LookinIvarTrace.h" -#import "Color+Lookin.h" -#import "NSArray+Lookin.h" -#import "NSObject+Lookin.h" -#import "NSValue+Lookin.h" -#import "LookinDashboardBlueprint.h" - -#if TARGET_OS_IPHONE -#import "../LookinServer/Server/Category/UIColor+LookinServer.h" -#import "../LookinServer/Server/Category/UIImage+LookinServer.h" -#elif TARGET_OS_OSX -#endif - -@interface LookinDisplayItem () - -@property(nonatomic, assign, readwrite) CGRect frameToRoot; -@property(nonatomic, assign, readwrite) BOOL inNoPreviewHierarchy; -@property(nonatomic, assign) NSInteger indentLevel; -@property(nonatomic, assign, readwrite) BOOL isExpandable; -@property(nonatomic, assign, readwrite) BOOL inHiddenHierarchy; -@property(nonatomic, assign, readwrite) BOOL displayingInHierarchy; - -@end - -@implementation LookinDisplayItem - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinDisplayItem *newDisplayItem = [[LookinDisplayItem allocWithZone:zone] init]; - newDisplayItem.subitems = [self.subitems lookin_map:^id(NSUInteger idx, LookinDisplayItem *value) { - return value.copy; - }]; - newDisplayItem.customInfo = self.customInfo.copy; - newDisplayItem.isHidden = self.isHidden; - newDisplayItem.alpha = self.alpha; - newDisplayItem.frame = self.frame; - newDisplayItem.bounds = self.bounds; -#if TARGET_OS_OSX - newDisplayItem.flipped = self.isFlipped; -#endif - newDisplayItem.soloScreenshot = self.soloScreenshot; - newDisplayItem.groupScreenshot = self.groupScreenshot; - newDisplayItem.soloScreenshotRegion = self.soloScreenshotRegion; - newDisplayItem.groupScreenshotRegion = self.groupScreenshotRegion; - newDisplayItem.viewObject = self.viewObject.copy; - newDisplayItem.layerObject = self.layerObject.copy; - newDisplayItem.windowObject = self.windowObject.copy; - newDisplayItem.nodeKind = self.nodeKind; - newDisplayItem.kindObject = self.kindObject.copy; - newDisplayItem.representsSystemManagedNode = self.representsSystemManagedNode; - newDisplayItem.cellObject = self.cellObject.copy; - newDisplayItem.hostViewControllerObject = self.hostViewControllerObject.copy; - newDisplayItem.hostWindowControllerObject = self.hostWindowControllerObject.copy; - newDisplayItem.attributesGroupList = [self.attributesGroupList lookin_map:^id(NSUInteger idx, LookinAttributesGroup *value) { - return value.copy; - }]; - newDisplayItem.customAttrGroupList = [self.customAttrGroupList lookin_map:^id(NSUInteger idx, LookinAttributesGroup *value) { - return value.copy; - }]; - newDisplayItem.eventHandlers = [self.eventHandlers lookin_map:^id(NSUInteger idx, LookinEventHandler *value) { - return value.copy; - }]; - newDisplayItem.shouldCaptureImage = self.shouldCaptureImage; - newDisplayItem.representedAsKeyWindow = self.representedAsKeyWindow; - newDisplayItem.customDisplayTitle = self.customDisplayTitle; - newDisplayItem.danceuiSource = self.danceuiSource; - [newDisplayItem _updateDisplayingInHierarchyProperty]; - return newDisplayItem; -} -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.customInfo forKey:@"customInfo"]; - [aCoder encodeObject:self.subitems forKey:@"subitems"]; - [aCoder encodeBool:self.isHidden forKey:@"hidden"]; - [aCoder encodeFloat:self.alpha forKey:@"alpha"]; -#if TARGET_OS_OSX - [aCoder encodeBool:self.isFlipped forKey:@"isFlipped"]; -#endif - [aCoder encodeObject:self.viewObject forKey:@"viewObject"]; - [aCoder encodeObject:self.layerObject forKey:@"layerObject"]; - [aCoder encodeObject:self.windowObject forKey:@"windowObject"]; - [aCoder encodeInteger:self.nodeKind forKey:@"nodeKind"]; - [aCoder encodeObject:self.kindObject forKey:@"kindObject"]; - [aCoder encodeBool:self.representsSystemManagedNode forKey:@"representsSystemManagedNode"]; - [aCoder encodeObject:self.cellObject forKey:@"cellObject"]; - [aCoder encodeObject:self.hostViewControllerObject forKey:@"hostViewControllerObject"]; - [aCoder encodeObject:self.hostWindowControllerObject forKey:@"hostWindowControllerObject"]; - [aCoder encodeObject:self.attributesGroupList forKey:@"attributesGroupList"]; - [aCoder encodeObject:self.customAttrGroupList forKey:@"customAttrGroupList"]; - [aCoder encodeBool:self.representedAsKeyWindow forKey:@"representedAsKeyWindow"]; - [aCoder encodeObject:self.eventHandlers forKey:@"eventHandlers"]; - [aCoder encodeBool:self.shouldCaptureImage forKey:@"shouldCaptureImage"]; - if (self.screenshotEncodeType == LookinDisplayItemImageEncodeTypeNSData) { - [aCoder encodeObject:[self.soloScreenshot lookin_encodedObjectWithType:LookinCodingValueTypeImage] forKey:@"soloScreenshot"]; - [aCoder encodeObject:[self.groupScreenshot lookin_encodedObjectWithType:LookinCodingValueTypeImage] forKey:@"groupScreenshot"]; - } else if (self.screenshotEncodeType == LookinDisplayItemImageEncodeTypeImage) { - [aCoder encodeObject:self.soloScreenshot forKey:@"soloScreenshot"]; - [aCoder encodeObject:self.groupScreenshot forKey:@"groupScreenshot"]; - } - [aCoder encodeObject:self.customDisplayTitle forKey:@"customDisplayTitle"]; - [aCoder encodeObject:self.danceuiSource forKey:@"danceuiSource"]; -#if TARGET_OS_IPHONE - [aCoder encodeCGRect:self.frame forKey:@"frame"]; - [aCoder encodeCGRect:self.bounds forKey:@"bounds"]; - [aCoder encodeCGRect:self.soloScreenshotRegion forKey:@"soloScreenshotRegion"]; - [aCoder encodeCGRect:self.groupScreenshotRegion forKey:@"groupScreenshotRegion"]; - [aCoder encodeObject:self.backgroundColor.lks_rgbaComponents forKey:@"backgroundColor"]; - -#elif TARGET_OS_OSX - [aCoder encodeRect:self.frame forKey:@"frame"]; - [aCoder encodeRect:self.bounds forKey:@"bounds"]; - [aCoder encodeRect:self.soloScreenshotRegion forKey:@"soloScreenshotRegion"]; - [aCoder encodeRect:self.groupScreenshotRegion forKey:@"groupScreenshotRegion"]; - [aCoder encodeObject:self.backgroundColor.lookin_rgbaComponents forKey:@"backgroundColor"]; -#endif -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.customInfo = [aDecoder decodeObjectForKey:@"customInfo"]; - self.subitems = [aDecoder decodeObjectForKey:@"subitems"]; - self.isHidden = [aDecoder decodeBoolForKey:@"hidden"]; - self.alpha = [aDecoder decodeFloatForKey:@"alpha"]; -#if TARGET_OS_OSX - self.flipped = [aDecoder decodeBoolForKey:@"isFlipped"]; -#endif - self.windowObject = [aDecoder decodeObjectForKey:@"windowObject"]; - self.viewObject = [aDecoder decodeObjectForKey:@"viewObject"]; - self.layerObject = [aDecoder decodeObjectForKey:@"layerObject"]; - /// These fields exist since the nodeKind mechanism landed; absent - /// keys decode to Unspecified / nil / NO, which is exactly the - /// legacy-data contract resolvedNodeKind derives from. - self.nodeKind = [aDecoder containsValueForKey:@"nodeKind"] ? [aDecoder decodeIntegerForKey:@"nodeKind"] : LookinDisplayItemNodeKindUnspecified; - self.kindObject = [aDecoder decodeObjectForKey:@"kindObject"]; - self.representsSystemManagedNode = [aDecoder decodeBoolForKey:@"representsSystemManagedNode"]; - self.cellObject = [aDecoder decodeObjectForKey:@"cellObject"]; - self.hostViewControllerObject = [aDecoder decodeObjectForKey:@"hostViewControllerObject"]; - self.hostWindowControllerObject = [aDecoder decodeObjectForKey:@"hostWindowControllerObject"]; - self.attributesGroupList = [aDecoder decodeObjectForKey:@"attributesGroupList"]; - self.customAttrGroupList = [aDecoder decodeObjectForKey:@"customAttrGroupList"]; - self.representedAsKeyWindow = [aDecoder decodeBoolForKey:@"representedAsKeyWindow"]; - - id soloScreenshotObj = [aDecoder decodeObjectForKey:@"soloScreenshot"]; - if (soloScreenshotObj) { - if ([soloScreenshotObj isKindOfClass:[NSData class]]) { - self.soloScreenshot = [soloScreenshotObj lookin_decodedObjectWithType:LookinCodingValueTypeImage]; - } else if ([soloScreenshotObj isKindOfClass:[LookinImage class]]) { - self.soloScreenshot = soloScreenshotObj; - } else { - NSAssert(NO, @""); - } - } - - id groupScreenshotObj = [aDecoder decodeObjectForKey:@"groupScreenshot"]; - if (groupScreenshotObj) { - if ([groupScreenshotObj isKindOfClass:[NSData class]]) { - self.groupScreenshot = [groupScreenshotObj lookin_decodedObjectWithType:LookinCodingValueTypeImage]; - } else if ([groupScreenshotObj isKindOfClass:[LookinImage class]]) { - self.groupScreenshot = groupScreenshotObj; - } else { - NSAssert(NO, @""); - } - } - - self.eventHandlers = [aDecoder decodeObjectForKey:@"eventHandlers"]; - /// 该属性从 LookinServer 1.1.3 开始添加 - self.shouldCaptureImage = [aDecoder containsValueForKey:@"shouldCaptureImage"] ? [aDecoder decodeBoolForKey:@"shouldCaptureImage"] : YES; - self.customDisplayTitle = [aDecoder decodeObjectForKey:@"customDisplayTitle"]; - self.danceuiSource = [aDecoder decodeObjectForKey:@"danceuiSource"]; -#if TARGET_OS_IPHONE - self.frame = [aDecoder decodeCGRectForKey:@"frame"]; - self.bounds = [aDecoder decodeCGRectForKey:@"bounds"]; - // Absent in archives and wire data that predate the field: zero, the whole of bounds. - self.soloScreenshotRegion = [aDecoder decodeCGRectForKey:@"soloScreenshotRegion"]; - self.groupScreenshotRegion = [aDecoder decodeCGRectForKey:@"groupScreenshotRegion"]; - self.backgroundColor = [UIColor lks_colorFromRGBAComponents:[aDecoder decodeObjectForKey:@"backgroundColor"]]; -#elif TARGET_OS_OSX - self.frame = [aDecoder decodeRectForKey:@"frame"]; - self.bounds = [aDecoder decodeRectForKey:@"bounds"]; - self.soloScreenshotRegion = [aDecoder decodeRectForKey:@"soloScreenshotRegion"]; - self.groupScreenshotRegion = [aDecoder decodeRectForKey:@"groupScreenshotRegion"]; - self.backgroundColor = [NSColor lookin_colorFromRGBAComponents:[aDecoder decodeObjectForKey:@"backgroundColor"]]; - -#endif - [self _updateDisplayingInHierarchyProperty]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (instancetype)init { - if (self = [super init]) { - /// 在手机端,displayItem 被创建时会调用这个方法 - [self _updateDisplayingInHierarchyProperty]; - } - return self; -} - -- (LookinObject *)displayingObject { - return self.kindObject ? : self.windowObject ? : self.viewObject ? : self.layerObject; -} - -- (LookinDisplayItemNodeKind)resolvedNodeKind { - if (self.nodeKind != LookinDisplayItemNodeKindUnspecified) { - return self.nodeKind; - } - // Derivation for data that predates the field, matching how the maker - // used the legacy slots: scene and window nodes both borrowed - // windowObject (alone), custom nodes carry customInfo, view nodes carry - // viewObject, and everything else is a bare layer. - if (self.customInfo) { - return LookinDisplayItemNodeKindCustom; - } - if (self.windowObject && !self.viewObject && !self.layerObject) { - BOOL representsWindowScene = [self.windowObject.classChainList lookin_any:^BOOL(NSString *className) { - return [className isEqualToString:@"UIWindowScene"]; - }]; - return representsWindowScene ? LookinDisplayItemNodeKindWindowScene : LookinDisplayItemNodeKindWindow; - } - if (self.viewObject) { - return LookinDisplayItemNodeKindView; - } - return LookinDisplayItemNodeKindLayer; -} - -- (BOOL)shouldRenderAsCoplanarPreviewOverlay { - switch (self.resolvedNodeKind) { - case LookinDisplayItemNodeKindLayoutGuide: - case LookinDisplayItemNodeKindCell: - return YES; - default: - return NO; - } -} - -- (BOOL)isPixelBearing { - switch (self.resolvedNodeKind) { - case LookinDisplayItemNodeKindLayoutGuide: - case LookinDisplayItemNodeKindCell: - case LookinDisplayItemNodeKindViewOuterLayer: - return NO; - default: - return YES; - } -} - -- (BOOL)hasPixelBearingSubitems { - for (LookinDisplayItem *subitem in self.subitems) { - if (subitem.isPixelBearing || subitem.hasPixelBearingSubitems) { - return YES; - } - } - return NO; -} - -- (BOOL)hasValidFrameToRoot { - if (self.customInfo) { - return [self.customInfo hasValidFrame]; - } - return LookinIsUsableRect(self.frame); -} - -- (CGRect)calculateFrameToRoot { - if (self.customInfo) { - return [self hasValidFrameToRoot] ? [self.customInfo.frameInWindow CGRectValue] : CGRectZero; - } - if (!LookinIsUsableRect(self.frame)) { - return CGRectZero; - } - if (!self.superItem) { - return self.frame; - } - - CGRect superFrameToRoot = [self.superItem calculateFrameToRoot]; - CGRect superBounds = self.superItem.bounds; - if (!LookinIsUsableRect(superBounds)) { - superBounds = CGRectZero; - } - CGRect selfFrame = self.frame; - - CGFloat x = selfFrame.origin.x - superBounds.origin.x + superFrameToRoot.origin.x; - CGFloat y; - - if (self.superItem.isFlipped) { - y = superFrameToRoot.origin.y + (superBounds.size.height - selfFrame.origin.y - selfFrame.size.height); - } else { - y = selfFrame.origin.y - superBounds.origin.y + superFrameToRoot.origin.y; - } - - CGFloat width = selfFrame.size.width; - CGFloat height = selfFrame.size.height; - return CGRectMake(x, y, width, height); -} - -- (void)setAttributesGroupList:(NSArray *)attributesGroupList { - _attributesGroupList = attributesGroupList; - - [_attributesGroupList enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop) { - [group.attrSections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop) { - [section.attributes enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop) { - attr.targetDisplayItem = self; - }]; - }]; - }]; -} - -- (void)setCustomAttrGroupList:(NSArray *)customAttrGroupList { - _customAttrGroupList = customAttrGroupList; - // 传进来的时候就已经排好序了 - [customAttrGroupList enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop) { - [group.attrSections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop) { - [section.attributes enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop) { - attr.targetDisplayItem = self; - }]; - }]; - }]; -} - -- (void)setSubitems:(NSArray *)subitems { - [_subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.superItem = nil; - }]; - - _subitems = subitems; - - self.isExpandable = (subitems.count > 0); - - [subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSAssert(!obj.superItem, @""); - obj.superItem = self; - - [obj _updateInHiddenHierarchyProperty]; - [obj _updateDisplayingInHierarchyProperty]; - }]; -} - -- (void)setIsExpandable:(BOOL)isExpandable { - if (_isExpandable == isExpandable) { - return; - } - _isExpandable = isExpandable; - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsExpandable]; -} - -- (void)setIsExpanded:(BOOL)isExpanded { - if (_isExpanded == isExpanded) { - return; - } - _isExpanded = isExpanded; - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj _updateDisplayingInHierarchyProperty]; - }]; - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsExpanded]; -} - -- (void)setSoloScreenshot:(LookinImage *)soloScreenshot { - if (_soloScreenshot == soloScreenshot) { - return; - } - _soloScreenshot = soloScreenshot; - [self _notifyDelegatesWith:LookinDisplayItemProperty_SoloScreenshot]; -} - -- (void)notifySelectionChangeToDelegates { - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsSelected]; -} - -- (void)notifyHoverChangeToDelegates { - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsHovered]; -} - -- (void)setDoNotFetchScreenshotReason:(LookinDoNotFetchScreenshotReason)doNotFetchScreenshotReason { - if (_doNotFetchScreenshotReason == doNotFetchScreenshotReason) { - return; - } - _doNotFetchScreenshotReason = doNotFetchScreenshotReason; - [self _notifyDelegatesWith:LookinDisplayItemProperty_AvoidSyncScreenshot]; -} - -- (void)setGroupScreenshot:(LookinImage *)groupScreenshot { - if (_groupScreenshot == groupScreenshot) { - return; - } - _groupScreenshot = groupScreenshot; - [self _notifyDelegatesWith:LookinDisplayItemProperty_GroupScreenshot]; -} - -- (void)setDisplayingInHierarchy:(BOOL)displayingInHierarchy { - if (_displayingInHierarchy == displayingInHierarchy) { - return; - } - _displayingInHierarchy = displayingInHierarchy; - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj _updateDisplayingInHierarchyProperty]; - }]; - - [self _notifyDelegatesWith:LookinDisplayItemProperty_DisplayingInHierarchy]; -} - -- (void)_updateDisplayingInHierarchyProperty { - if (self.superItem && (!self.superItem.displayingInHierarchy || !self.superItem.isExpanded)) { - self.displayingInHierarchy = NO; - } else { - self.displayingInHierarchy = YES; - } -} - -- (void)setIsHidden:(BOOL)isHidden { - _isHidden = isHidden; - [self _updateInHiddenHierarchyProperty]; -} - -- (void)setAlpha:(float)alpha { - _alpha = alpha; - [self _updateInHiddenHierarchyProperty]; -} - -- (void)setInHiddenHierarchy:(BOOL)inHiddenHierarchy { - if (_inHiddenHierarchy == inHiddenHierarchy) { - return; - } - _inHiddenHierarchy = inHiddenHierarchy; - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj _updateInHiddenHierarchyProperty]; - }]; - - [self _notifyDelegatesWith:LookinDisplayItemProperty_InHiddenHierarchy]; -} - -- (void)_updateInHiddenHierarchyProperty { - if (self.superItem.inHiddenHierarchy || self.isHidden || self.alpha <= 0) { - self.inHiddenHierarchy = YES; - } else { - self.inHiddenHierarchy = NO; - } -} - -+ (NSArray *)flatItemsFromHierarchicalItems:(NSArray *)items { - NSMutableArray *resultArray = [NSMutableArray array]; - - [items enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.superItem) { - obj.indentLevel = obj.superItem.indentLevel + 1; - } - [resultArray addObject:obj]; - if (obj.subitems.count) { - [resultArray addObjectsFromArray:[self flatItemsFromHierarchicalItems:obj.subitems]]; - } - }]; - - return resultArray; -} - -- (NSString *)description { - if (self.viewObject) { - return self.viewObject.rawClassName; - } else if (self.layerObject) { - return self.layerObject.rawClassName; - } else if (self.windowObject) { - return self.windowObject.rawClassName; - } else { - return [super description]; - } -} - -- (void)setPreviewItemDelegate:(id)previewItemDelegate { - _previewItemDelegate = previewItemDelegate; - - if (![previewItemDelegate respondsToSelector:@selector(displayItem:propertyDidChange:)]) { - NSAssert(NO, @""); - _previewItemDelegate = nil; - return; - } - [self.previewItemDelegate displayItem:self propertyDidChange:LookinDisplayItemProperty_None]; -} - -- (void)setRowViewDelegate:(id)rowViewDelegate { - if (_rowViewDelegate == rowViewDelegate) { - return; - } - _rowViewDelegate = rowViewDelegate; - - if (![rowViewDelegate respondsToSelector:@selector(displayItem:propertyDidChange:)]) { - NSAssert(NO, @""); - _rowViewDelegate = nil; - return; - } - [self.rowViewDelegate displayItem:self propertyDidChange:LookinDisplayItemProperty_None]; -} - -- (void)setFrame:(CGRect)frame { - _frame = frame; - [self recursivelyNotifyFrameToRootMayChange]; -} - -- (void)recursivelyNotifyFrameToRootMayChange { - [self _notifyDelegatesWith:LookinDisplayItemProperty_FrameToRoot]; - - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj recursivelyNotifyFrameToRootMayChange]; - }]; -} - -- (void)setBounds:(CGRect)bounds { - _bounds = bounds; - [self recursivelyNotifyFrameToRootMayChange]; -} - -- (void)setInNoPreviewHierarchy:(BOOL)inNoPreviewHierarchy { - if (_inNoPreviewHierarchy == inNoPreviewHierarchy) { - return; - } - _inNoPreviewHierarchy = inNoPreviewHierarchy; - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj _updateInNoPreviewHierarchy]; - }]; - [self _notifyDelegatesWith:LookinDisplayItemProperty_InNoPreviewHierarchy]; -} - -- (void)setNoPreview:(BOOL)noPreview { - _noPreview = noPreview; - [self _updateInNoPreviewHierarchy]; -} - -- (void)_updateInNoPreviewHierarchy { - if (self.superItem.inNoPreviewHierarchy || self.noPreview) { - self.inNoPreviewHierarchy = YES; - } else { - self.inNoPreviewHierarchy = NO; - } -} - -- (void)_notifyDelegatesWith:(LookinDisplayItemProperty)property { - [self.previewItemDelegate displayItem:self propertyDidChange:property]; - [self.rowViewDelegate displayItem:self propertyDidChange:property]; -} - -- (void)setIsInSearch:(BOOL)isInSearch { - _isInSearch = isInSearch; - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsInSearch]; -} - -- (void)setHighlightedSearchString:(NSString *)highlightedSearchString { - _highlightedSearchString = highlightedSearchString; - [self _notifyDelegatesWith:LookinDisplayItemProperty_HighlightedSearchString]; -} - -- (NSArray *)queryAllAttrGroupList { - NSMutableArray *array = [NSMutableArray array]; - if (self.attributesGroupList) { - [array addObjectsFromArray:self.attributesGroupList]; - } - if (self.customAttrGroupList) { - [array addObjectsFromArray:self.customAttrGroupList]; - } - return array; -} - -//- (void)dealloc -//{ -// NSLog(@"moss dealloc -%@", self); -//} - - -#if TARGET_OS_IPHONE -- (void)setFlipped:(BOOL)flipped {} - -- (BOOL)isFlipped { - return NO; -} -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinDisplayItemDetail.h b/Sources/LookinCore/LookinDisplayItemDetail.h deleted file mode 100644 index 8fc87490..00000000 --- a/Sources/LookinCore/LookinDisplayItemDetail.h +++ /dev/null @@ -1,67 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayItemDetail.h -// Lookin -// -// Created by Li Kai on 2019/2/19. -// https://lookin.work -// - -#import "LookinDefines.h" - -@class LookinAttributesGroup; -@class LookinDisplayItem; - -@interface LookinDisplayItemDetail : NSObject - -@property(nonatomic, assign) unsigned long displayItemOid; - -@property(nonatomic, strong) LookinImage *groupScreenshot; - -@property(nonatomic, strong) LookinImage *soloScreenshot; - -@property(nonatomic, strong) NSValue *frameValue; - -@property(nonatomic, strong) NSValue *boundsValue; - -@property(nonatomic, strong) NSNumber *hiddenValue; - -@property(nonatomic, strong) NSNumber *alphaValue; - -@property(nonatomic, copy) NSString *customDisplayTitle; - -@property(nonatomic, copy) NSString *danceUISource; - -@property(nonatomic, copy) NSArray *attributesGroupList; -@property(nonatomic, copy) NSArray *customAttrGroupList; - -/// 注意 nil 和空数组的区别:nil 表示该属性无意义,空数组表示 subviews 为空 -/// Client 1.0.7 & Server 1.2.7 开始支持该属性 -/// 默认为 nil -@property(nonatomic, copy) NSArray *subitems; - -/// 当 Server 找不到 task 对应的图层时,会返回一个特殊的 LookinDisplayItemDetail 对象,这个对象会被设置 displayItemOid 和 failureCode,其中 failureCode 会被置为 -1 -/// Client 1.0.7 & Server 1.2.7 开始支持该属性 -/// 默认为 0 -@property(nonatomic, assign) NSInteger failureCode; - -@end - -/// Values of `failureCode`. The property stays a plain NSInteger on the wire. -typedef NS_ENUM(NSInteger, LookinDisplayItemDetailFailureCode) { - LookinDisplayItemDetailFailureCodeNone = 0, - /// The oid resolved to an object no detail branch handles — a real fault - /// worth surfacing. The only value servers sent before 2026-08, so old - /// hosts alert on exactly this one. - LookinDisplayItemDetailFailureCodeUnhandledObject = -1, - /// The oid resolved to nothing: the object was released after the - /// hierarchy build. The inspected app's short-lived internals (TextKit 2 - /// fragment views, portals) go away on their own, so this is routine — - /// the node keeps showing the data captured at build time and hosts log - /// it without alerting. Old hosts treat it as a successful empty detail, - /// which is harmless. - LookinDisplayItemDetailFailureCodeObjectGone = -2, -}; - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinDisplayItemDetail.m b/Sources/LookinCore/LookinDisplayItemDetail.m deleted file mode 100644 index 448294cb..00000000 --- a/Sources/LookinCore/LookinDisplayItemDetail.m +++ /dev/null @@ -1,71 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayItemDetail.m -// Lookin -// -// Created by Li Kai on 2019/2/19. -// https://lookin.work -// - -#import "LookinDisplayItemDetail.h" -#import "Image+Lookin.h" - -#if TARGET_OS_IPHONE -#import "../LookinServer/Server/Category/UIImage+LookinServer.h" -#endif - -@implementation LookinDisplayItemDetail - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:@(self.displayItemOid) forKey:@"displayItemOid"]; - [aCoder encodeObject:self.groupScreenshot.lookin_data forKey:@"groupScreenshot"]; - [aCoder encodeObject:self.soloScreenshot.lookin_data forKey:@"soloScreenshot"]; - [aCoder encodeObject:self.frameValue forKey:@"frameValue"]; - [aCoder encodeObject:self.boundsValue forKey:@"boundsValue"]; - [aCoder encodeObject:self.hiddenValue forKey:@"hiddenValue"]; - [aCoder encodeObject:self.alphaValue forKey:@"alphaValue"]; - [aCoder encodeObject:self.attributesGroupList forKey:@"attributesGroupList"]; - [aCoder encodeObject:self.customAttrGroupList forKey:@"customAttrGroupList"]; - [aCoder encodeObject:self.customDisplayTitle forKey:@"customDisplayTitle"]; - [aCoder encodeObject:self.danceUISource forKey:@"danceUISource"]; - [aCoder encodeInteger:self.failureCode forKey:@"failureCode"]; - if (self.subitems) { - [aCoder encodeObject:self.subitems forKey:@"subitems"]; - } -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.displayItemOid = [[aDecoder decodeObjectForKey:@"displayItemOid"] unsignedLongValue]; - self.groupScreenshot = [[LookinImage alloc] initWithData:[aDecoder decodeObjectForKey:@"groupScreenshot"]]; - self.soloScreenshot = [[LookinImage alloc] initWithData:[aDecoder decodeObjectForKey:@"soloScreenshot"]]; - self.frameValue = [aDecoder decodeObjectForKey:@"frameValue"]; - self.boundsValue = [aDecoder decodeObjectForKey:@"boundsValue"]; - self.hiddenValue = [aDecoder decodeObjectForKey:@"hiddenValue"]; - self.alphaValue = [aDecoder decodeObjectForKey:@"alphaValue"]; - self.attributesGroupList = [aDecoder decodeObjectForKey:@"attributesGroupList"]; - self.customAttrGroupList = [aDecoder decodeObjectForKey:@"customAttrGroupList"]; - self.customDisplayTitle = [aDecoder decodeObjectForKey:@"customDisplayTitle"]; - self.danceUISource = [aDecoder decodeObjectForKey:@"danceUISource"]; - - if ([aDecoder containsValueForKey:@"failureCode"]) { - self.failureCode = [aDecoder decodeIntegerForKey:@"failureCode"]; - } else { - self.failureCode = 0; - } - - if ([aDecoder containsValueForKey:@"subitems"]) { - self.subitems = [aDecoder decodeObjectForKey:@"subitems"]; - } - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinEventHandler.h b/Sources/LookinCore/LookinEventHandler.h deleted file mode 100644 index cf44b76c..00000000 --- a/Sources/LookinCore/LookinEventHandler.h +++ /dev/null @@ -1,43 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinEventHandler.h -// Lookin -// -// Created by Li Kai on 2019/8/7. -// https://lookin.work -// - - - -#import - -@class LookinObject, LookinIvarTrace, LookinStringTwoTuple; - -typedef NS_ENUM(NSInteger, LookinEventHandlerType) { - LookinEventHandlerTypeTargetAction, - LookinEventHandlerTypeGesture -}; - -@interface LookinEventHandler : NSObject - -@property(nonatomic, assign) LookinEventHandlerType handlerType; - -/// 比如 "UIControlEventTouchUpInside", "UITapGestureRecognizer" -@property(nonatomic, copy) NSString *eventName; -/// tuple.first => @"",tuple.second => @"handleTap" -@property(nonatomic, copy) NSArray *targetActions; - -/// 返回当前 recognizer 是继承自哪一个基本款 recognizer。 -/// 基本款 recognizer 指的是 TapRecognizer, PinchRecognizer 之类的常见 recognizer -/// 如果当前 recognizer 本身就是基本款 recognizer,则该属性为 nil -@property(nonatomic, copy) NSString *inheritedRecognizerName; -@property(nonatomic, assign) BOOL gestureRecognizerIsEnabled; -@property(nonatomic, copy) NSString *gestureRecognizerDelegator; -@property(nonatomic, copy) NSArray *recognizerIvarTraces; -/// recognizer 对象 -@property(nonatomic, assign) unsigned long long recognizerOid; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinEventHandler.m b/Sources/LookinCore/LookinEventHandler.m deleted file mode 100644 index 8a1576cb..00000000 --- a/Sources/LookinCore/LookinEventHandler.m +++ /dev/null @@ -1,71 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinEventHandler.m -// Lookin -// -// Created by Li Kai on 2019/8/7. -// https://lookin.work -// - - - -#import "LookinEventHandler.h" -#import "LookinObject.h" -#import "LookinTuple.h" - -#import "NSArray+Lookin.h" - -@implementation LookinEventHandler - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinEventHandler *newHandler = [[LookinEventHandler allocWithZone:zone] init]; - newHandler.handlerType = self.handlerType; - newHandler.eventName = self.eventName; - newHandler.targetActions = [self.targetActions lookin_map:^id(NSUInteger idx, LookinStringTwoTuple *value) { - return value.copy; - }]; - newHandler.gestureRecognizerIsEnabled = self.gestureRecognizerIsEnabled; - newHandler.gestureRecognizerDelegator = self.gestureRecognizerDelegator; - newHandler.inheritedRecognizerName = self.inheritedRecognizerName; - newHandler.recognizerIvarTraces = self.recognizerIvarTraces.copy; - newHandler.recognizerOid = self.recognizerOid; - return newHandler; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInteger:self.handlerType forKey:@"handlerType"]; - [aCoder encodeBool:self.gestureRecognizerIsEnabled forKey:@"gestureRecognizerIsEnabled"]; - [aCoder encodeObject:self.eventName forKey:@"eventName"]; - [aCoder encodeObject:self.gestureRecognizerDelegator forKey:@"gestureRecognizerDelegator"]; - [aCoder encodeObject:self.targetActions forKey:@"targetActions"]; - [aCoder encodeObject:self.inheritedRecognizerName forKey:@"inheritedRecognizerName"]; - [aCoder encodeObject:self.recognizerIvarTraces forKey:@"recognizerIvarTraces"]; - [aCoder encodeObject:@(self.recognizerOid) forKey:@"recognizerOid"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.handlerType = [aDecoder decodeIntegerForKey:@"handlerType"]; - self.gestureRecognizerIsEnabled = [aDecoder decodeBoolForKey:@"gestureRecognizerIsEnabled"]; - self.eventName = [aDecoder decodeObjectForKey:@"eventName"]; - self.gestureRecognizerDelegator = [aDecoder decodeObjectForKey:@"gestureRecognizerDelegator"]; - self.targetActions = [aDecoder decodeObjectForKey:@"targetActions"]; - self.inheritedRecognizerName = [aDecoder decodeObjectForKey:@"inheritedRecognizerName"]; - self.recognizerIvarTraces = [aDecoder decodeObjectForKey:@"recognizerIvarTraces"]; - self.recognizerOid = ((NSNumber *)[aDecoder decodeObjectForKey:@"recognizerOid"]).unsignedLongValue; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinHierarchyFile.h b/Sources/LookinCore/LookinHierarchyFile.h deleted file mode 100644 index afa02cd2..00000000 --- a/Sources/LookinCore/LookinHierarchyFile.h +++ /dev/null @@ -1,32 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinHierarchyFile.h -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - - - -#import - -@class LookinHierarchyInfo; - -@interface LookinHierarchyFile : NSObject - -/// 记录创建该文件的 LookinServer 的版本 -@property(nonatomic, assign) int serverVersion; - -@property(nonatomic, strong) LookinHierarchyInfo *hierarchyInfo; - -@property(nonatomic, copy) NSDictionary *soloScreenshots; -@property(nonatomic, copy) NSDictionary *groupScreenshots; - -/// 验证 file 的版本之类的是否和当前 Lookin 客户端匹配,如果没有问题则返回 nil,如果有问题则返回 error -+ (NSError *)verifyHierarchyFile:(LookinHierarchyFile *)file; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinHierarchyFile.m b/Sources/LookinCore/LookinHierarchyFile.m deleted file mode 100644 index 077890c0..00000000 --- a/Sources/LookinCore/LookinHierarchyFile.m +++ /dev/null @@ -1,64 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinHierarchyFile.m -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - - - -#import "LookinHierarchyFile.h" - -#import "NSArray+Lookin.h" - -@implementation LookinHierarchyFile - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInt:self.serverVersion forKey:@"serverVersion"]; - [aCoder encodeObject:self.hierarchyInfo forKey:@"hierarchyInfo"]; - [aCoder encodeObject:self.soloScreenshots forKey:@"soloScreenshots"]; - [aCoder encodeObject:self.groupScreenshots forKey:@"groupScreenshots"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.serverVersion = [aDecoder decodeIntForKey:@"serverVersion"]; - self.hierarchyInfo = [aDecoder decodeObjectForKey:@"hierarchyInfo"]; - self.soloScreenshots = [aDecoder decodeObjectForKey:@"soloScreenshots"]; - self.groupScreenshots = [aDecoder decodeObjectForKey:@"groupScreenshots"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -+ (NSError *)verifyHierarchyFile:(LookinHierarchyFile *)hierarchyFile { - if (![hierarchyFile isKindOfClass:[LookinHierarchyFile class]]) { - return LookinErr_Inner; - } - - if (hierarchyFile.serverVersion < LOOKIN_SUPPORTED_SERVER_MIN) { - // 文件版本太旧 - // 如果不存在 serverVersion 这个字段,说明版本是 6 - int fileVersion = hierarchyFile.serverVersion ? : 6; - NSString *detail = [NSString stringWithFormat:NSLocalizedString(@"The document was created by a LookInside app with too old version. Current LookInside app version is %@, but the document version is %@.", nil), @(LOOKIN_CLIENT_VERSION), @(fileVersion)]; - return [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_ServerVersionTooLow userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"Failed to open the document.", nil), NSLocalizedRecoverySuggestionErrorKey:detail}]; - } - - if (hierarchyFile.serverVersion > LOOKIN_SUPPORTED_SERVER_MAX) { - // 文件版本太新 - NSString *detail = [NSString stringWithFormat:NSLocalizedString(@"Current LookInside app is too old to open this document. Current LookInside app version is %@, but the document version is %@.", nil), @(LOOKIN_CLIENT_VERSION), @(hierarchyFile.serverVersion)]; - return [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_ServerVersionTooHigh userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"Failed to open the document.", nil), NSLocalizedRecoverySuggestionErrorKey:detail}]; - } - - return nil; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinHierarchyInfo.h b/Sources/LookinCore/LookinHierarchyInfo.h deleted file mode 100644 index cc1a9dab..00000000 --- a/Sources/LookinCore/LookinHierarchyInfo.h +++ /dev/null @@ -1,47 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayInfo.h -// WeRead -// -// Created by Li Kai on 2018/10/22. -// Copyright © 2018年 tencent. All rights reserved. -// - - - -#import "LookinDefines.h" -#import "TargetConditionals.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_OSX -#import -#endif - -@class LookinDisplayItem, LookinAttributesGroup, LookinAppInfo; - -@interface LookinHierarchyInfo : NSObject - -#if TARGET_OS_IPHONE || TARGET_OS_MAC - -/// version 可能为 nil,此时说明 Client 版本号 < 1.0.4 -+ (instancetype)staticInfoWithLookinVersion:(NSString *)version; - -+ (instancetype)exportedInfo; - -#endif - -/// 这里其实就是顶端的那几个 UIWindow -@property(nonatomic, copy) NSArray *displayItems; - -@property(nonatomic, copy) NSDictionary *colorAlias; - -@property(nonatomic, copy) NSArray *collapsedClassList; - -@property(nonatomic, strong) LookinAppInfo *appInfo; - -@property(nonatomic, assign) int serverVersion; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinHierarchyInfo.m b/Sources/LookinCore/LookinHierarchyInfo.m deleted file mode 100644 index dc09e9af..00000000 --- a/Sources/LookinCore/LookinHierarchyInfo.m +++ /dev/null @@ -1,125 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayInfo.m -// WeRead -// -// Created by Li Kai on 2018/10/22. -// Copyright © 2018年 tencent. All rights reserved. -// - -#import -#import "LookinHierarchyInfo.h" -#import "LookinAttributesGroup.h" -#import "LookinDisplayItem.h" -#import "LookinAppInfo.h" -#import "NSArray+Lookin.h" -#import "NSString+Lookin.h" - -@implementation LookinHierarchyInfo - -#if TARGET_OS_IPHONE || TARGET_OS_MAC - -+ (NSArray *)_displayItemsWithScreenshots:(BOOL)hasScreenshots - attrList:(BOOL)hasAttrList - lowImageQuality:(BOOL)lowQuality - readCustomInfo:(BOOL)readCustomInfo - saveCustomSetter:(BOOL)saveCustomSetter { - Class makerClass = NSClassFromString(@"LKS_HierarchyDisplayItemsMaker"); - SEL selector = NSSelectorFromString(@"itemsWithScreenshots:attrList:lowImageQuality:readCustomInfo:saveCustomSetter:"); - if (!makerClass || ![makerClass respondsToSelector:selector]) { - return @[]; - } - - NSMethodSignature *signature = [makerClass methodSignatureForSelector:selector]; - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; - invocation.target = makerClass; - invocation.selector = selector; - [invocation setArgument:&hasScreenshots atIndex:2]; - [invocation setArgument:&hasAttrList atIndex:3]; - [invocation setArgument:&lowQuality atIndex:4]; - [invocation setArgument:&readCustomInfo atIndex:5]; - [invocation setArgument:&saveCustomSetter atIndex:6]; - [invocation invoke]; - - __unsafe_unretained NSArray *items = nil; - [invocation getReturnValue:&items]; - return items ?: @[]; -} - -+ (instancetype)staticInfoWithLookinVersion:(NSString *)version { - BOOL readCustomInfo = NO; - // Client 1.0.4 开始支持 customInfo - if (version && [version lookin_numbericOSVersion] >= 10004) { - readCustomInfo = YES; - } - - LookinHierarchyInfo *info = [LookinHierarchyInfo new]; - info.serverVersion = LOOKIN_SERVER_VERSION; - info.displayItems = [self _displayItemsWithScreenshots:NO attrList:NO lowImageQuality:NO readCustomInfo:readCustomInfo saveCustomSetter:YES]; - info.appInfo = [LookinAppInfo currentInfoWithScreenshot:NO icon:YES localIdentifiers:nil]; - info.collapsedClassList = @[]; - info.colorAlias = @{}; - return info; -} - -+ (instancetype)exportedInfo { - LookinHierarchyInfo *info = [LookinHierarchyInfo new]; - info.serverVersion = LOOKIN_SERVER_VERSION; - info.displayItems = [self _displayItemsWithScreenshots:YES attrList:YES lowImageQuality:YES readCustomInfo:YES saveCustomSetter:NO]; - info.appInfo = [LookinAppInfo currentInfoWithScreenshot:NO icon:YES localIdentifiers:nil]; - info.collapsedClassList = @[]; - info.colorAlias = @{}; - return info; -} - -#endif - -#pragma mark - - -static NSString * const LookinHierarchyInfoCodingKey_DisplayItems = @"1"; -static NSString * const LookinHierarchyInfoCodingKey_AppInfo = @"2"; -static NSString * const LookinHierarchyInfoCodingKey_ColorAlias = @"3"; -static NSString * const LookinHierarchyInfoCodingKey_CollapsedClassList = @"4"; - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.displayItems forKey:LookinHierarchyInfoCodingKey_DisplayItems]; - [aCoder encodeObject:self.colorAlias forKey:LookinHierarchyInfoCodingKey_ColorAlias]; - [aCoder encodeObject:self.collapsedClassList forKey:LookinHierarchyInfoCodingKey_CollapsedClassList]; - [aCoder encodeObject:self.appInfo forKey:LookinHierarchyInfoCodingKey_AppInfo]; - [aCoder encodeInt:self.serverVersion forKey:@"serverVersion"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.displayItems = [aDecoder decodeObjectForKey:LookinHierarchyInfoCodingKey_DisplayItems]; - self.colorAlias = [aDecoder decodeObjectForKey:LookinHierarchyInfoCodingKey_ColorAlias]; - self.collapsedClassList = [aDecoder decodeObjectForKey:LookinHierarchyInfoCodingKey_CollapsedClassList]; - self.appInfo = [aDecoder decodeObjectForKey:LookinHierarchyInfoCodingKey_AppInfo]; - self.serverVersion = [aDecoder decodeIntForKey:@"serverVersion"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinHierarchyInfo *newAppInfo = [[LookinHierarchyInfo allocWithZone:zone] init]; - newAppInfo.serverVersion = self.serverVersion; - newAppInfo.appInfo = self.appInfo.copy; - newAppInfo.collapsedClassList = self.collapsedClassList; - newAppInfo.colorAlias = self.colorAlias; - newAppInfo.displayItems = [self.displayItems lookin_map:^id(NSUInteger idx, LookinDisplayItem *oldItem) { - return oldItem.copy; - }]; - - return newAppInfo; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinObject.h b/Sources/LookinCore/LookinObject.h deleted file mode 100644 index 72604b4e..00000000 --- a/Sources/LookinCore/LookinObject.h +++ /dev/null @@ -1,42 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinObject.h -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - - - -#import - -@class LookinObjectIvar, LookinIvarTrace; - -@interface LookinObject : NSObject - -#if TARGET_OS_IPHONE || TARGET_OS_MAC -+ (instancetype)instanceWithObject:(NSObject *)object; -#endif - -@property(nonatomic, assign) unsigned long oid; - -@property(nonatomic, copy) NSString *memoryAddress; - -/** - 比如有一个 UILabel 对象,则它的 classChainList 为 @[@"UILabel", @"UIView", @"UIResponder", @"NSObject"],而它的 ivarList 长度为 4,idx 从小到大分别是 UILabel 层级的 ivars, UIView 层级的 ivars..... - */ -@property(nonatomic, copy) NSArray *classChainList; - -@property(nonatomic, copy) NSString *specialTrace; - -@property(nonatomic, copy) NSArray *ivarTraces; - -/// 没有 demangle,会包含 Swift Module Name -/// 在 Lookin 的展示中,绝大多数情况下应该使用 lk_demangledSwiftName -- (NSString *)rawClassName; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinObject.m b/Sources/LookinCore/LookinObject.m deleted file mode 100644 index bab83d0a..00000000 --- a/Sources/LookinCore/LookinObject.m +++ /dev/null @@ -1,87 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinObject.m -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - -#import "LookinObject.h" -#import "LookinIvarTrace.h" -#import "NSArray+Lookin.h" -#import "NSString+Lookin.h" - -#if TARGET_OS_IPHONE || TARGET_OS_MAC -@interface NSObject (LookinObjectServerBridge) -- (unsigned long)lks_registerOid; -- (NSArray *)lks_classChainList; -@property(nonatomic, copy) NSString *lks_specialTrace; -@property(nonatomic, copy) NSArray *lks_ivarTraces; -@end -#endif - -@implementation LookinObject - -#if TARGET_OS_IPHONE || TARGET_OS_MAC -+ (instancetype)instanceWithObject:(NSObject *)object { - LookinObject *lookinObj = [LookinObject new]; - lookinObj.oid = [object lks_registerOid]; - - lookinObj.memoryAddress = [NSString stringWithFormat:@"%p", object]; - lookinObj.classChainList = [object lks_classChainList]; - - lookinObj.specialTrace = object.lks_specialTrace; - lookinObj.ivarTraces = object.lks_ivarTraces; - - return lookinObj; -} -#endif - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinObject *newObject = [[LookinObject allocWithZone:zone] init]; - newObject.oid = self.oid; - newObject.memoryAddress = self.memoryAddress; - newObject.classChainList = self.classChainList; - newObject.specialTrace = self.specialTrace; - newObject.ivarTraces = [self.ivarTraces lookin_map:^id(NSUInteger idx, LookinIvarTrace *value) { - return value.copy; - }]; - return newObject; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:@(self.oid) forKey:@"oid"]; - [aCoder encodeObject:self.memoryAddress forKey:@"memoryAddress"]; - [aCoder encodeObject:self.classChainList forKey:@"classChainList"]; - [aCoder encodeObject:self.specialTrace forKey:@"specialTrace"]; - [aCoder encodeObject:self.ivarTraces forKey:@"ivarTraces"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.oid = [(NSNumber *)[aDecoder decodeObjectForKey:@"oid"] unsignedLongValue]; - self.memoryAddress = [aDecoder decodeObjectForKey:@"memoryAddress"]; - self.classChainList = [aDecoder decodeObjectForKey:@"classChainList"]; - self.specialTrace = [aDecoder decodeObjectForKey:@"specialTrace"]; - self.ivarTraces = [aDecoder decodeObjectForKey:@"ivarTraces"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (NSString *)rawClassName { - return self.classChainList.firstObject; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinStaticAsyncUpdateTask.h b/Sources/LookinCore/LookinStaticAsyncUpdateTask.h deleted file mode 100644 index 85e1925c..00000000 --- a/Sources/LookinCore/LookinStaticAsyncUpdateTask.h +++ /dev/null @@ -1,67 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinStaticAsyncUpdateTask.h -// Lookin -// -// Created by Li Kai on 2019/6/21. -// https://lookin.work -// - - - -#import "LookinDefines.h" - -typedef NS_ENUM(NSInteger, LookinStaticAsyncUpdateTaskType) { - LookinStaticAsyncUpdateTaskTypeNoScreenshot, - LookinStaticAsyncUpdateTaskTypeSoloScreenshot, - LookinStaticAsyncUpdateTaskTypeGroupScreenshot -}; - -typedef NS_ENUM(NSInteger, LookinDetailUpdateTaskAttrRequest) { - /// 由 Server 端自己决定:同一批 task 里,server 端会保证同一个 layer 只会构造一次 attr - /// 在 Lookin turbo 模式下,由于同一个 layer 的 task 可能位于不同批的 task 里,因此这会导致冗余的 attr 构造行为、浪费一定时间 - LookinDetailUpdateTaskAttrRequest_Automatic, - /// 需要返回 attr - LookinDetailUpdateTaskAttrRequest_Need, - /// 不需要返回 attr - LookinDetailUpdateTaskAttrRequest_NotNeed -}; - -/// 业务重写了 isEqual -@interface LookinStaticAsyncUpdateTask : NSObject - -@property(nonatomic, assign) unsigned long oid; - -@property(nonatomic, assign) LookinStaticAsyncUpdateTaskType taskType; - -/// 是否需要返回 attr 数据,默认为 Automatic -/// Client 1.0.7 & Server 1.2.7 开始支持这个参数 -@property(nonatomic, assign) LookinDetailUpdateTaskAttrRequest attrRequest; - -/// 如果置为 YES,则 server 侧会返回这些基础信息:frameValue, boundsValue, hiddenValue, alphaValue -/// 默认为 NO -/// Client 1.0.7 & Server 1.2.7 开始支持这个参数 -@property(nonatomic, assign) BOOL needBasisVisualInfo; - -/// 如果置为 YES,则 server 侧会返回 subitems -/// 默认为 NO -/// Client 1.0.7 & Server 1.2.7 开始支持这个参数 -@property(nonatomic, assign) BOOL needSubitems; - -/// Client 1.0.4 开始加入这个参数 -@property(nonatomic, copy) NSString *clientReadableVersion; - -#pragma mark - Non Coding - -@property(nonatomic, assign) CGSize frameSize; - -@end - -@interface LookinStaticAsyncUpdateTasksPackage : NSObject - -@property(nonatomic, copy) NSArray *tasks; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinStaticAsyncUpdateTask.m b/Sources/LookinCore/LookinStaticAsyncUpdateTask.m deleted file mode 100644 index aef73a11..00000000 --- a/Sources/LookinCore/LookinStaticAsyncUpdateTask.m +++ /dev/null @@ -1,105 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinStaticAsyncUpdateTask.m -// Lookin -// -// Created by Li Kai on 2019/6/21. -// https://lookin.work -// - - - -#import "LookinStaticAsyncUpdateTask.h" - -@implementation LookinStaticAsyncUpdateTask - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:@(self.oid) forKey:@"oid"]; - [aCoder encodeInteger:self.taskType forKey:@"taskType"]; - [aCoder encodeObject:self.clientReadableVersion forKey:@"clientReadableVersion"]; - [aCoder encodeInteger:self.attrRequest forKey:@"attrRequest"]; - [aCoder encodeBool:self.needBasisVisualInfo forKey:@"needBasisVisualInfo"]; - [aCoder encodeBool:self.needSubitems forKey:@"needSubitems"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.oid = [[aDecoder decodeObjectForKey:@"oid"] unsignedLongValue]; - self.taskType = [aDecoder decodeIntegerForKey:@"taskType"]; - self.clientReadableVersion = [aDecoder decodeObjectForKey:@"clientReadableVersion"]; - if ([aDecoder containsValueForKey:@"attrRequest"]) { - NSInteger value = [aDecoder decodeIntegerForKey:@"attrRequest"]; - if (value >= LookinDetailUpdateTaskAttrRequest_Automatic && value <= LookinDetailUpdateTaskAttrRequest_NotNeed) { - self.attrRequest = value; - } else { - self.attrRequest = LookinDetailUpdateTaskAttrRequest_Automatic; - } - } else { - self.attrRequest = LookinDetailUpdateTaskAttrRequest_Automatic; - } - - if ([aDecoder containsValueForKey:@"needBasisVisualInfo"]) { - self.needBasisVisualInfo = [aDecoder decodeBoolForKey:@"needBasisVisualInfo"]; - } else { - self.needBasisVisualInfo = NO; - } - - if ([aDecoder containsValueForKey:@"needSubitems"]) { - self.needSubitems = [aDecoder decodeBoolForKey:@"needSubitems"]; - } else { - self.needSubitems = NO; - } - - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (NSUInteger)hash { - return self.oid ^ self.taskType ^ self.attrRequest ^ self.needBasisVisualInfo ^ self.needSubitems; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinStaticAsyncUpdateTask class]]) { - return NO; - } - LookinStaticAsyncUpdateTask *targetTask = object; - if (self.oid == targetTask.oid - && self.taskType == targetTask.taskType - && self.attrRequest == targetTask.attrRequest - && self.needBasisVisualInfo == targetTask.needBasisVisualInfo - && self.needSubitems == targetTask.needSubitems) { - return YES; - } - return NO; -} - -@end - -@implementation LookinStaticAsyncUpdateTasksPackage - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.tasks forKey:@"tasks"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.tasks = [aDecoder decodeObjectForKey:@"tasks"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinTuple.h b/Sources/LookinCore/LookinTuple.h deleted file mode 100644 index 296a4b80..00000000 --- a/Sources/LookinCore/LookinTuple.h +++ /dev/null @@ -1,31 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinTuples.h -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - - - -#import - -@interface LookinTwoTuple : NSObject - -@property(nonatomic, strong) NSObject *first; -@property(nonatomic, strong) NSObject *second; - -@end - -@interface LookinStringTwoTuple : NSObject - -+ (instancetype)tupleWithFirst:(NSString *)firstString second:(NSString *)secondString; - -@property(nonatomic, copy) NSString *first; -@property(nonatomic, copy) NSString *second; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinTuple.m b/Sources/LookinCore/LookinTuple.m deleted file mode 100644 index b84a960b..00000000 --- a/Sources/LookinCore/LookinTuple.m +++ /dev/null @@ -1,93 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinTuples.m -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - - - -#import "LookinTuple.h" - -@implementation LookinTwoTuple - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.first forKey:@"first"]; - [aCoder encodeObject:self.second forKey:@"second"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.first = [aDecoder decodeObjectForKey:@"first"]; - self.second = [aDecoder decodeObjectForKey:@"second"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (NSUInteger)hash { - return self.first.hash ^ self.second.hash; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinTwoTuple class]]) { - return NO; - } - LookinTwoTuple *comparedObj = object; - if ([self.first isEqual:comparedObj.first] && [self.second isEqual:comparedObj.second]) { - return YES; - } - return NO; -} - -@end - -@implementation LookinStringTwoTuple - -+ (instancetype)tupleWithFirst:(NSString *)firstString second:(NSString *)secondString { - LookinStringTwoTuple *tuple = [LookinStringTwoTuple new]; - tuple.first = firstString; - tuple.second = secondString; - return tuple; -} - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinStringTwoTuple *newTuple = [[LookinStringTwoTuple allocWithZone:zone] init]; - newTuple.first = self.first; - newTuple.second = self.second; - return newTuple; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.first forKey:@"first"]; - [aCoder encodeObject:self.second forKey:@"second"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.first = [aDecoder decodeObjectForKey:@"first"]; - self.second = [aDecoder decodeObjectForKey:@"second"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinWeakContainer.h b/Sources/LookinCore/LookinWeakContainer.h deleted file mode 100644 index af7b2ffe..00000000 --- a/Sources/LookinCore/LookinWeakContainer.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinWeakContainer.h -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - - - -#import - -@interface LookinWeakContainer : NSObject - -+ (instancetype)containerWithObject:(id)object; - -@property (nonatomic, weak) id object; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinWeakContainer.m b/Sources/LookinCore/LookinWeakContainer.m deleted file mode 100644 index ff9d2aa1..00000000 --- a/Sources/LookinCore/LookinWeakContainer.m +++ /dev/null @@ -1,43 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinWeakContainer.m -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - - - -#import "LookinWeakContainer.h" - -@implementation LookinWeakContainer - -+ (instancetype)containerWithObject:(id)object { - LookinWeakContainer *container = [LookinWeakContainer new]; - container.object = object; - return container; -} - -- (NSUInteger)hash { - return [self.object hash]; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinWeakContainer class]]) { - return NO; - } - LookinWeakContainer *comparedObj = object; - if ([self.object isEqual:comparedObj.object]) { - return YES; - } - return NO; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Peertalk/Lookin_PTChannel.h b/Sources/LookinCore/Peertalk/Lookin_PTChannel.h deleted file mode 100644 index 498a604e..00000000 --- a/Sources/LookinCore/Peertalk/Lookin_PTChannel.h +++ /dev/null @@ -1,136 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - - - -// -// Represents a communication channel between two endpoints talking the same -// Lookin_PTProtocol. -// -#import -#import -#import -#import - -#import "Lookin_PTProtocol.h" -#import "Lookin_PTUSBHub.h" - -@class Lookin_PTData, Lookin_PTAddress; -@protocol Lookin_PTChannelDelegate; - -@interface Lookin_PTChannel : NSObject - -// Delegate -@property (strong) id delegate; - -// Communication protocol. Must not be nil. -@property Lookin_PTProtocol *protocol; - -// YES if this channel is a listening server -@property (readonly) BOOL isListening; - -// YES if this channel is a connected peer -@property (readonly) BOOL isConnected; - -// Arbitrary attachment. Note that if you set this, the object will grow by -// 8 bytes (64 bits). -@property (strong) id userInfo; - -@property(nonatomic, assign) int uniqueID; -@property(nonatomic, assign) NSInteger targetPort; -- (NSString *)debugTag; - -// Create a new channel using the shared Lookin_PTProtocol for the current dispatch -// queue, with *delegate*. -+ (Lookin_PTChannel*)channelWithDelegate:(id)delegate; - - -// Initialize a new frame channel, configuring it to use the calling queue's -// protocol instance (as returned by [Lookin_PTProtocol sharedProtocolForQueue: -// dispatch_get_current_queue()]) -- (id)init; - -// Initialize a new frame channel with a specific protocol. -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol; - -// Initialize a new frame channel with a specific protocol and delegate. -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol delegate:(id)delegate; - - -// Connect to a TCP port on a device connected over USB -- (void)connectToPort:(int)port overUSBHub:(Lookin_PTUSBHub*)usbHub deviceID:(NSNumber*)deviceID callback:(void(^)(NSError *error))callback; - -// Connect to a TCP port at IPv4 address. Provided port must NOT be in network -// byte order. Provided in_addr_t must NOT be in network byte order. A value returned -// from inet_aton() will be in network byte order. You can use a value of inet_aton() -// as the address parameter here, but you must flip the byte order before passing the -// in_addr_t to this function. -- (void)connectToPort:(in_port_t)port IPv4Address:(in_addr_t)address callback:(void(^)(NSError *error, Lookin_PTAddress *address))callback; - -// Listen for connections on port and address, effectively starting a socket -// server. Provided port must NOT be in network byte order. Provided in_addr_t -// must NOT be in network byte order. -// For this to make sense, you should provide a onAccept block handler -// or a delegate implementing ioFrameChannel:didAcceptConnection:. -- (void)listenOnPort:(in_port_t)port IPv4Address:(in_addr_t)address callback:(void(^)(NSError *error))callback; - -// Send a frame with an optional payload and optional callback. -// If *callback* is not NULL, the block is invoked when either an error occured -// or when the frame (and payload, if any) has been completely sent. -- (void)sendFrameOfType:(uint32_t)frameType tag:(uint32_t)tag withPayload:(dispatch_data_t)payload callback:(void(^)(NSError *error))callback; - -// Lower-level method to assign a connected dispatch IO channel to this channel -- (BOOL)startReadingFromConnectedChannel:(dispatch_io_t)channel error:(__autoreleasing NSError**)error; - -// Close the channel, preventing further reading and writing. Any ongoing and -// queued reads and writes will be aborted. -- (void)close; - -// "graceful" close -- any ongoing and queued reads and writes will complete -// before the channel ends. -- (void)cancel; - -@end - - -// Wraps a mapped dispatch_data_t object. The memory pointed to by *data* is -// valid until *dispatchData* is deallocated (normally when the receiver is -// deallocated). -@interface Lookin_PTData : NSObject -@property (readonly) dispatch_data_t dispatchData; -@property (readonly) void *data; -@property (readonly) size_t length; -@end - - -// Represents a peer's address -@interface Lookin_PTAddress : NSObject -// For network addresses, this is the IP address in textual format -@property (readonly) NSString *name; -// For network addresses, this is the port number. Otherwise 0 (zero). -@property (readonly) NSInteger port; -@end - - -// Protocol for Lookin_PTChannel delegates -@protocol Lookin_PTChannelDelegate - -@required -// Invoked when a new frame has arrived on a channel. -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didReceiveFrameOfType:(uint32_t)type tag:(uint32_t)tag payload:(Lookin_PTData*)payload; - -@optional -// Invoked to accept an incoming frame on a channel. Reply NO ignore the -// incoming frame. If not implemented by the delegate, all frames are accepted. -- (BOOL)ioFrameChannel:(Lookin_PTChannel*)channel shouldAcceptFrameOfType:(uint32_t)type tag:(uint32_t)tag payloadSize:(uint32_t)payloadSize; - -// Invoked when the channel closed. If it closed because of an error, *error* is -// a non-nil NSError object. -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didEndWithError:(NSError*)error; - -// For listening channels, this method is invoked when a new connection has been -// accepted. -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didAcceptConnection:(Lookin_PTChannel*)otherChannel fromAddress:(Lookin_PTAddress*)address; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Peertalk/Lookin_PTChannel.m b/Sources/LookinCore/Peertalk/Lookin_PTChannel.m deleted file mode 100644 index bb8529ed..00000000 --- a/Sources/LookinCore/Peertalk/Lookin_PTChannel.m +++ /dev/null @@ -1,675 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -#import "Lookin_PTChannel.h" -#import "Lookin_PTPrivate.h" -#include -#include -#include -#include -#include -#import - -// Read member of sockaddr_in without knowing the family -#define PT_SOCKADDR_ACCESS(ss, member4, member6) \ - (((ss)->ss_family == AF_INET) ? ( \ - ((const struct sockaddr_in *)(ss))->member4 \ - ) : ( \ - ((const struct sockaddr_in6 *)(ss))->member6 \ - )) - -// Connection state (storage: uint8_t) -#define kConnStateNone 0 -#define kConnStateConnecting 1 -#define kConnStateConnected 2 -#define kConnStateListening 3 - -// Delegate support optimization (storage: uint8_t) -#define kDelegateFlagImplements_ioFrameChannel_shouldAcceptFrameOfType_tag_payloadSize 1 -#define kDelegateFlagImplements_ioFrameChannel_didEndWithError 2 -#define kDelegateFlagImplements_ioFrameChannel_didAcceptConnection_fromAddress 4 - - -static int ChannelInstanceCount = 0; -static int ChannelUniqueID = 0; - -#pragma mark - -// Note: We are careful about the size of this struct as each connected peer -// implies one allocation of this struct. -@interface Lookin_PTChannel () { - dispatch_io_t dispatchObj_channel_; - dispatch_source_t dispatchObj_source_; - NSError *endError_; // 64 bit -@public // here be hacks - id delegate_; // 64 bit - uint8_t delegateFlags_; // 8 bit -@private - uint8_t connState_; // 8 bit - //char padding_[6]; // 48 bit -- only if allocation speed is important -} -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol delegate:(id)delegate; -- (BOOL)acceptIncomingConnection:(dispatch_fd_t)serverSocketFD; -@end -static const uint8_t kUserInfoKey; - -#pragma mark - -@interface Lookin_PTData () -- (id)initWithMappedDispatchData:(dispatch_data_t)mappedContiguousData data:(void*)data length:(size_t)length; -@end - -#pragma mark - -@interface Lookin_PTAddress () { - struct sockaddr_storage sockaddr_; -} -- (id)initWithSockaddr:(const struct sockaddr_storage*)addr; -@end - -#pragma mark - -@implementation Lookin_PTChannel - -@synthesize protocol = protocol_; - - -+ (Lookin_PTChannel*)channelWithDelegate:(id)delegate { - return [[Lookin_PTChannel alloc] initWithProtocol:[Lookin_PTProtocol sharedProtocolForQueue:dispatch_get_main_queue()] delegate:delegate]; -} - - -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol delegate:(id)delegate { - if (!(self = [super init])) return nil; - protocol_ = protocol; - self.delegate = delegate; - - [self didInit]; - - return self; -} - - -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol { - if (!(self = [super init])) return nil; - protocol_ = protocol; - - [self didInit]; - - return self; -} - - -- (id)init { - [self didInit]; - - return [self initWithProtocol:[Lookin_PTProtocol sharedProtocolForQueue:dispatch_get_main_queue()]]; -} - -- (void)didInit { - ChannelUniqueID++; - ChannelInstanceCount++; - self.uniqueID = ChannelUniqueID; -// NSLog(@"LookinServer - Init channel(ID: %@). Total count: %@", @(self.uniqueID), @(ChannelInstanceCount)); -} - -- (void)dealloc { - ChannelInstanceCount--; -// NSLog(@"LookinServer - Dealloc channel%@. Still lives count: %@", self.debugTag, @(ChannelInstanceCount)); -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchObj_channel_) dispatch_release(dispatchObj_channel_); - else if (dispatchObj_source_) dispatch_release(dispatchObj_source_); -#endif -} - - -- (BOOL)isConnected { - return connState_ == kConnStateConnecting || connState_ == kConnStateConnected; -} - - -- (BOOL)isListening { - return connState_ == kConnStateListening; -} - - -- (id)userInfo { - return objc_getAssociatedObject(self, (void*)&kUserInfoKey); -} - -- (void)setUserInfo:(id)userInfo { - objc_setAssociatedObject(self, (const void*)&kUserInfoKey, userInfo, OBJC_ASSOCIATION_RETAIN); -} - - -- (void)setConnState:(char)connState { - connState_ = connState; -} - - -- (void)setDispatchChannel:(dispatch_io_t)channel { - assert(connState_ == kConnStateConnecting || connState_ == kConnStateConnected || connState_ == kConnStateNone); - dispatch_io_t prevChannel = dispatchObj_channel_; - if (prevChannel != channel) { - dispatchObj_channel_ = channel; -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchObj_channel_) dispatch_retain(dispatchObj_channel_); - if (prevChannel) dispatch_release(prevChannel); -#endif - if (!dispatchObj_channel_ && !dispatchObj_source_) { - connState_ = kConnStateNone; - } - } -} - - -- (void)setDispatchSource:(dispatch_source_t)source { - assert(connState_ == kConnStateListening || connState_ == kConnStateNone); - dispatch_source_t prevSource = dispatchObj_source_; - if (prevSource != source) { - dispatchObj_source_ = source; -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchObj_source_) dispatch_retain(dispatchObj_source_); - if (prevSource) dispatch_release(prevSource); -#endif - if (!dispatchObj_channel_ && !dispatchObj_source_) { - connState_ = kConnStateNone; - } - } -} - - -- (id)delegate { - return delegate_; -} - - -- (void)setDelegate:(id)delegate { - delegate_ = delegate; - delegateFlags_ = 0; - if (!delegate_) { - return; - } - - if ([delegate respondsToSelector:@selector(ioFrameChannel:shouldAcceptFrameOfType:tag:payloadSize:)]) { - delegateFlags_ |= kDelegateFlagImplements_ioFrameChannel_shouldAcceptFrameOfType_tag_payloadSize; - } - - if (delegate_ && [delegate respondsToSelector:@selector(ioFrameChannel:didEndWithError:)]) { - delegateFlags_ |= kDelegateFlagImplements_ioFrameChannel_didEndWithError; - } - - if (delegate_ && [delegate respondsToSelector:@selector(ioFrameChannel:didAcceptConnection:fromAddress:)]) { - delegateFlags_ |= kDelegateFlagImplements_ioFrameChannel_didAcceptConnection_fromAddress; - } -} - -- (NSString *)debugTag { - NSString *state = @""; - if (connState_ == kConnStateNone) { - state = @"None"; - } else if (connState_ == kConnStateConnecting) { - state = @"Connecting"; - } else if (connState_ == kConnStateConnected) { - state = @"Connected"; - } else if (connState_ == kConnStateListening) { - state = @"Listening"; - } else { - state = @"Undefined"; - } - return [NSString stringWithFormat:@"[%@-%@,%@]", @(self.uniqueID), @(self.targetPort), state]; -} - - -//- (void)setFileDescriptor:(dispatch_fd_t)fd { -// [self setDispatchChannel:dispatch_io_create(DISPATCH_IO_STREAM, fd, protocol_.queue, ^(int error) { -// close(fd); -// })]; -//} - - -#pragma mark - Connecting - - -- (void)connectToPort:(int)port overUSBHub:(Lookin_PTUSBHub*)usbHub deviceID:(NSNumber*)deviceID callback:(void(^)(NSError *error))callback { - assert(protocol_ != NULL); - if (connState_ != kConnStateNone) { - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:EPERM userInfo:nil]); - return; - } - connState_ = kConnStateConnecting; - [usbHub connectToDevice:deviceID port:port onStart:^(NSError *err, dispatch_io_t dispatchChannel) { - NSError *error = err; - if (!error) { - [self startReadingFromConnectedChannel:dispatchChannel error:&error]; - } else { - self->connState_ = kConnStateNone; - } - if (callback) callback(error); - } onEnd:^(NSError *error) { - if (self->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didEndWithError) { - [self->delegate_ ioFrameChannel:self didEndWithError:error]; - } - self->endError_ = nil; - }]; -} - - -- (void)connectToPort:(in_port_t)port IPv4Address:(in_addr_t)address callback:(void(^)(NSError *error, Lookin_PTAddress *address))callback { - assert(protocol_ != NULL); - if (connState_ != kConnStateNone) { - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:EPERM userInfo:nil], nil); - return; - } - connState_ = kConnStateConnecting; - - int error = 0; - - // Create socket - dispatch_fd_t fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd == -1) { - perror("socket(AF_INET, SOCK_STREAM, 0) failed"); - error = errno; - if (callback) callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil], nil); - return; - } - - // Connect socket - struct sockaddr_in addr; - bzero((char *)&addr, sizeof(addr)); - - addr.sin_len = sizeof(addr); - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - //addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - //addr.sin_addr.s_addr = htonl(INADDR_ANY); - addr.sin_addr.s_addr = htonl(address); - - // prevent SIGPIPE - int on = 1; - setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); - - // int socket, const struct sockaddr *address, socklen_t address_len - if (connect(fd, (const struct sockaddr *)&addr, addr.sin_len) == -1) { - //perror("connect"); - error = errno; - close(fd); - if (callback) callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], nil); - return; - } - - // get actual address - //if (getsockname(fd, (struct sockaddr*)&addr, (socklen_t*)&addr.sin_len) == -1) { - // error = errno; - // close(fd); - // if (callback) callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], nil); - // return; - //} - - dispatch_io_t dispatchChannel = dispatch_io_create(DISPATCH_IO_STREAM, fd, protocol_.queue, ^(int error) { - close(fd); - if (self->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didEndWithError) { - NSError *err = error == 0 ? self->endError_ : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil]; - [self->delegate_ ioFrameChannel:self didEndWithError:err]; - self->endError_ = nil; - } - }); - - if (!dispatchChannel) { - close(fd); - if (callback) callback([[NSError alloc] initWithDomain:@"PTError" code:0 userInfo:nil], nil); - return; - } - - // Success - NSError *err = nil; - Lookin_PTAddress *ptAddr = [[Lookin_PTAddress alloc] initWithSockaddr:(struct sockaddr_storage*)&addr]; - [self startReadingFromConnectedChannel:dispatchChannel error:&err]; - if (callback) callback(err, ptAddr); -} - - -#pragma mark - Listening and serving - - -- (void)listenOnPort:(in_port_t)port IPv4Address:(in_addr_t)address callback:(void(^)(NSError *error))callback { - assert(dispatchObj_source_ == nil); - - // Create socket - dispatch_fd_t fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd == -1) { - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - // Connect socket - struct sockaddr_in addr; - bzero((char *)&addr, sizeof(addr)); - - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - //addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - //addr.sin_addr.s_addr = htonl(INADDR_ANY); - addr.sin_addr.s_addr = htonl(address); - - socklen_t socklen = sizeof(addr); - - int on = 1; - - if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) == -1) { - close(fd); - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - if (fcntl(fd, F_SETFL, O_NONBLOCK) == -1) { - close(fd); - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - if (bind(fd, (struct sockaddr*)&addr, socklen) != 0) { - close(fd); - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - if (listen(fd, 512) != 0) { - close(fd); - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - [self setDispatchSource:dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, fd, 0, protocol_.queue)]; - - dispatch_source_set_event_handler(dispatchObj_source_, ^{ - unsigned long nconns = dispatch_source_get_data(self->dispatchObj_source_); - while ([self acceptIncomingConnection:fd] && --nconns); - }); - - dispatch_source_set_cancel_handler(dispatchObj_source_, ^{ - // Captures *self*, effectively holding a reference to *self* until cancelled. - self->dispatchObj_source_ = nil; - close(fd); - if (self->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didEndWithError) { - [self->delegate_ ioFrameChannel:self didEndWithError:self->endError_]; - self->endError_ = nil; - } - }); - - dispatch_resume(dispatchObj_source_); - //NSLog(@"%@ opened on fd #%d", self, fd); - - connState_ = kConnStateListening; - if (callback) callback(nil); -} - - -- (BOOL)acceptIncomingConnection:(dispatch_fd_t)serverSocketFD { - struct sockaddr_in addr; - socklen_t addrLen = sizeof(addr); - dispatch_fd_t clientSocketFD = accept(serverSocketFD, (struct sockaddr*)&addr, &addrLen); - - if (clientSocketFD == -1) { - perror("accept()"); - return NO; - } - - // prevent SIGPIPE - int on = 1; - setsockopt(clientSocketFD, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); - - if (fcntl(clientSocketFD, F_SETFL, O_NONBLOCK) == -1) { - perror("fcntl(.. O_NONBLOCK)"); - close(clientSocketFD); - return NO; - } - - if (delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didAcceptConnection_fromAddress) { - Lookin_PTChannel *peerChannel = [[Lookin_PTChannel alloc] initWithProtocol:protocol_ delegate:delegate_]; - __block Lookin_PTChannel *localChannelRef = self; - dispatch_io_t dispatchChannel = dispatch_io_create(DISPATCH_IO_STREAM, clientSocketFD, protocol_.queue, ^(int error) { - // Important note: This block captures *self*, thus a reference is held to - // *self* until the fd is truly closed. - localChannelRef = nil; - - close(clientSocketFD); - - if (peerChannel->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didEndWithError) { - NSError *err = error == 0 ? peerChannel->endError_ : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil]; - [peerChannel->delegate_ ioFrameChannel:peerChannel didEndWithError:err]; - peerChannel->endError_ = nil; - } - }); - - [peerChannel setConnState:kConnStateConnected]; - [peerChannel setDispatchChannel:dispatchChannel]; - - assert(((struct sockaddr_storage*)&addr)->ss_len == addrLen); - Lookin_PTAddress *address = [[Lookin_PTAddress alloc] initWithSockaddr:(struct sockaddr_storage*)&addr]; - [delegate_ ioFrameChannel:self didAcceptConnection:peerChannel fromAddress:address]; - - NSError *err = nil; - if (![peerChannel startReadingFromConnectedChannel:dispatchChannel error:&err]) { -// NSLog(@"startReadingFromConnectedChannel failed in accept: %@", err); - } - } else { - close(clientSocketFD); - } - return YES; -} - - -#pragma mark - Closing the channel - - -- (void)close { -// NSLog(@"LookinServer - Will close chanel: %@", self.debugTag); - - if ((connState_ == kConnStateConnecting || connState_ == kConnStateConnected) && dispatchObj_channel_) { - dispatch_io_close(dispatchObj_channel_, DISPATCH_IO_STOP); - [self setDispatchChannel:NULL]; - } else if (connState_ == kConnStateListening && dispatchObj_source_) { - dispatch_source_cancel(dispatchObj_source_); - } -} - -/// 曾经连接上 Client,然后 Client 端关闭时,Peertalk 内部会对之前 connect 的 channel 调用该方法 -- (void)cancel { -// NSLog(@"LookinServer - Will cancel chanel: %@", self.debugTag); - - if ((connState_ == kConnStateConnecting || connState_ == kConnStateConnected) && dispatchObj_channel_) { - dispatch_io_close(dispatchObj_channel_, 0); - [self setDispatchChannel:NULL]; - } else if (connState_ == kConnStateListening && dispatchObj_source_) { - dispatch_source_cancel(dispatchObj_source_); - } -} - - -#pragma mark - Reading - - -- (BOOL)startReadingFromConnectedChannel:(dispatch_io_t)channel error:(__autoreleasing NSError**)error { - if (connState_ != kConnStateNone && connState_ != kConnStateConnecting && connState_ != kConnStateConnected) { - if (error) *error = [NSError errorWithDomain:NSPOSIXErrorDomain code:EPERM userInfo:nil]; - return NO; - } - - if (dispatchObj_channel_ != channel) { - [self close]; - [self setDispatchChannel:channel]; - } - - connState_ = kConnStateConnected; - - // helper - BOOL(^handleError)(NSError*,BOOL) = ^BOOL(NSError *error, BOOL isEOS) { - if (error) { - //NSLog(@"Error while communicating: %@", error); - self->endError_ = error; - [self close]; - return YES; - } else if (isEOS) { - [self cancel]; - return YES; - } - return NO; - }; - - [protocol_ readFramesOverChannel:channel onFrame:^(NSError *error, uint32_t type, uint32_t tag, uint32_t payloadSize, dispatch_block_t resumeReadingFrames) { - if (handleError(error, type == PTFrameTypeEndOfStream)) { - return; - } - - BOOL accepted = (channel == self->dispatchObj_channel_); - if (accepted && (self->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_shouldAcceptFrameOfType_tag_payloadSize)) { - accepted = [self->delegate_ ioFrameChannel:self shouldAcceptFrameOfType:type tag:tag payloadSize:payloadSize]; - } - - if (payloadSize == 0) { - if (accepted && self->delegate_) { - [self->delegate_ ioFrameChannel:self didReceiveFrameOfType:type tag:tag payload:nil]; - } else { - // simply ignore the frame - } - resumeReadingFrames(); - } else { - // has payload - if (!accepted) { - // Read and discard payload, ignoring frame - [self->protocol_ readAndDiscardDataOfSize:payloadSize overChannel:channel callback:^(NSError *error, BOOL endOfStream) { - if (!handleError(error, endOfStream)) { - resumeReadingFrames(); - } - }]; - } else { - [self->protocol_ readPayloadOfSize:payloadSize overChannel:channel callback:^(NSError *error, dispatch_data_t contiguousData, const uint8_t *buffer, size_t bufferSize) { - if (handleError(error, bufferSize == 0)) { - return; - } - - if (self->delegate_) { - Lookin_PTData *payload = [[Lookin_PTData alloc] initWithMappedDispatchData:contiguousData data:(void*)buffer length:bufferSize]; - [self->delegate_ ioFrameChannel:self didReceiveFrameOfType:type tag:tag payload:payload]; - } - - resumeReadingFrames(); - }]; - } - } - }]; - - return YES; -} - - -#pragma mark - Sending - -- (void)sendFrameOfType:(uint32_t)frameType tag:(uint32_t)tag withPayload:(dispatch_data_t)payload callback:(void(^)(NSError *error))callback { - if (connState_ == kConnStateConnecting || connState_ == kConnStateConnected) { - [protocol_ sendFrameOfType:frameType tag:tag withPayload:payload overChannel:dispatchObj_channel_ callback:callback]; - } else if (callback) { - callback([NSError errorWithDomain:NSPOSIXErrorDomain code:EPERM userInfo:nil]); - } -} - -#pragma mark - NSObject - -- (NSString*)description { - id userInfo = objc_getAssociatedObject(self, (void*)&kUserInfoKey); - return [NSString stringWithFormat:@"", self, ( connState_ == kConnStateConnecting ? @"connecting" - : connState_ == kConnStateConnected ? @"connected" - : connState_ == kConnStateListening ? @"listening" - : @"closed"), - userInfo ? " " : "", userInfo ? userInfo : @""]; -} - - -@end - - -#pragma mark - -@implementation Lookin_PTAddress - -- (id)initWithSockaddr:(const struct sockaddr_storage*)addr { - if (!(self = [super init])) return nil; - assert(addr); - memcpy((void*)&sockaddr_, (const void*)addr, addr->ss_len); - return self; -} - - -- (NSString*)name { - if (sockaddr_.ss_len) { - const void *sin_addr = NULL; - size_t bufsize = 0; - if (sockaddr_.ss_family == AF_INET6) { - bufsize = INET6_ADDRSTRLEN; - sin_addr = (const void *)&((const struct sockaddr_in6*)&sockaddr_)->sin6_addr; - } else { - bufsize = INET_ADDRSTRLEN; - sin_addr = (const void *)&((const struct sockaddr_in*)&sockaddr_)->sin_addr; - } - char *buf = CFAllocatorAllocate(kCFAllocatorDefault, bufsize+1, 0); - if (inet_ntop(sockaddr_.ss_family, sin_addr, buf, (unsigned int)bufsize-1) == NULL) { - CFAllocatorDeallocate(kCFAllocatorDefault, buf); - return nil; - } - return [[NSString alloc] initWithBytesNoCopy:(void*)buf length:strlen(buf) encoding:NSUTF8StringEncoding freeWhenDone:YES]; - } else { - return nil; - } -} - - -- (NSInteger)port { - if (sockaddr_.ss_len) { - return ntohs(PT_SOCKADDR_ACCESS(&sockaddr_, sin_port, sin6_port)); - } else { - return 0; - } -} - - -- (NSString*)description { - if (sockaddr_.ss_len) { - return [NSString stringWithFormat:@"%@:%u", self.name, (unsigned)self.port]; - } else { - return @"(?)"; - } -} - -@end - - -#pragma mark - -@implementation Lookin_PTData - -@synthesize dispatchData = dispatchData_; -@synthesize data = data_; -@synthesize length = length_; - -- (id)initWithMappedDispatchData:(dispatch_data_t)mappedContiguousData data:(void*)data length:(size_t)length { - if (!(self = [super init])) return nil; - dispatchData_ = mappedContiguousData; -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchData_) dispatch_retain(dispatchData_); -#endif - data_ = data; - length_ = length; - return self; -} - -- (void)dealloc { -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchData_) dispatch_release(dispatchData_); -#endif - data_ = NULL; - length_ = 0; -} - -#pragma mark - NSObject - -- (NSString*)description { - return [NSString stringWithFormat:@"", self, length_]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Peertalk/Lookin_PTPrivate.h b/Sources/LookinCore/Peertalk/Lookin_PTPrivate.h deleted file mode 100644 index 6480de85..00000000 --- a/Sources/LookinCore/Peertalk/Lookin_PTPrivate.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - - - -#if (defined(__IPHONE_OS_VERSION_MIN_REQUIRED) && (!defined(__IPHONE_6_0) || __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_6_0)) || \ - (defined(__MAC_OS_X_VERSION_MIN_REQUIRED) && (!defined(__MAC_10_8) || __MAC_OS_X_VERSION_MIN_REQUIRED < __MAC_10_8)) -#define PT_DISPATCH_RETAIN_RELEASE 1 -#else -#define PT_DISPATCH_RETAIN_RELEASE 0 -#endif - -#if PT_DISPATCH_RETAIN_RELEASE -#define PT_PRECISE_LIFETIME -#define PT_PRECISE_LIFETIME_UNUSED -#else -#define PT_PRECISE_LIFETIME __attribute__((objc_precise_lifetime)) -#define PT_PRECISE_LIFETIME_UNUSED __attribute__((objc_precise_lifetime, unused)) -#endif - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Peertalk/Lookin_PTProtocol.h b/Sources/LookinCore/Peertalk/Lookin_PTProtocol.h deleted file mode 100644 index b0207d37..00000000 --- a/Sources/LookinCore/Peertalk/Lookin_PTProtocol.h +++ /dev/null @@ -1,122 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - - - -// -// A universal frame-based communication protocol which can be used to exchange -// arbitrary structured data. -// -// In short: -// -// - Each transmission is comprised by one fixed-size frame. -// - Each frame contains a protocol version number. -// - Each frame contains an application frame type. -// - Each frame can contain an identifying tag. -// - Each frame can have application-specific data of up to UINT32_MAX size. -// - Transactions style messaging can be modeled on top using frame tags. -// - Lightweight API on top of libdispatch (aka GCD) -- close to the metal. -// -#include -#import - -// Special frame tag that signifies "no tag". Your implementation should never -// create a reply for a frame with this tag. -static const uint32_t PTFrameNoTag = 0; - -// Special frame type that signifies that the stream has ended. -static const uint32_t PTFrameTypeEndOfStream = 0; - -// NSError domain -FOUNDATION_EXPORT NSString * const Lookin_PTProtocolErrorDomain; - - -@interface Lookin_PTProtocol : NSObject - -// Queue on which to run data processing blocks. -@property dispatch_queue_t queue; - -// Get the shared protocol object for *queue* -+ (Lookin_PTProtocol*)sharedProtocolForQueue:(dispatch_queue_t)queue; - -// Initialize a new protocol object to use a specific queue. -- (id)initWithDispatchQueue:(dispatch_queue_t)queue; - -// Initialize a new protocol object to use the current calling queue. -- (id)init; - -#pragma mark Sending frames - -// Generate a new tag that is unique within this protocol object. -- (uint32_t)newTag; - -// Send a frame over *channel* with an optional payload and optional callback. -// If *callback* is not NULL, the block is invoked when either an error occured -// or when the frame (and payload, if any) has been completely sent. -- (void)sendFrameOfType:(uint32_t)frameType - tag:(uint32_t)tag - withPayload:(dispatch_data_t)payload - overChannel:(dispatch_io_t)channel - callback:(void(^)(NSError *error))callback; - -#pragma mark Receiving frames - -// Read frames over *channel* as they arrive. -// The onFrame handler is responsible for reading (or discarding) any payload -// and call *resumeReadingFrames* afterwards to resume reading frames. -// To stop reading frames, simply do not invoke *resumeReadingFrames*. -// When the stream ends, a frame of type PTFrameTypeEndOfStream is received. -- (void)readFramesOverChannel:(dispatch_io_t)channel - onFrame:(void(^)(NSError *error, - uint32_t type, - uint32_t tag, - uint32_t payloadSize, - dispatch_block_t resumeReadingFrames))onFrame; - -// Read a single frame over *channel*. A frame of type PTFrameTypeEndOfStream -// denotes the stream has ended. -- (void)readFrameOverChannel:(dispatch_io_t)channel - callback:(void(^)(NSError *error, - uint32_t frameType, - uint32_t frameTag, - uint32_t payloadSize))callback; - -#pragma mark Receiving frame payloads - -// Read a complete payload. It's the callers responsibility to make sure -// payloadSize is not too large since memory will be automatically allocated -// where only payloadSize is the limit. -// The returned dispatch_data_t object owns *buffer* and thus you need to call -// dispatch_retain on *contiguousData* if you plan to keep *buffer* around after -// returning from the callback. -- (void)readPayloadOfSize:(size_t)payloadSize - overChannel:(dispatch_io_t)channel - callback:(void(^)(NSError *error, - dispatch_data_t contiguousData, - const uint8_t *buffer, - size_t bufferSize))callback; - -// Discard data of *size* waiting on *channel*. *callback* can be NULL. -- (void)readAndDiscardDataOfSize:(size_t)size - overChannel:(dispatch_io_t)channel - callback:(void(^)(NSError *error, BOOL endOfStream))callback; - -@end - -@interface NSData (Lookin_PTProtocol) -// Creates a new dispatch_data_t object which references the receiver and uses -// the receivers bytes as its backing data. The returned dispatch_data_t object -// holds a reference to the recevier. It's the callers responsibility to call -// dispatch_release on the returned object when done. -- (dispatch_data_t)createReferencingDispatchData; -+ (NSData *)dataWithContentsOfDispatchData:(dispatch_data_t)data; -@end - -@interface NSDictionary (Lookin_PTProtocol) -// See description of -[NSData(Lookin_PTProtocol) createReferencingDispatchData] -- (dispatch_data_t)createReferencingDispatchData; - -// Decode *data* as a peroperty list-encoded dictionary. Returns nil on failure. -+ (NSDictionary*)dictionaryWithContentsOfDispatchData:(dispatch_data_t)data; -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Peertalk/Lookin_PTProtocol.m b/Sources/LookinCore/Peertalk/Lookin_PTProtocol.m deleted file mode 100644 index c095a1e3..00000000 --- a/Sources/LookinCore/Peertalk/Lookin_PTProtocol.m +++ /dev/null @@ -1,433 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -#import "Lookin_PTProtocol.h" -#import "Lookin_PTPrivate.h" -#import "LookInsideServerLogger.h" -#import - -static const uint32_t PTProtocolVersion1 = 1; - -NSString * const Lookin_PTProtocolErrorDomain = @"PTProtocolError"; - -// This is what we send as the header for each frame. -typedef struct _PTFrame { - // The version of the frame and protocol. - uint32_t version; - - // Type of frame - uint32_t type; - - // Unless zero, a tag is retained in frames that are responses to previous - // frames. Applications can use this to build transactions or request-response - // logic. - uint32_t tag; - - // If payloadSize is larger than zero, *payloadSize* number of bytes are - // following, constituting application-specific data. - uint32_t payloadSize; - -} PTFrame; - - -@interface Lookin_PTProtocol () { - uint32_t nextFrameTag_; - @public - dispatch_queue_t queue_; -} -- (dispatch_data_t)createDispatchDataWithFrameOfType:(uint32_t)type frameTag:(uint32_t)frameTag payload:(dispatch_data_t)payload; -@end - - -static void _release_queue_local_protocol(void *objcobj) { - if (objcobj) { - Lookin_PTProtocol *protocol = (__bridge_transfer id)objcobj; - protocol->queue_ = NULL; - } -} - - -@interface Lookin_RQueueLocalIOFrameProtocol : Lookin_PTProtocol -@end -@implementation Lookin_RQueueLocalIOFrameProtocol -- (void)setQueue:(dispatch_queue_t)queue { -} -@end - - -@implementation Lookin_PTProtocol - - -+ (Lookin_PTProtocol*)sharedProtocolForQueue:(dispatch_queue_t)queue { - static const char currentQueueFrameProtocolKey; - //dispatch_queue_t queue = dispatch_get_current_queue(); - Lookin_PTProtocol *currentQueueFrameProtocol = (__bridge Lookin_PTProtocol*)dispatch_queue_get_specific(queue, ¤tQueueFrameProtocolKey); - if (!currentQueueFrameProtocol) { - currentQueueFrameProtocol = [[Lookin_RQueueLocalIOFrameProtocol alloc] initWithDispatchQueue:NULL]; - currentQueueFrameProtocol->queue_ = queue; // reference, no retain, since we would create cyclic references - dispatch_queue_set_specific(queue, ¤tQueueFrameProtocolKey, (__bridge_retained void*)currentQueueFrameProtocol, &_release_queue_local_protocol); - return (__bridge Lookin_PTProtocol*)dispatch_queue_get_specific(queue, ¤tQueueFrameProtocolKey); // to avoid race conds - } else { - return currentQueueFrameProtocol; - } -} - - -- (id)initWithDispatchQueue:(dispatch_queue_t)queue { - if (!(self = [super init])) return nil; - queue_ = queue; -#if PT_DISPATCH_RETAIN_RELEASE - if (queue_) dispatch_retain(queue_); -#endif - return self; -} - -- (id)init { - return [self initWithDispatchQueue:dispatch_get_main_queue()]; -} - -- (void)dealloc { - if (queue_) { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(queue_); -#endif - } -} - -- (dispatch_queue_t)queue { - return queue_; -} - -- (void)setQueue:(dispatch_queue_t)queue { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_queue_t prev_queue = queue_; - queue_ = queue; - if (queue_) dispatch_retain(queue_); - if (prev_queue) dispatch_release(prev_queue); -#else - queue_ = queue; -#endif -} - - -- (uint32_t)newTag { - return ++nextFrameTag_; -} - - -#pragma mark - -#pragma mark Creating frames - - -- (dispatch_data_t)createDispatchDataWithFrameOfType:(uint32_t)type frameTag:(uint32_t)frameTag payload:(dispatch_data_t)payload { - PTFrame *frame = CFAllocatorAllocate(kCFAllocatorDefault, sizeof(PTFrame), 0); - frame->version = htonl(PTProtocolVersion1); - frame->type = htonl(type); - frame->tag = htonl(frameTag); - - if (payload) { - size_t payloadSize = dispatch_data_get_size(payload); - assert(payloadSize <= UINT32_MAX); - frame->payloadSize = htonl((uint32_t)payloadSize); - } else { - frame->payloadSize = 0; - } - - dispatch_data_t frameData = dispatch_data_create((const void*)frame, sizeof(PTFrame), queue_, ^{ - CFAllocatorDeallocate(kCFAllocatorDefault, (void*)frame); - }); - - if (payload && frame->payloadSize != 0) { - // chain frame + payload - dispatch_data_t data = dispatch_data_create_concat(frameData, payload); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(frameData); -#endif - frameData = data; - } - - return frameData; -} - - -#pragma mark - -#pragma mark Sending frames - - -- (void)sendFrameOfType:(uint32_t)frameType tag:(uint32_t)tag withPayload:(dispatch_data_t)payload overChannel:(dispatch_io_t)channel callback:(void(^)(NSError*))callback { - dispatch_data_t frame = [self createDispatchDataWithFrameOfType:frameType frameTag:tag payload:payload]; - dispatch_io_write(channel, 0, frame, queue_, ^(bool done, dispatch_data_t data, int _errno) { - if (done && callback) { - callback(_errno == 0 ? nil : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:_errno userInfo:nil]); - } - }); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(frame); -#endif -} - - -#pragma mark - -#pragma mark Receiving frames - - -- (void)readFrameOverChannel:(dispatch_io_t)channel callback:(void(^)(NSError *error, uint32_t frameType, uint32_t frameTag, uint32_t payloadSize))callback { - __block dispatch_data_t allData = NULL; - - dispatch_io_read(channel, 0, sizeof(PTFrame), queue_, ^(bool done, dispatch_data_t data, int error) { - //NSLog(@"dispatch_io_read: done=%d data=%p error=%d", done, data, error); - size_t dataSize = data ? dispatch_data_get_size(data) : 0; - - if (dataSize) { - if (!allData) { - allData = data; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_retain(allData); -#endif - } else { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_data_t allDataPrev = allData; - allData = dispatch_data_create_concat(allData, data); - dispatch_release(allDataPrev); -#else - allData = dispatch_data_create_concat(allData, data); -#endif - } - } - - if (done) { - if (error != 0) { - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], 0, 0, 0); - return; - } - - if (dataSize == 0) { - callback(nil, PTFrameTypeEndOfStream, 0, 0); - return; - } - - if (!allData || dispatch_data_get_size(allData) < sizeof(PTFrame)) { -#if PT_DISPATCH_RETAIN_RELEASE - if (allData) dispatch_release(allData); -#endif - callback([[NSError alloc] initWithDomain:Lookin_PTProtocolErrorDomain code:0 userInfo:nil], 0, 0, 0); - return; - } - - PTFrame *frame = NULL; - size_t size = 0; - - PT_PRECISE_LIFETIME dispatch_data_t contiguousData = dispatch_data_create_map(allData, (const void **)&frame, &size); // precise lifetime guarantees bytes in frame will stay valid till the end of scope -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(allData); -#endif - if (!contiguousData) { - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:ENOMEM userInfo:nil], 0, 0, 0); - return; - } - - frame->version = ntohl(frame->version); - if (frame->version != PTProtocolVersion1) { - callback([[NSError alloc] initWithDomain:Lookin_PTProtocolErrorDomain code:0 userInfo:nil], 0, 0, 0); - } else { - frame->type = ntohl(frame->type); - frame->tag = ntohl(frame->tag); - frame->payloadSize = ntohl(frame->payloadSize); - callback(nil, frame->type, frame->tag, frame->payloadSize); - } - -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(contiguousData); -#endif - } - }); -} - - -- (void)readPayloadOfSize:(size_t)payloadSize overChannel:(dispatch_io_t)channel callback:(void(^)(NSError *error, dispatch_data_t contiguousData, const uint8_t *buffer, size_t bufferSize))callback { - __block dispatch_data_t allData = NULL; - dispatch_io_read(channel, 0, payloadSize, queue_, ^(bool done, dispatch_data_t data, int error) { - //NSLog(@"dispatch_io_read: done=%d data=%p error=%d", done, data, error); - if (data == nil) { - return; - } - size_t dataSize = dispatch_data_get_size(data); - - if (dataSize) { - if (!allData) { - allData = data; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_retain(allData); -#endif - } else { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_data_t allDataPrev = allData; - allData = dispatch_data_create_concat(allData, data); - dispatch_release(allDataPrev); -#else - allData = dispatch_data_create_concat(allData, data); -#endif - } - } - - if (done) { - if (error != 0) { -#if PT_DISPATCH_RETAIN_RELEASE - if (allData) dispatch_release(allData); -#endif - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], NULL, NULL, 0); - return; - } - - if (dataSize == 0) { -#if PT_DISPATCH_RETAIN_RELEASE - if (allData) dispatch_release(allData); -#endif - callback(nil, NULL, NULL, 0); - return; - } - - uint8_t *buffer = NULL; - size_t bufferSize = 0; - PT_PRECISE_LIFETIME dispatch_data_t contiguousData = NULL; - - if (allData) { - contiguousData = dispatch_data_create_map(allData, (const void **)&buffer, &bufferSize); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(allData); allData = NULL; -#endif - if (!contiguousData) { - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:ENOMEM userInfo:nil], NULL, NULL, 0); - return; - } - } - - callback(nil, contiguousData, buffer, bufferSize); -#if PT_DISPATCH_RETAIN_RELEASE - if (contiguousData) dispatch_release(contiguousData); -#endif - } - }); -} - - -- (void)readAndDiscardDataOfSize:(size_t)size overChannel:(dispatch_io_t)channel callback:(void(^)(NSError*, BOOL))callback { - dispatch_io_read(channel, 0, size, queue_, ^(bool done, dispatch_data_t data, int error) { - if (done && callback) { - size_t dataSize = data ? dispatch_data_get_size(data) : 0; - callback(error == 0 ? nil : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], dataSize == 0); - } - }); -} - - -- (void)readFramesOverChannel:(dispatch_io_t)channel onFrame:(void(^)(NSError*, uint32_t, uint32_t, uint32_t, dispatch_block_t))onFrame { - [self readFrameOverChannel:channel callback:^(NSError *error, uint32_t type, uint32_t tag, uint32_t payloadSize) { - onFrame(error, type, tag, payloadSize, ^{ - if (type != PTFrameTypeEndOfStream) { - [self readFramesOverChannel:channel onFrame:onFrame]; - } - }); - }]; -} - - -@end - - -@interface Lookin_PTDispatchData : NSObject { - dispatch_data_t dispatchData_; -} -@end -@implementation Lookin_PTDispatchData -- (id)initWithDispatchData:(dispatch_data_t)dispatchData { - if (!(self = [super init])) return nil; - dispatchData_ = dispatchData; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_retain(dispatchData_); -#endif - return self; -} -- (void)dealloc { -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchData_) dispatch_release(dispatchData_); -#endif -} -@end - -@implementation NSData (Lookin_PTProtocol) - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-getter-return-value" - -- (dispatch_data_t)createReferencingDispatchData { - // Note: The queue is used to submit the destructor. Since we only perform an - // atomic release of self, it doesn't really matter which queue is used, thus - // we use the current calling queue. - return dispatch_data_create((const void*)self.bytes, self.length, dispatch_get_main_queue(), ^{ - // trick to have the block capture the data, thus retain/releasing - [self length]; - }); -} - -#pragma clang diagnostic pop - -+ (NSData *)dataWithContentsOfDispatchData:(dispatch_data_t)data { - if (!data) { - return nil; - } - uint8_t *buffer = NULL; - size_t bufferSize = 0; - PT_PRECISE_LIFETIME dispatch_data_t contiguousData = dispatch_data_create_map(data, (const void **)&buffer, &bufferSize); - if (!contiguousData) { - return nil; - } - - Lookin_PTDispatchData *dispatchDataRef = [[Lookin_PTDispatchData alloc] initWithDispatchData:contiguousData]; - NSData *newData = [NSData dataWithBytesNoCopy:(void*)buffer length:bufferSize freeWhenDone:NO]; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(contiguousData); -#endif - static const bool kDispatchDataRefKey; - objc_setAssociatedObject(newData, (const void*)kDispatchDataRefKey, dispatchDataRef, OBJC_ASSOCIATION_RETAIN); - - return newData; -} - -@end - - -@implementation NSDictionary (Lookin_PTProtocol) - -- (dispatch_data_t)createReferencingDispatchData { - NSError *error = nil; - NSData *plistData = [NSPropertyListSerialization dataWithPropertyList:self format:NSPropertyListBinaryFormat_v1_0 options:0 error:&error]; - if (!plistData) { - [LookInsideServerLogger logWithLevel:LookInsideServerLogLevelError - message:[NSString stringWithFormat:@"Failed to serialize property list: %@", error]]; - return nil; - } else { - return [plistData createReferencingDispatchData]; - } -} - -// Decode *data* as a peroperty list-encoded dictionary. Returns nil on failure. -+ (NSDictionary*)dictionaryWithContentsOfDispatchData:(dispatch_data_t)data { - if (!data) { - return nil; - } - uint8_t *buffer = NULL; - size_t bufferSize = 0; - PT_PRECISE_LIFETIME dispatch_data_t contiguousData = dispatch_data_create_map(data, (const void **)&buffer, &bufferSize); - if (!contiguousData) { - return nil; - } - NSDictionary *dict = [NSPropertyListSerialization propertyListWithData:[NSData dataWithBytesNoCopy:(void*)buffer length:bufferSize freeWhenDone:NO] options:NSPropertyListImmutable format:NULL error:nil]; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(contiguousData); -#endif - return dict; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Peertalk/Lookin_PTUSBHub.h b/Sources/LookinCore/Peertalk/Lookin_PTUSBHub.h deleted file mode 100644 index 30c97a7e..00000000 --- a/Sources/LookinCore/Peertalk/Lookin_PTUSBHub.h +++ /dev/null @@ -1,88 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - - - -#include -#import - -// Lookin_PTUSBDeviceDidAttachNotification -// Posted when a device has been attached. Also posted for each device that is -// already attached when the Lookin_PTUSBHub starts listening. -// -// .userInfo = { -// DeviceID = 3; -// MessageType = Attached; -// Properties = { -// ConnectionSpeed = 480000000; -// ConnectionType = USB; -// DeviceID = 3; -// LocationID = 1234567890; -// ProductID = 1234; -// SerialNumber = 0123456789abcdef0123456789abcdef01234567; -// }; -// } -// -FOUNDATION_EXPORT NSString * const Lookin_PTUSBDeviceDidAttachNotification; - -// Lookin_PTUSBDeviceDidDetachNotification -// Posted when a device has been detached. -// -// .userInfo = { -// DeviceID = 3; -// MessageType = Detached; -// } -// -FOUNDATION_EXPORT NSString * const Lookin_PTUSBDeviceDidDetachNotification; - -// NSError domain -FOUNDATION_EXPORT NSString * const Lookin_PTUSBHubErrorDomain; - -// Error codes returned with NSError.code for NSError domain Lookin_PTUSBHubErrorDomain -typedef enum { - PTUSBHubErrorBadDevice = 2, - PTUSBHubErrorConnectionRefused = 3, -} PTUSBHubError; - -@interface Lookin_PTUSBHub : NSObject - -// Shared, implicitly opened hub. -+ (Lookin_PTUSBHub*)sharedHub; - -// Connect to a TCP *port* on a device, while the actual transport is over USB. -// Upon success, *error* is nil and *channel* is a duplex I/O channel. -// You can retrieve the underlying file descriptor using -// dispatch_io_get_descriptor(channel). The dispatch_io_t channel behaves just -// like any stream type dispatch_io_t, making it possible to use the same logic -// for both USB bridged connections and e.g. ethernet-based connections. -// -// *onStart* is called either when a connection failed, in which case the error -// argument is non-nil, or when the connection was successfully established (the -// error argument is nil). Must not be NULL. -// -// *onEnd* is called when a connection was open and just did close. If the error -// argument is non-nil, the channel closed because of an error. Pass NULL for no -// callback. -// -- (void)connectToDevice:(NSNumber*)deviceID - port:(int)port - onStart:(void(^)(NSError *error, dispatch_io_t channel))onStart - onEnd:(void(^)(NSError *error))onEnd; - -// Start listening for devices. You only need to invoke this method on custom -// instances to start receiving notifications. The shared instance returned from -// +sharedHub is always in listening mode. -// -// *onStart* is called either when the system failed to start listening, in -// which case the error argument is non-nil, or when the receiver is listening. -// Pass NULL for no callback. -// -// *onEnd* is called when listening stopped. If the error argument is non-nil, -// listening stopped because of an error. Pass NULL for no callback. -// -- (void)listenOnQueue:(dispatch_queue_t)queue - onStart:(void(^)(NSError*))onStart - onEnd:(void(^)(NSError*))onEnd; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Peertalk/Lookin_PTUSBHub.m b/Sources/LookinCore/Peertalk/Lookin_PTUSBHub.m deleted file mode 100644 index de032569..00000000 --- a/Sources/LookinCore/Peertalk/Lookin_PTUSBHub.m +++ /dev/null @@ -1,682 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -#import "Lookin_PTUSBHub.h" - - - -#import "LookInsideServerLogger.h" -#import "Lookin_PTPrivate.h" - -#include -#include -#include -#include -#include - -NSString * const Lookin_PTUSBHubErrorDomain = @"PTUSBHubError"; - -typedef uint32_t USBMuxPacketType; -enum { - USBMuxPacketTypeResult = 1, - USBMuxPacketTypeConnect = 2, - USBMuxPacketTypeListen = 3, - USBMuxPacketTypeDeviceAdd = 4, - USBMuxPacketTypeDeviceRemove = 5, - // ? = 6, - // ? = 7, - USBMuxPacketTypePlistPayload = 8, -}; - -typedef uint32_t USBMuxPacketProtocol; -enum { - USBMuxPacketProtocolBinary = 0, - USBMuxPacketProtocolPlist = 1, -}; - -typedef uint32_t USBMuxReplyCode; -enum { - USBMuxReplyCodeOK = 0, - USBMuxReplyCodeBadCommand = 1, - USBMuxReplyCodeBadDevice = 2, - USBMuxReplyCodeConnectionRefused = 3, - // ? = 4, - // ? = 5, - USBMuxReplyCodeBadVersion = 6, -}; - - -typedef struct usbmux_packet { - uint32_t size; - USBMuxPacketProtocol protocol; - USBMuxPacketType type; - uint32_t tag; - char data[0]; -} __attribute__((__packed__)) usbmux_packet_t; - -static const uint32_t kUsbmuxPacketMaxPayloadSize = UINT32_MAX - (uint32_t)sizeof(usbmux_packet_t); - - -static uint32_t usbmux_packet_payload_size(usbmux_packet_t *upacket) { - return upacket->size - sizeof(usbmux_packet_t); -} - - -static void *usbmux_packet_payload(usbmux_packet_t *upacket) { - return (void*)upacket->data; -} - - -static void usbmux_packet_set_payload(usbmux_packet_t *upacket, - const void *payload, - uint32_t payloadLength) -{ - memcpy(usbmux_packet_payload(upacket), payload, payloadLength); -} - - -static usbmux_packet_t *usbmux_packet_alloc(uint32_t payloadSize) { - assert(payloadSize <= kUsbmuxPacketMaxPayloadSize); - uint32_t upacketSize = sizeof(usbmux_packet_t) + payloadSize; - usbmux_packet_t *upacket = CFAllocatorAllocate(kCFAllocatorDefault, upacketSize, 0); - memset(upacket, 0, sizeof(usbmux_packet_t)); - upacket->size = upacketSize; - return upacket; -} - - -static usbmux_packet_t *usbmux_packet_create(USBMuxPacketProtocol protocol, - USBMuxPacketType type, - uint32_t tag, - const void *payload, - uint32_t payloadSize) -{ - usbmux_packet_t *upacket = usbmux_packet_alloc(payloadSize); - if (!upacket) { - return NULL; - } - - upacket->protocol = protocol; - upacket->type = type; - upacket->tag = tag; - - if (payload && payloadSize) { - usbmux_packet_set_payload(upacket, payload, (uint32_t)payloadSize); - } - - return upacket; -} - - -static void usbmux_packet_free(usbmux_packet_t *upacket) { - CFAllocatorDeallocate(kCFAllocatorDefault, upacket); -} - - -NSString * const Lookin_PTUSBDeviceDidAttachNotification = @"Lookin_PTUSBDeviceDidAttachNotification"; -NSString * const Lookin_PTUSBDeviceDidDetachNotification = @"Lookin_PTUSBDeviceDidDetachNotification"; - -static NSString *kPlistPacketTypeListen = @"Listen"; -static NSString *kPlistPacketTypeConnect = @"Connect"; - - -// Represents a channel of communication between the host process and a remote -// (device) system. In practice, a Lookin_PTUSBChannel is connected to a usbmuxd -// endpoint which is configured to either listen for device changes (the -// PTUSBHub's channel is usually configured as a device notification listener) or -// configured as a TCP bridge (e.g. channels returned from PTUSBHub's -// connectToDevice:port:callback:). You should not create channels yourself, but -// let Lookin_PTUSBHub provide you with already configured channels. -@interface Lookin_PTUSBChannel : NSObject { - dispatch_io_t channel_; - dispatch_queue_t queue_; - uint32_t nextPacketTag_; - NSMutableDictionary *responseQueue_; - BOOL autoReadPackets_; - BOOL isReadingPackets_; -} - -// The underlying dispatch I/O channel. This is handy if you want to handle your -// own I/O logic without Lookin_PTUSBChannel. Remember to dispatch_retain() the channel -// if you plan on using it as it might be released from the Lookin_PTUSBChannel at any -// point in time. -@property (readonly) dispatch_io_t dispatchChannel; - -// The underlying file descriptor. -@property (readonly) dispatch_fd_t fileDescriptor; - -// Send data -- (void)sendDispatchData:(dispatch_data_t)data callback:(void(^)(NSError*))callback; -- (void)sendData:(NSData*)data callback:(void(^)(NSError*))callback; - -// Read data -- (void)readFromOffset:(off_t)offset length:(size_t)length callback:(void(^)(NSError *error, dispatch_data_t data))callback; - -// Close the channel, preventing further reads and writes, but letting currently -// queued reads and writes finish. -- (void)cancel; - -// Close the channel, preventing further reads and writes, immediately -// terminating any ongoing reads and writes. -- (void)stop; - -@end - - -@interface Lookin_PTUSBChannel (Private) - -+ (NSDictionary*)packetDictionaryWithPacketType:(NSString*)messageType payload:(NSDictionary*)payload; -- (BOOL)openOnQueue:(dispatch_queue_t)queue error:(NSError**)error onEnd:(void(^)(NSError *error))onEnd; -- (void)listenWithBroadcastHandler:(void(^)(NSDictionary *packet))broadcastHandler callback:(void(^)(NSError*))callback; -- (BOOL)errorFromPlistResponse:(NSDictionary*)packet error:(NSError**)error; -- (uint32_t)nextPacketTag; -- (void)sendPacketOfType:(USBMuxPacketType)type overProtocol:(USBMuxPacketProtocol)protocol tag:(uint32_t)tag payload:(NSData*)payload callback:(void(^)(NSError*))callback; -- (void)sendPacket:(NSDictionary*)packet tag:(uint32_t)tag callback:(void(^)(NSError *error))callback; -- (void)sendRequest:(NSDictionary*)packet callback:(void(^)(NSError *error, NSDictionary *responsePacket))callback; -- (void)scheduleReadPacketWithCallback:(void(^)(NSError *error, NSDictionary *packet, uint32_t packetTag))callback; -- (void)scheduleReadPacketWithBroadcastHandler:(void(^)(NSDictionary *packet))broadcastHandler; -- (void)setNeedsReadingPacket; -@end - - -@interface Lookin_PTUSBHub () { - Lookin_PTUSBChannel *channel_; -} -- (void)handleBroadcastPacket:(NSDictionary*)packet; -@end - - -@implementation Lookin_PTUSBHub - - -+ (Lookin_PTUSBHub*)sharedHub { - static Lookin_PTUSBHub *gSharedHub; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - gSharedHub = [Lookin_PTUSBHub new]; - [gSharedHub listenOnQueue:dispatch_get_main_queue() onStart:^(NSError *error) { - if (error) { - [LookInsideServerLogger logWithLevel:LookInsideServerLogLevelError - message:[NSString stringWithFormat:@"Lookin_PTUSBHub failed to initialize: %@", error]]; - } - } onEnd:nil]; - }); - return gSharedHub; -} - - -- (id)init { - if (!(self = [super init])) return nil; - - return self; -} - - -- (void)listenOnQueue:(dispatch_queue_t)queue onStart:(void(^)(NSError*))onStart onEnd:(void(^)(NSError*))onEnd { - if (channel_) { - if (onStart) onStart(nil); - return; - } - channel_ = [Lookin_PTUSBChannel new]; - NSError *error = nil; - if ([channel_ openOnQueue:queue error:&error onEnd:onEnd]) { - [channel_ listenWithBroadcastHandler:^(NSDictionary *packet) { [self handleBroadcastPacket:packet]; } callback:onStart]; - } else if (onStart) { - onStart(error); - } -} - - -- (void)connectToDevice:(NSNumber*)deviceID port:(int)port onStart:(void(^)(NSError*, dispatch_io_t))onStart onEnd:(void(^)(NSError*))onEnd { - Lookin_PTUSBChannel *channel = [Lookin_PTUSBChannel new]; - NSError *error = nil; - - if (![channel openOnQueue:dispatch_get_main_queue() error:&error onEnd:onEnd]) { - onStart(error, nil); - return; - } - - port = ((port<<8) & 0xFF00) | (port>>8); // limit - - NSDictionary *packet = [Lookin_PTUSBChannel packetDictionaryWithPacketType:kPlistPacketTypeConnect - payload:[NSDictionary dictionaryWithObjectsAndKeys: - deviceID, @"DeviceID", - [NSNumber numberWithInt:port], @"PortNumber", - nil]]; - - [channel sendRequest:packet callback:^(NSError *error_, NSDictionary *responsePacket) { - NSError *error = error_; - [channel errorFromPlistResponse:responsePacket error:&error]; - onStart(error, (error ? nil : channel.dispatchChannel) ); - }]; -} - - -- (void)handleBroadcastPacket:(NSDictionary*)packet { - NSString *messageType = [packet objectForKey:@"MessageType"]; - - if ([@"Attached" isEqualToString:messageType]) { - [[NSNotificationCenter defaultCenter] postNotificationName:Lookin_PTUSBDeviceDidAttachNotification object:self userInfo:packet]; - } else if ([@"Detached" isEqualToString:messageType]) { - [[NSNotificationCenter defaultCenter] postNotificationName:Lookin_PTUSBDeviceDidDetachNotification object:self userInfo:packet]; - } else { - if ([LookInsideServerLogger shouldLogAtLevel:LookInsideServerLogLevelWarning]) { - [LookInsideServerLogger logWithLevel:LookInsideServerLogLevelWarning - message:[NSString stringWithFormat:@"Unhandled broadcast message: %@", packet]]; - } - } -} - - -@end - -#pragma mark - - -@implementation Lookin_PTUSBChannel - -+ (NSDictionary*)packetDictionaryWithPacketType:(NSString*)messageType payload:(NSDictionary*)payload { - NSDictionary *packet = nil; - - static NSString *bundleName = nil; - static NSString *bundleVersion = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSDictionary *infoDict = [NSBundle mainBundle].infoDictionary; - if (infoDict) { - bundleName = [infoDict objectForKey:@"CFBundleName"]; - bundleVersion = [[infoDict objectForKey:@"CFBundleVersion"] description]; - } - }); - - if (bundleName) { - packet = [NSDictionary dictionaryWithObjectsAndKeys: - messageType, @"MessageType", - bundleName, @"ProgName", - bundleVersion, @"ClientVersionString", - nil]; - } else { - packet = [NSDictionary dictionaryWithObjectsAndKeys:messageType, @"MessageType", nil]; - } - - if (payload) { - NSMutableDictionary *mpacket = [NSMutableDictionary dictionaryWithDictionary:payload]; - [mpacket addEntriesFromDictionary:packet]; - packet = mpacket; - } - - return packet; -} - - -- (id)init { - if (!(self = [super init])) return nil; - - return self; -} - - -- (void)dealloc { - //NSLog(@"dealloc %@", self); - if (channel_) { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(channel_); -#endif - channel_ = nil; - } -} - - -- (BOOL)valid { - return !!channel_; -} - - -- (dispatch_io_t)dispatchChannel { - return channel_; -} - - -- (dispatch_fd_t)fileDescriptor { - return dispatch_io_get_descriptor(channel_); -} - - -- (BOOL)openOnQueue:(dispatch_queue_t)queue error:(NSError**)error onEnd:(void(^)(NSError*))onEnd { - assert(queue != nil); - assert(channel_ == nil); - queue_ = queue; - - // Create socket - dispatch_fd_t fd = socket(AF_UNIX, SOCK_STREAM, 0); - if (fd == -1) { - if (error) *error = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]; - return NO; - } - - // prevent SIGPIPE - int on = 1; - setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); - - // Connect socket - struct sockaddr_un addr; - addr.sun_family = AF_UNIX; - strcpy(addr.sun_path, "/var/run/usbmuxd"); - socklen_t socklen = sizeof(addr); - if (connect(fd, (struct sockaddr*)&addr, socklen) == -1) { - if (error) *error = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]; - return NO; - } - - channel_ = dispatch_io_create(DISPATCH_IO_STREAM, fd, queue_, ^(int error) { - close(fd); - if (onEnd) { - onEnd(error == 0 ? nil : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil]); - } - }); - - return YES; -} - - -- (void)listenWithBroadcastHandler:(void(^)(NSDictionary *packet))broadcastHandler callback:(void(^)(NSError*))callback { - autoReadPackets_ = YES; - [self scheduleReadPacketWithBroadcastHandler:broadcastHandler]; - - NSDictionary *packet = [Lookin_PTUSBChannel packetDictionaryWithPacketType:kPlistPacketTypeListen payload:nil]; - - [self sendRequest:packet callback:^(NSError *error_, NSDictionary *responsePacket) { - if (!callback) - return; - - NSError *error = error_; - [self errorFromPlistResponse:responsePacket error:&error]; - - callback(error); - }]; -} - - -- (BOOL)errorFromPlistResponse:(NSDictionary*)packet error:(NSError**)error { - if (!*error) { - NSNumber *n = [packet objectForKey:@"Number"]; - - if (!n) { - *error = [NSError errorWithDomain:Lookin_PTUSBHubErrorDomain code:(n ? n.integerValue : 0) userInfo:nil]; - return NO; - } - - USBMuxReplyCode replyCode = (USBMuxReplyCode)n.integerValue; - if (replyCode != 0) { - NSString *errmessage = @"Unspecified error"; - switch (replyCode) { - case USBMuxReplyCodeBadCommand: errmessage = @"illegal command"; break; - case USBMuxReplyCodeBadDevice: errmessage = @"unknown device"; break; - case USBMuxReplyCodeConnectionRefused: errmessage = @"connection refused"; break; - case USBMuxReplyCodeBadVersion: errmessage = @"invalid version"; break; - default: break; - } - *error = [NSError errorWithDomain:Lookin_PTUSBHubErrorDomain code:replyCode userInfo:[NSDictionary dictionaryWithObject:errmessage forKey:NSLocalizedDescriptionKey]]; - return NO; - } - } - return YES; -} - - -- (uint32_t)nextPacketTag { - return ++nextPacketTag_; -} - - -- (void)sendRequest:(NSDictionary*)packet callback:(void(^)(NSError*, NSDictionary*))callback { - uint32_t tag = [self nextPacketTag]; - [self sendPacket:packet tag:tag callback:^(NSError *error) { - if (error) { - callback(error, nil); - return; - } - // TODO: timeout un-triggered callbacks in responseQueue_ - if (!self->responseQueue_) self->responseQueue_ = [NSMutableDictionary new]; - [self->responseQueue_ setObject:callback forKey:[NSNumber numberWithUnsignedInt:tag]]; - }]; - - // We are awaiting a response - [self setNeedsReadingPacket]; -} - - -- (void)setNeedsReadingPacket { - if (!isReadingPackets_) { - [self scheduleReadPacketWithBroadcastHandler:nil]; - } -} - - -- (void)scheduleReadPacketWithBroadcastHandler:(void(^)(NSDictionary *packet))broadcastHandler { - assert(isReadingPackets_ == NO); - - [self scheduleReadPacketWithCallback:^(NSError *error, NSDictionary *packet, uint32_t packetTag) { - // Interpret the package we just received - if (packetTag == 0) { - // Broadcast message - //NSLog(@"Received broadcast: %@", packet); - if (broadcastHandler) broadcastHandler(packet); - } else if (self->responseQueue_) { - // Reply - NSNumber *key = [NSNumber numberWithUnsignedInt:packetTag]; - void(^requestCallback)(NSError*,NSDictionary*) = [self->responseQueue_ objectForKey:key]; - if (requestCallback) { - [self->responseQueue_ removeObjectForKey:key]; - requestCallback(error, packet); - } else { - if ([LookInsideServerLogger shouldLogAtLevel:LookInsideServerLogLevelWarning]) { - [LookInsideServerLogger logWithLevel:LookInsideServerLogLevelWarning - message:[NSString stringWithFormat:@"Ignoring reply packet for which there is no registered callback. Packet => %@", packet]]; - } - } - } - - // Schedule reading another incoming package - if (self->autoReadPackets_) { - [self scheduleReadPacketWithBroadcastHandler:broadcastHandler]; - } - }]; -} - - -- (void)scheduleReadPacketWithCallback:(void(^)(NSError*, NSDictionary*, uint32_t))callback { - static usbmux_packet_t ref_upacket; - isReadingPackets_ = YES; - - // Read the first `sizeof(ref_upacket.size)` bytes off the channel_ - dispatch_io_read(channel_, 0, sizeof(ref_upacket.size), queue_, ^(bool done, dispatch_data_t data, int error) { - //NSLog(@"dispatch_io_read 0,4: done=%d data=%p error=%d", done, data, error); - - if (!done) - return; - - if (error) { - self->isReadingPackets_ = NO; - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], nil, 0); - return; - } - - // Read size of incoming usbmux_packet_t - uint32_t upacket_len = 0; - char *buffer = NULL; - size_t buffer_size = 0; - PT_PRECISE_LIFETIME_UNUSED dispatch_data_t map_data = dispatch_data_create_map(data, (const void **)&buffer, &buffer_size); // objc_precise_lifetime guarantees 'map_data' isn't released before memcpy has a chance to do its thing - assert(buffer_size == sizeof(ref_upacket.size)); - assert(sizeof(upacket_len) == sizeof(ref_upacket.size)); - memcpy((void *)&(upacket_len), (const void *)buffer, buffer_size); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(map_data); -#endif - - // Allocate a new usbmux_packet_t for the expected size - uint32_t payloadLength = upacket_len - (uint32_t)sizeof(usbmux_packet_t); - usbmux_packet_t *upacket = usbmux_packet_alloc(payloadLength); - - // Read rest of the incoming usbmux_packet_t - off_t offset = sizeof(ref_upacket.size); - dispatch_io_read(self->channel_, offset, (size_t)(upacket->size - offset), self->queue_, ^(bool done, dispatch_data_t data, int error) { - //NSLog(@"dispatch_io_read X,Y: done=%d data=%p error=%d", done, data, error); - - if (!done) { - return; - } - - self->isReadingPackets_ = NO; - - if (error) { - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], nil, 0); - usbmux_packet_free(upacket); - return; - } - - if (upacket_len > kUsbmuxPacketMaxPayloadSize) { - callback( - [[NSError alloc] initWithDomain:Lookin_PTUSBHubErrorDomain code:1 userInfo:@{ - NSLocalizedDescriptionKey:@"Received a packet that is too large"}], - nil, - 0 - ); - usbmux_packet_free(upacket); - return; - } - - // Copy read bytes onto our usbmux_packet_t - char *buffer = NULL; - size_t buffer_size = 0; - PT_PRECISE_LIFETIME_UNUSED dispatch_data_t map_data = dispatch_data_create_map(data, (const void **)&buffer, &buffer_size); - assert(buffer_size == upacket->size - offset); - memcpy(((void *)(upacket))+offset, (const void *)buffer, buffer_size); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(map_data); -#endif - - // We only support plist protocol - if (upacket->protocol != USBMuxPacketProtocolPlist) { - callback([[NSError alloc] initWithDomain:Lookin_PTUSBHubErrorDomain code:0 userInfo:[NSDictionary dictionaryWithObject:@"Unexpected package protocol" forKey:NSLocalizedDescriptionKey]], nil, upacket->tag); - usbmux_packet_free(upacket); - return; - } - - // Only one type of packet in the plist protocol - if (upacket->type != USBMuxPacketTypePlistPayload) { - callback([[NSError alloc] initWithDomain:Lookin_PTUSBHubErrorDomain code:0 userInfo:[NSDictionary dictionaryWithObject:@"Unexpected package type" forKey:NSLocalizedDescriptionKey]], nil, upacket->tag); - usbmux_packet_free(upacket); - return; - } - - // Try to decode any payload as plist - NSError *err = nil; - NSDictionary *dict = nil; - if (usbmux_packet_payload_size(upacket)) { - dict = [NSPropertyListSerialization propertyListWithData:[NSData dataWithBytesNoCopy:usbmux_packet_payload(upacket) length:usbmux_packet_payload_size(upacket) freeWhenDone:NO] options:NSPropertyListImmutable format:NULL error:&err]; - } - - // Invoke callback - callback(err, dict, upacket->tag); - usbmux_packet_free(upacket); - }); - }); -} - - -- (void)sendPacketOfType:(USBMuxPacketType)type - overProtocol:(USBMuxPacketProtocol)protocol - tag:(uint32_t)tag - payload:(NSData*)payload - callback:(void(^)(NSError*))callback -{ - assert(payload.length <= kUsbmuxPacketMaxPayloadSize); - usbmux_packet_t *upacket = usbmux_packet_create( - protocol, - type, - tag, - payload ? payload.bytes : nil, - (uint32_t)(payload ? payload.length : 0) - ); - dispatch_data_t data = dispatch_data_create((const void*)upacket, upacket->size, queue_, ^{ - // Free packet when data is freed - usbmux_packet_free(upacket); - }); - //NSData *data1 = [NSData dataWithBytesNoCopy:(void*)upacket length:upacket->size freeWhenDone:NO]; - //[data1 writeToFile:[NSString stringWithFormat:@"/Users/rsms/c-packet-%u.data", tag] atomically:NO]; - [self sendDispatchData:data callback:callback]; -} - - -- (void)sendPacket:(NSDictionary*)packet tag:(uint32_t)tag callback:(void(^)(NSError*))callback { - NSError *error = nil; - // NSPropertyListBinaryFormat_v1_0 - NSData *plistData = [NSPropertyListSerialization dataWithPropertyList:packet format:NSPropertyListXMLFormat_v1_0 options:0 error:&error]; - if (!plistData) { - callback(error); - } else { - [self sendPacketOfType:USBMuxPacketTypePlistPayload overProtocol:USBMuxPacketProtocolPlist tag:tag payload:plistData callback:callback]; - } -} - - -- (void)sendDispatchData:(dispatch_data_t)data callback:(void(^)(NSError*))callback { - off_t offset = 0; - dispatch_io_write(channel_, offset, data, queue_, ^(bool done, dispatch_data_t data, int _errno) { - //NSLog(@"dispatch_io_write: done=%d data=%p error=%d", done, data, error); - if (!done) - return; - if (callback) { - NSError *err = nil; - if (_errno) err = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:_errno userInfo:nil]; - callback(err); - } - }); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(data); // Release our ref. A ref is still held by dispatch_io_write -#endif -} - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-getter-return-value" - -- (void)sendData:(NSData*)data callback:(void(^)(NSError*))callback { - dispatch_data_t ddata = dispatch_data_create((const void*)data.bytes, data.length, queue_, ^{ - // trick to have the block capture and retain the data - [data length]; - }); - [self sendDispatchData:ddata callback:callback]; -} - -#pragma clang diagnostic pop - -- (void)readFromOffset:(off_t)offset length:(size_t)length callback:(void(^)(NSError *error, dispatch_data_t data))callback { - dispatch_io_read(channel_, offset, length, queue_, ^(bool done, dispatch_data_t data, int _errno) { - if (!done) - return; - - NSError *error = nil; - if (_errno != 0) { - error = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:_errno userInfo:nil]; - } - - callback(error, data); - }); -} - - -- (void)cancel { - if (channel_) { - dispatch_io_close(channel_, 0); - } -} - - -- (void)stop { - if (channel_) { - dispatch_io_close(channel_, DISPATCH_IO_STOP); - } -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Peertalk/Peertalk.h b/Sources/LookinCore/Peertalk/Peertalk.h deleted file mode 100644 index 82493292..00000000 --- a/Sources/LookinCore/Peertalk/Peertalk.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Peertalk.h -// Peertalk -// -// Created by Marek Cirkos on 12/04/2016. -// -// - - - -#import - -//! Project version number for Peertalk. -FOUNDATION_EXPORT double PeertalkVersionNumber; - -//! Project version string for Peertalk. -FOUNDATION_EXPORT const unsigned char PeertalkVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - -#import "Lookin_PTChannel.h" -#import "Lookin_PTProtocol.h" -#import "Lookin_PTUSBHub.h" - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Shim/LookinRuntimeShim.m b/Sources/LookinCore/Shim/LookinRuntimeShim.m new file mode 100644 index 00000000..8f199aab --- /dev/null +++ b/Sources/LookinCore/Shim/LookinRuntimeShim.m @@ -0,0 +1,194 @@ +#ifdef SHOULD_COMPILE_LOOKIN_SERVER + +// +// LookinRuntimeShim.m +// LookinCore +// +// See LookinRuntimeShim.h. This file stays Objective-C on purpose. +// + +#import "LookinRuntimeShim.h" +#import "LookinDefines.h" +#import +#if __has_feature(ptrauth_calls) +#import +#endif + +NSException *LookinCatchException(NS_NOESCAPE void (^block)(void)) { + @try { + block(); + } @catch (NSException *exception) { + return exception; + } + return nil; +} + +NSString *LookinMethodReturnType(id target, SEL selector, NSUInteger *outArgumentCount) { + NSMethodSignature *signature = [target methodSignatureForSelector:selector]; + if (!signature) { + return nil; + } + if (outArgumentCount) { + *outArgumentCount = signature.numberOfArguments; + } + return [NSString stringWithUTF8String:signature.methodReturnType]; +} + +NSString *LookinMethodArgumentType(id target, SEL selector, NSUInteger index) { + NSMethodSignature *signature = [target methodSignatureForSelector:selector]; + if (!signature || index + 2 >= signature.numberOfArguments) { + return nil; + } + return [NSString stringWithUTF8String:[signature getArgumentTypeAtIndex:index + 2]]; +} + +static NSError *LookinInvokeError(NSString *reason) { + return [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_Inner userInfo:@{NSLocalizedDescriptionKey: reason}]; +} + +static BOOL LookinIsObjectType(const char *type) { + return type[0] == _C_ID || type[0] == _C_CLASS; +} + +id LookinInvoke(id target, SEL selector, NSArray *arguments, NSError **outError) { + NSMethodSignature *signature = [target methodSignatureForSelector:selector]; + if (!signature) { + if (outError) { + *outError = LookinInvokeError([NSString stringWithFormat:@"%@ does not respond to %@", [target class], NSStringFromSelector(selector)]); + } + return nil; + } + NSUInteger parameterCount = signature.numberOfArguments - 2; + if (arguments.count != parameterCount) { + if (outError) { + *outError = LookinInvokeError([NSString stringWithFormat:@"%@ takes %lu arguments, got %lu", NSStringFromSelector(selector), (unsigned long)parameterCount, (unsigned long)arguments.count]); + } + return nil; + } + NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; + invocation.target = target; + invocation.selector = selector; + // Argument buffers must outlive -invoke; NSInvocation copies them in + // -setArgument:atIndex:, so a scoped buffer per argument is enough. + for (NSUInteger index = 0; index < parameterCount; index++) { + const char *type = [signature getArgumentTypeAtIndex:index + 2]; + id argument = arguments[index]; + if (LookinIsObjectType(type)) { + __unsafe_unretained id object = (argument == [NSNull null]) ? nil : argument; + [invocation setArgument:&object atIndex:index + 2]; + continue; + } + if (![argument isKindOfClass:[NSValue class]]) { + if (outError) { + *outError = LookinInvokeError([NSString stringWithFormat:@"argument %lu of %@ needs an NSValue", (unsigned long)index, NSStringFromSelector(selector)]); + } + return nil; + } + NSUInteger parameterSize = 0; + NSGetSizeAndAlignment(type, ¶meterSize, NULL); + NSUInteger valueSize = 0; + NSGetSizeAndAlignment([(NSValue *)argument objCType], &valueSize, NULL); + if (parameterSize != valueSize) { + if (outError) { + *outError = LookinInvokeError([NSString stringWithFormat:@"argument %lu of %@ has size %lu, the parameter %lu", (unsigned long)index, NSStringFromSelector(selector), (unsigned long)valueSize, (unsigned long)parameterSize]); + } + return nil; + } + void *buffer = calloc(1, parameterSize); + [(NSValue *)argument getValue:buffer size:parameterSize]; + [invocation setArgument:buffer atIndex:index + 2]; + free(buffer); + } + [invocation invoke]; + + const char *returnType = signature.methodReturnType; + if (returnType[0] == _C_VOID) { + return nil; + } + if (LookinIsObjectType(returnType)) { + __unsafe_unretained id returnValue = nil; + [invocation getReturnValue:&returnValue]; + return returnValue; + } + NSUInteger returnLength = signature.methodReturnLength; + void *returnBuffer = calloc(1, returnLength); + [invocation getReturnValue:returnBuffer]; + NSValue *value = [NSValue valueWithBytes:returnBuffer objCType:returnType]; + free(returnBuffer); + return value; +} + +NSString *LookinEncoding(LookinEncodedType type) { + switch (type) { + case LookinEncodedTypeVoid: + return @(@encode(void)); + case LookinEncodedTypeId: + return @(@encode(id)); + case LookinEncodedTypeClass: + return @(@encode(Class)); + case LookinEncodedTypeSEL: + return @(@encode(SEL)); + case LookinEncodedTypeChar: + return @(@encode(char)); + case LookinEncodedTypeInt: + return @(@encode(int)); + case LookinEncodedTypeShort: + return @(@encode(short)); + case LookinEncodedTypeLong: + return @(@encode(long)); + case LookinEncodedTypeLongLong: + return @(@encode(long long)); + case LookinEncodedTypeUnsignedChar: + return @(@encode(unsigned char)); + case LookinEncodedTypeUnsignedInt: + return @(@encode(unsigned int)); + case LookinEncodedTypeUnsignedShort: + return @(@encode(unsigned short)); + case LookinEncodedTypeUnsignedLong: + return @(@encode(unsigned long)); + case LookinEncodedTypeUnsignedLongLong: + return @(@encode(unsigned long long)); + case LookinEncodedTypeFloat: + return @(@encode(float)); + case LookinEncodedTypeDouble: + return @(@encode(double)); + case LookinEncodedTypeBOOL: + return @(@encode(BOOL)); + case LookinEncodedTypeCGPoint: + return @(@encode(CGPoint)); + case LookinEncodedTypeCGVector: + return @(@encode(CGVector)); + case LookinEncodedTypeCGSize: + return @(@encode(CGSize)); + case LookinEncodedTypeCGRect: + return @(@encode(CGRect)); + case LookinEncodedTypeCGAffineTransform: + return @(@encode(CGAffineTransform)); + case LookinEncodedTypeLookinInsets: + return @(@encode(LookinInsets)); +#if TARGET_OS_IPHONE + case LookinEncodedTypeUIOffset: + return @(@encode(UIOffset)); +#endif + } + return @""; +} + +const void *LookinStripProcessIndependentDataPointer(const void *pointer) { +#if __has_feature(ptrauth_calls) + return ptrauth_strip(pointer, ptrauth_key_process_independent_data); +#else + return pointer; +#endif +} + +SEL LookinReadSelectorIvar(id object, const char *ivarName) { + Ivar ivar = class_getInstanceVariable([object class], ivarName); + if (!ivar) { + return NULL; + } + void *raw = *(void **)((uint8_t *)(__bridge void *)object + ivar_getOffset(ivar)); + return (SEL)LookinStripProcessIndependentDataPointer(raw); +} + +#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinAttrIdentifiers.h b/Sources/LookinCore/include/LookinAttrIdentifiers.h similarity index 100% rename from Sources/LookinCore/LookinAttrIdentifiers.h rename to Sources/LookinCore/include/LookinAttrIdentifiers.h diff --git a/Sources/LookinCore/LookinAttrType.h b/Sources/LookinCore/include/LookinAttrType.h similarity index 100% rename from Sources/LookinCore/LookinAttrType.h rename to Sources/LookinCore/include/LookinAttrType.h diff --git a/Sources/LookinCore/LookinCodingValueType.h b/Sources/LookinCore/include/LookinCodingValueType.h similarity index 100% rename from Sources/LookinCore/LookinCodingValueType.h rename to Sources/LookinCore/include/LookinCodingValueType.h diff --git a/Sources/LookinCore/include/LookinCore.h b/Sources/LookinCore/include/LookinCore.h index ceada143..0c6a3542 100644 --- a/Sources/LookinCore/include/LookinCore.h +++ b/Sources/LookinCore/include/LookinCore.h @@ -1,23 +1,25 @@ +// Umbrella header of the LookinCore module. +// +// Only C declarations live here: the macros and port ranges, the exported +// attribute identifier constants, the archived enums, the NSStringFrom* +// prototypes the Swift `@_cdecl` functions export, and the runtime shim +// (NSException catching, NSInvocation, @encode, ptrauth_strip). The model +// classes and categories are plain Swift in LookinCoreImpl, declared with +// `@objc(OriginalName)` and their original selectors, so archived class +// names and Objective-C callers are unchanged. +// +// SwiftPM does not pass a target's C defines to Swift's Clang importer, so the +// guard the headers are wrapped in is defined here first. + #ifndef SHOULD_COMPILE_LOOKIN_SERVER #define SHOULD_COMPILE_LOOKIN_SERVER 1 #endif -#import "../LookinDefines.h" -#import "../LookinHitTargetSize.h" -#import "../LookinAppInfo.h" -#import "../LookinAttribute.h" -#import "../LookinAttributesGroup.h" -#import "../LookinAttributesSection.h" -#import "../LookinAttrIdentifiers.h" -#import "../LookinAttrType.h" -#import "../LookinConnectionAttachment.h" -#import "../LookinConnectionResponseAttachment.h" -#import "../LookinCustomDisplayItemInfo.h" -#import "../LookinDashboardBlueprint.h" -#import "../LookinDisplayItem.h" -#import "../LookinDisplayItemDetail.h" -#import "../LookinHierarchyFile.h" -#import "../LookinHierarchyInfo.h" -#import "../LookinObject.h" -#import "../Peertalk/Lookin_PTChannel.h" -#import "../Peertalk/Lookin_PTUSBHub.h" +#import "LookinAttrIdentifiers.h" +#import "LookinAttrType.h" +#import "LookinCodingValueType.h" +#import "LookinCoreTypes.h" +#import "LookinDefines.h" +#import "LookinHitTargetSize.h" +#import "LookinRuntimeShim.h" +#import "NSValue+Lookin.h" diff --git a/Sources/LookinCore/include/LookinCoreTypes.h b/Sources/LookinCore/include/LookinCoreTypes.h new file mode 100644 index 00000000..122e02b6 --- /dev/null +++ b/Sources/LookinCore/include/LookinCoreTypes.h @@ -0,0 +1,178 @@ +#ifdef SHOULD_COMPILE_LOOKIN_SERVER + +// +// LookinCoreTypes.h +// Lookin +// +// The enums of the LookinCore model classes, moved verbatim out of the class +// headers when those classes became plain Swift. Their raw values travel on +// the wire and inside .lookin archives: never renumber a case. +// + +#import + +// From LookinDisplayItem.h. + +typedef NS_ENUM(NSUInteger, LookinDisplayItemImageEncodeType) { + LookinDisplayItemImageEncodeTypeNone, // 不进行 encode + LookinDisplayItemImageEncodeTypeNSData, // 转换为 NSData + LookinDisplayItemImageEncodeTypeImage // 使用 NSImage / UIImage 自身的 encode 方法 +}; + +typedef NS_ENUM(NSUInteger, LookinDoNotFetchScreenshotReason) { + // 可以同步截图 + LookinFetchScreenshotPermitted, + // layer 尺寸过大 + LookinDoNotFetchScreenshotForTooLarge, + // 被 LookinServer 的用户配置拒绝 + LookinDoNotFetchScreenshotForUserConfig, + // 节点本身没有像素(layout guide 等)——不发截图任务,但照常进预览画线框 + LookinDoNotFetchScreenshotForNoPixels +}; + +typedef NS_ENUM(NSUInteger, LookinDisplayItemProperty) { + // 当初次设置 delegate 对象时,会立即以该值触发一次 displayItem:propertyDidChange: + LookinDisplayItemProperty_None, + LookinDisplayItemProperty_FrameToRoot, + LookinDisplayItemProperty_DisplayingInHierarchy, + LookinDisplayItemProperty_InHiddenHierarchy, + LookinDisplayItemProperty_IsExpandable, + LookinDisplayItemProperty_IsExpanded, + LookinDisplayItemProperty_SoloScreenshot, + LookinDisplayItemProperty_GroupScreenshot, + LookinDisplayItemProperty_IsSelected, + LookinDisplayItemProperty_IsHovered, + LookinDisplayItemProperty_AvoidSyncScreenshot, + LookinDisplayItemProperty_InNoPreviewHierarchy, + LookinDisplayItemProperty_IsInSearch, + LookinDisplayItemProperty_HighlightedSearchString, +}; + +/// What a hierarchy node *is*. Append-only: the integers travel on the wire +/// and inside .lookin archives, so existing cases must never be renumbered. +typedef NS_ENUM(NSInteger, LookinDisplayItemNodeKind) { + /// Data produced before this field existed (old servers, old archives). + /// Consumers must read resolvedNodeKind, which derives the kind from the + /// legacy object slots in this case. + LookinDisplayItemNodeKindUnspecified = 0, + LookinDisplayItemNodeKindLayer = 1, + LookinDisplayItemNodeKindView = 2, + LookinDisplayItemNodeKindWindow = 3, + LookinDisplayItemNodeKindWindowScene = 4, + /// A customInfo node (including SwiftUI virtual nodes — those are told + /// apart by customInfo.isSwiftUI, not by a separate kind). + LookinDisplayItemNodeKindCustom = 5, + LookinDisplayItemNodeKindLayoutGuide = 6, + /// AppKit-only: an NSControl's cell as a child node of the control + /// (cell-node proposal). Wireframe preview, attributes read from the cell. + LookinDisplayItemNodeKindCell = 7, + /// UIKit-only: the outermost layer of a view whose backing layer UIKit + /// wrapped in an intermediate one (_UIMultiLayer, iOS 26+). The geometry and + /// the group-compositing rules moved onto it while the contents stayed on + /// the view's own backing layer, so it has no pixels of its own — no + /// screenshot is ever fetched for it — but it still gets a preview plane of + /// its own, parallel to the view it wraps, like any other child node + /// (node-pixel-ownership proposal). + LookinDisplayItemNodeKindViewOuterLayer = 8, + /// A view's backing layer, emitted as a child of that view's node when the + /// show-backing-layers toggle is on (backing-layer-toggle proposal). An + /// ordinary pixel-bearing node: with the toggle on, content shows on the + /// layer that really renders it, and the view nodes above render as + /// wireframes. Never emitted while the toggle is off. + LookinDisplayItemNodeKindBackingLayer = 9, +}; + +// From LookinAppInfo.h. + +/// 设备类型。该枚举的原始值会经由 wire 协议传给 Lookin 客户端,因此顺序不可调整, +/// 新增的档位只能追加在末尾。上游 Lookin 只认识前三档(0–2),收到更大的值时会落到 +/// 自己的 default 分支(表现为设备图标缺失,而非报错)。 +typedef NS_ENUM(NSInteger, LookinAppInfoDevice) { + LookinAppInfoDeviceSimulator, // 0,模拟器 + LookinAppInfoDeviceIPad, // 1,iPad 真机 + LookinAppInfoDeviceOthers, // 2,应该视为 iPhone 真机 + LookinAppInfoDeviceMac, // 3,使用AppKit的Mac应用(非 Catalyst) + LookinAppInfoDeviceMacCatalyst, // 4,Mac Catalyst 应用:跑在 Mac 上,但界面是 UIKit +}; + +// From LookinAutoLayoutConstraint.h. + +typedef NS_ENUM(NSInteger, LookinConstraintItemType) { + LookinConstraintItemTypeUnknown, + LookinConstraintItemTypeNil, + LookinConstraintItemTypeView, + LookinConstraintItemTypeSelf, + LookinConstraintItemTypeSuper, + LookinConstraintItemTypeLayoutGuide +}; + +typedef NS_ENUM(NSInteger, LookinConstraintEndpoint) { + LookinConstraintEndpointFirst = 0, + LookinConstraintEndpointSecond = 1, +}; + +// From LookinDashboardBlueprint.h. + +/// Which object on a display item an attribute reads from and writes to. The +/// host picks the target oid for a modification with this; the old +/// per-kind boolean queries below remain as wrappers around it. +typedef NS_ENUM(NSInteger, LookinAttrTargetKind) { + LookinAttrTargetKindLayer = 0, + LookinAttrTargetKindView, + LookinAttrTargetKindWindow, + LookinAttrTargetKindCell, // AppKit only; wired up in the NSCell phase +}; + +// From LookinDisplayItemDetail.h. + +/// Values of `failureCode`. The property stays a plain NSInteger on the wire. +typedef NS_ENUM(NSInteger, LookinDisplayItemDetailFailureCode) { + LookinDisplayItemDetailFailureCodeNone = 0, + /// The oid resolved to an object no detail branch handles — a real fault + /// worth surfacing. The only value servers sent before 2026-08, so old + /// hosts alert on exactly this one. + LookinDisplayItemDetailFailureCodeUnhandledObject = -1, + /// The oid resolved to nothing: the object was released after the + /// hierarchy build. The inspected app's short-lived internals (TextKit 2 + /// fragment views, portals) go away on their own, so this is routine — + /// the node keeps showing the data captured at build time and hosts log + /// it without alerting. Old hosts treat it as a successful empty detail, + /// which is harmless. + LookinDisplayItemDetailFailureCodeObjectGone = -2, +}; + +// From LookinEventHandler.h. + +typedef NS_ENUM(NSInteger, LookinEventHandlerType) { + LookinEventHandlerTypeTargetAction, + LookinEventHandlerTypeGesture +}; + +// From LookinStaticAsyncUpdateTask.h. + +typedef NS_ENUM(NSInteger, LookinStaticAsyncUpdateTaskType) { + LookinStaticAsyncUpdateTaskTypeNoScreenshot, + LookinStaticAsyncUpdateTaskTypeSoloScreenshot, + LookinStaticAsyncUpdateTaskTypeGroupScreenshot +}; + +typedef NS_ENUM(NSInteger, LookinDetailUpdateTaskAttrRequest) { + /// 由 Server 端自己决定:同一批 task 里,server 端会保证同一个 layer 只会构造一次 attr + /// 在 Lookin turbo 模式下,由于同一个 layer 的 task 可能位于不同批的 task 里,因此这会导致冗余的 attr 构造行为、浪费一定时间 + LookinDetailUpdateTaskAttrRequest_Automatic, + /// 需要返回 attr + LookinDetailUpdateTaskAttrRequest_Need, + /// 不需要返回 attr + LookinDetailUpdateTaskAttrRequest_NotNeed +}; + +// From LookinAttributesSection.h. + +typedef NS_ENUM (NSInteger, LookinAttributesSectionStyle) { + LookinAttributesSectionStyleDefault, // 每个 attr 独占一行 + LookinAttributesSectionStyle0, // frame 等卡片使用,前 4 个 attr 每行两个,之后每个 attr 在同一排,每个宽度为 1/4 + LookinAttributesSectionStyle1, // 第一个 attr 在第一排靠左,第二个 attr 在第一排靠右,之后的 attr 每个独占一行 + LookinAttributesSectionStyle2 // 第一排独占一行,剩下的在同一行且均分宽度 +}; + +#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/LookinDefines.h b/Sources/LookinCore/include/LookinDefines.h similarity index 100% rename from Sources/LookinCore/LookinDefines.h rename to Sources/LookinCore/include/LookinDefines.h diff --git a/Sources/LookinCore/LookinHitTargetSize.h b/Sources/LookinCore/include/LookinHitTargetSize.h similarity index 100% rename from Sources/LookinCore/LookinHitTargetSize.h rename to Sources/LookinCore/include/LookinHitTargetSize.h diff --git a/Sources/LookinCore/include/LookinRuntimeShim.h b/Sources/LookinCore/include/LookinRuntimeShim.h new file mode 100644 index 00000000..65f9e49c --- /dev/null +++ b/Sources/LookinCore/include/LookinRuntimeShim.h @@ -0,0 +1,112 @@ +#ifdef SHOULD_COMPILE_LOOKIN_SERVER + +// +// LookinRuntimeShim.h +// LookinCore +// +// The few Objective-C runtime operations Swift cannot express, for the +// Swift implementations in LookinCoreImpl and LookinServerImpl: +// catching NSExceptions, NSInvocation (calls whose argument and return types +// are only known from the method signature), the exact @encode strings, and +// ptrauth_strip on arm64e. Each helper does what the Objective-C original +// did inline; keep their behaviour exactly as is. +// + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +#pragma mark - Exceptions + +/// Runs `block` inside @try and returns the NSException it raised, or nil. +/// Swift cannot catch Objective-C exceptions; use this wherever the original +/// had a @try / @catch. +FOUNDATION_EXPORT NSException *_Nullable LookinCatchException(NS_NOESCAPE void (^block)(void)) NS_SWIFT_NAME(LookinCatchException(_:)); + +#pragma mark - NSInvocation + +/// The `@encode` string of the return type of `selector` on `target` +/// (`[[target methodSignatureForSelector:selector] methodReturnType]`), or +/// nil when `target` does not answer `selector`. `outArgumentCount`, when not +/// NULL, receives `numberOfArguments` (2 for a method without parameters). +FOUNDATION_EXPORT NSString *_Nullable LookinMethodReturnType(id target, SEL selector, NSUInteger *_Nullable outArgumentCount); + +/// The `@encode` string of the parameter at `index` (0 = first parameter after +/// self and _cmd), or nil when there is no such parameter. +FOUNDATION_EXPORT NSString *_Nullable LookinMethodArgumentType(id target, SEL selector, NSUInteger index); + +/// Calls `selector` on `target` through NSInvocation, as the Objective-C code +/// did. +/// +/// Each element of `arguments` supplies one parameter, in order: +/// - an NSValue whose `objCType` has the same size as the parameter type: its +/// bytes are copied in (build it with `NSValue(bytes:objCType:)` and +/// `LookinEncoding(_:)`, or `NSNumber` for scalars of the matching type); +/// - any other object, or NSNull for nil: passed as an object parameter. +/// +/// The return value comes back as: +/// - nil for a void method or a nil object; +/// - the object (or Class) itself for `@` / `#` return types; +/// - otherwise an NSValue holding the raw return bytes with `objCType` equal +/// to the method's return type (read it with `-getValue:size:`). +/// +/// The invocation is not wrapped in @try: an exception propagates exactly as +/// before, so call it inside LookinCatchException where the original caught. +/// Returns nil and sets `*outError` when `target` does not answer `selector` +/// or an argument does not fit its parameter; it never invokes in that case. +FOUNDATION_EXPORT id _Nullable LookinInvoke(id target, SEL selector, NSArray *_Nullable arguments, NSError *_Nullable *_Nullable outError); + +#pragma mark - @encode + +/// The types the server compares method signatures and NSValue contents +/// against. Swift has no @encode; use `LookinEncoding(_:)` instead of +/// hand-written strings. +typedef NS_ENUM(NSInteger, LookinEncodedType) { + LookinEncodedTypeVoid, + LookinEncodedTypeId, + LookinEncodedTypeClass, + LookinEncodedTypeSEL, + LookinEncodedTypeChar, + LookinEncodedTypeInt, + LookinEncodedTypeShort, + LookinEncodedTypeLong, + LookinEncodedTypeLongLong, + LookinEncodedTypeUnsignedChar, + LookinEncodedTypeUnsignedInt, + LookinEncodedTypeUnsignedShort, + LookinEncodedTypeUnsignedLong, + LookinEncodedTypeUnsignedLongLong, + LookinEncodedTypeFloat, + LookinEncodedTypeDouble, + LookinEncodedTypeBOOL, + LookinEncodedTypeCGPoint, + LookinEncodedTypeCGVector, + LookinEncodedTypeCGSize, + LookinEncodedTypeCGRect, + LookinEncodedTypeCGAffineTransform, + LookinEncodedTypeLookinInsets, +#if TARGET_OS_IPHONE + LookinEncodedTypeUIOffset, +#endif +}; + +/// `@encode(T)` for `type` (`LookinEncodedTypeLookinInsets` is UIEdgeInsets on +/// UIKit, NSEdgeInsets on AppKit). +FOUNDATION_EXPORT NSString *LookinEncoding(LookinEncodedType type); + +#pragma mark - Pointer authentication + +/// `ptrauth_strip(pointer, ptrauth_key_process_independent_data)` on arm64e, +/// the pointer unchanged elsewhere. +FOUNDATION_EXPORT const void *_Nullable LookinStripProcessIndependentDataPointer(const void *_Nullable pointer); + +/// Reads the SEL stored in the instance variable `ivarName` of `object` +/// without going through KVC, stripping its pointer signature on arm64e +/// (UIKit signs `UIGestureRecognizerTarget._action` there; the raw value +/// faults in NSStringFromSelector). NULL when the class has no such ivar. +FOUNDATION_EXPORT SEL _Nullable LookinReadSelectorIvar(id object, const char *ivarName); + +NS_ASSUME_NONNULL_END + +#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinCore/Category/NSValue+Lookin.h b/Sources/LookinCore/include/NSValue+Lookin.h similarity index 55% rename from Sources/LookinCore/Category/NSValue+Lookin.h rename to Sources/LookinCore/include/NSValue+Lookin.h index ab863042..a03f08e9 100644 --- a/Sources/LookinCore/Category/NSValue+Lookin.h +++ b/Sources/LookinCore/include/NSValue+Lookin.h @@ -11,6 +11,10 @@ #import #import "LookinDefines.h" +// Exported by the `@_cdecl` functions in LookinCoreImpl/Category/NSValue+Lookin.swift. +// The NSValue (Lookin) category itself is a Swift extension with the +// original selectors. + NS_ASSUME_NONNULL_BEGIN #if TARGET_OS_OSX @@ -25,25 +29,6 @@ NSString *NSStringFromDirectionalEdgeInsets(NSDirectionalEdgeInsets insets); NSString *NSStringFromInsets(UIEdgeInsets insets); #endif -@interface NSValue (Lookin) -#if TARGET_OS_OSX -+ (NSValue *)valueWithCGVector:(CGVector)vector; -+ (NSValue *)valueWithCGRect:(CGRect)rect; -+ (NSValue *)valueWithCGPoint:(CGPoint)point; -+ (NSValue *)valueWithCGSize:(CGSize)size; -+ (NSValue *)valueWithCGAffineTransform:(CGAffineTransform)transform; -- (CGAffineTransform)CGAffineTransformValue; -- (CGVector)CGVectorValue; -- (CGRect)CGRectValue; -- (CGPoint)CGPointValue; -- (CGSize)CGSizeValue; -+ (NSValue *)valueWithInsets:(NSEdgeInsets)insets; -- (NSEdgeInsets)InsetsValue; -#else -+ (NSValue *)valueWithInsets:(UIEdgeInsets)insets; -- (UIEdgeInsets)InsetsValue; -#endif -@end NS_ASSUME_NONNULL_END diff --git a/Sources/LookinCoreImpl/Category/CALayer+Lookin.swift b/Sources/LookinCoreImpl/Category/CALayer+Lookin.swift new file mode 100644 index 00000000..3d52aec3 --- /dev/null +++ b/Sources/LookinCoreImpl/Category/CALayer+Lookin.swift @@ -0,0 +1,88 @@ +// +// CALayer+Lookin.swift +// LookinCore +// +// Was CALayer+Lookin.m. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + import QuartzCore + #if SWIFT_PACKAGE + import LookinCore + #endif + + public extension CALayer { + @objc(lookin_removeImplicitAnimations) + func lookin_removeImplicitAnimations() { + // The keys are the property names the Objective-C original spelled + // as NSStringFromSelector(@selector(...)). + var actions: [String: any CAAction] = [:] + for key in lookinLayerActionKeys { + actions[key] = NSNull() + } + if isKind(of: CAShapeLayer.self) { + for key in lookinShapeLayerActionKeys { + actions[key] = NSNull() + } + } + if isKind(of: CAGradientLayer.self) { + for key in lookinGradientLayerActionKeys { + actions[key] = NSNull() + } + } + self.actions = actions + } + } + + private let lookinLayerActionKeys = [ + "bounds", + "position", + "zPosition", + "anchorPoint", + "anchorPointZ", + "transform", + "sublayerTransform", + "masksToBounds", + "contents", + "contentsRect", + "contentsScale", + "contentsCenter", + "minificationFilterBias", + "backgroundColor", + "cornerRadius", + "borderWidth", + "borderColor", + "opacity", + "compositingFilter", + "filters", + "backgroundFilters", + "shouldRasterize", + "rasterizationScale", + "shadowColor", + "shadowOpacity", + "shadowOffset", + "shadowRadius", + "shadowPath", + ] + + private let lookinShapeLayerActionKeys = [ + "path", + "fillColor", + "strokeColor", + "strokeStart", + "strokeEnd", + "lineWidth", + "miterLimit", + "lineDashPhase", + ] + + private let lookinGradientLayerActionKeys = [ + "colors", + "locations", + "startPoint", + "endPoint", + ] + +#endif diff --git a/Sources/LookinCoreImpl/Category/Color+Lookin.swift b/Sources/LookinCoreImpl/Category/Color+Lookin.swift new file mode 100644 index 00000000..641f8155 --- /dev/null +++ b/Sources/LookinCoreImpl/Category/Color+Lookin.swift @@ -0,0 +1,56 @@ +// +// Color+Lookin.swift +// LookinCore +// +// Was Color+Lookin.m. The category +// only exists on AppKit. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER && os(macOS) + + import AppKit + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + public extension NSColor { + @objc(lookin_colorFromRGBAComponents:) + class func lookin_color(fromRGBAComponents components: [NSNumber]?) -> Self! { + guard let components else { + return nil + } + guard components.count == 4 else { + assertionFailure("") + return nil + } + let color = NSColor( + red: components[0].doubleValue, + green: components[1].doubleValue, + blue: components[2].doubleValue, + alpha: components[3].doubleValue + ) + // The original returned a plain NSColor whatever class received the + // message, which `instancetype` allows in Objective-C. + return unsafeDowncast(color, to: self) + } + + @objc(lookin_rgbaComponents) + func lookin_rgbaComponents() -> [NSNumber]! { + // Messaging a nil sRGB conversion left the components unset; read + // them as 0 here. + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + usingColorSpace(.sRGB)?.getRed(&r, green: &g, blue: &b, alpha: &a) + return [ + NSNumber(value: Double(r)), + NSNumber(value: Double(g)), + NSNumber(value: Double(b)), + NSNumber(value: Double(a)), + ] + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Category/Image+Lookin.swift b/Sources/LookinCoreImpl/Category/Image+Lookin.swift new file mode 100644 index 00000000..ead68e22 --- /dev/null +++ b/Sources/LookinCoreImpl/Category/Image+Lookin.swift @@ -0,0 +1,37 @@ +// +// Image+Lookin.swift +// LookinCore +// +// Was Image+Lookin.m. The category +// only exists on AppKit. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER && os(macOS) + + import AppKit + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + public extension NSImage { + @objc(lookin_data) + func lookin_data() -> Data! { + if representations.isEmpty { + return nil + } + // Bitmap representations first (raster images). + if let data = NSBitmapImageRep.representationOfImageReps(in: representations, using: .png, properties: [:]) { + return data + } + // Fallback: render into a bitmap (vector images, SF Symbols). + var proposedRect = CGRect(x: 0, y: 0, width: size.width, height: size.height) + guard let cgImage = cgImage(forProposedRect: &proposedRect, context: nil, hints: nil) else { + return nil + } + let bitmapRep = NSBitmapImageRep(cgImage: cgImage) + return bitmapRep.representation(using: .png, properties: [:]) + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Category/NSArray+Lookin.swift b/Sources/LookinCoreImpl/Category/NSArray+Lookin.swift new file mode 100644 index 00000000..38ade6a9 --- /dev/null +++ b/Sources/LookinCoreImpl/Category/NSArray+Lookin.swift @@ -0,0 +1,301 @@ +// +// NSArray+Lookin.swift +// LookinCore +// +// Was NSArray+Lookin.m. The +// results are built as Foundation collections and handed back as-is, so the +// Objective-C callers get the same NSArray contents as before. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + public extension NSArray { + @objc(lookin_resizeWithCount:add:remove:doNext:) + func lookin_resize( + withCount count: UInt, + add addBlock: ((UInt) -> Any?)?, + remove removeBlock: ((UInt, Any?) -> Void)?, + doNext doBlock: ((UInt, Any?) -> Void)? + ) -> [Any]! { + let resultArray = NSMutableArray(capacity: Int(count)) + for i in 0 ..< count { + if UInt(self.count) > i { + let obj = object(at: Int(i)) + resultArray.add(obj) + doBlock?(i, obj) + } else if let addBlock { + if let newObj = addBlock(i) { + resultArray.add(newObj) + doBlock?(i, newObj) + } else { + assertionFailure("") + } + } else { + assertionFailure("") + } + } + + if let removeBlock, UInt(self.count) > count { + for i in count ..< UInt(self.count) { + removeBlock(i, object(at: Int(i))) + } + } + + return lookinBridgedArray(resultArray) + } + + @objc(lookin_arrayWithCount:block:) + class func lookin_array(withCount count: UInt, block: ((UInt) -> Any?)?) -> [Any]! { + let array = NSMutableArray(capacity: Int(count)) + for i in 0 ..< count { + // Calling a nil block crashed in Objective-C as well. + if let obj = block!(i) { + array.add(obj) + } + } + return lookinBridgedArray(array) + } + + @objc(lookin_hasIndex:) + func lookin_hasIndex(_ index: Int) -> Bool { + if index == NSNotFound || index < 0 { + return false + } + return count > index + } + + @objc(lookin_map:) + func lookin_map(_ block: ((UInt, Any?) -> Any?)?) -> [Any]! { + guard let block else { + assertionFailure("") + return nil + } + let array = NSMutableArray(capacity: count) + enumerateObjects { obj, idx, _ in + if let newObj = block(UInt(idx), obj) { + array.add(newObj) + } + } + return lookinBridgedArray(array) + } + + @objc(lookin_filter:) + func lookin_filter(_ block: ((Any?) -> Bool)?) -> [Any]! { + guard let block else { + assertionFailure("") + return nil + } + let mArray = NSMutableArray() + enumerateObjects { obj, _, _ in + if block(obj) { + mArray.add(obj) + } + } + return lookinBridgedArray(mArray) + } + + @objc(lookin_firstFiltered:) + func lookin_firstFiltered(_ block: ((Any?) -> Bool)?) -> Any! { + guard let block else { + assertionFailure("") + return nil + } + var targetObj: Any? + enumerateObjects { obj, _, stop in + if block(obj) { + targetObj = obj + stop.pointee = true + } + } + return targetObj + } + + @objc(lookin_lastFiltered:) + func lookin_lastFiltered(_ block: ((Any?) -> Bool)?) -> Any! { + guard let block else { + assertionFailure("") + return nil + } + var targetObj: Any? + enumerateObjects(options: .reverse) { obj, _, stop in + if block(obj) { + targetObj = obj + stop.pointee = true + } + } + return targetObj + } + + @objc(lookin_reduce:) + func lookin_reduce(_ block: ((Any?, UInt, Any?) -> Any?)?) -> Any! { + guard let block else { + assertionFailure("") + return nil + } + var accumulator: Any? + enumerateObjects { obj, idx, _ in + accumulator = block(accumulator, UInt(idx), obj) + } + return accumulator + } + + @objc(lookin_reduceCGFloat:initialAccumlator:) + func lookin_reduceCGFloat(_ block: ((CGFloat, UInt, Any?) -> CGFloat)?, initialAccumlator: CGFloat) -> CGFloat { + guard let block else { + assertionFailure("") + return initialAccumlator + } + var accumulator = initialAccumlator + enumerateObjects { obj, idx, _ in + accumulator = block(accumulator, UInt(idx), obj) + } + return accumulator + } + + @objc(lookin_reduceInteger:initialAccumlator:) + func lookin_reduceInteger(_ block: ((Int, UInt, Any?) -> Int)?, initialAccumlator: Int) -> Int { + guard let block else { + assertionFailure("") + return initialAccumlator + } + var accumulator = initialAccumlator + enumerateObjects { obj, idx, _ in + accumulator = block(accumulator, UInt(idx), obj) + } + return accumulator + } + + @objc(lookin_all:) + func lookin_all(_ block: ((Any?) -> Bool)?) -> Bool { + guard let block else { + assertionFailure("") + return false + } + var allPass = true + enumerateObjects { obj, _, stop in + if !block(obj) { + allPass = false + stop.pointee = true + } + } + return allPass + } + + @objc(lookin_any:) + func lookin_any(_ block: ((Any?) -> Bool)?) -> Bool { + guard let block else { + assertionFailure("") + return false + } + var anyPass = false + enumerateObjects { obj, _, stop in + if block(obj) { + anyPass = true + stop.pointee = true + } + } + return anyPass + } + + @objc(lookin_arrayByRemovingObject:) + func lookin_array(byRemoving obj: Any?) -> [Any]! { + guard let obj, contains(obj) else { + return lookinBridgedArray(self) + } + let mutableArray = mutableCopy() as! NSMutableArray + mutableArray.remove(obj) + return lookinBridgedArray(mutableArray) + } + + @objc(lookin_nonredundantArray) + func lookin_nonredundant() -> [Any]! { + let set = NSSet(array: self as! [Any]) + return set.allObjects + } + + @objc(lookin_safeObjectAtIndex:) + func lookin_safeObject(at idx: Int) -> Any! { + if idx == NSNotFound || idx < 0 { + return nil + } + if count <= idx { + return nil + } + return object(at: idx) + } + + @objc(lookin_sortedArrayByStringLength) + func lookin_sortedArrayByStringLength() -> [Any]! { + return sortedArray(comparator: { obj1, obj2 in + let length1 = (obj1 as! NSString).length + let length2 = (obj2 as! NSString).length + if length1 > length2 { + return .orderedDescending + } else if length1 == length2 { + return .orderedSame + } else { + return .orderedAscending + } + }) + } + } + + public extension NSMutableArray { + @objc(lookin_dequeueWithCount:add:notDequeued:doNext:) + func lookin_dequeue( + withCount count: UInt, + add addBlock: ((UInt) -> Any?)?, + notDequeued notDequeuedBlock: ((UInt, Any?) -> Void)?, + doNext doBlock: ((UInt, Any?) -> Void)? + ) { + for i in 0 ..< count { + if lookin_hasIndex(Int(i)) { + let obj = object(at: Int(i)) + doBlock?(i, obj) + } else if let addBlock { + if let newObj = addBlock(i) { + add(newObj) + doBlock?(i, newObj) + } else { + assertionFailure("") + } + } else { + assertionFailure("") + } + } + + if let notDequeuedBlock, UInt(self.count) > count { + for i in count ..< UInt(self.count) { + notDequeuedBlock(i, object(at: Int(i))) + } + } + } + + @objc(lookin_removeObjectsPassingTest:) + func lookin_removeObjects(passingTest block: ((UInt, Any?) -> Bool)?) { + guard let block else { + return + } + let indexSet = NSMutableIndexSet() + enumerateObjects { currentObj, idx, _ in + if block(UInt(idx), currentObj) { + indexSet.add(idx) + } + } + removeObjects(at: indexSet as IndexSet) + } + } + + /// An immutable copy of `array`, handed to Objective-C without copying its + /// elements: bridging an immutable NSArray to `[Any]` and back returns the + /// same NSArray. + private func lookinBridgedArray(_ array: NSArray) -> [Any] { + array.copy() as! [Any] + } + +#endif diff --git a/Sources/LookinCoreImpl/Category/NSObject+Lookin.swift b/Sources/LookinCoreImpl/Category/NSObject+Lookin.swift new file mode 100644 index 00000000..5c446e06 --- /dev/null +++ b/Sources/LookinCoreImpl/Category/NSObject+Lookin.swift @@ -0,0 +1,297 @@ +// +// NSObject+Lookin.swift +// LookinCore +// +// Was NSObject+Lookin.m: the +// associated-object bindings and the color / image coding that +// LookinConnectionAttachment uses on the wire (pinned by +// Tests/WireFormatGolden, LookinConnectionAttachment-color and -image). +// +// Plain `@objc` extension members with the original selectors: the runtime +// registers them as a category when the image loads, as it did the +// Objective-C category. Swift sees them under the names the old header's +// import gave them. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + import ObjectiveC + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + public extension NSObject { + // MARK: - Data Bind + + @objc(lookin_bindObject:forKey:) + func lookin_bindObject(_ object: Any?, forKey key: String?) { + guard let key, !key.isEmpty else { + assertionFailure("") + return + } + // @synchronized (self): objc_sync_enter / objc_sync_exit is the + // same recursive lock. + objc_sync_enter(self) + defer { objc_sync_exit(self) } + if let object { + lookinAllBindObjects(self).setObject(object, forKey: key as NSString) + } else { + lookinAllBindObjects(self).removeObject(forKey: key as NSString) + } + } + + @objc(lookin_bindObjectWeakly:forKey:) + func lookin_bindObjectWeakly(_ object: Any?, forKey key: String?) { + guard let key, !key.isEmpty else { + assertionFailure("") + return + } + if let object { + let container = LookinWeakContainer() + container.object = object as AnyObject + lookin_bindObject(container, forKey: key) + } else { + lookin_bindObject(nil, forKey: key) + } + } + + @objc(lookin_getBindObjectForKey:) + func lookin_getBindObject(forKey key: String?) -> Any? { + guard let key, !key.isEmpty else { + assertionFailure("") + return nil + } + objc_sync_enter(self) + defer { objc_sync_exit(self) } + let storedObj = lookinAllBindObjects(self).object(forKey: key as NSString) + if let container = storedObj as? LookinWeakContainer { + return container.object + } + return storedObj + } + + @objc(lookin_bindDouble:forKey:) + func lookin_bindDouble(_ doubleValue: Double, forKey key: String?) { + lookin_bindObject(NSNumber(value: doubleValue), forKey: key) + } + + @objc(lookin_getBindDoubleForKey:) + func lookin_getBindDouble(forKey key: String?) -> Double { + guard let number = lookin_getBindObject(forKey: key) as? NSNumber else { + return 0.0 + } + return number.doubleValue + } + + @objc(lookin_bindBOOL:forKey:) + func lookin_bindBOOL(_ boolValue: Bool, forKey key: String?) { + lookin_bindObject(NSNumber(value: boolValue), forKey: key) + } + + @objc(lookin_getBindBOOLForKey:) + func lookin_getBindBOOL(forKey key: String?) -> Bool { + guard let number = lookin_getBindObject(forKey: key) as? NSNumber else { + return false + } + return number.boolValue + } + + @objc(lookin_bindLong:forKey:) + func lookin_bindLong(_ longValue: Int, forKey key: String?) { + lookin_bindObject(NSNumber(value: longValue), forKey: key) + } + + @objc(lookin_getBindLongForKey:) + func lookin_getBindLong(forKey key: String?) -> Int { + guard let number = lookin_getBindObject(forKey: key) as? NSNumber else { + return 0 + } + return number.intValue + } + + @objc(lookin_bindPoint:forKey:) + func lookin_bindPoint(_ pointValue: CGPoint, forKey key: String?) { + #if canImport(UIKit) + lookin_bindObject(NSValue(cgPoint: pointValue), forKey: key) + #elseif os(macOS) + lookin_bindObject(NSValue(point: pointValue), forKey: key) + #endif + } + + @objc(lookin_getBindPointForKey:) + func lookin_getBindPoint(forKey key: String?) -> CGPoint { + guard let value = lookin_getBindObject(forKey: key) as? NSValue else { + return .zero + } + #if canImport(UIKit) + return value.cgPointValue + #elseif os(macOS) + return value.pointValue + #endif + } + + @objc(lookin_clearBindForKey:) + func lookin_clearBind(forKey key: String?) { + lookin_bindObject(nil, forKey: key) + } + } + + public extension NSObject { + @objc(lookin_encodedObjectWithType:) + func lookin_encodedObject(with type: LookinCodingValueType) -> Any? { + switch type { + case .color: + guard isKind(of: LookinColor.self) else { + assertionFailure("") + return nil + } + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + #if canImport(UIKit) + let color = unsafeDowncast(self, to: UIColor.self) + var white: CGFloat = 0 + if color.getRed(&r, green: &g, blue: &b, alpha: &a) { + // valid + } else if color.getWhite(&white, alpha: &a) { + r = white + g = white + b = white + } else { + assertionFailure("") + r = 0 + g = 0 + b = 0 + a = 0 + } + #elseif os(macOS) + // Messaging a nil sRGB conversion left the components unset; + // read them as 0 here. + unsafeDowncast(self, to: NSColor.self).usingColorSpace(.sRGB)?.getRed(&r, green: &g, blue: &b, alpha: &a) + #endif + let rgba: NSArray = [ + NSNumber(value: Double(r)), + NSNumber(value: Double(g)), + NSNumber(value: Double(b)), + NSNumber(value: Double(a)), + ] + return rgba + + case .image: + #if canImport(UIKit) + guard isKind(of: UIImage.self) else { + assertionFailure("") + return nil + } + return lookinPNGRepresentation(unsafeDowncast(self, to: UIImage.self)) + #elseif os(macOS) + guard isKind(of: NSImage.self) else { + assertionFailure("") + return nil + } + return unsafeDowncast(self, to: NSImage.self).lookin_data().map { $0 as NSData } + #endif + + default: + return self + } + } + + @objc(lookin_decodedObjectWithType:) + func lookin_decodedObject(with type: LookinCodingValueType) -> Any? { + switch type { + case .color: + guard isKind(of: NSArray.self) else { + assertionFailure("") + return nil + } + let rgba = unsafeDowncast(self, to: NSArray.self) + let r = lookinDoubleValue(rgba.object(at: 0)) + let g = lookinDoubleValue(rgba.object(at: 1)) + let b = lookinDoubleValue(rgba.object(at: 2)) + let a = lookinDoubleValue(rgba.object(at: 3)) + return LookinColor(red: r, green: g, blue: b, alpha: a) + + case .image: + guard isKind(of: NSData.self) else { + assertionFailure("") + return nil + } + return LookinImage(data: unsafeDowncast(self, to: NSData.self) as Data) + + default: + return self + } + } + } + + // MARK: - Helpers + + #if canImport(UIKit) + /// `UIImagePNGRepresentation(image)` itself, looked up at run time: Swift + /// only offers `pngData()`, whose `Data` bridges back as a different + /// NSData class than the NSMutableData UIKit returns, and the class name + /// is archived on the wire (Tests/WireFormatGolden, LookinDisplayItem and + /// LookinConnectionAttachment-image). + private let lookinUIImagePNGRepresentation: (@convention(c) (UIImage) -> Unmanaged?)? = { + // RTLD_DEFAULT + guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "UIImagePNGRepresentation") else { + return nil + } + return unsafeBitCast(symbol, to: (@convention(c) (UIImage) -> Unmanaged?).self) + }() + + private func lookinPNGRepresentation(_ image: UIImage) -> NSData? { + if let function = lookinUIImagePNGRepresentation { + return function(image)?.takeUnretainedValue() + } + return image.pngData().map { $0 as NSData } + } + #endif + + private nonisolated(unsafe) var kAssociatedObjectKey_LookinAllBindObjects: UInt8 = 0 + + /// `-lookin_allBindObjects`: the mutable dictionary every binding lives in, + /// created on first use. + private func lookinAllBindObjects(_ object: NSObject) -> NSMutableDictionary { + if let dict = objc_getAssociatedObject(object, &kAssociatedObjectKey_LookinAllBindObjects) as? NSMutableDictionary { + return dict + } + let dict = NSMutableDictionary() + objc_setAssociatedObject(object, &kAssociatedObjectKey_LookinAllBindObjects, dict, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + return dict + } + + /// `[value doubleValue]` for an archived rgba element. NSNumber and NSString + /// answer it; any other object gets the same unrecognized-selector + /// exception the message send raised. + private func lookinDoubleValue(_ value: Any) -> CGFloat { + if let number = value as? NSNumber { + return CGFloat(number.doubleValue) + } + if let string = value as? NSString { + return CGFloat(string.doubleValue) + } + let selector = NSSelectorFromString("doubleValue") + let object = value as AnyObject + guard object.responds(to: selector) else { + (object as? NSObject)?.doesNotRecognizeSelector(selector) + return 0 + } + var result: Double = 0 + if let returned = LookinInvoke(object, selector, nil, nil) as? NSValue, + LookinMethodReturnType(object, selector, nil) == LookinEncoding(.double) + { + returned.getValue(&result, size: MemoryLayout.size) + } + return CGFloat(result) + } + +#endif diff --git a/Sources/LookinCoreImpl/Category/NSSet+Lookin.swift b/Sources/LookinCoreImpl/Category/NSSet+Lookin.swift new file mode 100644 index 00000000..16f120a4 --- /dev/null +++ b/Sources/LookinCoreImpl/Category/NSSet+Lookin.swift @@ -0,0 +1,87 @@ +// +// NSSet+Lookin.swift +// LookinCore +// +// Was NSSet+Lookin.m. The results +// are built as Foundation sets and handed back as-is. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + public extension NSSet { + @objc(lookin_map:) + func lookin_map(_ block: ((Any?) -> Any?)?) -> Set! { + guard let block else { + assertionFailure("") + return nil + } + let newSet = NSMutableSet(capacity: count) + enumerateObjects { obj, _ in + if let newObj = block(obj) { + newSet.add(newObj) + } + } + return lookinBridgedSet(newSet) + } + + @objc(lookin_firstFiltered:) + func lookin_firstFiltered(_ block: ((Any?) -> Bool)?) -> Any! { + guard let block else { + assertionFailure("") + return nil + } + var targetObj: Any? + enumerateObjects { obj, stop in + if block(obj) { + targetObj = obj + stop.pointee = true + } + } + return targetObj + } + + @objc(lookin_filter:) + func lookin_filter(_ block: ((Any?) -> Bool)?) -> Set! { + guard let block else { + assertionFailure("") + return nil + } + let mSet = NSMutableSet() + enumerateObjects { obj, _ in + if block(obj) { + mSet.add(obj) + } + } + return lookinBridgedSet(mSet) + } + + @objc(lookin_any:) + func lookin_any(_ block: ((Any?) -> Bool)?) -> Bool { + guard let block else { + assertionFailure("") + return false + } + var boolValue = false + enumerateObjects { obj, stop in + if block(obj) { + boolValue = true + stop.pointee = true + } + } + return boolValue + } + } + + /// An immutable copy of `set`, handed to Objective-C without copying its + /// elements: bridging an immutable NSSet to `Set` and back returns the same + /// NSSet. + private func lookinBridgedSet(_ set: NSSet) -> Set { + set.copy() as! Set + } + +#endif diff --git a/Sources/LookinCoreImpl/Category/NSString+Lookin.swift b/Sources/LookinCoreImpl/Category/NSString+Lookin.swift new file mode 100644 index 00000000..7aab963d --- /dev/null +++ b/Sources/LookinCoreImpl/Category/NSString+Lookin.swift @@ -0,0 +1,158 @@ +// +// NSString+Lookin.swift +// LookinCore +// +// Was NSString+Lookin.m. The +// number formatting goes through NSString formats, as before, so the +// attribute texts the Host shows stay byte-identical. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + public extension NSString { + @objc(lookin_stringFromDouble:decimal:) + class func lookin_string(from doubleValue: Double, decimal: UInt) -> String! { + let formatString = NSString(format: "%%.%@f", NSNumber(value: decimal)) + var string = NSString(format: formatString, doubleValue) + // A zero-length string would raise in -substringFromIndex: in the + // Objective-C original; "%.Nf" never formats to one. + for _ in 0 ..< decimal { + if string.substring(from: string.length - 1) == "0" { + string = string.substring(to: string.length - 1) as NSString + } + } + if string.substring(from: string.length - 1) == "." { + string = string.substring(to: string.length - 1) as NSString + } + return string as String + } + + @objc(lookin_stringFromRect:) + class func lookin_string(from rect: CGRect) -> String! { + String(format: "{%@, %@, %@, %@}", + lookinString(rect.origin.x), + lookinString(rect.origin.y), + lookinString(rect.size.width), + lookinString(rect.size.height)) + } + + @objc(lookin_stringFromInset:) + class func lookin_string(fromInset insets: LookinInsets) -> String! { + String(format: "{%@, %@, %@, %@}", + lookinString(insets.top), + lookinString(insets.left), + lookinString(insets.bottom), + lookinString(insets.right)) + } + + @objc(lookin_stringFromSize:) + class func lookin_string(from size: CGSize) -> String! { + String(format: "{%@, %@}", + lookinString(size.width), + lookinString(size.height)) + } + + @objc(lookin_stringFromPoint:) + class func lookin_string(from point: CGPoint) -> String! { + String(format: "{%@, %@}", + lookinString(point.x), + lookinString(point.y)) + } + + @objc(lookin_rgbaStringFromColor:) + class func lookin_rgbaString(from color: LookinColor?) -> String! { + guard let color else { + return "nil" + } + #if canImport(UIKit) + let rgbColor: LookinColor? = color + #elseif os(macOS) + let rgbColor = color.usingColorSpace(.sRGB) + #endif + + // Components the color does not report (a nil sRGB conversion, a + // pattern color) were left unset in Objective-C; read them as 0. + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + _ = rgbColor?.getRed(&r, green: &g, blue: &b, alpha: &a) + + if a >= 1 { + return String(format: "(%.0f, %.0f, %.0f)", Double(r * 255), Double(g * 255), Double(b * 255)) + } + return String(format: "(%.0f, %.0f, %.0f, %@)", + Double(r * 255), Double(g * 255), Double(b * 255), + NSString.lookin_string(from: Double(a), decimal: 2)! as NSString) + } + + @objc(lookin_safeInitWithUTF8String:) + func lookin_safeInit(withUTF8String string: UnsafePointer?) -> String! { + // `[[NSString alloc] lookin_safeInitWithUTF8String:]`: the receiver + // is the class's placeholder; -initWithUTF8String: on a fresh + // NSString gives the same string, nil for invalid UTF-8. + guard let string else { + return nil + } + return NSString(utf8String: string) as String? + } + + @objc(lookin_numbericOSVersion) + func lookin_numbericOSVersion() -> Int { + if length == 0 { + return 0 + } + let versionArr = components(separatedBy: ".") + if versionArr.isEmpty { + return 0 + } + + var numbericOSVersion = 0 + var pos = 0 + let componentCount = min(versionArr.count, 3) + + while pos < componentCount { + // NSInteger += NSInteger * double: computed in double, then + // converted back to NSInteger. + let component = Double((versionArr[pos] as NSString).integerValue) + numbericOSVersion = lookinIntegerFromDouble(Double(numbericOSVersion) + component * pow(10, Double(4 - pos * 2))) + pos += 1 + } + + return numbericOSVersion + } + } + + /// The C double-to-NSInteger conversion as arm64 performs it: truncate + /// toward zero, saturate out-of-range values, NaN becomes 0. Swift's + /// `Int(_:)` traps instead, and the version strings come from the peer. + private func lookinIntegerFromDouble(_ value: Double) -> Int { + if value.isNaN { + return 0 + } + if value >= 9_223_372_036_854_775_807.0 { + return Int.max + } + if value <= -9_223_372_036_854_775_808.0 { + return Int.min + } + return Int(value) + } + + /// `[NSString lookin_stringFromDouble:value decimal:2]` as an NSString + /// format argument. + private func lookinString(_ value: CGFloat) -> NSString { + NSString.lookin_string(from: Double(value), decimal: 2)! as NSString + } + +#endif diff --git a/Sources/LookinCoreImpl/Category/NSValue+Lookin.swift b/Sources/LookinCoreImpl/Category/NSValue+Lookin.swift new file mode 100644 index 00000000..07fa75f9 --- /dev/null +++ b/Sources/LookinCoreImpl/Category/NSValue+Lookin.swift @@ -0,0 +1,157 @@ +// +// NSValue+Lookin.swift +// LookinCore +// +// Was NSValue+Lookin.m: the +// UIKit-style NSValue boxing on AppKit, the insets boxing on both, and the +// exported NSStringFrom* C functions (defined here with @_cdecl). +// +// CGVector and CGAffineTransform values are still built from raw bytes with +// their @encode type; such values cannot be keyed-archived, the known wire +// limitation recorded in PLAN 阶段 0. Keep it unchanged. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + // MARK: - C functions + + /// The `%.*g` precision the Objective-C original passed (an `int`). + private let precision17: Int32 = 17 + + #if os(macOS) + @_cdecl("NSStringFromInsets") + public func lookin_NSStringFromInsets(_ insets: NSEdgeInsets) -> NSString { + NSString(format: "{%.*g, %.*g, %.*g, %.*g}", precision17, insets.top, precision17, insets.left, precision17, insets.bottom, precision17, insets.right) + } + + @_cdecl("NSStringFromCGAffineTransform") + public func lookin_NSStringFromCGAffineTransform(_ transform: CGAffineTransform) -> NSString { + NSString(format: "[%.*g, %.*g, %.*g, %.*g, %.*g, %.*g]", + precision17, transform.a, precision17, transform.b, precision17, transform.c, + precision17, transform.d, precision17, transform.tx, precision17, transform.ty) + } + + @_cdecl("NSStringFromCGVector") + public func lookin_NSStringFromCGVector(_ vector: CGVector) -> NSString { + NSString(format: "{%.*g, %.*g}", precision17, vector.dx, precision17, vector.dy) + } + + @_cdecl("NSStringFromCGRect") + public func lookin_NSStringFromCGRect(_ rect: CGRect) -> NSString { + NSStringFromRect(rect) as NSString + } + + @_cdecl("NSStringFromCGPoint") + public func lookin_NSStringFromCGPoint(_ point: CGPoint) -> NSString { + NSStringFromPoint(point) as NSString + } + + @_cdecl("NSStringFromCGSize") + public func lookin_NSStringFromCGSize(_ size: CGSize) -> NSString { + NSStringFromSize(size) as NSString + } + + @_cdecl("NSStringFromDirectionalEdgeInsets") + public func lookin_NSStringFromDirectionalEdgeInsets(_ insets: NSDirectionalEdgeInsets) -> NSString { + NSString(format: "{%.*g, %.*g, %.*g, %.*g}", precision17, insets.top, precision17, insets.leading, precision17, insets.bottom, precision17, insets.trailing) + } + #else + @_cdecl("NSStringFromInsets") + public func lookin_NSStringFromInsets(_ insets: UIEdgeInsets) -> NSString { + NSString(format: "{%.*g, %.*g, %.*g, %.*g}", precision17, insets.top, precision17, insets.left, precision17, insets.bottom, precision17, insets.right) + } + #endif + + // MARK: - NSValue (Lookin) + + public extension NSValue { + #if os(macOS) + @objc(valueWithCGVector:) + class func lookin_value(cgVector vector: CGVector) -> NSValue { + var vector = vector + return NSValue(bytes: &vector, objCType: LookinEncoding(.cgVector)) + } + + @objc(valueWithCGRect:) + class func lookin_value(cgRect rect: CGRect) -> NSValue { + NSValue(rect: rect) + } + + @objc(valueWithCGPoint:) + class func lookin_value(cgPoint point: CGPoint) -> NSValue { + NSValue(point: point) + } + + @objc(valueWithCGSize:) + class func lookin_value(cgSize size: CGSize) -> NSValue { + NSValue(size: size) + } + + @objc(valueWithCGAffineTransform:) + class func lookin_value(cgAffineTransform transform: CGAffineTransform) -> NSValue { + var transform = transform + return NSValue(bytes: &transform, objCType: LookinEncoding(.cgAffineTransform)) + } + + @objc(CGAffineTransformValue) + func cgAffineTransform() -> CGAffineTransform { + var transform = CGAffineTransform() + getValue(&transform) + return transform + } + + @objc(CGVectorValue) + func cgVectorValue() -> CGVector { + var vector = CGVector() + getValue(&vector) + return vector + } + + @objc(CGRectValue) + func cgRectValue() -> CGRect { + rectValue + } + + @objc(CGPointValue) + func cgPointValue() -> CGPoint { + pointValue + } + + @objc(CGSizeValue) + func cgSizeValue() -> CGSize { + sizeValue + } + + @objc(valueWithInsets:) + class func lookin_value(insets: NSEdgeInsets) -> NSValue { + NSValue(edgeInsets: insets) + } + + @objc(InsetsValue) + func insetsValue() -> NSEdgeInsets { + edgeInsetsValue + } + #else + @objc(valueWithInsets:) + class func lookin_value(insets: UIEdgeInsets) -> NSValue { + NSValue(uiEdgeInsets: insets) + } + + @objc(InsetsValue) + func insetsValue() -> UIEdgeInsets { + uiEdgeInsetsValue + } + #endif + } + +#endif diff --git a/Sources/LookinCoreImpl/LKS_MultiplatformAdapter.swift b/Sources/LookinCoreImpl/LKS_MultiplatformAdapter.swift new file mode 100644 index 00000000..eb09be2e --- /dev/null +++ b/Sources/LookinCoreImpl/LKS_MultiplatformAdapter.swift @@ -0,0 +1,229 @@ +// +// LKS_MultiplatformAdapter.swift +// LookinCore +// +// Was LKS_MultiplatformAdapter.m. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + @objc(LKS_MultiplatformAdapter) + public class LKS_MultiplatformAdapter: NSObject { + @objc(isiPad) + public class func isiPad() -> Bool { + #if canImport(UIKit) + return lookinIsiPad + #else + return false + #endif + } + + @objc(isMac) + public class func isMac() -> Bool { + #if os(macOS) + return true + #else + return false + #endif + } + + @objc(isMacCatalyst) + public class func isMacCatalyst() -> Bool { + #if targetEnvironment(macCatalyst) + return true + #else + return false + #endif + } + + @objc(mainScreenBounds) + public class func mainScreenBounds() -> CGRect { + #if os(visionOS) || targetEnvironment(macCatalyst) + return lookinFirstActiveWindowScene()?.coordinateSpace.bounds ?? .zero + #elseif canImport(UIKit) + return UIScreen.main.bounds + #elseif os(macOS) + // Not the screen's bounds: a Mac window need not fill the + // screen, and Lookin is window based (on iOS the screen bounds + // are the window bounds), so return the largest window size. + var maxWidth: CGFloat = 0 + var maxHeight: CGFloat = 0 + for window in NSApplication.shared.windows { + maxWidth = max(maxWidth, window.frame.size.width) + maxHeight = max(maxHeight, window.frame.size.height) + } + return CGRect(x: 0, y: 0, width: maxWidth, height: maxHeight) + #else + return .zero + #endif + } + + @objc(mainScreenScale) + public class func mainScreenScale() -> CGFloat { + #if os(visionOS) + return 2 + #elseif canImport(UIKit) + return UIScreen.main.scale + #elseif os(macOS) + // [NSScreen mainScreen] is nil without a screen; the message to + // nil answered 0. + return NSScreen.main?.backingScaleFactor ?? 0 + #else + return 1 + #endif + } + + @objc(keyWindow) + public class func keyWindow() -> LookinWindow? { + #if os(visionOS) + return lookinFirstActiveWindowScene()?.keyWindow + #elseif canImport(UIKit) + return lookinSharedApplication()?.keyWindow + #elseif os(macOS) + return NSApplication.shared.keyWindow + #endif + } + + @objc(allWindows) + public class func allWindows() -> [LookinWindow] { + #if canImport(UIKit) + let scenes = allWindowScenes() + if !scenes.isEmpty { + // NSMutableArray, so -containsObject: compares with + // -isEqual: as the original did. + let windows = NSMutableArray() + for windowScene in scenes { + for window in allWindows(for: windowScene) where !windows.contains(window) { + windows.add(window) + } + + // UIModalPresentationFormSheet uses a private window that + // is missing from scene.windows but reachable via + // scene.keyWindow (iOS 15+). + if #available(iOS 15.0, tvOS 15.0, macCatalyst 15.0, *) { + if let sceneKeyWindow = windowScene.keyWindow, !windows.contains(sceneKeyWindow) { + if !NSStringFromClass(lookinObjCClass(of: sceneKeyWindow)).contains("HUD") { + windows.add(sceneKeyWindow) + } + } + } + } + if windows.count > 0 { + return windows.copy() as! [LookinWindow] + } + } + #if os(visionOS) + // visionOS has no global window list to fall back to. + return [] + #else + return lookinSharedApplication()?.windows ?? [] + #endif + #else + return NSApplication.shared.windows + #endif + } + + #if canImport(UIKit) + @objc(allWindowScenes) + public class func allWindowScenes() -> [UIWindowScene] { + var allScenes: [UIScene] = [] + // Private +[UIScene _scenesIncludingInternal:], available since + // iOS 13: every scene, including the ones connectedScenes hides + // (e.g. _UIKeyboardWindowScene on iOS 17+). + let selector = NSSelectorFromString("_scenesIncludingInternal:") + if UIScene.responds(to: selector) { + typealias ScenesIncludingInternal = @convention(c) (AnyClass, Selector, ObjCBool) -> Unmanaged? + let function = unsafeBitCast(UIScene.method(for: selector), to: ScenesIncludingInternal.self) + if let scenes = function(UIScene.self, selector, true)?.takeUnretainedValue() as? [UIScene] { + allScenes = scenes + } + } + if allScenes.isEmpty { + if let connectedScenes = lookinSharedApplication()?.connectedScenes { + allScenes = (connectedScenes as NSSet).allObjects as! [UIScene] + } + } + + var windowScenes: [UIWindowScene] = [] + windowScenes.reserveCapacity(allScenes.count) + for scene in allScenes { + if let windowScene = scene as? UIWindowScene { + windowScenes.append(windowScene) + } + } + return windowScenes + } + + @objc(allWindowsForWindowScene:) + public class func allWindows(for scene: UIWindowScene?) -> [UIWindow] { + guard let scene else { + return [] + } + // Private -[UIWindowScene _allWindowsIncludingInternalWindows: + // onlyVisibleWindows:]. The public `windows` getter passes + // includeInternal=NO, which drops every window whose + // -isInternalWindow is YES; on iOS 26+ that includes + // UIRemoteKeyboardWindow, the soft keyboard. + let selector = NSSelectorFromString("_allWindowsIncludingInternalWindows:onlyVisibleWindows:") + if scene.responds(to: selector) { + typealias AllWindows = @convention(c) (AnyObject, Selector, ObjCBool, ObjCBool) -> Unmanaged? + let function = unsafeBitCast(scene.method(for: selector), to: AllWindows.self) + if let windows = function(scene, selector, true, false)?.takeUnretainedValue() as? [UIWindow], + !windows.isEmpty + { + return windows + } + } + return scene.windows + } + #endif + } + + #if canImport(UIKit) + /// `[UIApplication sharedApplication]`, which is nil outside an app + /// (an XCTest bundle, an extension); the Objective-C messages to it then + /// answered nil. `UIApplication.shared` is implicitly unwrapped and + /// would trap instead. + private func lookinSharedApplication() -> UIApplication? { + return UIApplication.shared + } + + /// `+isiPad`'s dispatch_once value; a global `let` is initialized once, + /// thread-safely, on first use. + private let lookinIsiPad: Bool = UIDevice.current.model.hasPrefix("iPad") + + /// `[object class]`, not `type(of:)`: for a KVO-observed object the + /// former is the original class. + private func lookinObjCClass(of object: NSObject) -> AnyClass { + object.perform(NSSelectorFromString("class")) + .map { $0.takeUnretainedValue() as! AnyClass } ?? type(of: object) + } + #endif + + #if os(visionOS) || targetEnvironment(macCatalyst) + /// `+getFirstActiveWindowScene`: the first foreground-active window + /// scene. + private func lookinFirstActiveWindowScene() -> UIWindowScene? { + for scene in lookinSharedApplication()?.connectedScenes ?? [] { + guard let windowScene = scene as? UIWindowScene else { + continue + } + if windowScene.activationState == .foregroundActive { + return windowScene + } + } + return nil + } + #endif + +#endif diff --git a/Sources/LookinCoreImpl/LookInsideServerLogger.swift b/Sources/LookinCoreImpl/LookInsideServerLogger.swift new file mode 100644 index 00000000..32857fea --- /dev/null +++ b/Sources/LookinCoreImpl/LookInsideServerLogger.swift @@ -0,0 +1,82 @@ +// +// LookInsideServerLogger.swift +// LookinCore +// +// Swift implementation of LookinServerBase/LookInsideServerLogger.h (the +// class was in LookInsideServerLogger.m). The exported C constant +// LookInsideServerLogLevelEnvironmentKey stays in LookInsideServerLogger.m: +// Swift cannot define a C global. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinServerBase + #endif + + @objc @implementation extension LookInsideServerLogger { + class var minimumLogLevel: LookInsideServerLogLevel { + lookInsideServerMinimumLogLevel + } + + @objc(shouldLogAtLevel:) + class func shouldLog(at level: LookInsideServerLogLevel) -> Bool { + level.rawValue >= minimumLogLevel.rawValue + } + + @objc(logWithLevel:message:) + class func log(with level: LookInsideServerLogLevel, message: String) { + if !shouldLog(at: level) { + return + } + NSLog("LookinServer [%@] - %@", lookInsideServerLogLevelName(level) as NSString, message as NSString) + } + } + + /// Read once from the environment, like the original's dispatch_once: a + /// global `let` is initialized once, thread-safely, on first use. + private let lookInsideServerMinimumLogLevel: LookInsideServerLogLevel = lookInsideServerLogLevelFromEnvironment() + + private func lookInsideServerLogLevelFromEnvironment() -> LookInsideServerLogLevel { + guard let rawValue = ProcessInfo.processInfo.environment[LookInsideServerLogLevelEnvironmentKey], + !rawValue.isEmpty + else { + return .default + } + switch rawValue.trimmingCharacters(in: .whitespacesAndNewlines) { + case "0": + return .debug + case "1": + return .info + case "2": + return .default + case "3": + return .warning + case "4": + return .error + default: + return .default + } + } + + private func lookInsideServerLogLevelName(_ level: LookInsideServerLogLevel) -> String { + switch level { + case .debug: + "debug" + case .info: + "info" + case .default: + "default" + case .warning: + "warning" + case .error: + "error" + @unknown default: + // The Objective-C switch fell off the end here; NSLog printed + // "(null)" for the nil name. + "(null)" + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinAppInfo.swift b/Sources/LookinCoreImpl/LookinAppInfo.swift new file mode 100644 index 00000000..244a8799 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinAppInfo.swift @@ -0,0 +1,462 @@ +// +// LookinAppInfo.swift +// qmuidemo +// +// Was LookinAppInfo.m: the 201 +// reply and part of every hierarchy. The coding keys and scalar encodings are +// pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + private let CodingKey_AppIcon = "1" + private let CodingKey_Screenshot = "2" + private let CodingKey_DeviceDescription = "3" + private let CodingKey_OsDescription = "4" + private let CodingKey_AppName = "5" + private let CodingKey_ScreenWidth = "6" + private let CodingKey_ScreenHeight = "7" + private let CodingKey_DeviceType = "8" + + /// Reads a sysctl entry as a string, or nil when it is unavailable or empty. + private func LookinAppInfoSysctlStringValue(_ sysctlName: String) -> String? { + var valueSize = 0 + if sysctlbyname(sysctlName, nil, &valueSize, nil, 0) != 0 || valueSize == 0 { + return nil + } + var valueBuffer = [CChar](repeating: 0, count: valueSize) + var value: String? + if sysctlbyname(sysctlName, &valueBuffer, &valueSize, nil, 0) == 0 { + value = String(validatingCString: valueBuffer) + } + guard let value, !value.isEmpty else { + return nil + } + return value + } + + #if targetEnvironment(macCatalyst) + /// This Mac's user-facing computer name, e.g. "JH's Mac Studio Ultra". + /// Nil when it cannot be read, which callers must tolerate. + /// + /// Needed because -[UIDevice name] answers the literal string "iPad" on + /// Catalyst. That names a device family, not this machine, so it + /// identifies nothing to the person reading the host's device label. + private func LookinAppInfoMacHostLocalizedName() -> String? { + let currentHostSelector = NSSelectorFromString("currentHost") + guard let hostClass = NSClassFromString("NSHost"), (hostClass as AnyObject).responds(to: currentHostSelector) else { + return nil + } + // NSHost is declared only in the AppKit-flavoured Foundation headers, + // which a Catalyst target does not see, but the class is present and + // works at runtime. + guard let currentHost = (hostClass as AnyObject).perform(currentHostSelector)?.takeUnretainedValue() else { + return nil + } + let localizedNameSelector = NSSelectorFromString("localizedName") + guard currentHost.responds(to: localizedNameSelector) else { + return nil + } + guard let localizedName = currentHost.perform(localizedNameSelector)?.takeUnretainedValue() as? NSString, localizedName.length > 0 else { + return nil + } + return localizedName as String + } + #endif + + /// `+getAppInfoIdentifier`: random per launch, stable until the app is + /// killed. + private let LookinAppInfoIdentifier: Int = { + let nowMicros = UInt64(Date().timeIntervalSince1970 * 1_000_000.0) + let processID = UInt64(ProcessInfo.processInfo.processIdentifier) + let randomBits = UInt64(arc4random()) + var identifier = Int(truncatingIfNeeded: (nowMicros << 12) ^ (processID << 1) ^ randomBits) + if identifier <= 0 { + identifier = Int(truncatingIfNeeded: processID ^ (randomBits != 0 ? randomBits : 1)) + } + return identifier + }() + + /// `+currentDeviceModelIdentifier`: the hardware model identifier of the + /// device this process runs on, e.g. "iPhone16,2". Nil when it cannot be + /// determined, which callers must tolerate. + private let LookinAppInfoDeviceModelIdentifier: String? = { + #if targetEnvironment(simulator) + // hw.machine reports the *host* Mac's architecture ("arm64") inside a + // simulator, which identifies no device at all. The simulated model is + // only available from the environment simctl prepares for the process. + return ProcessInfo.processInfo.environment["SIMULATOR_MODEL_IDENTIFIER"] + #elseif os(macOS) || targetEnvironment(macCatalyst) + // On macOS hw.machine is the CPU architecture; the Mac model lives in + // hw.model. + return LookinAppInfoSysctlStringValue("hw.model") + #else + return LookinAppInfoSysctlStringValue("hw.machine") + #endif + }() + + /// `+isSimulator`. + private func LookinAppInfoIsSimulator() -> Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + private func LookinAppInfoAppName() -> String { + let info = Bundle.main.infoDictionary + if let displayName = info?["CFBundleDisplayName"] as? NSString, displayName.length > 0 { + return displayName as String + } + if let name = info?["CFBundleName"] as? NSString, name.length > 0 { + return name as String + } + return ProcessInfo.processInfo.processName + } + + private func LookinAppInfoAppIcon() -> LookinImage? { + #if os(tvOS) + return nil + #elseif canImport(UIKit) + var imageName: String? + if let bundleIcons = Bundle.main.infoDictionary?["CFBundleIcons"] as? NSDictionary { + let primaryIcon = bundleIcons.object(forKey: "CFBundlePrimaryIcon") + if let primaryIcon = primaryIcon as? NSDictionary { + imageName = (primaryIcon.object(forKey: "CFBundleIconFiles") as? NSArray)?.lastObject as? String + } else if let primaryIcon = primaryIcon as? String { + imageName = primaryIcon + } + } + guard let imageName, !imageName.isEmpty else { + // The name is normally something like "AppIcon60x60", but it can + // be nil; [UIImage imageNamed:nil] would log "CUICatalog: Invalid + // asset name supplied: '(null)'". + return nil + } + return UIImage(named: imageName) + #elseif os(macOS) + return NSApplication.shared.applicationIconImage + #endif + } + + private func LookinAppInfoScreenshotImage() -> LookinImage? { + // The old header declared +keyWindow nonnull, but it answers nil whenever + // no window is key; read it through the runtime so Swift sees the nil. + var window = (LKS_MultiplatformAdapter.self as AnyObject).perform(NSSelectorFromString("keyWindow"))?.takeUnretainedValue() as? LookinWindow + #if os(macOS) + // keyWindow is nil on macOS while the app is inactive or in the + // background. Fall back to the main window, then to any window. + if window == nil { + window = NSApplication.shared.mainWindow + } + if window == nil { + window = NSApplication.shared.windows.first + } + #endif + guard let window else { + return nil + } + #if canImport(UIKit) + let size = window.bounds.size + if size.width <= 0 || size.height <= 0 { + // UIGraphicsBeginImageContext() raises for a zero size: + // https://github.com/hughkli/Lookin/issues/21 + return nil + } + UIGraphicsBeginImageContextWithOptions(size, true, 0.4) + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + let image = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return image + #elseif os(macOS) + // ScreenCaptureKit is async and prompts for screen recording + // permission; the synchronous CGWindowListCreateImage is enough to + // capture this app's own window thumbnail. + guard let cgImage = LookinAppInfoWindowListImage(CGWindowID(window.windowNumber)) else { + return nil + } + return NSImage(cgImage: cgImage, size: window.frame.size) + #endif + } + + #if os(macOS) + @available(macOS, deprecated: 14.0) + private func LookinAppInfoDeprecatedWindowListImage(_ windowID: CGWindowID) -> CGImage? { + CGWindowListCreateImage(.zero, .optionIncludingWindow, windowID, .boundsIgnoreFraming) + } + + /// Calls the deprecated CGWindowListCreateImage without a warning at + /// every use site (the original silenced -Wdeprecated-declarations). + private let LookinAppInfoWindowListImage: (CGWindowID) -> CGImage? = LookinAppInfoDeprecatedWindowListImage + #endif + + @objc(LookinAppInfo) + public class LookinAppInfo: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(appInfoIdentifier) + public var appInfoIdentifier: UInt = 0 + @objc(shouldUseCache) + public var shouldUseCache: Bool = false + @objc(serverVersion) + public var serverVersion: Int32 = 0 + /// Declared `assign` before the Swift rewrite (a dangling-pointer risk); + /// strong now, with no change on the wire. + @objc(serverReadableVersion) + public var serverReadableVersion: String! + @objc(swiftEnabledInLookinServer) + public var swiftEnabledInLookinServer: Int32 = 0 + @objc(screenshot) + public var screenshot: LookinImage! + @objc(appIcon) + public var appIcon: LookinImage! + @objc(appName) + public var appName: String! + @objc(appBundleIdentifier) + public var appBundleIdentifier: String! + @objc(deviceDescription) + public var deviceDescription: String! + @objc(deviceModelIdentifier) + public var deviceModelIdentifier: String! + @objc(osDescription) + public var osDescription: String! + @objc(osMainVersion) + public var osMainVersion: UInt = 0 + @objc(deviceType) + public var deviceType: LookinAppInfoDevice = .simulator + @objc(screenWidth) + public var screenWidth: Double = 0 + @objc(screenHeight) + public var screenHeight: Double = 0 + @objc(screenScale) + public var screenScale: Double = 0 + @objc(cachedTimestamp) + public var cachedTimestamp: TimeInterval = 0 + /// Zero for older/unsupported servers; currently 1 for gesture capture. + @objc(gestureDebugProtocolVersion) + public var gestureDebugProtocolVersion: UInt = 0 + + override public init() { + super.init() + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newAppInfo = LookinAppInfo() + newAppInfo.appIcon = appIcon + newAppInfo.gestureDebugProtocolVersion = gestureDebugProtocolVersion + newAppInfo.appName = appName + newAppInfo.deviceDescription = deviceDescription + newAppInfo.deviceModelIdentifier = deviceModelIdentifier + newAppInfo.osDescription = osDescription + newAppInfo.osMainVersion = osMainVersion + newAppInfo.deviceType = deviceType + newAppInfo.screenWidth = screenWidth + newAppInfo.screenHeight = screenHeight + newAppInfo.screenScale = screenScale + newAppInfo.appInfoIdentifier = appInfoIdentifier + newAppInfo.cachedTimestamp = cachedTimestamp + return newAppInfo + } + + // MARK: NSSecureCoding + + public required init?(coder aDecoder: NSCoder) { + super.init() + serverVersion = aDecoder.decodeCInt(forKey: "serverVersion") + serverReadableVersion = aDecoder.decodeObject(forKey: "serverReadableVersion") as? String + swiftEnabledInLookinServer = aDecoder.decodeCInt(forKey: "swiftEnabledInLookinServer") + gestureDebugProtocolVersion = UInt(bitPattern: aDecoder.decodeInteger(forKey: "gestureDebugProtocolVersion")) + let screenshotData = aDecoder.decodeObject(forKey: CodingKey_Screenshot) as? Data + screenshot = screenshotData.flatMap { LookinImage(data: $0) } + + let appIconData = aDecoder.decodeObject(forKey: CodingKey_AppIcon) as? Data + appIcon = appIconData.flatMap { LookinImage(data: $0) } + + appName = aDecoder.decodeObject(forKey: CodingKey_AppName) as? String + appBundleIdentifier = aDecoder.decodeObject(forKey: "appBundleIdentifier") as? String + deviceDescription = aDecoder.decodeObject(forKey: CodingKey_DeviceDescription) as? String + deviceModelIdentifier = aDecoder.decodeObject(forKey: "deviceModelIdentifier") as? String + osDescription = aDecoder.decodeObject(forKey: CodingKey_OsDescription) as? String + osMainVersion = UInt(bitPattern: aDecoder.decodeInteger(forKey: "osMainVersion")) + deviceType = LookinAppInfoDevice(rawValue: aDecoder.decodeInteger(forKey: CodingKey_DeviceType)) ?? .simulator + screenWidth = aDecoder.decodeDouble(forKey: CodingKey_ScreenWidth) + screenHeight = aDecoder.decodeDouble(forKey: CodingKey_ScreenHeight) + screenScale = aDecoder.decodeDouble(forKey: "screenScale") + appInfoIdentifier = UInt(bitPattern: aDecoder.decodeInteger(forKey: "appInfoIdentifier")) + shouldUseCache = aDecoder.decodeBool(forKey: "shouldUseCache") + } + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encodeCInt(serverVersion, forKey: "serverVersion") + aCoder.encode(serverReadableVersion, forKey: "serverReadableVersion") + aCoder.encodeCInt(swiftEnabledInLookinServer, forKey: "swiftEnabledInLookinServer") + aCoder.encode(Int(bitPattern: gestureDebugProtocolVersion), forKey: "gestureDebugProtocolVersion") + + #if canImport(UIKit) + let screenshotData = lookinPNGRepresentation(screenshot) + aCoder.encode(screenshotData, forKey: CodingKey_Screenshot) + + let appIconData = lookinPNGRepresentation(appIcon) + aCoder.encode(appIconData, forKey: CodingKey_AppIcon) + #elseif os(macOS) + let screenshotData = lookinTIFFRepresentation(screenshot) + aCoder.encode(screenshotData, forKey: CodingKey_Screenshot) + + let appIconData = lookinTIFFRepresentation(appIcon) + aCoder.encode(appIconData, forKey: CodingKey_AppIcon) + #endif + + aCoder.encode(appName, forKey: CodingKey_AppName) + aCoder.encode(appBundleIdentifier, forKey: "appBundleIdentifier") + aCoder.encode(deviceDescription, forKey: CodingKey_DeviceDescription) + aCoder.encode(deviceModelIdentifier, forKey: "deviceModelIdentifier") + aCoder.encode(osDescription, forKey: CodingKey_OsDescription) + aCoder.encode(Int(bitPattern: osMainVersion), forKey: "osMainVersion") + aCoder.encode(deviceType.rawValue, forKey: CodingKey_DeviceType) + aCoder.encode(screenWidth, forKey: CodingKey_ScreenWidth) + aCoder.encode(screenHeight, forKey: CodingKey_ScreenHeight) + aCoder.encode(screenScale, forKey: "screenScale") + aCoder.encode(Int(bitPattern: appInfoIdentifier), forKey: "appInfoIdentifier") + aCoder.encode(shouldUseCache, forKey: "shouldUseCache") + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + // MARK: Equality + + override open func isEqual(_ object: Any?) -> Bool { + if let object = object as? NSObject, object === self { + return true + } + guard let object = object as? LookinAppInfo else { + return false + } + return isEqual(to: object) + } + + override open var hash: Int { + lookinStringHash(appName) ^ lookinStringHash(deviceDescription) ^ lookinStringHash(osDescription) ^ deviceType.rawValue + } + + @objc(isEqualToAppInfo:) + public func isEqual(to info: LookinAppInfo?) -> Bool { + guard let info else { + return false + } + return lookinStringsEqual(appName, info.appName) + && lookinStringsEqual(deviceDescription, info.deviceDescription) + && lookinStringsEqual(osDescription, info.osDescription) + && deviceType == info.deviceType + } + + // MARK: Current app + + @objc(currentInfoWithScreenshot:icon:localIdentifiers:) + public class func currentInfo(withScreenshot hasScreenshot: Bool, icon hasIcon: Bool, localIdentifiers: [NSNumber]?) -> LookinAppInfo! { + let selfIdentifier = LookinAppInfoIdentifier + if let localIdentifiers, (localIdentifiers as NSArray).contains(NSNumber(value: selfIdentifier)) { + let info = LookinAppInfo() + info.appInfoIdentifier = UInt(bitPattern: selfIdentifier) + info.shouldUseCache = true + return info + } + + let info = LookinAppInfo() + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, *) { + info.gestureDebugProtocolVersion = NSClassFromString("LKS_GestureDebugService") != nil ? 1 : 0 + } + info.serverReadableVersion = LOOKIN_SERVER_READABLE_VERSION + // Report Swift optimization as enabled whenever the Swift-aware build + // is compiled in. The CocoaPods subspec path defines + // LOOKIN_SERVER_SWIFT_ENABLED; the SwiftPM / XCFramework path defines + // SPM_LOOKIN_SERVER_ENABLED. Both imply the optimization is active, + // matching the contract documented on -swiftEnabledInLookinServer + // ("SPM 或 Swift Subspec → 1"). + #if LOOKIN_SERVER_SWIFT_ENABLED || SPM_LOOKIN_SERVER_ENABLED + info.swiftEnabledInLookinServer = 1 + #else + info.swiftEnabledInLookinServer = -1 + #endif + info.appInfoIdentifier = UInt(bitPattern: selfIdentifier) + info.appName = LookinAppInfoAppName() + #if targetEnvironment(macCatalyst) + // Ask the Mac for its own name. -[UIDevice name] is still consulted + // as a last resort so the field is never empty, but on Catalyst it + // only ever answers "iPad". + info.deviceDescription = LookinAppInfoMacHostLocalizedName() ?? UIDevice.current.name + #elseif canImport(UIKit) + info.deviceDescription = UIDevice.current.name + #elseif os(macOS) + info.deviceDescription = Host.current().localizedName + #endif + info.deviceModelIdentifier = LookinAppInfoDeviceModelIdentifier + info.appBundleIdentifier = Bundle.main.bundleIdentifier + if LookinAppInfoIsSimulator() { + info.deviceType = .simulator + } else if LKS_MultiplatformAdapter.isMacCatalyst() { + // Must precede the iPad check: -[UIDevice model] reports "iPad" on + // Catalyst, so +isiPad matches a Catalyst build too and would + // otherwise claim it first. +isMac never fires here either. + info.deviceType = .macCatalyst + } else if LKS_MultiplatformAdapter.isiPad() { + info.deviceType = .iPad + } else if LKS_MultiplatformAdapter.isMac() { + info.deviceType = .mac + } else { + info.deviceType = .others + } + + #if canImport(UIKit) + let systemVersion = UIDevice.current.systemVersion + #if targetEnvironment(macCatalyst) + // On Catalyst -[UIDevice systemVersion] answers the *macOS* + // version, not the iOS version Catalyst maps onto. + info.osDescription = "macCatalyst \(systemVersion)" + #else + info.osDescription = "iOS \(systemVersion)" + #endif + let mainVersionStr = (systemVersion as NSString).components(separatedBy: ".").first + info.osMainVersion = UInt(bitPattern: (mainVersionStr as NSString?)?.integerValue ?? 0) + #elseif os(macOS) + let operatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion + if operatingSystemVersion.patchVersion != 0 { + info.osDescription = "macOS \(operatingSystemVersion.majorVersion).\(operatingSystemVersion.minorVersion).\(operatingSystemVersion.patchVersion)" + } else { + info.osDescription = "macOS \(operatingSystemVersion.majorVersion).\(operatingSystemVersion.minorVersion)" + } + info.osMainVersion = UInt(bitPattern: operatingSystemVersion.majorVersion) + #endif + + let screenSize = LKS_MultiplatformAdapter.mainScreenBounds().size + info.screenWidth = Double(screenSize.width) + info.screenHeight = Double(screenSize.height) + info.screenScale = Double(LKS_MultiplatformAdapter.mainScreenScale()) + + if hasScreenshot { + info.screenshot = LookinAppInfoScreenshotImage() + } + if hasIcon { + info.appIcon = LookinAppInfoAppIcon() + } + + return info + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinAttribute.swift b/Sources/LookinCoreImpl/LookinAttribute.swift new file mode 100644 index 00000000..a6837af0 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinAttribute.swift @@ -0,0 +1,84 @@ +// +// LookinAttribute.swift +// qmuidemo +// +// Was LookinAttribute.m. The +// coding keys and value encodings are pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinAttribute) + public class LookinAttribute: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(identifier) + public var identifier: String! + @objc(displayTitle) + public var displayTitle: String! + @objc(attrType) + public var attrType: LookinAttrType = .none + @objc(value) + public var value: Any! + @objc(extraValue) + public var extraValue: Any! + @objc(customSetterID) + public var customSetterID: String! + @objc(targetDisplayItem) + public weak var targetDisplayItem: LookinDisplayItem? + + override public init() { + super.init() + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newAttr = LookinAttribute() + newAttr.identifier = identifier + newAttr.displayTitle = displayTitle + newAttr.value = value + newAttr.attrType = attrType + newAttr.extraValue = extraValue + newAttr.customSetterID = customSetterID + return newAttr + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(displayTitle, forKey: "displayTitle") + aCoder.encode(identifier, forKey: "identifier") + aCoder.encode(attrType.rawValue, forKey: "attrType") + aCoder.encode(value, forKey: "value") + aCoder.encode(extraValue, forKey: "extraValue") + aCoder.encode(customSetterID, forKey: "customSetterID") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + displayTitle = aDecoder.decodeObject(forKey: "displayTitle") as? String + identifier = aDecoder.decodeObject(forKey: "identifier") as? String + attrType = LookinAttrType(rawValue: aDecoder.decodeInteger(forKey: "attrType")) ?? .none + value = aDecoder.decodeObject(forKey: "value") + extraValue = aDecoder.decodeObject(forKey: "extraValue") + customSetterID = aDecoder.decodeObject(forKey: "customSetterID") as? String + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + @objc(isUserCustom) + public func isUserCustom() -> Bool { + lookinStringsEqual(identifier, LookinAttr_UserCustom) + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinAttributeModification.swift b/Sources/LookinCoreImpl/LookinAttributeModification.swift new file mode 100644 index 00000000..51a23c05 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinAttributeModification.swift @@ -0,0 +1,64 @@ +// +// LookinAttributeModification.swift +// Lookin +// +// Was LookinAttributeModification.m. The coding keys and value encodings are +// pinned by Tests/WireFormatGolden (the 204 request). +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinAttributeModification) + public class LookinAttributeModification: NSObject, NSCoding, NSSecureCoding { + @objc(targetOid) + public var targetOid: UInt = 0 + @objc(setterSelector) + public var setterSelector: Selector! + /// Not archived. + @objc(getterSelector) + public var getterSelector: Selector! + @objc(attrType) + public var attrType: LookinAttrType = .none + @objc(value) + public var value: Any! + @objc(clientReadableVersion) + public var clientReadableVersion: String! + + override public init() { + super.init() + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(NSNumber(value: targetOid), forKey: "targetOid") + // NSStringFromSelector(NULL) is nil. + aCoder.encode(setterSelector.map { NSStringFromSelector($0) }, forKey: "setterSelector") + aCoder.encode(attrType.rawValue, forKey: "attrType") + aCoder.encode(value, forKey: "value") + aCoder.encode(clientReadableVersion, forKey: "clientReadableVersion") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + targetOid = (aDecoder.decodeObject(forKey: "targetOid") as? NSNumber)?.uintValue ?? 0 + // NSSelectorFromString(nil) is NULL. + setterSelector = (aDecoder.decodeObject(forKey: "setterSelector") as? String).map { NSSelectorFromString($0) } + attrType = LookinAttrType(rawValue: aDecoder.decodeInteger(forKey: "attrType")) ?? .none + value = aDecoder.decodeObject(forKey: "value") + clientReadableVersion = aDecoder.decodeObject(forKey: "clientReadableVersion") as? String + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinAttributesGroup.swift b/Sources/LookinCoreImpl/LookinAttributesGroup.swift new file mode 100644 index 00000000..9d41f53a --- /dev/null +++ b/Sources/LookinCoreImpl/LookinAttributesGroup.swift @@ -0,0 +1,119 @@ +// +// LookinAttributesGroup.swift +// Lookin +// +// Was LookinAttributesGroup.m. The coding keys are pinned by +// Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinAttributesGroup) + public class LookinAttributesGroup: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(userCustomTitle) + public var userCustomTitle: String! + @objc(identifier) + public var identifier: String! + @objc(attrSections) + public var attrSections: [LookinAttributesSection]! + @objc(isSwiftUIGroup) + public var isSwiftUIGroup: Bool = false + + override public init() { + super.init() + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newGroup = LookinAttributesGroup() + newGroup.userCustomTitle = userCustomTitle + newGroup.identifier = identifier + newGroup.attrSections = attrSections?.map { $0.copy() as! LookinAttributesSection } + newGroup.isSwiftUIGroup = isSwiftUIGroup + return newGroup + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(userCustomTitle, forKey: "userCustomTitle") + aCoder.encode(identifier, forKey: "identifier") + aCoder.encode(attrSections, forKey: "attrSections") + aCoder.encode(isSwiftUIGroup, forKey: "isSwiftUIGroup") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + userCustomTitle = aDecoder.decodeObject(forKey: "userCustomTitle") as? String + identifier = aDecoder.decodeObject(forKey: "identifier") as? String + attrSections = aDecoder.decodeObject(forKey: "attrSections") as? [LookinAttributesSection] + isSwiftUIGroup = aDecoder.decodeBool(forKey: "isSwiftUIGroup") + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + // MARK: Equality + + override open var hash: Int { + lookinStringHash(uniqueKey()) + } + + override open func isEqual(_ object: Any?) -> Bool { + if let object = object as? NSObject, object === self { + return true + } + guard let targetObject = object as? LookinAttributesGroup else { + return false + } + if !lookinStringsEqual(identifier, targetObject.identifier) { + return false + } + if lookinStringsEqual(identifier, LookinAttrGroup_UserCustom) { + return lookinStringsEqual(userCustomTitle, targetObject.userCustomTitle) + } + return true + } + + @objc(uniqueKey) + public func uniqueKey() -> String! { + if lookinStringsEqual(identifier, LookinAttrGroup_UserCustom) { + return userCustomTitle + } + return identifier + } + + @objc(isUserCustom) + public func isUserCustom() -> Bool { + lookinStringsEqual(identifier, LookinAttrGroup_UserCustom) + } + + @objc(groupsByKeepingFirstGroupForEachUniqueKey:) + public class func groupsByKeepingFirstGroup(forEachUniqueKey groups: [LookinAttributesGroup]?) -> [LookinAttributesGroup]! { + // NSMutableSet compares through -isEqual: / -hash, like the original. + let seenUniqueKeys = NSMutableSet() + var deduplicatedGroups: [LookinAttributesGroup] = [] + for group in groups ?? [] { + if let uniqueKey = group.uniqueKey() { + if seenUniqueKeys.contains(uniqueKey) { + continue + } + seenUniqueKeys.add(uniqueKey) + } + deduplicatedGroups.append(group) + } + return deduplicatedGroups + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinAttributesSection.swift b/Sources/LookinCoreImpl/LookinAttributesSection.swift new file mode 100644 index 00000000..dfc9c695 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinAttributesSection.swift @@ -0,0 +1,62 @@ +// +// LookinAttributesSection.swift +// Lookin +// +// Was LookinAttributesSection.m. The coding keys are pinned by +// Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinAttributesSection) + public class LookinAttributesSection: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(identifier) + public var identifier: String! + @objc(attributes) + public var attributes: [LookinAttribute]! + + override public init() { + super.init() + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newSection = LookinAttributesSection() + newSection.identifier = identifier + newSection.attributes = attributes?.map { $0.copy() as! LookinAttribute } + return newSection + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(identifier, forKey: "identifier") + aCoder.encode(attributes, forKey: "attributes") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + identifier = aDecoder.decodeObject(forKey: "identifier") as? String + attributes = aDecoder.decodeObject(forKey: "attributes") as? [LookinAttribute] + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + @objc(isUserCustom) + public func isUserCustom() -> Bool { + lookinStringsEqual(identifier, LookinAttrSec_UserCustom) + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinAutoLayoutConstraint.swift b/Sources/LookinCoreImpl/LookinAutoLayoutConstraint.swift new file mode 100644 index 00000000..0f14d1de --- /dev/null +++ b/Sources/LookinCoreImpl/LookinAutoLayoutConstraint.swift @@ -0,0 +1,167 @@ +// +// LookinAutoLayoutConstraint.swift +// Lookin +// +// Was LookinAutoLayoutConstraint.m. The coding keys and scalar encodings are +// pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + /// View endpoints have always had a node to land on; layout guide + /// endpoints gained one when guides entered the hierarchy tree. + private func LookinConstraintItemTypeIsJumpable(_ itemType: LookinConstraintItemType) -> Bool { + itemType == .view || itemType == .layoutGuide + } + + /// The asserts help find attributes private to the system; the original + /// notes they should go before a release. + private func lookinAssertUnknownAttribute(_ attribute: Int) { + if attribute > 20, attribute < 32 { + assertionFailure() + } + if attribute > 37 { + assertionFailure() + } + } + + @objc(LookinAutoLayoutConstraint) + public class LookinAutoLayoutConstraint: NSObject, NSCoding, NSSecureCoding { + @objc(effective) + public var effective: Bool = false + @objc(active) + public var active: Bool = false + @objc(shouldBeArchived) + public var shouldBeArchived: Bool = false + @objc(firstItem) + public var firstItem: LookinObject! + @objc(firstItemType) + public var firstItemType: LookinConstraintItemType = .unknown + @objc(firstAttribute) + public var firstAttribute: Int = 0 { + didSet { + lookinAssertUnknownAttribute(firstAttribute) + } + } + + @objc(relation) + public var relation: NSLayoutConstraint.Relation = .equal + @objc(secondItem) + public var secondItem: LookinObject! + @objc(secondItemType) + public var secondItemType: LookinConstraintItemType = .unknown + @objc(secondAttribute) + public var secondAttribute: Int = 0 { + didSet { + lookinAssertUnknownAttribute(secondAttribute) + } + } + + @objc(multiplier) + public var multiplier: CGFloat = 0 + @objc(constant) + public var constant: CGFloat = 0 + @objc(priority) + public var priority: CGFloat = 0 + @objc(identifier) + public var identifier: String! + @objc(constraintOid) + public var constraintOid: UInt = 0 + + /// `+instanceFromNSConstraint:isEffective:firstItemType:secondItemType:`. + /// An Objective-C factory must be a class method here. + @objc(instanceFromNSConstraint:isEffective:firstItemType:secondItemType:) + public class func instance(fromNSConstraint constraint: NSLayoutConstraint?, isEffective: Bool, firstItemType: LookinConstraintItemType, secondItemType: LookinConstraintItemType) -> Self! { + let instance = unsafeDowncast((self as NSObject.Type).init(), to: self) + instance.effective = isEffective + instance.active = constraint?.isActive ?? false + instance.shouldBeArchived = constraint?.shouldBeArchived ?? false + instance.firstItem = LookinObject.instance(with: constraint?.firstItem as? NSObject) + instance.firstItemType = firstItemType + instance.firstAttribute = constraint?.firstAttribute.rawValue ?? 0 + instance.relation = constraint?.relation ?? NSLayoutConstraint.Relation(rawValue: 0)! + instance.secondItem = LookinObject.instance(with: constraint?.secondItem as? NSObject) + instance.secondItemType = secondItemType + instance.secondAttribute = constraint?.secondAttribute.rawValue ?? 0 + instance.multiplier = constraint?.multiplier ?? 0 + instance.constant = constraint?.constant ?? 0 + instance.priority = CGFloat(constraint?.priority.rawValue ?? 0) + instance.identifier = constraint?.identifier + return instance + } + + override public init() { + super.init() + } + + // MARK: NSSecureCoding + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(effective, forKey: "effective") + aCoder.encode(active, forKey: "active") + aCoder.encode(shouldBeArchived, forKey: "shouldBeArchived") + aCoder.encode(firstItem, forKey: "firstItem") + aCoder.encode(firstItemType.rawValue, forKey: "firstItemType") + aCoder.encode(firstAttribute, forKey: "firstAttribute") + aCoder.encode(relation.rawValue, forKey: "relation") + aCoder.encode(secondItem, forKey: "secondItem") + aCoder.encode(secondItemType.rawValue, forKey: "secondItemType") + aCoder.encode(secondAttribute, forKey: "secondAttribute") + aCoder.encode(Double(multiplier), forKey: "multiplier") + aCoder.encode(Double(constant), forKey: "constant") + aCoder.encode(Double(priority), forKey: "priority") + aCoder.encode(identifier, forKey: "identifier") + aCoder.encode(Int(bitPattern: constraintOid), forKey: "constraintOid") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + effective = aDecoder.decodeBool(forKey: "effective") + active = aDecoder.decodeBool(forKey: "active") + shouldBeArchived = aDecoder.decodeBool(forKey: "shouldBeArchived") + firstItem = aDecoder.decodeObject(forKey: "firstItem") as? LookinObject + firstItemType = LookinConstraintItemType(rawValue: aDecoder.decodeInteger(forKey: "firstItemType")) ?? .unknown + firstAttribute = aDecoder.decodeInteger(forKey: "firstAttribute") + relation = NSLayoutConstraint.Relation(rawValue: aDecoder.decodeInteger(forKey: "relation")) ?? .equal + secondItem = aDecoder.decodeObject(forKey: "secondItem") as? LookinObject + secondItemType = LookinConstraintItemType(rawValue: aDecoder.decodeInteger(forKey: "secondItemType")) ?? .unknown + secondAttribute = aDecoder.decodeInteger(forKey: "secondAttribute") + multiplier = CGFloat(aDecoder.decodeDouble(forKey: "multiplier")) + constant = CGFloat(aDecoder.decodeDouble(forKey: "constant")) + priority = CGFloat(aDecoder.decodeDouble(forKey: "priority")) + identifier = aDecoder.decodeObject(forKey: "identifier") as? String + // Absent in old archives: decodeInteger returns 0, which is exactly + // the "no identity" contract. + constraintOid = UInt(bitPattern: aDecoder.decodeInteger(forKey: "constraintOid")) + } + + @objc(jumpableItemObjectForEndpoint:) + public func jumpableItemObject(for endpoint: LookinConstraintEndpoint) -> LookinObject! { + switch endpoint { + case .first: + return LookinConstraintItemTypeIsJumpable(firstItemType) ? firstItem : nil + case .second: + return LookinConstraintItemTypeIsJumpable(secondItemType) ? secondItem : nil + @unknown default: + return nil + } + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinConnectionAttachment.swift b/Sources/LookinCoreImpl/LookinConnectionAttachment.swift new file mode 100644 index 00000000..7f6e3322 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinConnectionAttachment.swift @@ -0,0 +1,49 @@ +// +// LookinConnectionAttachment.swift +// Lookin +// +// Was LookinConnectionAttachment.m. Every request body travels in one of these; +// the coding keys "0" / "1" are pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + private let Key_Data = "0" + private let Key_DataType = "1" + + @objc(LookinConnectionAttachment) + public class LookinConnectionAttachment: NSObject, NSCoding, NSSecureCoding { + @objc(dataType) + public var dataType: LookinCodingValueType = .unknown + @objc(data) + public var data: Any! + + override public init() { + super.init() + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode((data as? NSObject)?.lookin_encodedObject(with: dataType), forKey: Key_Data) + aCoder.encode(dataType.rawValue, forKey: Key_DataType) + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + dataType = LookinCodingValueType(rawValue: aDecoder.decodeInteger(forKey: Key_DataType)) ?? .unknown + data = (aDecoder.decodeObject(forKey: Key_Data) as? NSObject)?.lookin_decodedObject(with: dataType) + } + + public class var supportsSecureCoding: Bool { + true + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinConnectionResponseAttachment.swift b/Sources/LookinCoreImpl/LookinConnectionResponseAttachment.swift new file mode 100644 index 00000000..bbddb325 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinConnectionResponseAttachment.swift @@ -0,0 +1,72 @@ +// +// LookinConnectionResponseAttachment.swift +// Lookin +// +// Was LookinConnectionResponseAttachment.m. Every reply travels in one of these; +// the coding keys and scalar encodings are pinned by Tests/WireFormatGolden +// (Response-* fixtures and LivePayloadTests). +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinConnectionResponseAttachment) + public class LookinConnectionResponseAttachment: LookinConnectionAttachment { + @objc(lookinServerVersion) + public var lookinServerVersion: Int32 = 0 + @objc(error) + public var error: (any Error)! + @objc(appIsInBackground) + public var appIsInBackground: Bool = false + @objc(dataTotalCount) + public var dataTotalCount: UInt = 0 + @objc(currentDataCount) + public var currentDataCount: UInt = 0 + + /// `+attachmentWithError:`. An Objective-C factory must be a class + /// method here: implemented as a Swift initializer it compiles but + /// registers no class method. + @objc(attachmentWithError:) + public class func attachment(error: (any Error)?) -> Self! { + let attachment = unsafeDowncast((self as NSObject.Type).init(), to: self) + attachment.error = error + return attachment + } + + override public init() { + super.init() + lookinServerVersion = Int32(LOOKIN_SERVER_VERSION) + dataTotalCount = 0 + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + override public func encode(with aCoder: NSCoder) { + super.encode(with: aCoder) + aCoder.encodeCInt(lookinServerVersion, forKey: "lookinServerVersion") + aCoder.encode(error, forKey: "error") + aCoder.encode(NSNumber(value: dataTotalCount), forKey: "dataTotalCount") + aCoder.encode(NSNumber(value: currentDataCount), forKey: "currentDataCount") + aCoder.encode(appIsInBackground, forKey: "appIsInBackground") + } + + public required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + lookinServerVersion = aDecoder.decodeCInt(forKey: "lookinServerVersion") + error = aDecoder.decodeObject(forKey: "error") as? NSError + dataTotalCount = (aDecoder.decodeObject(forKey: "dataTotalCount") as? NSNumber)?.uintValue ?? 0 + currentDataCount = (aDecoder.decodeObject(forKey: "currentDataCount") as? NSNumber)?.uintValue ?? 0 + appIsInBackground = aDecoder.decodeBool(forKey: "appIsInBackground") + } + + override public class var supportsSecureCoding: Bool { + true + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinCoreModelSupport.swift b/Sources/LookinCoreImpl/LookinCoreModelSupport.swift new file mode 100644 index 00000000..f94e1ab3 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinCoreModelSupport.swift @@ -0,0 +1,104 @@ +// +// LookinCoreModelSupport.swift +// LookinCore +// +// Objective-C semantics the Swift implementations of the LookinCore model +// classes rely on. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + // MARK: Strings + + // The originals compared and hashed NSString values through NSString + // (literal comparison, -[NSString hash]) and leaned on nil messaging + // (`[nil isEqualToString:]` is NO, `[nil hash]` is 0). Swift's String + // equality and hashing differ (canonical equivalence, per-process seeds), + // so these go through NSString explicitly. + + /// `[lhs isEqualToString:rhs]`: NO when either side is nil. + func lookinStringsEqual(_ lhs: String?, _ rhs: String?) -> Bool { + guard let lhs, let rhs else { + return false + } + return (lhs as NSString).isEqual(to: rhs) + } + + /// `string.hash`: -[NSString hash], or 0 for nil. + func lookinStringHash(_ string: String?) -> Int { + guard let string else { + return 0 + } + return (string as NSString).hash + } + + // MARK: Image data + + // The encoders archive image data as the very NSData object the + // Objective-C API returned. UIImagePNGRepresentation answers an + // NSMutableData, which NSKeyedArchiver writes as an NSMutableData object, + // while the same bytes bridged through Swift's Data are written inline. + // These helpers reach the APIs without bridging, so the archives keep + // their structure (pinned by Tests/WireFormatGolden on iOS). + + /// `-[UIImage lookin_data]` lives in LookinServer (UIImage+LookinServer.h) + /// and LookinCore does not link against the server, so it is sent + /// dynamically, as the original's import did; `-[NSImage lookin_data]` + /// (Image+Lookin.h) goes the same way so it is not bridged to Data. + @objc private protocol LookinScreenshotDataBridge { + func lookin_data() -> NSData? + } + + /// `image.lookin_data` (PNG on iOS, TIFF on macOS); nil for a nil image. + func lookinScreenshotData(_ image: LookinImage?) -> NSData? { + guard let image else { + return nil + } + return unsafeBitCast(image, to: LookinScreenshotDataBridge.self).lookin_data() + } + + #if canImport(UIKit) + private typealias LookinPNGRepresentationFunction = @convention(c) (UIImage) -> Unmanaged? + + /// `UIImagePNGRepresentation`, which Swift imports returning Data. + private let LookinPNGRepresentationFunctionPointer: LookinPNGRepresentationFunction? = { + // RTLD_DEFAULT, which Swift does not import on every platform. + let defaultHandle = UnsafeMutableRawPointer(bitPattern: -2) + guard let symbol = dlsym(defaultHandle, "UIImagePNGRepresentation") else { + return nil + } + return unsafeBitCast(symbol, to: LookinPNGRepresentationFunction.self) + }() + + /// `UIImagePNGRepresentation(image)`; nil for a nil image. + func lookinPNGRepresentation(_ image: UIImage?) -> NSData? { + guard let image, let function = LookinPNGRepresentationFunctionPointer else { + return nil + } + return function(image)?.takeUnretainedValue() + } + #elseif os(macOS) + @objc private protocol LookinTIFFRepresentationBridge { + @objc(TIFFRepresentation) var lookinTIFFRepresentation: NSData? { get } + } + + /// `image.TIFFRepresentation`; nil for a nil image. + func lookinTIFFRepresentation(_ image: NSImage?) -> NSData? { + guard let image else { + return nil + } + return unsafeBitCast(image, to: LookinTIFFRepresentationBridge.self).lookinTIFFRepresentation + } + #endif + +#endif diff --git a/Sources/LookinCoreImpl/LookinCustomAttrModification.swift b/Sources/LookinCoreImpl/LookinCustomAttrModification.swift new file mode 100644 index 00000000..32276d7b --- /dev/null +++ b/Sources/LookinCoreImpl/LookinCustomAttrModification.swift @@ -0,0 +1,51 @@ +// +// LookinCustomAttrModification.swift +// LookinShared +// +// Was LookinCustomAttrModification.m. The coding keys and value encodings are +// pinned by Tests/WireFormatGolden (the 214 request). +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinCustomAttrModification) + public class LookinCustomAttrModification: NSObject, NSCoding, NSSecureCoding { + @objc(attrType) + public var attrType: LookinAttrType = .none + @objc(customSetterID) + public var customSetterID: String! + @objc(value) + public var value: Any! + + override public init() { + super.init() + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(attrType.rawValue, forKey: "attrType") + aCoder.encode(value, forKey: "value") + aCoder.encode(customSetterID, forKey: "customSetterID") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + attrType = LookinAttrType(rawValue: aDecoder.decodeInteger(forKey: "attrType")) ?? .none + value = aDecoder.decodeObject(forKey: "value") + customSetterID = aDecoder.decodeObject(forKey: "customSetterID") as? String + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinCustomDisplayItemInfo.swift b/Sources/LookinCoreImpl/LookinCustomDisplayItemInfo.swift new file mode 100644 index 00000000..f7193992 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinCustomDisplayItemInfo.swift @@ -0,0 +1,100 @@ +// +// LookinCustomDisplayItemInfo.swift +// LookinServer +// +// Was LookinCustomDisplayItemInfo.m. The coding keys are pinned by +// Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + @objc(LookinCustomDisplayItemInfo) + public class LookinCustomDisplayItemInfo: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(frameInWindow) + public var frameInWindow: NSValue? + @objc(title) + public var title: String? + @objc(subtitle) + public var subtitle: String? + @objc(danceuiSource) + public var danceuiSource: String? + @objc(isSwiftUI) + public var isSwiftUI: Bool = false + @objc(swiftUIDisplayItemID) + public var swiftUIDisplayItemID: String? + + override public init() { + super.init() + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newInstance = LookinCustomDisplayItemInfo() + if let frameInWindow { + #if canImport(UIKit) + newInstance.frameInWindow = NSValue(cgRect: frameInWindow.cgRectValue) + #elseif os(macOS) + newInstance.frameInWindow = NSValue(rect: frameInWindow.rectValue) + #endif + } + newInstance.title = title + newInstance.subtitle = subtitle + newInstance.danceuiSource = danceuiSource + newInstance.isSwiftUI = isSwiftUI + newInstance.swiftUIDisplayItemID = swiftUIDisplayItemID + return newInstance + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(frameInWindow, forKey: "frameInWindow") + aCoder.encode(title, forKey: "title") + aCoder.encode(subtitle, forKey: "subtitle") + aCoder.encode(danceuiSource, forKey: "danceuiSource") + aCoder.encode(isSwiftUI, forKey: "isSwiftUI") + aCoder.encode(swiftUIDisplayItemID, forKey: "swiftUIDisplayItemID") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + frameInWindow = aDecoder.decodeObject(of: NSValue.self, forKey: "frameInWindow") + title = aDecoder.decodeObject(of: NSString.self, forKey: "title") as String? + subtitle = aDecoder.decodeObject(of: NSString.self, forKey: "subtitle") as String? + danceuiSource = aDecoder.decodeObject(of: NSString.self, forKey: "danceuiSource") as String? + isSwiftUI = aDecoder.decodeBool(forKey: "isSwiftUI") + swiftUIDisplayItemID = aDecoder.decodeObject(of: NSString.self, forKey: "swiftUIDisplayItemID") as String? + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + @objc(hasValidFrame) + public func hasValidFrame() -> Bool { + guard let frameInWindow else { + return false + } + #if canImport(UIKit) + return LookinIsUsableRect(frameInWindow.cgRectValue) + #else + return LookinIsUsableRect(frameInWindow.rectValue) + #endif + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinDashboardBlueprint+AttrInfo.swift b/Sources/LookinCoreImpl/LookinDashboardBlueprint+AttrInfo.swift new file mode 100644 index 00000000..1975a8dc --- /dev/null +++ b/Sources/LookinCoreImpl/LookinDashboardBlueprint+AttrInfo.swift @@ -0,0 +1,585 @@ +// +// LookinDashboardBlueprint+AttrInfo.swift +// LookinCore +// +// Class, titles, accessors and flags of every attribute. +// Translated from LookinDashboardBlueprint.m; the output of every public +// class method is pinned by Tests/LookinServerHierarchyTests/ +// BlueprintEquivalenceTests and its Fixtures/blueprint-.txt. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + extension LookinDashboardBlueprintTables { + /// The per-attribute information behind the attribute queries. See + /// `AttrInfo` for what each field means. + static let attrInfo: [String: AttrInfo] = { + var table: [String: AttrInfo] = [:] + table[LookinAttr_Class_Class_Class] = AttrInfo(className: "CALayer", getterString: "lks_relatedClassChainList", setterString: "", typeIfObj: .customObj) + table[LookinAttr_Relation_Relation_Relation] = AttrInfo(className: "CALayer", getterString: "lks_selfRelation", setterString: "", typeIfObj: .customObj, hideIfNil: true) + table[LookinAttr_Layout_Frame_Frame] = AttrInfo(className: "CALayer", fullTitle: "Frame", patch: true) + table[LookinAttr_Layout_Bounds_Bounds] = AttrInfo(className: "CALayer", fullTitle: "Bounds", patch: true) + // Attributes that exist on both platforms must appear exactly once + // in this table, with the className split per platform + // (viewClassName, layoutGuideClassName). A second assignment under + // the same key would overwrite the first, so one platform's + // className would name a class it does not have and the row would + // vanish from the dashboard. + table[LookinAttr_Layout_SafeArea_SafeArea] = AttrInfo(className: viewClassName, fullTitle: "SafeAreaInsets", setterString: "", osVersion: 11) + table[LookinAttr_Layout_Position_Position] = AttrInfo(className: "CALayer", fullTitle: "Position", patch: true) + table[LookinAttr_Layout_AnchorPoint_AnchorPoint] = AttrInfo(className: "CALayer", fullTitle: "AnchorPoint", patch: true) + table[LookinAttr_AutoLayout_Hugging_Hor] = AttrInfo(className: viewClassName, fullTitle: "ContentHuggingPriority(Horizontal)", briefTitle: "H", getterString: "lks_horizontalContentHuggingPriority", setterString: "setLks_horizontalContentHuggingPriority:", patch: true) + table[LookinAttr_AutoLayout_Hugging_Ver] = AttrInfo(className: viewClassName, fullTitle: "ContentHuggingPriority(Vertical)", briefTitle: "V", getterString: "lks_verticalContentHuggingPriority", setterString: "setLks_verticalContentHuggingPriority:", patch: true) + table[LookinAttr_AutoLayout_Resistance_Hor] = AttrInfo(className: viewClassName, fullTitle: "ContentCompressionResistancePriority(Horizontal)", briefTitle: "H", getterString: "lks_horizontalContentCompressionResistancePriority", setterString: "setLks_horizontalContentCompressionResistancePriority:", patch: true) + table[LookinAttr_AutoLayout_Resistance_Ver] = AttrInfo(className: viewClassName, fullTitle: "ContentCompressionResistancePriority(Vertical)", briefTitle: "V", getterString: "lks_verticalContentCompressionResistancePriority", setterString: "setLks_verticalContentCompressionResistancePriority:", patch: true) + table[LookinAttr_AutoLayout_Constraints_Constraints] = AttrInfo(className: viewClassName, getterString: "lks_constraints", setterString: "", typeIfObj: .customObj, hideIfNil: true) + table[LookinAttr_AutoLayout_IntrinsicSize_Size] = AttrInfo(className: viewClassName, fullTitle: "IntrinsicContentSize", setterString: "") + table[LookinAttr_ViewLayer_InterationAndMasks_Interaction] = AttrInfo(className: "UIView", fullTitle: "UserInteractionEnabled", getterString: "isUserInteractionEnabled", patch: false) + table[LookinAttr_ViewLayer_ContentMode_Mode] = AttrInfo(className: "UIView", fullTitle: "ContentMode", enumList: "UIViewContentMode", patch: true) + table[LookinAttr_ViewLayer_TintColor_Color] = AttrInfo(className: "UIView", fullTitle: "TintColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_ViewLayer_TintColor_Mode] = AttrInfo(className: "UIView", fullTitle: "TintAdjustmentMode", enumList: "UIViewTintAdjustmentMode", patch: true) + table[LookinAttr_LayoutGuide_Identifier_Identifier] = AttrInfo(className: layoutGuideClassName, fullTitle: "Identifier", getterString: "identifier", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_LayoutGuide_LayoutFrame_LayoutFrame] = AttrInfo(className: layoutGuideClassName, fullTitle: "LayoutFrame", getterString: "lks_layoutFrame", setterString: "") + table[LookinAttr_LayoutGuide_OwningView_OwningView] = AttrInfo(className: layoutGuideClassName, fullTitle: "OwningView", getterString: "lks_owningViewDescription", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_NSCell_Cell_Type] = AttrInfo(className: "NSCell", fullTitle: "CellType", getterString: "type", setterString: "", enumList: "NSCellType") + table[LookinAttr_NSCell_Cell_State] = AttrInfo(className: "NSCell", fullTitle: "State", getterString: "state", setterString: "", enumList: "NSControlStateValue") + table[LookinAttr_NSCell_Cell_Enabled] = AttrInfo(className: "NSCell", fullTitle: "Enabled", getterString: "isEnabled", setterString: "") + table[LookinAttr_NSCell_Cell_Bordered] = AttrInfo(className: "NSCell", fullTitle: "Bordered", getterString: "isBordered", setterString: "") + table[LookinAttr_NSCell_Cell_Bezeled] = AttrInfo(className: "NSCell", fullTitle: "Bezeled", getterString: "isBezeled", setterString: "") + table[LookinAttr_NSCell_Cell_Highlighted] = AttrInfo(className: "NSCell", fullTitle: "Highlighted", getterString: "isHighlighted", setterString: "") + table[LookinAttr_NSCell_Cell_Editable] = AttrInfo(className: "NSCell", fullTitle: "Editable", getterString: "isEditable", setterString: "") + table[LookinAttr_NSCell_Cell_Selectable] = AttrInfo(className: "NSCell", fullTitle: "Selectable", getterString: "isSelectable", setterString: "") + table[LookinAttr_NSCell_Cell_Alignment] = AttrInfo(className: "NSCell", fullTitle: "Alignment", getterString: "alignment", setterString: "", enumList: "NSTextAlignment_AppKit") + table[LookinAttr_NSCell_Cell_ControlSize] = AttrInfo(className: "NSCell", fullTitle: "ControlSize", getterString: "controlSize", setterString: "", enumList: "NSControlSize") + table[LookinAttr_NSCell_ButtonCell_BezelStyle] = AttrInfo(className: "NSButtonCell", fullTitle: "BezelStyle", getterString: "bezelStyle", setterString: "", enumList: "NSBezelStyle") + table[LookinAttr_NSCell_ButtonCell_ImagePosition] = AttrInfo(className: "NSButtonCell", fullTitle: "ImagePosition", getterString: "imagePosition", setterString: "", enumList: "NSCellImagePosition") + table[LookinAttr_NSCell_ButtonCell_ShowsBorderOnlyWhileMouseInside] = AttrInfo(className: "NSButtonCell", fullTitle: "ShowsBorderOnlyWhileMouseInside", getterString: "showsBorderOnlyWhileMouseInside", setterString: "") + table[LookinAttr_NSCell_TextFieldCell_Placeholder] = AttrInfo(className: "NSTextFieldCell", fullTitle: "Placeholder", getterString: "placeholderString", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_NSCell_TextFieldCell_DrawsBackground] = AttrInfo(className: "NSTextFieldCell", fullTitle: "DrawsBackground", getterString: "drawsBackground", setterString: "") + table[LookinAttr_NSCell_Content_Title] = AttrInfo(className: "NSCell", fullTitle: "Title", getterString: "title", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_NSCell_Content_FontName] = AttrInfo(className: "NSCell", fullTitle: "FontName", getterString: "lks_fontName", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_NSCell_Content_FontSize] = AttrInfo(className: "NSCell", fullTitle: "FontSize", getterString: "lks_fontSize", setterString: "") + table[LookinAttr_NSCell_Content_LineBreakMode] = AttrInfo(className: "NSCell", fullTitle: "LineBreakMode", getterString: "lineBreakMode", setterString: "", enumList: "NSLineBreakMode") + table[LookinAttr_NSCell_Content_Wraps] = AttrInfo(className: "NSCell", fullTitle: "Wraps", getterString: "wraps", setterString: "") + table[LookinAttr_NSCell_Behavior_Tag] = AttrInfo(className: "NSCell", fullTitle: "Tag", getterString: "tag", setterString: "") + table[LookinAttr_NSCell_Behavior_Continuous] = AttrInfo(className: "NSCell", fullTitle: "Continuous", getterString: "isContinuous", setterString: "") + table[LookinAttr_NSCell_Behavior_AllowsMixedState] = AttrInfo(className: "NSCell", fullTitle: "AllowsMixedState", getterString: "allowsMixedState", setterString: "") + table[LookinAttr_NSCell_Behavior_SendsActionOnEndEditing] = AttrInfo(className: "NSCell", fullTitle: "SendsActionOnEndEditing", getterString: "sendsActionOnEndEditing", setterString: "") + table[LookinAttr_NSCell_ButtonCell_KeyEquivalent] = AttrInfo(className: "NSButtonCell", fullTitle: "KeyEquivalent", getterString: "keyEquivalent", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_NSCell_ButtonCell_AlternateTitle] = AttrInfo(className: "NSButtonCell", fullTitle: "AlternateTitle", getterString: "alternateTitle", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_NSCell_TextFieldCell_TextColor] = AttrInfo(className: "NSTextFieldCell", fullTitle: "TextColor", getterString: "textColor", setterString: "", typeIfObj: .uiColor, hideIfNil: true) + table[LookinAttr_NSCell_TextFieldCell_BackgroundColor] = AttrInfo(className: "NSTextFieldCell", fullTitle: "BackgroundColor", getterString: "backgroundColor", setterString: "", typeIfObj: .uiColor, hideIfNil: true) + table[LookinAttr_NSCell_TextFieldCell_BezelStyle] = AttrInfo(className: "NSTextFieldCell", fullTitle: "BezelStyle", getterString: "bezelStyle", setterString: "", enumList: "NSTextFieldBezelStyle") + table[LookinAttr_ViewLayer_Tag_Tag] = AttrInfo(className: viewClassName, fullTitle: "Tag", briefTitle: "", patch: false) + table[LookinAttr_ViewLayer_Visibility_Hidden] = AttrInfo(className: "CALayer", fullTitle: "Hidden", getterString: "isHidden", patch: true) + table[LookinAttr_ViewLayer_Visibility_Opacity] = AttrInfo(className: "CALayer", fullTitle: "Opacity / Alpha", getterString: "opacity", setterString: "setOpacity:", patch: true) + table[LookinAttr_ViewLayer_InterationAndMasks_MasksToBounds] = AttrInfo(className: "CALayer", fullTitle: "MasksToBounds / ClipsToBounds", briefTitle: "MasksToBounds", getterString: "masksToBounds", setterString: "setMasksToBounds:", patch: true) + table[LookinAttr_ViewLayer_Corner_Radius] = AttrInfo(className: "CALayer", fullTitle: "CornerRadius", briefTitle: "", patch: true) + table[LookinAttr_ViewLayer_BgColor_BgColor] = AttrInfo(className: "CALayer", fullTitle: "BackgroundColor", getterString: "lks_backgroundColor", setterString: "setLks_backgroundColor:", typeIfObj: .uiColor, patch: true) + table[LookinAttr_ViewLayer_Border_Color] = AttrInfo(className: "CALayer", fullTitle: "BorderColor", getterString: "lks_borderColor", setterString: "setLks_borderColor:", typeIfObj: .uiColor, patch: true) + table[LookinAttr_ViewLayer_Border_Width] = AttrInfo(className: "CALayer", fullTitle: "BorderWidth", patch: true) + table[LookinAttr_ViewLayer_Shadow_Color] = AttrInfo(className: "CALayer", fullTitle: "ShadowColor", getterString: "lks_shadowColor", setterString: "setLks_shadowColor:", typeIfObj: .uiColor, patch: true) + table[LookinAttr_ViewLayer_Shadow_Opacity] = AttrInfo(className: "CALayer", fullTitle: "ShadowOpacity", briefTitle: "Opacity", patch: true) + table[LookinAttr_ViewLayer_Shadow_Radius] = AttrInfo(className: "CALayer", fullTitle: "ShadowRadius", briefTitle: "Radius", patch: true) + table[LookinAttr_ViewLayer_Shadow_OffsetW] = AttrInfo(className: "CALayer", fullTitle: "ShadowOffsetWidth", briefTitle: "OffsetW", getterString: "lks_shadowOffsetWidth", setterString: "setLks_shadowOffsetWidth:", patch: true) + table[LookinAttr_ViewLayer_Shadow_OffsetH] = AttrInfo(className: "CALayer", fullTitle: "ShadowOffsetHeight", briefTitle: "OffsetH", getterString: "lks_shadowOffsetHeight", setterString: "setLks_shadowOffsetHeight:", patch: true) + table[LookinAttr_UIStackView_Axis_Axis] = AttrInfo(className: "UIStackView", fullTitle: "Axis", enumList: "UILayoutConstraintAxis", patch: true) + table[LookinAttr_UIStackView_Distribution_Distribution] = AttrInfo(className: "UIStackView", fullTitle: "Distribution", enumList: "UIStackViewDistribution", patch: true) + table[LookinAttr_UIStackView_Alignment_Alignment] = AttrInfo(className: "UIStackView", fullTitle: "Alignment", enumList: "UIStackViewAlignment", patch: true) + table[LookinAttr_UIStackView_Spacing_Spacing] = AttrInfo(className: "UIStackView", fullTitle: "Spacing", patch: true) + table[LookinAttr_UIVisualEffectView_Style_Style] = AttrInfo(className: "UIVisualEffectView", getterString: "lks_blurEffectStyleNumber", setterString: "setLks_blurEffectStyleNumber:", typeIfObj: .customObj, enumList: "UIBlurEffectStyle", patch: true, hideIfNil: true) + table[LookinAttr_UIVisualEffectView_QMUIForegroundColor_Color] = AttrInfo(className: "QMUIVisualEffectView", fullTitle: "ForegroundColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_UIImageView_Name_Name] = AttrInfo(className: "UIImageView", fullTitle: "ImageName", getterString: "lks_imageSourceName", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_UIImageView_Open_Open] = AttrInfo(className: "UIImageView", getterString: "lks_imageViewOidIfHasImage", setterString: "", typeIfObj: .customObj, hideIfNil: true) + table[LookinAttr_UILabel_Text_Text] = AttrInfo(className: "UILabel", fullTitle: "Text", typeIfObj: .nsString, patch: true) + table[LookinAttr_UILabel_NumberOfLines_NumberOfLines] = AttrInfo(className: "UILabel", fullTitle: "NumberOfLines", briefTitle: "", patch: true) + table[LookinAttr_UILabel_Font_Size] = AttrInfo(className: "UILabel", fullTitle: "FontSize", briefTitle: "FontSize", getterString: "lks_fontSize", setterString: "setLks_fontSize:", patch: true) + table[LookinAttr_UILabel_Font_Name] = AttrInfo(className: "UILabel", fullTitle: "FontName", getterString: "lks_fontName", setterString: "", typeIfObj: .nsString, patch: false) + table[LookinAttr_UILabel_TextColor_Color] = AttrInfo(className: "UILabel", fullTitle: "TextColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_UILabel_Alignment_Alignment] = AttrInfo(className: "UILabel", fullTitle: "TextAlignment", enumList: "NSTextAlignment", patch: true) + table[LookinAttr_UILabel_BreakMode_Mode] = AttrInfo(className: "UILabel", fullTitle: "LineBreakMode", enumList: "NSLineBreakMode", patch: true) + table[LookinAttr_UILabel_CanAdjustFont_CanAdjustFont] = AttrInfo(className: "UILabel", fullTitle: "AdjustsFontSizeToFitWidth", patch: true) + table[LookinAttr_UIControl_EnabledSelected_Enabled] = AttrInfo(className: "UIControl", fullTitle: "Enabled", getterString: "isEnabled", patch: false) + table[LookinAttr_UIControl_EnabledSelected_Selected] = AttrInfo(className: "UIControl", fullTitle: "Selected", getterString: "isSelected", patch: true) + table[LookinAttr_UIControl_VerAlignment_Alignment] = AttrInfo(className: "UIControl", fullTitle: "ContentVerticalAlignment", enumList: "UIControlContentVerticalAlignment", patch: true) + table[LookinAttr_UIControl_HorAlignment_Alignment] = AttrInfo(className: "UIControl", fullTitle: "ContentHorizontalAlignment", enumList: "UIControlContentHorizontalAlignment", patch: true) + table[LookinAttr_UIControl_QMUIOutsideEdge_Edge] = AttrInfo(className: "UIControl", fullTitle: "qmui_outsideEdge") + table[LookinAttr_UIButton_ContentInsets_Insets] = AttrInfo(className: "UIButton", fullTitle: "ContentEdgeInsets", patch: true) + table[LookinAttr_UIButton_TitleInsets_Insets] = AttrInfo(className: "UIButton", fullTitle: "TitleEdgeInsets", patch: true) + table[LookinAttr_UIButton_ImageInsets_Insets] = AttrInfo(className: "UIButton", fullTitle: "ImageEdgeInsets", patch: true) + table[LookinAttr_UIScrollView_Offset_Offset] = AttrInfo(className: "UIScrollView", fullTitle: "ContentOffset", patch: true) + table[LookinAttr_UIScrollView_ContentSize_Size] = AttrInfo(className: "UIScrollView", fullTitle: "ContentSize", patch: true) + table[LookinAttr_UIScrollView_ContentInset_Inset] = AttrInfo(className: "UIScrollView", fullTitle: "ContentInset", patch: true) + table[LookinAttr_UIScrollView_QMUIInitialInset_Inset] = AttrInfo(className: "UIScrollView", fullTitle: "qmui_initialContentInset", patch: true) + table[LookinAttr_UIScrollView_AdjustedInset_Inset] = AttrInfo(className: "UIScrollView", fullTitle: "AdjustedContentInset", setterString: "", osVersion: 11) + table[LookinAttr_UIScrollView_Behavior_Behavior] = AttrInfo(className: "UIScrollView", fullTitle: "ContentInsetAdjustmentBehavior", enumList: "UIScrollViewContentInsetAdjustmentBehavior", patch: true, osVersion: 11) + table[LookinAttr_UIScrollView_IndicatorInset_Inset] = AttrInfo(className: "UIScrollView", fullTitle: "ScrollIndicatorInsets", patch: false) + table[LookinAttr_UIScrollView_ScrollPaging_ScrollEnabled] = AttrInfo(className: "UIScrollView", fullTitle: "ScrollEnabled", getterString: "isScrollEnabled", patch: false) + table[LookinAttr_UIScrollView_ScrollPaging_PagingEnabled] = AttrInfo(className: "UIScrollView", fullTitle: "PagingEnabled", getterString: "isPagingEnabled", patch: false) + table[LookinAttr_UIScrollView_Bounce_Ver] = AttrInfo(className: "UIScrollView", fullTitle: "AlwaysBounceVertical", briefTitle: "Vertical", patch: false) + table[LookinAttr_UIScrollView_Bounce_Hor] = AttrInfo(className: "UIScrollView", fullTitle: "AlwaysBounceHorizontal", briefTitle: "Horizontal", patch: false) + table[LookinAttr_UIScrollView_ShowsIndicator_Hor] = AttrInfo(className: "UIScrollView", fullTitle: "ShowsHorizontalScrollIndicator", briefTitle: "Horizontal", patch: false) + table[LookinAttr_UIScrollView_ShowsIndicator_Ver] = AttrInfo(className: "UIScrollView", fullTitle: "ShowsVerticalScrollIndicator", briefTitle: "Vertical", patch: false) + table[LookinAttr_UIScrollView_ContentTouches_Delay] = AttrInfo(className: "UIScrollView", fullTitle: "DelaysContentTouches", patch: false) + table[LookinAttr_UIScrollView_ContentTouches_CanCancel] = AttrInfo(className: "UIScrollView", fullTitle: "CanCancelContentTouches", patch: false) + table[LookinAttr_UIScrollView_Zoom_MinScale] = AttrInfo(className: "UIScrollView", fullTitle: "MinimumZoomScale", briefTitle: "MinScale", patch: false) + table[LookinAttr_UIScrollView_Zoom_MaxScale] = AttrInfo(className: "UIScrollView", fullTitle: "MaximumZoomScale", briefTitle: "MaxScale", patch: false) + table[LookinAttr_UIScrollView_Zoom_Scale] = AttrInfo(className: "UIScrollView", fullTitle: "ZoomScale", briefTitle: "Scale", patch: true) + table[LookinAttr_UIScrollView_Zoom_Bounce] = AttrInfo(className: "UIScrollView", fullTitle: "BouncesZoom", patch: false) + table[LookinAttr_UITableView_Style_Style] = AttrInfo(className: "UITableView", fullTitle: "Style", setterString: "", enumList: "UITableViewStyle", patch: true) + table[LookinAttr_UITableView_SectionsNumber_Number] = AttrInfo(className: "UITableView", fullTitle: "NumberOfSections", setterString: "", patch: true) + table[LookinAttr_UITableView_RowsNumber_Number] = AttrInfo(className: "UITableView", getterString: "lks_numberOfRows", setterString: "", typeIfObj: .customObj) + table[LookinAttr_UITableView_SeparatorInset_Inset] = AttrInfo(className: "UITableView", fullTitle: "SeparatorInset", patch: false) + table[LookinAttr_UITableView_SeparatorColor_Color] = AttrInfo(className: "UITableView", fullTitle: "SeparatorColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_UITableView_SeparatorStyle_Style] = AttrInfo(className: "UITableView", fullTitle: "SeparatorStyle", enumList: "UITableViewCellSeparatorStyle", patch: true) + table[LookinAttr_UITextView_Text_Text] = AttrInfo(className: "UITextView", fullTitle: "Text", typeIfObj: .nsString, patch: true) + table[LookinAttr_UITextView_Font_Name] = AttrInfo(className: "UITextView", fullTitle: "FontName", getterString: "lks_fontName", setterString: "", typeIfObj: .nsString, patch: false) + table[LookinAttr_UITextView_Font_Size] = AttrInfo(className: "UITextView", fullTitle: "FontSize", getterString: "lks_fontSize", setterString: "setLks_fontSize:", patch: true) + table[LookinAttr_UITextView_Basic_Editable] = AttrInfo(className: "UITextView", fullTitle: "Editable", getterString: "isEditable", patch: false) + table[LookinAttr_UITextView_Basic_Selectable] = AttrInfo(className: "UITextView", fullTitle: "Selectable", getterString: "isSelectable", patch: false) + table[LookinAttr_UITextView_TextColor_Color] = AttrInfo(className: "UITextView", fullTitle: "TextColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_UITextView_Alignment_Alignment] = AttrInfo(className: "UITextView", fullTitle: "TextAlignment", enumList: "NSTextAlignment", patch: true) + table[LookinAttr_UITextView_ContainerInset_Inset] = AttrInfo(className: "UITextView", fullTitle: "TextContainerInset", patch: true) + table[LookinAttr_UITextField_Font_Name] = AttrInfo(className: "UITextField", fullTitle: "FontName", getterString: "lks_fontName", setterString: "", typeIfObj: .nsString, patch: false) + table[LookinAttr_UITextField_Font_Size] = AttrInfo(className: "UITextField", fullTitle: "FontSize", getterString: "lks_fontSize", setterString: "setLks_fontSize:", patch: true) + table[LookinAttr_UITextField_TextColor_Color] = AttrInfo(className: "UITextField", fullTitle: "TextColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_UITextField_Alignment_Alignment] = AttrInfo(className: "UITextField", fullTitle: "TextAlignment", enumList: "NSTextAlignment", patch: true) + table[LookinAttr_UITextField_Text_Text] = AttrInfo(className: "UITextField", fullTitle: "Text", typeIfObj: .nsString, patch: true) + table[LookinAttr_UITextField_Placeholder_Placeholder] = AttrInfo(className: "UITextField", fullTitle: "Placeholder", typeIfObj: .nsString, patch: true) + table[LookinAttr_UITextField_Clears_ClearsOnBeginEditing] = AttrInfo(className: "UITextField", fullTitle: "ClearsOnBeginEditing", patch: false) + table[LookinAttr_UITextField_Clears_ClearsOnInsertion] = AttrInfo(className: "UITextField", fullTitle: "ClearsOnInsertion", patch: false) + table[LookinAttr_UITextField_CanAdjustFont_CanAdjustFont] = AttrInfo(className: "UITextField", fullTitle: "AdjustsFontSizeToFitWidth", patch: true) + table[LookinAttr_UITextField_CanAdjustFont_MinSize] = AttrInfo(className: "UITextField", fullTitle: "MinimumFontSize", patch: true) + table[LookinAttr_UITextField_ClearButtonMode_Mode] = AttrInfo(className: "UITextField", fullTitle: "ClearButtonMode", enumList: "UITextFieldViewMode", patch: false) + table[LookinAttr_NSImageView_Name_Name] = AttrInfo(className: "NSImageView", fullTitle: "ImageName", getterString: "lks_imageSourceName", setterString: "", typeIfObj: .nsString, hideIfNil: true) + table[LookinAttr_NSImageView_Open_Open] = AttrInfo(className: "NSImageView", getterString: "lks_imageViewOidIfHasImage", setterString: "", typeIfObj: .customObj, hideIfNil: true) + table[LookinAttr_NSImageView_Scaling_ImageScaling] = AttrInfo(className: "NSImageView", fullTitle: "ImageScaling", enumList: "NSImageScaling", patch: true) + table[LookinAttr_NSImageView_Scaling_ImageAlignment] = AttrInfo(className: "NSImageView", fullTitle: "ImageAlignment", enumList: "NSImageAlignment", patch: true) + table[LookinAttr_NSImageView_Scaling_ImageFrameStyle] = AttrInfo(className: "NSImageView", fullTitle: "ImageFrameStyle", enumList: "NSImageFrameStyle", patch: true) + table[LookinAttr_NSImageView_Behavior_Animates] = AttrInfo(className: "NSImageView", fullTitle: "Animates", patch: true) + table[LookinAttr_NSImageView_Behavior_Editable] = AttrInfo(className: "NSImageView", fullTitle: "Editable", getterString: "isEditable", patch: true) + table[LookinAttr_NSImageView_ContentTintColor_ContentTintColor] = AttrInfo(className: "NSImageView", fullTitle: "ContentTintColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSControl_State_Enabled] = AttrInfo(className: "NSControl", fullTitle: "Enabled", getterString: "isEnabled", patch: true) + table[LookinAttr_NSControl_State_Highlighted] = AttrInfo(className: "NSControl", fullTitle: "Highlighted", getterString: "isHighlighted", patch: true) + table[LookinAttr_NSControl_State_Continuous] = AttrInfo(className: "NSControl", fullTitle: "Continuous", getterString: "isContinuous", patch: false) + table[LookinAttr_NSControl_ControlSize_Size] = AttrInfo(className: "NSControl", fullTitle: "ControlSize", enumList: "NSControlSize", patch: true) + table[LookinAttr_NSControl_Font_Name] = AttrInfo(className: "NSControl", fullTitle: "FontName", getterString: "lks_fontName", setterString: "", typeIfObj: .nsString, patch: false) + table[LookinAttr_NSControl_Font_Size] = AttrInfo(className: "NSControl", fullTitle: "FontSize", getterString: "lks_fontSize", setterString: "setLks_fontSize:", patch: true) + table[LookinAttr_NSControl_Alignment_Alignment] = AttrInfo(className: "NSControl", fullTitle: "Alignment", enumList: "NSTextAlignment_AppKit", patch: true) + table[LookinAttr_NSControl_Misc_WritingDirection] = AttrInfo(className: "NSControl", fullTitle: "BaseWritingDirection", enumList: "NSWritingDirection", patch: false) + table[LookinAttr_NSControl_Misc_IgnoresMultiClick] = AttrInfo(className: "NSControl", fullTitle: "IgnoresMultiClick", patch: false) + table[LookinAttr_NSControl_Misc_UsesSingleLineMode] = AttrInfo(className: "NSControl", fullTitle: "UsesSingleLineMode", patch: false) + table[LookinAttr_NSControl_Misc_AllowsExpansionToolTips] = AttrInfo(className: "NSControl", fullTitle: "AllowsExpansionToolTips", patch: false) + table[LookinAttr_NSControl_Value_StringValue] = AttrInfo(className: "NSControl", fullTitle: "StringValue", typeIfObj: .nsString, patch: true) + table[LookinAttr_NSControl_Value_IntValue] = AttrInfo(className: "NSControl", fullTitle: "IntValue", patch: true) + table[LookinAttr_NSControl_Value_IntegerValue] = AttrInfo(className: "NSControl", fullTitle: "IntegerValue", patch: true) + table[LookinAttr_NSControl_Value_FloatValue] = AttrInfo(className: "NSControl", fullTitle: "FloatValue", patch: true) + table[LookinAttr_NSControl_Value_DoubleValue] = AttrInfo(className: "NSControl", fullTitle: "DoubleValue", patch: true) + table[LookinAttr_NSButton_ButtonType_ButtonType] = AttrInfo(className: "NSButton", fullTitle: "ButtonType", getterString: "lks_buttonType", enumList: "NSButtonType", patch: true) + table[LookinAttr_NSButton_Title_Title] = AttrInfo(className: "NSButton", fullTitle: "Title", typeIfObj: .nsString, patch: true) + table[LookinAttr_NSButton_Title_AlernateTitle] = AttrInfo(className: "NSButton", fullTitle: "AlternateTitle", typeIfObj: .nsString, patch: true) + table[LookinAttr_NSButton_BezelStyle_BezelStyle] = AttrInfo(className: "NSButton", fullTitle: "BezelStyle", enumList: "NSBezelStyle", patch: true) + table[LookinAttr_NSButton_Bordered_Bordered] = AttrInfo(className: "NSButton", fullTitle: "Bordered", getterString: "isBordered", patch: true) + table[LookinAttr_NSButton_Transparent_Transparent] = AttrInfo(className: "NSButton", fullTitle: "Transparent", getterString: "isTransparent", patch: true) + table[LookinAttr_NSButton_BezelColor_BezelColor] = AttrInfo(className: "NSButton", fullTitle: "BezelColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSButton_ContentTintColor_ContentTintColor] = AttrInfo(className: "NSButton", fullTitle: "ContentTintColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSButton_Misc_ShowsBorderOnlyWhileMouseInside] = AttrInfo(className: "NSButton", fullTitle: "ShowsBorderOnlyWhileMouseInside", patch: true) + table[LookinAttr_NSButton_Misc_MaxAcceleratorLevel] = AttrInfo(className: "NSButton", fullTitle: "MaxAcceleratorLevel", patch: true) + // Derived getter would be -springLoaded; the property is -isSpringLoaded. + table[LookinAttr_NSButton_Misc_SpringLoaded] = AttrInfo(className: "NSButton", fullTitle: "SpringLoaded", getterString: "isSpringLoaded", patch: true) + table[LookinAttr_NSButton_Misc_HasDestructiveAction] = AttrInfo(className: "NSButton", fullTitle: "HasDestructiveAction", patch: true) + table[LookinAttr_NSScrollView_ContentOffset_Offset] = AttrInfo(className: "NSScrollView", fullTitle: "ContentOffset", getterString: "lks_contentOffset", setterString: "lks_setContentOffset:", patch: true) + table[LookinAttr_NSScrollView_ContentSize_Size] = AttrInfo(className: "NSScrollView", fullTitle: "ContentSize", getterString: "lks_contentSize", setterString: "lks_setContentSize:", patch: true) + table[LookinAttr_NSScrollView_ContentInset_ContentInset] = AttrInfo(className: "NSScrollView", fullTitle: "ContentInset", patch: true) + table[LookinAttr_NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets] = AttrInfo(className: "NSScrollView", fullTitle: "AutomaticallyAdjustsContentInsets", patch: true) + table[LookinAttr_NSScrollView_BorderType_BorderType] = AttrInfo(className: "NSScrollView", fullTitle: "BorderType", enumList: "NSBorderType", patch: true) + table[LookinAttr_NSScrollView_Scroller_Horizontal] = AttrInfo(className: "NSScrollView", fullTitle: "HasHorizontalScroller", patch: true) + table[LookinAttr_NSScrollView_Scroller_Vertical] = AttrInfo(className: "NSScrollView", fullTitle: "HasVerticalScroller", patch: true) + table[LookinAttr_NSScrollView_Scroller_AutohidesScrollers] = AttrInfo(className: "NSScrollView", fullTitle: "AutohidesScrollers", patch: true) + table[LookinAttr_NSScrollView_Scroller_ScrollerStyle] = AttrInfo(className: "NSScrollView", fullTitle: "ScrollerStyle", enumList: "NSScrollerStyle", patch: true) + table[LookinAttr_NSScrollView_Scroller_ScrollerKnobStyle] = AttrInfo(className: "NSScrollView", fullTitle: "ScrollerKnobStyle", enumList: "NSScrollerKnobStyle", patch: true) + table[LookinAttr_NSScrollView_Scroller_ScrollerInsets] = AttrInfo(className: "NSScrollView", fullTitle: "ScrollerInsets", patch: true) + table[LookinAttr_NSScrollView_Ruler_Horizontal] = AttrInfo(className: "NSScrollView", fullTitle: "HasHorizontalRuler", patch: true) + table[LookinAttr_NSScrollView_Ruler_Vertical] = AttrInfo(className: "NSScrollView", fullTitle: "HasVerticalRuler", patch: true) + table[LookinAttr_NSScrollView_Ruler_Visible] = AttrInfo(className: "NSScrollView", fullTitle: "RulersVisible", patch: true) + // briefTitle is display-only and never crosses the wire. These six + // sit three-to-a-row with the label inside the field, so the section + // title carries the meaning ("LineScroll") and the field only marks + // the axis — the same shape as the scroller inset row above. + table[LookinAttr_NSScrollView_LineScroll_Horizontal] = AttrInfo(className: "NSScrollView", fullTitle: "HorizontalLineScroll", briefTitle: "H", patch: true) + table[LookinAttr_NSScrollView_LineScroll_Vertical] = AttrInfo(className: "NSScrollView", fullTitle: "VerticalLineScroll", briefTitle: "V", patch: true) + table[LookinAttr_NSScrollView_LineScroll_LineScroll] = AttrInfo(className: "NSScrollView", fullTitle: "LineScroll", briefTitle: "⇄", patch: true) + table[LookinAttr_NSScrollView_PageScroll_Horizontal] = AttrInfo(className: "NSScrollView", fullTitle: "HorizontalPageScroll", briefTitle: "H", patch: true) + table[LookinAttr_NSScrollView_PageScroll_Vertical] = AttrInfo(className: "NSScrollView", fullTitle: "VerticalPageScroll", briefTitle: "V", patch: true) + table[LookinAttr_NSScrollView_PageScroll_PageScroll] = AttrInfo(className: "NSScrollView", fullTitle: "PageScroll", briefTitle: "⇄", patch: true) + table[LookinAttr_NSScrollView_ScrollElasiticity_Horizontal] = AttrInfo(className: "NSScrollView", fullTitle: "HorizontalScrollElasticity", enumList: "NSScrollElasticity", patch: true) + table[LookinAttr_NSScrollView_ScrollElasiticity_Vertical] = AttrInfo(className: "NSScrollView", fullTitle: "VerticalScrollElasticity", enumList: "NSScrollElasticity", patch: true) + table[LookinAttr_NSScrollView_Misc_ScrollsDynamically] = AttrInfo(className: "NSScrollView", fullTitle: "ScrollsDynamically", patch: true) + table[LookinAttr_NSScrollView_Misc_UsesPredominantAxisScrolling] = AttrInfo(className: "NSScrollView", fullTitle: "UsesPredominantAxisScrolling", patch: true) + table[LookinAttr_NSScrollView_Magnification_AllowsMagnification] = AttrInfo(className: "NSScrollView", fullTitle: "AllowsMagnification", patch: true) + // Three to a row under the AllowsMagnification switch; the section + // title already says "Magnification", so the fields only need to + // say which of the three values they are. + table[LookinAttr_NSScrollView_Magnification_Magnification] = AttrInfo(className: "NSScrollView", fullTitle: "Magnification", briefTitle: "Current", patch: true) + // Explicit getter/setter: the derived names would be + // -maximunMagnification / -minimumMagnification, and NSScrollView + // spells them -maxMagnification / -minMagnification. Deriving them + // produced selectors nobody responds to, so both rows silently read + // as nil and never appeared in the dashboard at all. + table[LookinAttr_NSScrollView_Magnification_Max] = AttrInfo(className: "NSScrollView", fullTitle: "MaximumMagnification", briefTitle: "Max", getterString: "maxMagnification", setterString: "setMaxMagnification:", patch: true) + table[LookinAttr_NSScrollView_Magnification_Min] = AttrInfo(className: "NSScrollView", fullTitle: "MinimumMagnification", briefTitle: "Min", getterString: "minMagnification", setterString: "setMinMagnification:", patch: true) + table[LookinAttr_NSTableView_AllowsColumnReordering_AllowsColumnReordering] = AttrInfo(className: "NSTableView", fullTitle: "AllowsColumnReordering", patch: true) + table[LookinAttr_NSTableView_AllowsColumnResizing_AllowsColumnResizing] = AttrInfo(className: "NSTableView", fullTitle: "AllowsColumnResizing", patch: true) + table[LookinAttr_NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle] = AttrInfo(className: "NSTableView", fullTitle: "ColumnAutoresizingStyle", enumList: "NSTableViewColumnAutoresizingStyle", patch: true) + table[LookinAttr_NSTableView_GridStyleMask_GridStyleMask] = AttrInfo(className: "NSTableView", fullTitle: "GridStyleMask", enumList: "NSTableViewGridLineStyle", patch: true) + table[LookinAttr_NSTableView_IntercellSpacing_IntercellSpacing] = AttrInfo(className: "NSTableView", fullTitle: "IntercellSpacing", patch: true) + table[LookinAttr_NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors] = AttrInfo(className: "NSTableView", fullTitle: "UsesAlternatingRowBackgroundColors", patch: true) + table[LookinAttr_NSTableView_GridColor_GridColor] = AttrInfo(className: "NSTableView", fullTitle: "GridColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSTableView_RowSizeStyle_RowSizeStyle] = AttrInfo(className: "NSTableView", fullTitle: "RowSizeStyle", enumList: "NSTableViewRowSizeStyle", patch: true) + table[LookinAttr_NSTableView_RowHeight_RowHeight] = AttrInfo(className: "NSTableView", fullTitle: "RowHeight", patch: true) + table[LookinAttr_NSTableView_NumberOfRows_NumberOfRows] = AttrInfo(className: "NSTableView", fullTitle: "NumberOfRows", setterString: "", patch: true) + table[LookinAttr_NSTableView_NumberOfColumns_NumberOfColumns] = AttrInfo(className: "NSTableView", fullTitle: "NumberOfColumns", setterString: "", patch: true) + table[LookinAttr_NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag] = AttrInfo(className: "NSTableView", fullTitle: "VerticalMotionCanBeginDrag", patch: true) + table[LookinAttr_NSTableView_AllowsMultipleSelection_AllowsMultipleSelection] = AttrInfo(className: "NSTableView", fullTitle: "AllowsMultipleSelection", patch: true) + table[LookinAttr_NSTableView_AllowsEmptySelection_AllowsEmptySelection] = AttrInfo(className: "NSTableView", fullTitle: "AllowsEmptySelection", patch: true) + table[LookinAttr_NSTableView_AllowsColumnSelection_AllowsColumnSelection] = AttrInfo(className: "NSTableView", fullTitle: "AllowsColumnSelection", patch: true) + table[LookinAttr_NSTableView_AllowsTypeSelect_AllowsTypeSelect] = AttrInfo(className: "NSTableView", fullTitle: "AllowsTypeSelect", patch: true) + table[LookinAttr_NSTableView_SelectionHighlightStyle_SelectionHighlightStyle] = AttrInfo(className: "NSTableView", fullTitle: "SelectionHighlightStyle", enumList: "NSTableViewSelectionHighlightStyle", patch: true) + table[LookinAttr_NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle] = AttrInfo(className: "NSTableView", fullTitle: "DraggingDestinationFeedbackStyle", enumList: "NSTableViewDraggingDestinationFeedbackStyle", patch: true) + // NSTableView spells this usesAutomaticRowHeights, so both the + // derived getter and the derived setter miss. + table[LookinAttr_NSTableView_AutomaticRowHeights_AutomaticRowHeights] = AttrInfo(className: "NSTableView", fullTitle: "AutomaticRowHeights", getterString: "usesAutomaticRowHeights", setterString: "setUsesAutomaticRowHeights:", patch: true) + table[LookinAttr_NSTableView_AutosaveName_AutosaveName] = AttrInfo(className: "NSTableView", fullTitle: "AutosaveName", patch: true) + table[LookinAttr_NSTableView_AutosaveTableColumns_AutosaveTableColumns] = AttrInfo(className: "NSTableView", fullTitle: "AutosaveTableColumns", patch: true) + table[LookinAttr_NSTableView_FloatsGroupRows_FloatsGroupRows] = AttrInfo(className: "NSTableView", fullTitle: "FloatsGroupRows", patch: true) + table[LookinAttr_NSTableView_RowActionsVisible_RowActionsVisible] = AttrInfo(className: "NSTableView", fullTitle: "RowActionsVisible", patch: true) + table[LookinAttr_NSTableView_UsesStaticContents_UsesStaticContents] = AttrInfo(className: "NSTableView", fullTitle: "UsesStaticContents", patch: true) + table[LookinAttr_NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection] = AttrInfo(className: "NSTableView", fullTitle: "UserInterfaceLayoutDirection", enumList: "NSUserInterfaceLayoutDirection", patch: true) + table[LookinAttr_NSTableView_Style_Style] = AttrInfo(className: "NSTableView", fullTitle: "Style", enumList: "NSTableViewStyle", patch: true) + table[LookinAttr_NSTextView_Font_Name] = AttrInfo(className: "NSTextView", fullTitle: "FontName", getterString: "lks_fontName", setterString: "", typeIfObj: .nsString, patch: false) + table[LookinAttr_NSTextView_Font_Size] = AttrInfo(className: "NSTextView", fullTitle: "FontSize", getterString: "lks_fontSize", setterString: "setLks_fontSize:", patch: true) + table[LookinAttr_NSTextView_Basic_Editable] = AttrInfo(className: "NSTextView", fullTitle: "Editable", getterString: "isEditable", patch: false) + table[LookinAttr_NSTextView_Basic_Selectable] = AttrInfo(className: "NSTextView", fullTitle: "Selectable", getterString: "isSelectable", patch: false) + table[LookinAttr_NSTextView_Basic_RichText] = AttrInfo(className: "NSTextView", fullTitle: "RichText", getterString: "isRichText", patch: false) + table[LookinAttr_NSTextView_Basic_FieldEditor] = AttrInfo(className: "NSTextView", fullTitle: "FieldEditor", getterString: "isFieldEditor", patch: false) + table[LookinAttr_NSTextView_Basic_ImportsGraphics] = AttrInfo(className: "NSTextView", fullTitle: "ImportsGraphics", patch: false) + table[LookinAttr_NSTextView_String_String] = AttrInfo(className: "NSTextView", fullTitle: "String", typeIfObj: .nsString, patch: true) + table[LookinAttr_NSTextView_TextColor_Color] = AttrInfo(className: "NSTextView", fullTitle: "TextColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSTextView_Alignment_Alignment] = AttrInfo(className: "NSTextView", fullTitle: "Alignment", enumList: "NSTextAlignment_AppKit", patch: true) + table[LookinAttr_NSTextView_ContainerInset_Inset] = AttrInfo(className: "NSTextView", fullTitle: "TextContainerInset", patch: true) + table[LookinAttr_NSTextView_BaseWritingDirection_BaseWritingDirection] = AttrInfo(className: "NSTextView", fullTitle: "BaseWritingDirection", enumList: "NSWritingDirection", patch: false) + table[LookinAttr_NSTextView_MaxSize_MaxSize] = AttrInfo(className: "NSTextView", fullTitle: "MaxSize", patch: true) + table[LookinAttr_NSTextView_MinSize_MinSize] = AttrInfo(className: "NSTextView", fullTitle: "MinSize", patch: true) + // Derived getter would be -horizontallyResizable; it is -isHorizontallyResizable. + table[LookinAttr_NSTextView_Resizable_Horizontal] = AttrInfo(className: "NSTextView", fullTitle: "HorizontallyResizable", getterString: "isHorizontallyResizable", patch: false) + // Derived getter would be -verticallyResizable; it is -isVerticallyResizable. + table[LookinAttr_NSTextView_Resizable_Vertical] = AttrInfo(className: "NSTextView", fullTitle: "VerticallyResizable", getterString: "isVerticallyResizable", patch: false) + table[LookinAttr_NSTextField_Bordered_Bordered] = AttrInfo(className: "NSTextField", fullTitle: "Bordered", getterString: "isBordered", patch: false) + table[LookinAttr_NSTextField_Bezeled_Bezeled] = AttrInfo(className: "NSTextField", fullTitle: "Bezeled", getterString: "isBezeled", patch: false) + table[LookinAttr_NSTextField_Editable_Editable] = AttrInfo(className: "NSTextField", fullTitle: "Editable", getterString: "isEditable", patch: false) + table[LookinAttr_NSTextField_Selectable_Selectable] = AttrInfo(className: "NSTextField", fullTitle: "Selectable", getterString: "isSelectable", patch: false) + table[LookinAttr_NSTextField_DrawsBackground_DrawsBackground] = AttrInfo(className: "NSTextField", fullTitle: "DrawsBackground", patch: true) + table[LookinAttr_NSTextField_BezelStyle_BezelStyle] = AttrInfo(className: "NSTextField", fullTitle: "BezelStyle", enumList: "NSTextFieldBezelStyle", patch: true) + table[LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth] = AttrInfo(className: "NSTextField", fullTitle: "PreferredMaxLayoutWidth", patch: true) + table[LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines] = AttrInfo(className: "NSTextField", fullTitle: "MaximumNumberOfLines", patch: true) + table[LookinAttr_NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation] = AttrInfo(className: "NSTextField", fullTitle: "AllowsDefaultTighteningForTruncation", patch: true) + table[LookinAttr_NSTextField_LineBreakStrategy_LineBreakStrategy] = AttrInfo(className: "NSTextField", fullTitle: "LineBreakStrategy", enumList: "NSLineBreakStrategy", patch: true) + table[LookinAttr_NSTextField_Placeholder_Placeholder] = AttrInfo(className: "NSTextField", fullTitle: "PlaceholderString", typeIfObj: .nsString, patch: true) + table[LookinAttr_NSTextField_TextColor_Color] = AttrInfo(className: "NSTextField", fullTitle: "TextColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSTextField_BackgroundColor_Color] = AttrInfo(className: "NSTextField", fullTitle: "BackgroundColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes] = AttrInfo(className: "NSTextField", fullTitle: "AllowsEditingTextAttributes", patch: true) + table[LookinAttr_NSTextField_ImportsGraphics_ImportsGraphics] = AttrInfo(className: "NSTextField", fullTitle: "ImportsGraphics", patch: true) + table[LookinAttr_NSVisualEffectView_Material_Material] = AttrInfo(className: "NSVisualEffectView", fullTitle: "Material", enumList: "NSVisualEffectMaterial", patch: true) + table[LookinAttr_NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle] = AttrInfo(className: "NSVisualEffectView", fullTitle: "InteriorBackgroundStyle", enumList: "NSBackgroundStyle", patch: true) + table[LookinAttr_NSVisualEffectView_BlendingMode_BlendingMode] = AttrInfo(className: "NSVisualEffectView", fullTitle: "BlendingMode", enumList: "NSVisualEffectBlendingMode", patch: true) + table[LookinAttr_NSVisualEffectView_State_State] = AttrInfo(className: "NSVisualEffectView", fullTitle: "State", enumList: "NSVisualEffectState", patch: true) + table[LookinAttr_NSVisualEffectView_Emphasized_Emphasized] = AttrInfo(className: "NSVisualEffectView", fullTitle: "Emphasized", getterString: "isEmphasized", patch: true) + table[LookinAttr_NSStackView_Orientation_Orientation] = AttrInfo(className: "NSStackView", fullTitle: "Orientation", enumList: "NSUserInterfaceLayoutOrientation", patch: true) + table[LookinAttr_NSStackView_EdgeInsets_EdgeInsets] = AttrInfo(className: "NSStackView", fullTitle: "EdgeInsets", patch: true) + table[LookinAttr_NSStackView_DetachesHiddenViews_DetachesHiddenViews] = AttrInfo(className: "NSStackView", fullTitle: "DetachesHiddenViews", patch: true) + table[LookinAttr_NSStackView_Distribution_Distribution] = AttrInfo(className: "NSStackView", fullTitle: "Distribution", enumList: "NSStackViewDistribution", patch: true) + table[LookinAttr_NSStackView_Alignment_Alignment] = AttrInfo(className: "NSStackView", fullTitle: "Alignment", enumList: "NSLayoutAttribute", patch: true) + table[LookinAttr_NSStackView_Spacing_Spacing] = AttrInfo(className: "NSStackView", fullTitle: "Spacing", patch: true) + + // MARK: - NSWindow + + table[LookinAttr_NSWindow_Title_Title] = AttrInfo(className: "NSWindow", fullTitle: "Title", typeIfObj: .nsString, patch: false) + table[LookinAttr_NSWindow_Title_Subtitle] = AttrInfo(className: "NSWindow", fullTitle: "Subtitle", typeIfObj: .nsString, patch: false, osVersion: 11) + table[LookinAttr_NSWindow_State_KeyWindow] = AttrInfo(className: "NSWindow", fullTitle: "KeyWindow", getterString: "isKeyWindow", setterString: "", patch: false) + table[LookinAttr_NSWindow_State_MainWindow] = AttrInfo(className: "NSWindow", fullTitle: "MainWindow", getterString: "isMainWindow", setterString: "", patch: false) + table[LookinAttr_NSWindow_State_Visible] = AttrInfo(className: "NSWindow", fullTitle: "Visible", getterString: "isVisible", setterString: "", patch: false) + table[LookinAttr_NSWindow_State_CanBecomeKeyWindow] = AttrInfo(className: "NSWindow", fullTitle: "CanBecomeKeyWindow", setterString: "", patch: false) + table[LookinAttr_NSWindow_State_CanBecomeMainWindow] = AttrInfo(className: "NSWindow", fullTitle: "CanBecomeMainWindow", setterString: "", patch: false) + table[LookinAttr_NSWindow_Style_Titled] = AttrInfo(className: "NSWindow", fullTitle: "Titled", getterString: "lks_styleMaskTitled", setterString: "setLks_styleMaskTitled:", patch: true) + table[LookinAttr_NSWindow_Style_Closable] = AttrInfo(className: "NSWindow", fullTitle: "Closable", getterString: "lks_styleMaskClosable", setterString: "setLks_styleMaskClosable:", patch: true) + table[LookinAttr_NSWindow_Style_Miniaturizable] = AttrInfo(className: "NSWindow", fullTitle: "Miniaturizable", getterString: "lks_styleMaskMiniaturizable", setterString: "setLks_styleMaskMiniaturizable:", patch: true) + table[LookinAttr_NSWindow_Style_Resizable] = AttrInfo(className: "NSWindow", fullTitle: "Resizable", getterString: "lks_styleMaskResizable", setterString: "setLks_styleMaskResizable:", patch: true) + table[LookinAttr_NSWindow_Style_UnifiedTitleAndToolbar] = AttrInfo(className: "NSWindow", fullTitle: "UnifiedTitleAndToolbar", getterString: "lks_styleMaskUnifiedTitleAndToolbar", setterString: "setLks_styleMaskUnifiedTitleAndToolbar:", patch: true) + table[LookinAttr_NSWindow_Style_FullScreen] = AttrInfo(className: "NSWindow", fullTitle: "FullScreen", getterString: "lks_styleMaskFullScreen", setterString: "", patch: false) + table[LookinAttr_NSWindow_Style_FullSizeContentView] = AttrInfo(className: "NSWindow", fullTitle: "FullSizeContentView", getterString: "lks_styleMaskFullSizeContentView", setterString: "setLks_styleMaskFullSizeContentView:", patch: true) + table[LookinAttr_NSWindow_Style_UtilityWindow] = AttrInfo(className: "NSWindow", fullTitle: "UtilityWindow", getterString: "lks_styleMaskUtilityWindow", setterString: "", patch: false) + table[LookinAttr_NSWindow_Style_DocModalWindow] = AttrInfo(className: "NSWindow", fullTitle: "DocModalWindow", getterString: "lks_styleMaskDocModalWindow", setterString: "", patch: false) + table[LookinAttr_NSWindow_Style_NonactivatingPanel] = AttrInfo(className: "NSWindow", fullTitle: "NonactivatingPanel", getterString: "lks_styleMaskNonactivatingPanel", setterString: "", patch: false) + table[LookinAttr_NSWindow_Style_HUDWindow] = AttrInfo(className: "NSWindow", fullTitle: "HUDWindow", getterString: "lks_styleMaskHUDWindow", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_CanJoinAllSpaces] = AttrInfo(className: "NSWindow", fullTitle: "CanJoinAllSpaces", getterString: "lks_collectionBehaviorCanJoinAllSpaces", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_MoveToActiveSpace] = AttrInfo(className: "NSWindow", fullTitle: "MoveToActiveSpace", getterString: "lks_collectionBehaviorMoveToActiveSpace", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_ParticipatesInCycle] = AttrInfo(className: "NSWindow", fullTitle: "ParticipatesInCycle", getterString: "lks_collectionBehaviorParticipatesInCycle", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_IgnoresCycle] = AttrInfo(className: "NSWindow", fullTitle: "IgnoresCycle", getterString: "lks_collectionBehaviorIgnoresCycle", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_FullScreenPrimary] = AttrInfo(className: "NSWindow", fullTitle: "FullScreenPrimary", getterString: "lks_collectionBehaviorFullScreenPrimary", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_FullScreenAuxiliary] = AttrInfo(className: "NSWindow", fullTitle: "FullScreenAuxiliary", getterString: "lks_collectionBehaviorFullScreenAuxiliary", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_FullScreenNone] = AttrInfo(className: "NSWindow", fullTitle: "FullScreenNone", getterString: "lks_collectionBehaviorFullScreenNone", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_FullScreenAllowsTiling] = AttrInfo(className: "NSWindow", fullTitle: "FullScreenAllowsTiling", getterString: "lks_collectionBehaviorFullScreenAllowsTiling", setterString: "", patch: false) + table[LookinAttr_NSWindow_CollectionBehavior_FullScreenDisallowsTiling] = AttrInfo(className: "NSWindow", fullTitle: "FullScreenDisallowsTiling", getterString: "lks_collectionBehaviorFullScreenDisallowsTiling", setterString: "", patch: false) + table[LookinAttr_NSWindow_Appearance_TitlebarAppearsTransparent] = AttrInfo(className: "NSWindow", fullTitle: "TitlebarAppearsTransparent", patch: true) + table[LookinAttr_NSWindow_Appearance_TitleVisibility] = AttrInfo(className: "NSWindow", fullTitle: "TitleVisibility", enumList: "NSWindowTitleVisibility", patch: true) + table[LookinAttr_NSWindow_Appearance_ToolbarStyle] = AttrInfo(className: "NSWindow", fullTitle: "ToolbarStyle", enumList: "NSWindowToolbarStyle", patch: true, osVersion: 11) + table[LookinAttr_NSWindow_Appearance_TitlebarSeparatorStyle] = AttrInfo(className: "NSWindow", fullTitle: "TitlebarSeparatorStyle", enumList: "NSTitlebarSeparatorStyle", patch: true, osVersion: 11) + table[LookinAttr_NSWindow_Appearance_BackgroundColor] = AttrInfo(className: "NSWindow", fullTitle: "BackgroundColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSWindow_Appearance_AlphaValue] = AttrInfo(className: "NSWindow", fullTitle: "AlphaValue", patch: true) + table[LookinAttr_NSWindow_Appearance_Opaque] = AttrInfo(className: "NSWindow", fullTitle: "Opaque", getterString: "isOpaque", setterString: "", patch: false) + table[LookinAttr_NSWindow_Appearance_HasShadow] = AttrInfo(className: "NSWindow", fullTitle: "HasShadow", patch: true) + table[LookinAttr_NSWindow_Behavior_Movable] = AttrInfo(className: "NSWindow", fullTitle: "Movable", getterString: "isMovable", patch: false) + table[LookinAttr_NSWindow_Behavior_MovableByWindowBackground] = AttrInfo(className: "NSWindow", fullTitle: "MovableByWindowBackground", getterString: "isMovableByWindowBackground", patch: false) + table[LookinAttr_NSWindow_Behavior_AnimationBehavior] = AttrInfo(className: "NSWindow", fullTitle: "AnimationBehavior", enumList: "NSWindowAnimationBehavior", patch: false) + table[LookinAttr_NSWindow_Behavior_Level] = AttrInfo(className: "NSWindow", fullTitle: "Level", enumList: "NSWindowLevel", patch: true) + table[LookinAttr_NSWindow_Behavior_HidesOnDeactivate] = AttrInfo(className: "NSWindow", fullTitle: "HidesOnDeactivate", patch: true) + table[LookinAttr_NSWindow_Behavior_TabbingMode] = AttrInfo(className: "NSWindow", fullTitle: "TabbingMode", enumList: "NSWindowTabbingMode", patch: true) + table[LookinAttr_NSWindow_Size_MinSize] = AttrInfo(className: "NSWindow", fullTitle: "MinSize", patch: true) + table[LookinAttr_NSWindow_Size_MaxSize] = AttrInfo(className: "NSWindow", fullTitle: "MaxSize", patch: true) + table[LookinAttr_NSWindow_Info_WindowNumber] = AttrInfo(className: "NSWindow", fullTitle: "WindowNumber", setterString: "", patch: false) + table[LookinAttr_NSWindow_Info_BackingScaleFactor] = AttrInfo(className: "NSWindow", fullTitle: "BackingScaleFactor", setterString: "", patch: false) + + // MARK: - UIWindowScene + + // Lives under the Layout group rather than the UIWindowScene group, + // because it is the only geometry a scene exposes — it has no frame. + table[LookinAttr_Layout_CoordinateSpace_CoordinateSpace] = AttrInfo(className: "UIWindowScene", fullTitle: "CoordinateSpace", getterString: "lks_coordinateSpaceBounds", setterString: "", patch: false) + table[LookinAttr_UIWindowScene_State_ActivationState] = AttrInfo(className: "UIWindowScene", fullTitle: "ActivationState", getterString: "activationState", setterString: "", enumList: "UISceneActivationState", patch: false) + table[LookinAttr_UIWindowScene_Title_Title] = AttrInfo(className: "UIWindowScene", fullTitle: "Title", typeIfObj: .nsString, patch: true) + table[LookinAttr_UIWindowScene_Title_Subtitle] = AttrInfo(className: "UIWindowScene", fullTitle: "Subtitle", typeIfObj: .nsString, patch: true, osVersion: 15) + table[LookinAttr_UIWindowScene_Orientation_InterfaceOrientation] = AttrInfo(className: "UIWindowScene", fullTitle: "InterfaceOrientation", getterString: "lks_interfaceOrientation", setterString: "", enumList: "UIInterfaceOrientation", patch: false) + table[LookinAttr_UIWindowScene_Windows_WindowCount] = AttrInfo(className: "UIWindowScene", fullTitle: "WindowCount", getterString: "lks_windowCount", setterString: "", patch: false) + table[LookinAttr_UIWindowScene_Windows_KeyWindowClassName] = AttrInfo(className: "UIWindowScene", fullTitle: "KeyWindowClassName", getterString: "lks_keyWindowClassName", setterString: "", typeIfObj: .nsString, patch: false) + table[LookinAttr_UIWindowScene_Screen_ScreenBounds] = AttrInfo(className: "UIWindowScene", fullTitle: "ScreenBounds", getterString: "lks_screenBounds", setterString: "", patch: false) + table[LookinAttr_UIWindowScene_Screen_ScreenScale] = AttrInfo(className: "UIWindowScene", fullTitle: "ScreenScale", getterString: "lks_screenScale", setterString: "", patch: false) + table[LookinAttr_UIWindowScene_StatusBar_StatusBarHidden] = AttrInfo(className: "UIWindowScene", fullTitle: "StatusBarHidden", getterString: "lks_statusBarHidden", setterString: "", patch: false) + table[LookinAttr_UIWindowScene_StatusBar_StatusBarStyle] = AttrInfo(className: "UIWindowScene", fullTitle: "StatusBarStyle", getterString: "lks_statusBarStyle", setterString: "", enumList: "UIStatusBarStyle", patch: false) + table[LookinAttr_UIWindowScene_StatusBar_StatusBarFrame] = AttrInfo(className: "UIWindowScene", fullTitle: "StatusBarFrame", getterString: "lks_statusBarFrame", setterString: "", patch: false) + table[LookinAttr_UIWindowScene_Traits_UserInterfaceStyle] = AttrInfo(className: "UIWindowScene", fullTitle: "UserInterfaceStyle", getterString: "lks_userInterfaceStyle", setterString: "", enumList: "UIUserInterfaceStyle", patch: false) + table[LookinAttr_UIWindowScene_Traits_HorizontalSizeClass] = AttrInfo(className: "UIWindowScene", fullTitle: "HorizontalSizeClass", getterString: "lks_horizontalSizeClass", setterString: "", enumList: "UIUserInterfaceSizeClass", patch: false) + table[LookinAttr_UIWindowScene_Traits_VerticalSizeClass] = AttrInfo(className: "UIWindowScene", fullTitle: "VerticalSizeClass", getterString: "lks_verticalSizeClass", setterString: "", enumList: "UIUserInterfaceSizeClass", patch: false) + table[LookinAttr_UIWindowScene_Traits_UserInterfaceLevel] = AttrInfo(className: "UIWindowScene", fullTitle: "UserInterfaceLevel", getterString: "lks_userInterfaceLevel", setterString: "", enumList: "UIUserInterfaceLevel", patch: false, osVersion: 13) + table[LookinAttr_UIWindowScene_Traits_ActiveAppearance] = AttrInfo(className: "UIWindowScene", fullTitle: "ActiveAppearance", getterString: "lks_activeAppearance", setterString: "", enumList: "UIUserInterfaceActiveAppearance", patch: false, osVersion: 14) + table[LookinAttr_UIWindowScene_Traits_AccessibilityContrast] = AttrInfo(className: "UIWindowScene", fullTitle: "AccessibilityContrast", getterString: "lks_accessibilityContrast", setterString: "", enumList: "UIAccessibilityContrast", patch: false, osVersion: 13) + table[LookinAttr_UIWindowScene_Traits_LegibilityWeight] = AttrInfo(className: "UIWindowScene", fullTitle: "LegibilityWeight", getterString: "lks_legibilityWeight", setterString: "", enumList: "UILegibilityWeight", patch: false, osVersion: 13) + table[LookinAttr_UIWindowScene_Traits_DisplayScale] = AttrInfo(className: "UIWindowScene", fullTitle: "DisplayScale", getterString: "lks_traitDisplayScale", setterString: "", patch: false, osVersion: 13) + table[LookinAttr_UIWindowScene_Traits_DisplayGamut] = AttrInfo(className: "UIWindowScene", fullTitle: "DisplayGamut", getterString: "lks_displayGamut", setterString: "", enumList: "UIDisplayGamut", patch: false, osVersion: 13) + table[LookinAttr_UIWindowScene_Traits_UserInterfaceIdiom] = AttrInfo(className: "UIWindowScene", fullTitle: "UserInterfaceIdiom", getterString: "lks_userInterfaceIdiom", setterString: "", enumList: "UIUserInterfaceIdiom", patch: false, osVersion: 13) + table[LookinAttr_UIWindowScene_Traits_LayoutDirection] = AttrInfo(className: "UIWindowScene", fullTitle: "LayoutDirection", getterString: "lks_layoutDirection", setterString: "", enumList: "UITraitEnvironmentLayoutDirection", patch: false, osVersion: 13) + table[LookinAttr_UIWindowScene_Traits_PreferredContentSizeCategory] = AttrInfo(className: "UIWindowScene", fullTitle: "PreferredContentSizeCategory", getterString: "lks_preferredContentSizeCategory", setterString: "", typeIfObj: .nsString, patch: false, osVersion: 13) + table[LookinAttr_UIWindowScene_Traits_SceneCaptureState] = AttrInfo(className: "UIWindowScene", fullTitle: "SceneCaptureState", getterString: "lks_sceneCaptureState", setterString: "", enumList: "UISceneCaptureState", patch: false, osVersion: 17) + table[LookinAttr_UIWindowScene_Traits_ImageDynamicRange] = AttrInfo(className: "UIWindowScene", fullTitle: "ImageDynamicRange", getterString: "lks_imageDynamicRange", setterString: "", enumList: "UIImageDynamicRange", patch: false, osVersion: 17) + table[LookinAttr_UIWindowScene_Traits_TypesettingLanguage] = AttrInfo(className: "UIWindowScene", fullTitle: "TypesettingLanguage", getterString: "lks_typesettingLanguage", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true, osVersion: 17) + table[LookinAttr_UIWindowScene_Session_PersistentIdentifier] = AttrInfo(className: "UIWindowScene", fullTitle: "PersistentIdentifier", getterString: "lks_sessionPersistentIdentifier", setterString: "", typeIfObj: .nsString, patch: false) + table[LookinAttr_UIWindowScene_Session_SessionRole] = AttrInfo(className: "UIWindowScene", fullTitle: "SessionRole", getterString: "lks_sessionRole", setterString: "", typeIfObj: .nsString, patch: false) + table[LookinAttr_UIWindowScene_Session_StateRestorationActivityType] = AttrInfo(className: "UIWindowScene", fullTitle: "StateRestorationActivity", getterString: "lks_sessionStateRestorationActivityType", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true) + table[LookinAttr_UIWindowScene_Session_UserInfo] = AttrInfo(className: "UIWindowScene", fullTitle: "UserInfo", getterString: "lks_sessionUserInfoJSONString", setterString: "", typeIfObj: .json, patch: false, hideIfNil: true) + // UISceneConfiguration — describes which classes and storyboard back + // this scene. DelegateClass is the class declared in the configuration, + // which is not necessarily the class of the delegate instance shown in + // the Relation group; a mismatch between the two is worth noticing. + table[LookinAttr_UIWindowScene_Configuration_Name] = AttrInfo(className: "UIWindowScene", fullTitle: "Name", getterString: "lks_configurationName", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true) + table[LookinAttr_UIWindowScene_Configuration_SceneClass] = AttrInfo(className: "UIWindowScene", fullTitle: "SceneClass", getterString: "lks_configurationSceneClassName", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true) + table[LookinAttr_UIWindowScene_Configuration_DelegateClass] = AttrInfo(className: "UIWindowScene", fullTitle: "DelegateClass", getterString: "lks_configurationDelegateClassName", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true) + table[LookinAttr_UIWindowScene_Configuration_Storyboard] = AttrInfo(className: "UIWindowScene", fullTitle: "Storyboard", getterString: "lks_configurationStoryboardDescription", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true) + // UIWindowSceneGeometry — every entry returns an object so that it can + // disappear via hideIfNil on platforms and OS versions that lack it. + table[LookinAttr_UIWindowScene_Geometry_SystemFrame] = AttrInfo(className: "UIWindowScene", fullTitle: "SystemFrame", getterString: "lks_geometrySystemFrame", setterString: "", typeIfObj: .cgRect, patch: false, hideIfNil: true, osVersion: 16) + table[LookinAttr_UIWindowScene_Geometry_InterfaceOrientationLocked] = AttrInfo(className: "UIWindowScene", fullTitle: "InterfaceOrientationLocked", getterString: "lks_geometryInterfaceOrientationLocked", setterString: "", typeIfObj: .BOOL, patch: false, hideIfNil: true, osVersion: 26) + table[LookinAttr_UIWindowScene_Geometry_InteractivelyResizing] = AttrInfo(className: "UIWindowScene", fullTitle: "InteractivelyResizing", getterString: "lks_geometryInteractivelyResizing", setterString: "", typeIfObj: .BOOL, patch: false, hideIfNil: true, osVersion: 26) + // UISceneActivationConditions — shown as the predicates' format strings. + table[LookinAttr_UIWindowScene_ActivationConditions_CanActivate] = AttrInfo(className: "UIWindowScene", fullTitle: "CanActivateForTargetContentIdentifier", briefTitle: "CanActivate", getterString: "lks_canActivateForTargetContentIdentifierPredicateFormat", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true) + table[LookinAttr_UIWindowScene_ActivationConditions_PrefersToActivate] = AttrInfo(className: "UIWindowScene", fullTitle: "PrefersToActivateForTargetContentIdentifier", briefTitle: "PrefersToActivate", getterString: "lks_prefersToActivateForTargetContentIdentifierPredicateFormat", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true) + // UISceneSizeRestrictions — nil on iPhone, so the whole section drops out there. + table[LookinAttr_UIWindowScene_SizeRestrictions_MinimumSize] = AttrInfo(className: "UIWindowScene", fullTitle: "MinimumSize", getterString: "lks_sizeRestrictionsMinimumSize", setterString: "", typeIfObj: .cgSize, patch: false, hideIfNil: true) + table[LookinAttr_UIWindowScene_SizeRestrictions_MaximumSize] = AttrInfo(className: "UIWindowScene", fullTitle: "MaximumSize", getterString: "lks_sizeRestrictionsMaximumSize", setterString: "", typeIfObj: .cgSize, patch: false, hideIfNil: true) + table[LookinAttr_UIWindowScene_SizeRestrictions_AllowsFullScreen] = AttrInfo(className: "UIWindowScene", fullTitle: "AllowsFullScreen", getterString: "lks_sizeRestrictionsAllowsFullScreen", setterString: "", typeIfObj: .BOOL, patch: false, hideIfNil: true) + // UISceneWindowingBehaviors — nil outside iPad multitasking and Catalyst. + table[LookinAttr_UIWindowScene_WindowingBehaviors_Closable] = AttrInfo(className: "UIWindowScene", fullTitle: "Closable", getterString: "lks_windowingBehaviorsClosable", setterString: "", typeIfObj: .BOOL, patch: false, hideIfNil: true, osVersion: 16) + table[LookinAttr_UIWindowScene_WindowingBehaviors_Miniaturizable] = AttrInfo(className: "UIWindowScene", fullTitle: "Miniaturizable", getterString: "lks_windowingBehaviorsMiniaturizable", setterString: "", typeIfObj: .BOOL, patch: false, hideIfNil: true, osVersion: 16) + table[LookinAttr_UIWindowScene_WindowingBehaviors_FullScreen] = AttrInfo(className: "UIWindowScene", fullTitle: "FullScreen", getterString: "lks_fullScreen", setterString: "", typeIfObj: .BOOL, patch: false, hideIfNil: true) + table[LookinAttr_UIWindowScene_Pointer_Locked] = AttrInfo(className: "UIWindowScene", fullTitle: "Locked", getterString: "lks_pointerLocked", setterString: "", typeIfObj: .BOOL, patch: false, hideIfNil: true, osVersion: 14) + table[LookinAttr_UIWindowScene_Protection_UserAuthenticationEnabled] = AttrInfo(className: "UIWindowScene", fullTitle: "UserAuthenticationEnabled", getterString: "lks_systemProtectionUserAuthenticationEnabled", setterString: "", typeIfObj: .BOOL, patch: false, hideIfNil: true, osVersion: 18) + + // MARK: - UITraitCollection + + table[LookinAttr_UITraitCollection_Appearance_UserInterfaceStyle] = AttrInfo(className: "UIView", fullTitle: "UserInterfaceStyle", getterString: "lks_traitCollection_userInterfaceStyle", setterString: "", enumList: "UIUserInterfaceStyle", patch: false, osVersion: 12) + table[LookinAttr_UITraitCollection_Appearance_UserInterfaceLevel] = AttrInfo(className: "UIView", fullTitle: "UserInterfaceLevel", getterString: "lks_traitCollection_userInterfaceLevel", setterString: "", enumList: "UIUserInterfaceLevel", patch: false, osVersion: 13) + table[LookinAttr_UITraitCollection_Appearance_ActiveAppearance] = AttrInfo(className: "UIView", fullTitle: "ActiveAppearance", getterString: "lks_traitCollection_activeAppearance", setterString: "", enumList: "UIUserInterfaceActiveAppearance", patch: false, osVersion: 14) + table[LookinAttr_UITraitCollection_Appearance_AccessibilityContrast] = AttrInfo(className: "UIView", fullTitle: "AccessibilityContrast", getterString: "lks_traitCollection_accessibilityContrast", setterString: "", enumList: "UIAccessibilityContrast", patch: false, osVersion: 13) + table[LookinAttr_UITraitCollection_Appearance_LegibilityWeight] = AttrInfo(className: "UIView", fullTitle: "LegibilityWeight", getterString: "lks_traitCollection_legibilityWeight", setterString: "", enumList: "UILegibilityWeight", patch: false, osVersion: 13) + table[LookinAttr_UITraitCollection_SizeClass_HorizontalSizeClass] = AttrInfo(className: "UIView", fullTitle: "HorizontalSizeClass", getterString: "lks_traitCollection_horizontalSizeClass", setterString: "", enumList: "UIUserInterfaceSizeClass", patch: false) + table[LookinAttr_UITraitCollection_SizeClass_VerticalSizeClass] = AttrInfo(className: "UIView", fullTitle: "VerticalSizeClass", getterString: "lks_traitCollection_verticalSizeClass", setterString: "", enumList: "UIUserInterfaceSizeClass", patch: false) + table[LookinAttr_UITraitCollection_Display_DisplayScale] = AttrInfo(className: "UIView", fullTitle: "DisplayScale", getterString: "lks_traitCollection_displayScale", setterString: "", patch: false) + table[LookinAttr_UITraitCollection_Display_DisplayGamut] = AttrInfo(className: "UIView", fullTitle: "DisplayGamut", getterString: "lks_traitCollection_displayGamut", setterString: "", enumList: "UIDisplayGamut", patch: false, osVersion: 10) + table[LookinAttr_UITraitCollection_Display_ImageDynamicRange] = AttrInfo(className: "UIView", fullTitle: "ImageDynamicRange", getterString: "lks_traitCollection_imageDynamicRange", setterString: "", enumList: "UIImageDynamicRange", patch: false, osVersion: 17) + table[LookinAttr_UITraitCollection_Device_UserInterfaceIdiom] = AttrInfo(className: "UIView", fullTitle: "UserInterfaceIdiom", getterString: "lks_traitCollection_userInterfaceIdiom", setterString: "", enumList: "UIUserInterfaceIdiom", patch: false) + table[LookinAttr_UITraitCollection_Device_ForceTouchCapability] = AttrInfo(className: "UIView", fullTitle: "ForceTouchCapability", getterString: "lks_traitCollection_forceTouchCapability", setterString: "", enumList: "UIForceTouchCapability", patch: false, osVersion: 9) + table[LookinAttr_UITraitCollection_Layout_LayoutDirection] = AttrInfo(className: "UIView", fullTitle: "LayoutDirection", getterString: "lks_traitCollection_layoutDirection", setterString: "", enumList: "UITraitEnvironmentLayoutDirection", patch: false, osVersion: 10) + table[LookinAttr_UITraitCollection_Content_PreferredContentSizeCategory] = AttrInfo(className: "UIView", fullTitle: "PreferredContentSizeCategory", getterString: "lks_traitCollection_preferredContentSizeCategory", setterString: "", typeIfObj: .nsString, patch: false, osVersion: 10) + table[LookinAttr_UITraitCollection_Content_TypesettingLanguage] = AttrInfo(className: "UIView", fullTitle: "TypesettingLanguage", getterString: "lks_traitCollection_typesettingLanguage", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true, osVersion: 17) + + // MARK: - NSSlider + + table[LookinAttr_NSSlider_SliderType_SliderType] = AttrInfo(className: "NSSlider", fullTitle: "SliderType", enumList: "NSSliderType", patch: true) + table[LookinAttr_NSSlider_Range_MinValue] = AttrInfo(className: "NSSlider", fullTitle: "MinValue", patch: true) + table[LookinAttr_NSSlider_Range_MaxValue] = AttrInfo(className: "NSSlider", fullTitle: "MaxValue", patch: true) + table[LookinAttr_NSSlider_TickMark_NumberOfTickMarks] = AttrInfo(className: "NSSlider", fullTitle: "NumberOfTickMarks", patch: true) + table[LookinAttr_NSSlider_TickMark_TickMarkPosition] = AttrInfo(className: "NSSlider", fullTitle: "TickMarkPosition", enumList: "NSTickMarkPosition", patch: true) + table[LookinAttr_NSSlider_TickMark_AllowsTickMarkValuesOnly] = AttrInfo(className: "NSSlider", fullTitle: "AllowsTickMarkValuesOnly", patch: true) + table[LookinAttr_NSSlider_Misc_Vertical] = AttrInfo(className: "NSSlider", fullTitle: "Vertical", getterString: "isVertical", setterString: "", patch: false) + table[LookinAttr_NSSlider_Misc_KnobThickness] = AttrInfo(className: "NSSlider", fullTitle: "KnobThickness", setterString: "", patch: false) + table[LookinAttr_NSSlider_Misc_AltIncrementValue] = AttrInfo(className: "NSSlider", fullTitle: "AltIncrementValue", patch: true) + table[LookinAttr_NSSlider_Misc_TrackFillColor] = AttrInfo(className: "NSSlider", fullTitle: "TrackFillColor", typeIfObj: .uiColor, patch: true, hideIfNil: true) + + // MARK: - NSProgressIndicator + + table[LookinAttr_NSProgressIndicator_Style_Style] = AttrInfo(className: "NSProgressIndicator", fullTitle: "Style", enumList: "NSProgressIndicatorStyle", patch: true) + table[LookinAttr_NSProgressIndicator_Range_MinValue] = AttrInfo(className: "NSProgressIndicator", fullTitle: "MinValue", patch: true) + table[LookinAttr_NSProgressIndicator_Range_MaxValue] = AttrInfo(className: "NSProgressIndicator", fullTitle: "MaxValue", patch: true) + table[LookinAttr_NSProgressIndicator_Range_DoubleValue] = AttrInfo(className: "NSProgressIndicator", fullTitle: "DoubleValue", patch: true) + table[LookinAttr_NSProgressIndicator_Misc_Indeterminate] = AttrInfo(className: "NSProgressIndicator", fullTitle: "Indeterminate", getterString: "isIndeterminate", patch: true) + table[LookinAttr_NSProgressIndicator_Misc_Bezeled] = AttrInfo(className: "NSProgressIndicator", fullTitle: "Bezeled", getterString: "isBezeled", patch: true) + table[LookinAttr_NSProgressIndicator_Misc_DisplayedWhenStopped] = AttrInfo(className: "NSProgressIndicator", fullTitle: "DisplayedWhenStopped", getterString: "isDisplayedWhenStopped", patch: true) + + // MARK: - NSSegmentedControl + + table[LookinAttr_NSSegmentedControl_SegmentCount_SegmentCount] = AttrInfo(className: "NSSegmentedControl", fullTitle: "SegmentCount", patch: true) + table[LookinAttr_NSSegmentedControl_Selection_SelectedSegment] = AttrInfo(className: "NSSegmentedControl", fullTitle: "SelectedSegment", patch: true) + table[LookinAttr_NSSegmentedControl_Style_SegmentStyle] = AttrInfo(className: "NSSegmentedControl", fullTitle: "SegmentStyle", enumList: "NSSegmentStyle", patch: true) + table[LookinAttr_NSSegmentedControl_Style_TrackingMode] = AttrInfo(className: "NSSegmentedControl", fullTitle: "TrackingMode", enumList: "NSSegmentSwitchTracking", patch: true) + table[LookinAttr_NSSegmentedControl_Colors_SelectedSegmentBezelColor] = AttrInfo(className: "NSSegmentedControl", fullTitle: "SelectedSegmentBezelColor", typeIfObj: .uiColor, patch: true, hideIfNil: true) + + // MARK: - NSPopUpButton + + table[LookinAttr_NSPopUpButton_Behavior_PullsDown] = AttrInfo(className: "NSPopUpButton", fullTitle: "PullsDown", patch: true) + table[LookinAttr_NSPopUpButton_Behavior_AutoenablesItems] = AttrInfo(className: "NSPopUpButton", fullTitle: "AutoenablesItems", patch: true) + table[LookinAttr_NSPopUpButton_Behavior_PreferredEdge] = AttrInfo(className: "NSPopUpButton", fullTitle: "PreferredEdge", enumList: "NSRectEdge", patch: true) + table[LookinAttr_NSPopUpButton_Selection_SelectedTag] = AttrInfo(className: "NSPopUpButton", fullTitle: "SelectedTag", setterString: "", patch: false) + table[LookinAttr_NSPopUpButton_Selection_IndexOfSelectedItem] = AttrInfo(className: "NSPopUpButton", fullTitle: "IndexOfSelectedItem", setterString: "", patch: false) + table[LookinAttr_NSPopUpButton_Selection_TitleOfSelectedItem] = AttrInfo(className: "NSPopUpButton", fullTitle: "TitleOfSelectedItem", setterString: "", typeIfObj: .nsString, patch: false, hideIfNil: true) + table[LookinAttr_NSPopUpButton_Items_NumberOfItems] = AttrInfo(className: "NSPopUpButton", fullTitle: "NumberOfItems", setterString: "", patch: false) + + // MARK: - NSComboBox + + table[LookinAttr_NSComboBox_Items_NumberOfItems] = AttrInfo(className: "NSComboBox", fullTitle: "NumberOfItems", setterString: "", patch: false) + table[LookinAttr_NSComboBox_Items_HasVerticalScroller] = AttrInfo(className: "NSComboBox", fullTitle: "HasVerticalScroller", patch: true) + table[LookinAttr_NSComboBox_Items_NumberOfVisibleItems] = AttrInfo(className: "NSComboBox", fullTitle: "NumberOfVisibleItems", patch: true) + table[LookinAttr_NSComboBox_Items_IntercellSpacing] = AttrInfo(className: "NSComboBox", fullTitle: "IntercellSpacing", patch: true) + table[LookinAttr_NSComboBox_Items_ItemHeight] = AttrInfo(className: "NSComboBox", fullTitle: "ItemHeight", patch: true) + table[LookinAttr_NSComboBox_Misc_ButtonBordered] = AttrInfo(className: "NSComboBox", fullTitle: "ButtonBordered", getterString: "isButtonBordered", patch: true) + table[LookinAttr_NSComboBox_Misc_Completes] = AttrInfo(className: "NSComboBox", fullTitle: "Completes", patch: true) + table[LookinAttr_NSComboBox_Misc_UsesDataSource] = AttrInfo(className: "NSComboBox", fullTitle: "UsesDataSource", patch: false) + + // MARK: - NSStepper + + table[LookinAttr_NSStepper_Range_MinValue] = AttrInfo(className: "NSStepper", fullTitle: "MinValue", patch: true) + table[LookinAttr_NSStepper_Range_MaxValue] = AttrInfo(className: "NSStepper", fullTitle: "MaxValue", patch: true) + table[LookinAttr_NSStepper_Range_Increment] = AttrInfo(className: "NSStepper", fullTitle: "Increment", patch: true) + table[LookinAttr_NSStepper_Misc_ValueWraps] = AttrInfo(className: "NSStepper", fullTitle: "ValueWraps", patch: true) + table[LookinAttr_NSStepper_Misc_Autorepeat] = AttrInfo(className: "NSStepper", fullTitle: "Autorepeat", patch: true) + + // MARK: - NSColorWell + + table[LookinAttr_NSColorWell_Color_Color] = AttrInfo(className: "NSColorWell", fullTitle: "Color", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSColorWell_Misc_Bordered] = AttrInfo(className: "NSColorWell", fullTitle: "Bordered", getterString: "isBordered", patch: true) + table[LookinAttr_NSColorWell_Misc_Active] = AttrInfo(className: "NSColorWell", fullTitle: "Active", getterString: "isActive", setterString: "", patch: false) + table[LookinAttr_NSColorWell_Misc_ColorWellStyle] = AttrInfo(className: "NSColorWell", fullTitle: "ColorWellStyle", enumList: "NSColorWellStyle", patch: true, osVersion: 13) + + // MARK: - NSSwitch + + table[LookinAttr_NSSwitch_State_State] = AttrInfo(className: "NSSwitch", fullTitle: "State", enumList: "NSControlStateValue", patch: true, osVersion: 15) + + // MARK: - NSDatePicker + + table[LookinAttr_NSDatePicker_Style_DatePickerStyle] = AttrInfo(className: "NSDatePicker", fullTitle: "DatePickerStyle", enumList: "NSDatePickerStyle", patch: true) + table[LookinAttr_NSDatePicker_Style_DatePickerMode] = AttrInfo(className: "NSDatePicker", fullTitle: "DatePickerMode", enumList: "NSDatePickerMode", patch: true) + table[LookinAttr_NSDatePicker_Range_DateValue] = AttrInfo(className: "NSDatePicker", fullTitle: "DateValue", setterString: "", typeIfObj: .customObj, patch: false) + table[LookinAttr_NSDatePicker_Range_MinDate] = AttrInfo(className: "NSDatePicker", fullTitle: "MinDate", setterString: "", typeIfObj: .customObj, patch: false, hideIfNil: true) + table[LookinAttr_NSDatePicker_Range_MaxDate] = AttrInfo(className: "NSDatePicker", fullTitle: "MaxDate", setterString: "", typeIfObj: .customObj, patch: false, hideIfNil: true) + table[LookinAttr_NSDatePicker_Misc_Bordered] = AttrInfo(className: "NSDatePicker", fullTitle: "Bordered", getterString: "isBordered", patch: true) + table[LookinAttr_NSDatePicker_Misc_Bezeled] = AttrInfo(className: "NSDatePicker", fullTitle: "Bezeled", getterString: "isBezeled", patch: true) + table[LookinAttr_NSDatePicker_Misc_DrawsBackground] = AttrInfo(className: "NSDatePicker", fullTitle: "DrawsBackground", patch: true) + + // MARK: - NSLevelIndicator + + table[LookinAttr_NSLevelIndicator_Style_Style] = AttrInfo(className: "NSLevelIndicator", fullTitle: "LevelIndicatorStyle", enumList: "NSLevelIndicatorStyle", patch: true) + table[LookinAttr_NSLevelIndicator_Range_MinValue] = AttrInfo(className: "NSLevelIndicator", fullTitle: "MinValue", patch: true) + table[LookinAttr_NSLevelIndicator_Range_MaxValue] = AttrInfo(className: "NSLevelIndicator", fullTitle: "MaxValue", patch: true) + table[LookinAttr_NSLevelIndicator_Range_WarningValue] = AttrInfo(className: "NSLevelIndicator", fullTitle: "WarningValue", patch: true) + table[LookinAttr_NSLevelIndicator_Range_CriticalValue] = AttrInfo(className: "NSLevelIndicator", fullTitle: "CriticalValue", patch: true) + table[LookinAttr_NSLevelIndicator_TickMark_NumberOfTickMarks] = AttrInfo(className: "NSLevelIndicator", fullTitle: "NumberOfTickMarks", patch: true) + table[LookinAttr_NSLevelIndicator_TickMark_NumberOfMajorTickMarks] = AttrInfo(className: "NSLevelIndicator", fullTitle: "NumberOfMajorTickMarks", patch: true) + + // MARK: - NSOutlineView + + table[LookinAttr_NSOutlineView_Indentation_IndentationPerLevel] = AttrInfo(className: "NSOutlineView", fullTitle: "IndentationPerLevel", patch: true) + table[LookinAttr_NSOutlineView_Misc_AutoresizesOutlineColumn] = AttrInfo(className: "NSOutlineView", fullTitle: "AutoresizesOutlineColumn", patch: true) + table[LookinAttr_NSOutlineView_Misc_IndentationMarkerFollowsCell] = AttrInfo(className: "NSOutlineView", fullTitle: "IndentationMarkerFollowsCell", patch: true) + table[LookinAttr_NSOutlineView_Misc_AutosaveExpandedItems] = AttrInfo(className: "NSOutlineView", fullTitle: "AutosaveExpandedItems", patch: true) + + // MARK: - NSCollectionView + + table[LookinAttr_NSCollectionView_Selection_Selectable] = AttrInfo(className: "NSCollectionView", fullTitle: "Selectable", getterString: "isSelectable", patch: true) + table[LookinAttr_NSCollectionView_Selection_AllowsMultipleSelection] = AttrInfo(className: "NSCollectionView", fullTitle: "AllowsMultipleSelection", patch: true) + table[LookinAttr_NSCollectionView_Selection_AllowsEmptySelection] = AttrInfo(className: "NSCollectionView", fullTitle: "AllowsEmptySelection", patch: true) + table[LookinAttr_NSCollectionView_Info_NumberOfSections] = AttrInfo(className: "NSCollectionView", fullTitle: "NumberOfSections", setterString: "", patch: false) + table[LookinAttr_NSCollectionView_Colors_BackgroundColors] = AttrInfo(className: "NSCollectionView", fullTitle: "BackgroundColors", setterString: "", typeIfObj: .customObj, patch: false) + + // MARK: - NSBox + + table[LookinAttr_NSBox_Type_BoxType] = AttrInfo(className: "NSBox", fullTitle: "BoxType", enumList: "NSBoxType", patch: true) + table[LookinAttr_NSBox_Type_BorderType] = AttrInfo(className: "NSBox", fullTitle: "BorderType", enumList: "NSBorderType", patch: true) + table[LookinAttr_NSBox_Title_Title] = AttrInfo(className: "NSBox", fullTitle: "Title", typeIfObj: .nsString, patch: true) + table[LookinAttr_NSBox_Title_TitlePosition] = AttrInfo(className: "NSBox", fullTitle: "TitlePosition", enumList: "NSTitlePosition", patch: true) + table[LookinAttr_NSBox_Appearance_Transparent] = AttrInfo(className: "NSBox", fullTitle: "Transparent", getterString: "isTransparent", patch: true) + table[LookinAttr_NSBox_Appearance_FillColor] = AttrInfo(className: "NSBox", fullTitle: "FillColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSBox_Appearance_BorderColor] = AttrInfo(className: "NSBox", fullTitle: "BorderColor", typeIfObj: .uiColor, patch: true) + table[LookinAttr_NSBox_Metrics_BorderWidth] = AttrInfo(className: "NSBox", fullTitle: "BorderWidth", patch: true) + table[LookinAttr_NSBox_Metrics_CornerRadius] = AttrInfo(className: "NSBox", fullTitle: "CornerRadius", patch: true) + table[LookinAttr_NSBox_Metrics_ContentViewMargins] = AttrInfo(className: "NSBox", fullTitle: "ContentViewMargins", patch: true) + + // MARK: - NSSplitView + + table[LookinAttr_NSSplitView_Orientation_Vertical] = AttrInfo(className: "NSSplitView", fullTitle: "Vertical", getterString: "isVertical", patch: true) + table[LookinAttr_NSSplitView_Style_DividerStyle] = AttrInfo(className: "NSSplitView", fullTitle: "DividerStyle", enumList: "NSSplitViewDividerStyle", patch: true) + table[LookinAttr_NSSplitView_Style_DividerThickness] = AttrInfo(className: "NSSplitView", fullTitle: "DividerThickness", setterString: "", patch: false) + table[LookinAttr_NSSplitView_Misc_ArrangesAllSubviews] = AttrInfo(className: "NSSplitView", fullTitle: "ArrangesAllSubviews", patch: true) + + // MARK: - NSTabView + + table[LookinAttr_NSTabView_Type_TabViewType] = AttrInfo(className: "NSTabView", fullTitle: "TabViewType", enumList: "NSTabViewType", patch: true) + table[LookinAttr_NSTabView_Type_TabPosition] = AttrInfo(className: "NSTabView", fullTitle: "TabPosition", enumList: "NSTabPosition", patch: true, osVersion: 14) + table[LookinAttr_NSTabView_Type_TabViewBorderType] = AttrInfo(className: "NSTabView", fullTitle: "TabViewBorderType", enumList: "NSTabViewBorderType", patch: true, osVersion: 14) + table[LookinAttr_NSTabView_Misc_AllowsTruncatedLabels] = AttrInfo(className: "NSTabView", fullTitle: "AllowsTruncatedLabels", patch: true) + table[LookinAttr_NSTabView_Misc_DrawsBackground] = AttrInfo(className: "NSTabView", fullTitle: "DrawsBackground", patch: true) + table[LookinAttr_NSTabView_Info_NumberOfTabViewItems] = AttrInfo(className: "NSTabView", fullTitle: "NumberOfTabViewItems", setterString: "", patch: false) + + // MARK: - NSGridView + + table[LookinAttr_NSGridView_Dimensions_NumberOfColumns] = AttrInfo(className: "NSGridView", fullTitle: "NumberOfColumns", setterString: "", patch: false) + table[LookinAttr_NSGridView_Dimensions_NumberOfRows] = AttrInfo(className: "NSGridView", fullTitle: "NumberOfRows", setterString: "", patch: false) + table[LookinAttr_NSGridView_Spacing_RowSpacing] = AttrInfo(className: "NSGridView", fullTitle: "RowSpacing", patch: true) + table[LookinAttr_NSGridView_Spacing_ColumnSpacing] = AttrInfo(className: "NSGridView", fullTitle: "ColumnSpacing", patch: true) + table[LookinAttr_NSGridView_Placement_XPlacement] = AttrInfo(className: "NSGridView", fullTitle: "XPlacement", enumList: "NSGridCellPlacement", patch: true) + table[LookinAttr_NSGridView_Placement_YPlacement] = AttrInfo(className: "NSGridView", fullTitle: "YPlacement", enumList: "NSGridCellPlacement", patch: true) + return table + }() + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinDashboardBlueprint+Groups.swift b/Sources/LookinCoreImpl/LookinDashboardBlueprint+Groups.swift new file mode 100644 index 00000000..43fe06ed --- /dev/null +++ b/Sources/LookinCoreImpl/LookinDashboardBlueprint+Groups.swift @@ -0,0 +1,726 @@ +// +// LookinDashboardBlueprint+Groups.swift +// LookinCore +// +// Group order, the sections of each group, and group and section titles. +// Translated from LookinDashboardBlueprint.m; the output of every public +// class method is pinned by Tests/LookinServerHierarchyTests/ +// BlueprintEquivalenceTests and its Fixtures/blueprint-.txt. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + extension LookinDashboardBlueprintTables { + /// `+groupIDs`, in display order. + static let groupIDs: [String] = { + var ids: [String] = [] + ids += [ + LookinAttrGroup_Class, + LookinAttrGroup_Relation, + LookinAttrGroup_Layout, + LookinAttrGroup_AutoLayout, + LookinAttrGroup_ViewLayer, + ] + #if canImport(UIKit) + ids += [ + LookinAttrGroup_UIStackView, + LookinAttrGroup_UIVisualEffectView, + LookinAttrGroup_UIImageView, + LookinAttrGroup_UILabel, + LookinAttrGroup_UIControl, + LookinAttrGroup_UIButton, + LookinAttrGroup_UIScrollView, + LookinAttrGroup_UITableView, + LookinAttrGroup_UITextView, + LookinAttrGroup_UITextField, + LookinAttrGroup_UIWindowScene, + LookinAttrGroup_UITraitCollection, + ] + #endif + #if os(macOS) + ids += [ + LookinAttrGroup_NSImageView, + LookinAttrGroup_NSControl, + LookinAttrGroup_NSButton, + LookinAttrGroup_NSScrollView, + LookinAttrGroup_NSTableView, + LookinAttrGroup_NSTextView, + LookinAttrGroup_NSTextField, + LookinAttrGroup_NSVisualEffectView, + LookinAttrGroup_NSStackView, + LookinAttrGroup_NSWindow, + LookinAttrGroup_NSSlider, + LookinAttrGroup_NSProgressIndicator, + LookinAttrGroup_NSSegmentedControl, + LookinAttrGroup_NSPopUpButton, + LookinAttrGroup_NSComboBox, + LookinAttrGroup_NSStepper, + LookinAttrGroup_NSColorWell, + LookinAttrGroup_NSSwitch, + LookinAttrGroup_NSDatePicker, + LookinAttrGroup_NSLevelIndicator, + LookinAttrGroup_NSOutlineView, + LookinAttrGroup_NSCollectionView, + LookinAttrGroup_NSBox, + LookinAttrGroup_NSSplitView, + LookinAttrGroup_NSTabView, + LookinAttrGroup_NSGridView, + LookinAttrGroup_NSCell, + ] + #endif + // Platform-neutral: LayoutGuide nodes exist on both platforms + ids.append(LookinAttrGroup_LayoutGuide) + return ids + }() + + /// `+sectionIDsForGroupID:`. + static let sectionIDsByGroupID: [String: [String]] = { + var table: [String: [String]] = [:] + table[LookinAttrGroup_Class] = [LookinAttrSec_Class_Class] + table[LookinAttrGroup_Relation] = [LookinAttrSec_Relation_Relation] + table[LookinAttrGroup_Layout] = [ + LookinAttrSec_Layout_Frame, + LookinAttrSec_Layout_Bounds, + LookinAttrSec_Layout_SafeArea, + LookinAttrSec_Layout_Position, + LookinAttrSec_Layout_AnchorPoint, + LookinAttrSec_Layout_CoordinateSpace, + ] + table[LookinAttrGroup_AutoLayout] = [ + LookinAttrSec_AutoLayout_Constraints, + LookinAttrSec_AutoLayout_IntrinsicSize, + LookinAttrSec_AutoLayout_Hugging, + LookinAttrSec_AutoLayout_Resistance, + ] + table[LookinAttrGroup_ViewLayer] = { + var ids: [String] = [] + ids += [ + LookinAttrSec_ViewLayer_Visibility, + LookinAttrSec_ViewLayer_InterationAndMasks, + LookinAttrSec_ViewLayer_BgColor, + LookinAttrSec_ViewLayer_Border, + LookinAttrSec_ViewLayer_Corner, + LookinAttrSec_ViewLayer_Shadow, + LookinAttrSec_ViewLayer_Tag, + ] + #if canImport(UIKit) + ids += [ + LookinAttrSec_ViewLayer_ContentMode, + LookinAttrSec_ViewLayer_TintColor, + ] + #endif + return ids + }() + #if canImport(UIKit) + table[LookinAttrGroup_UIStackView] = [ + LookinAttrSec_UIStackView_Axis, + LookinAttrSec_UIStackView_Distribution, + LookinAttrSec_UIStackView_Alignment, + LookinAttrSec_UIStackView_Spacing, + ] + table[LookinAttrGroup_UIVisualEffectView] = [ + LookinAttrSec_UIVisualEffectView_Style, + LookinAttrSec_UIVisualEffectView_QMUIForegroundColor, + ] + table[LookinAttrGroup_UIImageView] = [ + LookinAttrSec_UIImageView_Name, + LookinAttrSec_UIImageView_Open, + ] + table[LookinAttrGroup_UILabel] = [ + LookinAttrSec_UILabel_Text, + LookinAttrSec_UILabel_Font, + LookinAttrSec_UILabel_NumberOfLines, + LookinAttrSec_UILabel_TextColor, + LookinAttrSec_UILabel_BreakMode, + LookinAttrSec_UILabel_Alignment, + LookinAttrSec_UILabel_CanAdjustFont, + ] + table[LookinAttrGroup_UIControl] = [ + LookinAttrSec_UIControl_EnabledSelected, + LookinAttrSec_UIControl_QMUIOutsideEdge, + LookinAttrSec_UIControl_VerAlignment, + LookinAttrSec_UIControl_HorAlignment, + ] + table[LookinAttrGroup_UIButton] = [ + LookinAttrSec_UIButton_ContentInsets, + LookinAttrSec_UIButton_TitleInsets, + LookinAttrSec_UIButton_ImageInsets, + ] + table[LookinAttrGroup_UIScrollView] = [ + LookinAttrSec_UIScrollView_ContentInset, + LookinAttrSec_UIScrollView_AdjustedInset, + LookinAttrSec_UIScrollView_QMUIInitialInset, + LookinAttrSec_UIScrollView_IndicatorInset, + LookinAttrSec_UIScrollView_Offset, + LookinAttrSec_UIScrollView_ContentSize, + LookinAttrSec_UIScrollView_Behavior, + LookinAttrSec_UIScrollView_ShowsIndicator, + LookinAttrSec_UIScrollView_Bounce, + LookinAttrSec_UIScrollView_ScrollPaging, + LookinAttrSec_UIScrollView_ContentTouches, + LookinAttrSec_UIScrollView_Zoom, + ] + table[LookinAttrGroup_UITableView] = [ + LookinAttrSec_UITableView_Style, + LookinAttrSec_UITableView_SectionsNumber, + LookinAttrSec_UITableView_RowsNumber, + LookinAttrSec_UITableView_SeparatorStyle, + LookinAttrSec_UITableView_SeparatorColor, + LookinAttrSec_UITableView_SeparatorInset, + ] + table[LookinAttrGroup_UITextView] = [ + LookinAttrSec_UITextView_Basic, + LookinAttrSec_UITextView_Text, + LookinAttrSec_UITextView_Font, + LookinAttrSec_UITextView_TextColor, + LookinAttrSec_UITextView_Alignment, + LookinAttrSec_UITextView_ContainerInset, + ] + table[LookinAttrGroup_UITextField] = [ + LookinAttrSec_UITextField_Text, + LookinAttrSec_UITextField_Placeholder, + LookinAttrSec_UITextField_Font, + LookinAttrSec_UITextField_TextColor, + LookinAttrSec_UITextField_Alignment, + LookinAttrSec_UITextField_Clears, + LookinAttrSec_UITextField_CanAdjustFont, + LookinAttrSec_UITextField_ClearButtonMode, + ] + table[LookinAttrGroup_UIWindowScene] = [ + LookinAttrSec_UIWindowScene_State, + LookinAttrSec_UIWindowScene_Title, + LookinAttrSec_UIWindowScene_Orientation, + LookinAttrSec_UIWindowScene_Geometry, + LookinAttrSec_UIWindowScene_Windows, + LookinAttrSec_UIWindowScene_Screen, + LookinAttrSec_UIWindowScene_StatusBar, + LookinAttrSec_UIWindowScene_SizeRestrictions, + LookinAttrSec_UIWindowScene_WindowingBehaviors, + LookinAttrSec_UIWindowScene_Pointer, + LookinAttrSec_UIWindowScene_Protection, + LookinAttrSec_UIWindowScene_Traits, + LookinAttrSec_UIWindowScene_Session, + LookinAttrSec_UIWindowScene_Configuration, + LookinAttrSec_UIWindowScene_ActivationConditions, + ] + table[LookinAttrGroup_UITraitCollection] = [ + LookinAttrSec_UITraitCollection_Appearance, + LookinAttrSec_UITraitCollection_SizeClass, + LookinAttrSec_UITraitCollection_Display, + LookinAttrSec_UITraitCollection_Device, + LookinAttrSec_UITraitCollection_Layout, + LookinAttrSec_UITraitCollection_Content, + ] + #endif + #if os(macOS) + table[LookinAttrGroup_NSImageView] = [ + LookinAttrSec_NSImageView_Name, + LookinAttrSec_NSImageView_Open, + LookinAttrSec_NSImageView_Scaling, + LookinAttrSec_NSImageView_Behavior, + LookinAttrSec_NSImageView_ContentTintColor, + ] + table[LookinAttrGroup_NSControl] = [ + LookinAttrSec_NSControl_State, + LookinAttrSec_NSControl_ControlSize, + LookinAttrSec_NSControl_Font, + LookinAttrSec_NSControl_Alignment, + LookinAttrSec_NSControl_Misc, + LookinAttrSec_NSControl_StringValue, + LookinAttrSec_NSControl_Value, + ] + table[LookinAttrGroup_NSButton] = [ + LookinAttrSec_NSButton_ButtonType, + LookinAttrSec_NSButton_BezelStyle, + LookinAttrSec_NSButton_Title, + LookinAttrSec_NSButton_Bordered, + LookinAttrSec_NSButton_BezelColor, + LookinAttrSec_NSButton_Misc, + ] + table[LookinAttrGroup_NSScrollView] = [ + LookinAttrSec_NSScrollView_ContentOffset, + LookinAttrSec_NSScrollView_ContentSize, + LookinAttrSec_NSScrollView_ContentInset, + LookinAttrSec_NSScrollView_BorderType, + LookinAttrSec_NSScrollView_Scroller, + LookinAttrSec_NSScrollView_Ruler, + LookinAttrSec_NSScrollView_LineScroll, + LookinAttrSec_NSScrollView_PageScroll, + LookinAttrSec_NSScrollView_ScrollElasiticity, + LookinAttrSec_NSScrollView_Misc, + LookinAttrSec_NSScrollView_Magnification, + ] + table[LookinAttrGroup_NSTableView] = [ + LookinAttrSec_NSTableView_RowHeight, + LookinAttrSec_NSTableView_AutomaticRowHeights, + LookinAttrSec_NSTableView_IntercellSpacing, + LookinAttrSec_NSTableView_Style, + LookinAttrSec_NSTableView_ColumnAutoresizingStyle, + LookinAttrSec_NSTableView_GridStyleMask, + LookinAttrSec_NSTableView_SelectionHighlightStyle, + LookinAttrSec_NSTableView_GridColor, + LookinAttrSec_NSTableView_RowSizeStyle, + LookinAttrSec_NSTableView_NumberOfRows, + LookinAttrSec_NSTableView_NumberOfColumns, + LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors, + LookinAttrSec_NSTableView_AllowsColumnReordering, + LookinAttrSec_NSTableView_AllowsColumnResizing, + LookinAttrSec_NSTableView_AllowsMultipleSelection, + LookinAttrSec_NSTableView_AllowsEmptySelection, + LookinAttrSec_NSTableView_AllowsColumnSelection, + LookinAttrSec_NSTableView_AllowsTypeSelect, + LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle, + LookinAttrSec_NSTableView_Autosave, + LookinAttrSec_NSTableView_FloatsGroupRows, + LookinAttrSec_NSTableView_RowActionsVisible, + LookinAttrSec_NSTableView_UsesStaticContents, + LookinAttrSec_NSTableView_UserInterfaceLayoutDirection, + LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag, + ] + table[LookinAttrGroup_NSTextView] = [ + LookinAttrSec_NSTextView_Font, + LookinAttrSec_NSTextView_Basic, + LookinAttrSec_NSTextView_String, + LookinAttrSec_NSTextView_TextColor, + LookinAttrSec_NSTextView_Alignment, + LookinAttrSec_NSTextView_ContainerInset, + LookinAttrSec_NSTextView_BaseWritingDirection, + LookinAttrSec_NSTextView_Size, + LookinAttrSec_NSTextView_Resizable, + ] + table[LookinAttrGroup_NSTextField] = [ + LookinAttrSec_NSTextField_BezelStyle, + LookinAttrSec_NSTextField_LineBreakStrategy, + LookinAttrSec_NSTextField_Bordered, + LookinAttrSec_NSTextField_TextColor, + LookinAttrSec_NSTextField_Placeholder, + LookinAttrSec_NSTextField_PreferredMaxLayoutWidth, + ] + table[LookinAttrGroup_NSVisualEffectView] = [ + LookinAttrSec_NSVisualEffectView_Material, + LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle, + LookinAttrSec_NSVisualEffectView_BlendingMode, + LookinAttrSec_NSVisualEffectView_State, + LookinAttrSec_NSVisualEffectView_Emphasized, + ] + table[LookinAttrGroup_NSStackView] = [ + LookinAttrSec_NSStackView_Orientation, + LookinAttrSec_NSStackView_EdgeInsets, + LookinAttrSec_NSStackView_DetachesHiddenViews, + LookinAttrSec_NSStackView_Distribution, + LookinAttrSec_NSStackView_Alignment, + LookinAttrSec_NSStackView_Spacing, + ] + table[LookinAttrGroup_NSWindow] = [ + LookinAttrSec_NSWindow_Title, + LookinAttrSec_NSWindow_Subtitle, + LookinAttrSec_NSWindow_State, + LookinAttrSec_NSWindow_Style, + LookinAttrSec_NSWindow_CollectionBehavior, + LookinAttrSec_NSWindow_Appearance, + LookinAttrSec_NSWindow_TitleVisibility, + LookinAttrSec_NSWindow_ToolbarStyle, + LookinAttrSec_NSWindow_TitlebarSeparatorStyle, + LookinAttrSec_NSWindow_Behavior, + LookinAttrSec_NSWindow_AnimationBehavior, + LookinAttrSec_NSWindow_Level, + LookinAttrSec_NSWindow_TabbingMode, + LookinAttrSec_NSWindow_Size, + LookinAttrSec_NSWindow_Info, + ] + table[LookinAttrGroup_NSSlider] = [ + LookinAttrSec_NSSlider_SliderType, + LookinAttrSec_NSSlider_Range, + LookinAttrSec_NSSlider_TickMark, + LookinAttrSec_NSSlider_Misc, + ] + table[LookinAttrGroup_NSProgressIndicator] = [ + LookinAttrSec_NSProgressIndicator_Style, + LookinAttrSec_NSProgressIndicator_Range, + LookinAttrSec_NSProgressIndicator_Misc, + ] + table[LookinAttrGroup_NSSegmentedControl] = [ + LookinAttrSec_NSSegmentedControl_SegmentCount, + LookinAttrSec_NSSegmentedControl_Selection, + LookinAttrSec_NSSegmentedControl_Style, + LookinAttrSec_NSSegmentedControl_Colors, + ] + table[LookinAttrGroup_NSPopUpButton] = [ + LookinAttrSec_NSPopUpButton_Behavior, + LookinAttrSec_NSPopUpButton_Selection, + LookinAttrSec_NSPopUpButton_Items, + ] + table[LookinAttrGroup_NSComboBox] = [ + LookinAttrSec_NSComboBox_Items, + LookinAttrSec_NSComboBox_Misc, + ] + table[LookinAttrGroup_NSStepper] = [ + LookinAttrSec_NSStepper_Range, + LookinAttrSec_NSStepper_Misc, + ] + table[LookinAttrGroup_NSColorWell] = [ + LookinAttrSec_NSColorWell_Color, + LookinAttrSec_NSColorWell_Misc, + ] + table[LookinAttrGroup_NSSwitch] = [LookinAttrSec_NSSwitch_State] + table[LookinAttrGroup_NSDatePicker] = [ + LookinAttrSec_NSDatePicker_Style, + LookinAttrSec_NSDatePicker_Range, + LookinAttrSec_NSDatePicker_Misc, + ] + table[LookinAttrGroup_NSLevelIndicator] = [ + LookinAttrSec_NSLevelIndicator_Style, + LookinAttrSec_NSLevelIndicator_Range, + LookinAttrSec_NSLevelIndicator_TickMark, + ] + table[LookinAttrGroup_NSOutlineView] = [ + LookinAttrSec_NSOutlineView_Indentation, + LookinAttrSec_NSOutlineView_Misc, + ] + table[LookinAttrGroup_NSCollectionView] = [ + LookinAttrSec_NSCollectionView_Selection, + LookinAttrSec_NSCollectionView_Info, + LookinAttrSec_NSCollectionView_Colors, + ] + table[LookinAttrGroup_NSBox] = [ + LookinAttrSec_NSBox_Type, + LookinAttrSec_NSBox_Title, + LookinAttrSec_NSBox_Appearance, + LookinAttrSec_NSBox_Metrics, + ] + table[LookinAttrGroup_NSSplitView] = [ + LookinAttrSec_NSSplitView_Orientation, + LookinAttrSec_NSSplitView_Style, + LookinAttrSec_NSSplitView_Misc, + ] + table[LookinAttrGroup_NSTabView] = [ + LookinAttrSec_NSTabView_Type, + LookinAttrSec_NSTabView_Misc, + LookinAttrSec_NSTabView_Info, + ] + table[LookinAttrGroup_NSGridView] = [ + LookinAttrSec_NSGridView_Dimensions, + LookinAttrSec_NSGridView_Spacing, + LookinAttrSec_NSGridView_Placement, + ] + #endif + table[LookinAttrGroup_LayoutGuide] = [ + LookinAttrSec_LayoutGuide_Identifier, + LookinAttrSec_LayoutGuide_LayoutFrame, + LookinAttrSec_LayoutGuide_OwningView, + ] + table[LookinAttrGroup_NSCell] = [ + LookinAttrSec_NSCell_Cell, + LookinAttrSec_NSCell_Content, + LookinAttrSec_NSCell_Behavior, + LookinAttrSec_NSCell_ButtonCell, + LookinAttrSec_NSCell_TextFieldCell, + ] + return table + }() + + /// `+groupTitleWithGroupID:`. The macOS client displays iOS data, so + /// both platforms carry every title. + static let groupTitles: [String: String] = { + var table: [String: String] = [:] + table[LookinAttrGroup_Class] = "Class" + table[LookinAttrGroup_Relation] = "Relation" + table[LookinAttrGroup_Layout] = "Layout" + table[LookinAttrGroup_AutoLayout] = "AutoLayout" + // iOS groups (macOS client displays iOS data, so both platforms need all titles) + table[LookinAttrGroup_UIImageView] = "UIImageView" + table[LookinAttrGroup_UILabel] = "UILabel" + table[LookinAttrGroup_UIControl] = "UIControl" + table[LookinAttrGroup_UIButton] = "UIButton" + table[LookinAttrGroup_UIScrollView] = "UIScrollView" + table[LookinAttrGroup_UITableView] = "UITableView" + table[LookinAttrGroup_UITextView] = "UITextView" + table[LookinAttrGroup_UITextField] = "UITextField" + table[LookinAttrGroup_UIVisualEffectView] = "UIVisualEffectView" + table[LookinAttrGroup_UIStackView] = "UIStackView" + table[LookinAttrGroup_UIWindowScene] = "UIWindowScene" + table[LookinAttrGroup_UITraitCollection] = "UITraitCollection" + // macOS groups + table[LookinAttrGroup_NSImageView] = "NSImageView" + table[LookinAttrGroup_NSControl] = "NSControl" + table[LookinAttrGroup_NSButton] = "NSButton" + table[LookinAttrGroup_NSScrollView] = "NSScrollView" + table[LookinAttrGroup_NSTableView] = "NSTableView" + table[LookinAttrGroup_NSTextView] = "NSTextView" + table[LookinAttrGroup_NSTextField] = "NSTextField" + table[LookinAttrGroup_NSVisualEffectView] = "NSVisualEffectView" + table[LookinAttrGroup_NSStackView] = "NSStackView" + table[LookinAttrGroup_NSWindow] = "NSWindow" + table[LookinAttrGroup_NSSlider] = "NSSlider" + table[LookinAttrGroup_NSProgressIndicator] = "NSProgressIndicator" + table[LookinAttrGroup_NSSegmentedControl] = "NSSegmentedControl" + table[LookinAttrGroup_NSPopUpButton] = "NSPopUpButton" + table[LookinAttrGroup_NSComboBox] = "NSComboBox" + table[LookinAttrGroup_NSStepper] = "NSStepper" + table[LookinAttrGroup_NSColorWell] = "NSColorWell" + table[LookinAttrGroup_NSSwitch] = "NSSwitch" + table[LookinAttrGroup_NSDatePicker] = "NSDatePicker" + table[LookinAttrGroup_NSLevelIndicator] = "NSLevelIndicator" + table[LookinAttrGroup_NSOutlineView] = "NSOutlineView" + table[LookinAttrGroup_NSCollectionView] = "NSCollectionView" + table[LookinAttrGroup_NSBox] = "NSBox" + table[LookinAttrGroup_NSSplitView] = "NSSplitView" + table[LookinAttrGroup_NSTabView] = "NSTabView" + table[LookinAttrGroup_NSGridView] = "NSGridView" + // Platform-neutral; the host renders it as UILayoutGuide or + // NSLayoutGuide depending on the inspected app's platform. + table[LookinAttrGroup_LayoutGuide] = "LayoutGuide" + table[LookinAttrGroup_NSCell] = "NSCell" + #if canImport(UIKit) + table[LookinAttrGroup_ViewLayer] = "CALayer / UIView" + #else + table[LookinAttrGroup_ViewLayer] = "CALayer / NSView" + #endif + return table + }() + + /// `+sectionTitleWithSectionID:`; a missing entry means no title. + static let sectionTitles: [String: String] = { + var table: [String: String] = [:] + table[LookinAttrSec_Layout_Frame] = "Frame" + table[LookinAttrSec_Layout_Bounds] = "Bounds" + table[LookinAttrSec_Layout_SafeArea] = "SafeArea" + table[LookinAttrSec_Layout_Position] = "Position" + table[LookinAttrSec_Layout_AnchorPoint] = "AnchorPoint" + table[LookinAttrSec_Layout_CoordinateSpace] = "CoordinateSpace" + table[LookinAttrSec_AutoLayout_Hugging] = "HuggingPriority" + table[LookinAttrSec_AutoLayout_Resistance] = "ResistancePriority" + table[LookinAttrSec_AutoLayout_IntrinsicSize] = "IntrinsicSize" + table[LookinAttrSec_ViewLayer_Corner] = "CornerRadius" + table[LookinAttrSec_ViewLayer_BgColor] = "BackgroundColor" + table[LookinAttrSec_ViewLayer_Border] = "Border" + table[LookinAttrSec_ViewLayer_Shadow] = "Shadow" + table[LookinAttrSec_ViewLayer_Tag] = "Tag" + table[LookinAttrSec_ViewLayer_ContentMode] = "ContentMode" + table[LookinAttrSec_ViewLayer_TintColor] = "TintColor" + table[LookinAttrSec_UIStackView_Axis] = "Axis" + table[LookinAttrSec_UIStackView_Distribution] = "Distribution" + table[LookinAttrSec_UIStackView_Alignment] = "Alignment" + table[LookinAttrSec_UIVisualEffectView_Style] = "Style" + table[LookinAttrSec_UIVisualEffectView_QMUIForegroundColor] = "ForegroundColor" + table[LookinAttrSec_UIImageView_Name] = "ImageName" + table[LookinAttrSec_UILabel_TextColor] = "TextColor" + table[LookinAttrSec_UITextView_TextColor] = "TextColor" + table[LookinAttrSec_UITextField_TextColor] = "TextColor" + table[LookinAttrSec_UILabel_BreakMode] = "LineBreakMode" + table[LookinAttrSec_UILabel_NumberOfLines] = "NumberOfLines" + table[LookinAttrSec_UILabel_Text] = "Text" + table[LookinAttrSec_UITextView_Text] = "Text" + table[LookinAttrSec_UITextField_Text] = "Text" + table[LookinAttrSec_UITextField_Placeholder] = "Placeholder" + table[LookinAttrSec_UILabel_Alignment] = "TextAlignment" + table[LookinAttrSec_UITextView_Alignment] = "TextAlignment" + table[LookinAttrSec_UITextField_Alignment] = "TextAlignment" + table[LookinAttrSec_UIControl_HorAlignment] = "HorizontalAlignment" + table[LookinAttrSec_UIControl_VerAlignment] = "VerticalAlignment" + table[LookinAttrSec_UIControl_QMUIOutsideEdge] = "QMUI_outsideEdge" + table[LookinAttrSec_UIButton_ContentInsets] = "ContentInsets" + table[LookinAttrSec_UIButton_TitleInsets] = "TitleInsets" + table[LookinAttrSec_UIButton_ImageInsets] = "ImageInsets" + table[LookinAttrSec_UIScrollView_QMUIInitialInset] = "QMUI_initialContentInset" + table[LookinAttrSec_UIScrollView_ContentInset] = "ContentInset" + table[LookinAttrSec_UIScrollView_AdjustedInset] = "AdjustedContentInset" + table[LookinAttrSec_UIScrollView_IndicatorInset] = "ScrollIndicatorInsets" + table[LookinAttrSec_UIScrollView_Offset] = "ContentOffset" + table[LookinAttrSec_UIScrollView_ContentSize] = "ContentSize" + table[LookinAttrSec_UIScrollView_Behavior] = "InsetAdjustmentBehavior" + table[LookinAttrSec_UIScrollView_ShowsIndicator] = "ShowsScrollIndicator" + table[LookinAttrSec_UIScrollView_Bounce] = "AlwaysBounce" + table[LookinAttrSec_UIScrollView_Zoom] = "Zoom" + table[LookinAttrSec_UITableView_Style] = "Style" + table[LookinAttrSec_UITableView_SectionsNumber] = "NumberOfSections" + table[LookinAttrSec_UITableView_RowsNumber] = "NumberOfRows" + table[LookinAttrSec_UITableView_SeparatorColor] = "SeparatorColor" + table[LookinAttrSec_UITableView_SeparatorInset] = "SeparatorInset" + table[LookinAttrSec_UITableView_SeparatorStyle] = "SeparatorStyle" + table[LookinAttrSec_UILabel_Font] = "Font" + table[LookinAttrSec_UITextField_Font] = "Font" + table[LookinAttrSec_UITextView_Font] = "Font" + table[LookinAttrSec_UITextView_ContainerInset] = "ContainerInset" + table[LookinAttrSec_UITextField_ClearButtonMode] = "ClearButtonMode" + table[LookinAttrSec_NSImageView_Name] = "ImageName" + table[LookinAttrSec_NSImageView_Open] = "Open" + table[LookinAttrSec_NSImageView_Scaling] = "Scaling" + table[LookinAttrSec_NSImageView_Behavior] = "Behavior" + table[LookinAttrSec_NSImageView_ContentTintColor] = "ContentTintColor" + table[LookinAttrSec_NSControl_State] = "State" + table[LookinAttrSec_NSControl_ControlSize] = "ControlSize" + table[LookinAttrSec_NSControl_Font] = "Font" + table[LookinAttrSec_NSControl_Alignment] = "Alignment" + table[LookinAttrSec_NSControl_Misc] = "Misc" + table[LookinAttrSec_NSControl_StringValue] = "StringValue" + table[LookinAttrSec_NSControl_Value] = "Value" + table[LookinAttrSec_NSButton_ButtonType] = "ButtonType" + table[LookinAttrSec_NSButton_Title] = "Title" + table[LookinAttrSec_NSButton_BezelStyle] = "BezelStyle" + table[LookinAttrSec_NSButton_BezelColor] = "Colors" + table[LookinAttrSec_NSButton_Misc] = "Misc" + table[LookinAttrSec_NSScrollView_ContentOffset] = "ContentOffset" + table[LookinAttrSec_NSScrollView_ContentSize] = "ContentSize" + table[LookinAttrSec_NSScrollView_ContentInset] = "ContentInset" + table[LookinAttrSec_NSScrollView_BorderType] = "BorderType" + table[LookinAttrSec_NSScrollView_Scroller] = "Scroller" + table[LookinAttrSec_NSScrollView_Ruler] = "Ruler" + table[LookinAttrSec_NSScrollView_LineScroll] = "LineScroll" + table[LookinAttrSec_NSScrollView_PageScroll] = "PageScroll" + table[LookinAttrSec_NSScrollView_ScrollElasiticity] = "ScrollElasiticity" + table[LookinAttrSec_NSScrollView_Misc] = "Misc" + table[LookinAttrSec_NSScrollView_Magnification] = "Magnification" + table[LookinAttrSec_NSTableView_RowHeight] = "RowHeight" + table[LookinAttrSec_NSTableView_AutomaticRowHeights] = "AutomaticRowHeights" + table[LookinAttrSec_NSTableView_IntercellSpacing] = "IntercellSpacing" + table[LookinAttrSec_NSTableView_Style] = "Style" + table[LookinAttrSec_NSTableView_ColumnAutoresizingStyle] = "ColumnAutoresizingStyle" + table[LookinAttrSec_NSTableView_GridStyleMask] = "GridStyleMask" + table[LookinAttrSec_NSTableView_SelectionHighlightStyle] = "SelectionHighlightStyle" + table[LookinAttrSec_NSTableView_GridColor] = "GridColor" + table[LookinAttrSec_NSTableView_RowSizeStyle] = "RowSizeStyle" + table[LookinAttrSec_NSTableView_NumberOfRows] = "NumberOfRows" + table[LookinAttrSec_NSTableView_NumberOfColumns] = "NumberOfColumns" + table[LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors] = "UseAlternatingRowBackgroundColors" + table[LookinAttrSec_NSTableView_AllowsColumnReordering] = "AllowsColumnReordering" + table[LookinAttrSec_NSTableView_AllowsColumnResizing] = "AllowsColumnResizing" + table[LookinAttrSec_NSTableView_AllowsMultipleSelection] = "AllowsMultipleSelection" + table[LookinAttrSec_NSTableView_AllowsEmptySelection] = "AllowsEmptySelection" + table[LookinAttrSec_NSTableView_AllowsColumnSelection] = "AllowsColumnSelection" + table[LookinAttrSec_NSTableView_AllowsTypeSelect] = "AllowsTypeSelect" + table[LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle] = "DraggingDestinationFeedbackStyle" + table[LookinAttrSec_NSTableView_Autosave] = "Autosave" + table[LookinAttrSec_NSTableView_FloatsGroupRows] = "FloatsGroupRows" + table[LookinAttrSec_NSTableView_RowActionsVisible] = "RowActionsVisible" + table[LookinAttrSec_NSTableView_UsesStaticContents] = "UsesStaticContents" + table[LookinAttrSec_NSTableView_UserInterfaceLayoutDirection] = "UserInterfaceLayoutDirection" + table[LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag] = "VerticalMotionCanBeginDrag" + table[LookinAttrSec_NSTextView_Font] = "Font" + table[LookinAttrSec_NSTextView_Basic] = "Basic" + table[LookinAttrSec_NSTextView_String] = "String" + table[LookinAttrSec_NSTextView_TextColor] = "TextColor" + table[LookinAttrSec_NSTextView_Alignment] = "Alignment" + table[LookinAttrSec_NSTextView_ContainerInset] = "ContainerInset" + table[LookinAttrSec_NSTextView_BaseWritingDirection] = "BaseWritingDirection" + table[LookinAttrSec_NSTextView_Size] = "Size" + table[LookinAttrSec_NSTextView_Resizable] = "Resizable" + table[LookinAttrSec_NSTextField_BezelStyle] = "BezelStyle" + table[LookinAttrSec_NSTextField_LineBreakStrategy] = "LineBreakStrategy" + table[LookinAttrSec_NSTextField_TextColor] = "Colors" + table[LookinAttrSec_NSTextField_Placeholder] = "Placeholder" + table[LookinAttrSec_NSTextField_PreferredMaxLayoutWidth] = "Layout" + table[LookinAttrSec_NSVisualEffectView_Material] = "Material" + table[LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle] = "InteriorBackgroundStyle" + table[LookinAttrSec_NSVisualEffectView_BlendingMode] = "BlendingMode" + table[LookinAttrSec_NSVisualEffectView_State] = "State" + table[LookinAttrSec_NSVisualEffectView_Emphasized] = "Emphasized" + table[LookinAttrSec_NSStackView_Orientation] = "Orientation" + table[LookinAttrSec_NSStackView_EdgeInsets] = "EdgeInsets" + table[LookinAttrSec_NSStackView_DetachesHiddenViews] = "DetachesHiddenViews" + table[LookinAttrSec_NSStackView_Distribution] = "Distribution" + table[LookinAttrSec_NSStackView_Alignment] = "Alignment" + table[LookinAttrSec_NSStackView_Spacing] = "Spacing" + table[LookinAttrSec_NSWindow_Title] = "Title" + table[LookinAttrSec_NSWindow_Subtitle] = "Subtitle" + table[LookinAttrSec_NSWindow_State] = "State" + table[LookinAttrSec_NSWindow_Style] = "StyleMask" + table[LookinAttrSec_NSWindow_CollectionBehavior] = "CollectionBehavior" + table[LookinAttrSec_NSWindow_Appearance] = "Appearance" + table[LookinAttrSec_NSWindow_TitleVisibility] = "TitleVisibility" + table[LookinAttrSec_NSWindow_ToolbarStyle] = "ToolbarStyle" + table[LookinAttrSec_NSWindow_TitlebarSeparatorStyle] = "TitlebarSeparatorStyle" + table[LookinAttrSec_NSWindow_Behavior] = "Behavior" + table[LookinAttrSec_NSWindow_AnimationBehavior] = "AnimationBehavior" + table[LookinAttrSec_NSWindow_Level] = "Level" + table[LookinAttrSec_NSWindow_TabbingMode] = "TabbingMode" + table[LookinAttrSec_NSWindow_Size] = "Size" + table[LookinAttrSec_NSWindow_Info] = "Info" + table[LookinAttrSec_NSSlider_SliderType] = "SliderType" + table[LookinAttrSec_NSSlider_Range] = "Range" + table[LookinAttrSec_NSSlider_TickMark] = "TickMark" + table[LookinAttrSec_NSSlider_Misc] = "Misc" + table[LookinAttrSec_NSProgressIndicator_Style] = "Style" + table[LookinAttrSec_NSProgressIndicator_Range] = "Range" + table[LookinAttrSec_NSProgressIndicator_Misc] = "Misc" + table[LookinAttrSec_NSSegmentedControl_SegmentCount] = "SegmentCount" + table[LookinAttrSec_NSSegmentedControl_Selection] = "Selection" + table[LookinAttrSec_NSSegmentedControl_Style] = "Style" + table[LookinAttrSec_NSSegmentedControl_Colors] = "Colors" + table[LookinAttrSec_NSPopUpButton_Behavior] = "Behavior" + table[LookinAttrSec_NSPopUpButton_Selection] = "Selection" + table[LookinAttrSec_NSPopUpButton_Items] = "Items" + table[LookinAttrSec_NSComboBox_Items] = "Items" + table[LookinAttrSec_NSComboBox_Misc] = "Misc" + table[LookinAttrSec_NSStepper_Range] = "Range" + table[LookinAttrSec_NSStepper_Misc] = "Misc" + table[LookinAttrSec_NSColorWell_Color] = "Color" + table[LookinAttrSec_NSColorWell_Misc] = "Misc" + table[LookinAttrSec_NSSwitch_State] = "State" + table[LookinAttrSec_NSDatePicker_Style] = "Style" + table[LookinAttrSec_NSDatePicker_Range] = "Range" + table[LookinAttrSec_NSDatePicker_Misc] = "Misc" + table[LookinAttrSec_NSLevelIndicator_Style] = "Style" + table[LookinAttrSec_NSLevelIndicator_Range] = "Range" + table[LookinAttrSec_NSLevelIndicator_TickMark] = "TickMark" + table[LookinAttrSec_NSOutlineView_Indentation] = "Indentation" + table[LookinAttrSec_NSOutlineView_Misc] = "Misc" + table[LookinAttrSec_NSCollectionView_Selection] = "Selection" + table[LookinAttrSec_NSCollectionView_Info] = "Info" + table[LookinAttrSec_NSCollectionView_Colors] = "Colors" + table[LookinAttrSec_NSBox_Type] = "Type" + table[LookinAttrSec_NSBox_Title] = "Title" + table[LookinAttrSec_NSBox_Appearance] = "Appearance" + table[LookinAttrSec_NSBox_Metrics] = "Metrics" + table[LookinAttrSec_NSSplitView_Orientation] = "Orientation" + table[LookinAttrSec_NSSplitView_Style] = "Style" + table[LookinAttrSec_NSSplitView_Misc] = "Misc" + table[LookinAttrSec_NSTabView_Type] = "Type" + table[LookinAttrSec_NSTabView_Misc] = "Misc" + table[LookinAttrSec_NSTabView_Info] = "Info" + table[LookinAttrSec_NSGridView_Dimensions] = "Dimensions" + table[LookinAttrSec_NSGridView_Spacing] = "Spacing" + table[LookinAttrSec_NSGridView_Placement] = "Placement" + // LayoutGuide + table[LookinAttrSec_LayoutGuide_Identifier] = "Identifier" + table[LookinAttrSec_LayoutGuide_LayoutFrame] = "LayoutFrame" + table[LookinAttrSec_LayoutGuide_OwningView] = "OwningView" + // NSCell + table[LookinAttrSec_NSCell_Cell] = "Cell" + table[LookinAttrSec_NSCell_Content] = "Content" + table[LookinAttrSec_NSCell_Behavior] = "Behavior" + table[LookinAttrSec_NSCell_ButtonCell] = "NSButtonCell" + table[LookinAttrSec_NSCell_TextFieldCell] = "NSTextFieldCell" + // UIWindowScene + table[LookinAttrSec_UIWindowScene_State] = "State" + table[LookinAttrSec_UIWindowScene_Title] = "Title" + table[LookinAttrSec_UIWindowScene_Orientation] = "Orientation" + table[LookinAttrSec_UIWindowScene_Windows] = "Windows" + table[LookinAttrSec_UIWindowScene_Screen] = "Screen" + table[LookinAttrSec_UIWindowScene_StatusBar] = "StatusBar" + table[LookinAttrSec_UIWindowScene_Traits] = "Traits" + table[LookinAttrSec_UIWindowScene_Session] = "Session" + table[LookinAttrSec_UIWindowScene_Configuration] = "Configuration" + table[LookinAttrSec_UIWindowScene_Geometry] = "Geometry" + table[LookinAttrSec_UIWindowScene_ActivationConditions] = "ActivationConditions" + table[LookinAttrSec_UIWindowScene_SizeRestrictions] = "SizeRestrictions" + table[LookinAttrSec_UIWindowScene_WindowingBehaviors] = "WindowingBehaviors" + table[LookinAttrSec_UIWindowScene_Pointer] = "Pointer" + table[LookinAttrSec_UIWindowScene_Protection] = "Protection" + // UITraitCollection + table[LookinAttrSec_UITraitCollection_Appearance] = "Appearance" + table[LookinAttrSec_UITraitCollection_SizeClass] = "SizeClass" + table[LookinAttrSec_UITraitCollection_Display] = "Display" + table[LookinAttrSec_UITraitCollection_Device] = "Device" + table[LookinAttrSec_UITraitCollection_Layout] = "Layout" + table[LookinAttrSec_UITraitCollection_Content] = "Content" + return table + }() + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinDashboardBlueprint+Sections.swift b/Sources/LookinCoreImpl/LookinDashboardBlueprint+Sections.swift new file mode 100644 index 00000000..07094502 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinDashboardBlueprint+Sections.swift @@ -0,0 +1,708 @@ +// +// LookinDashboardBlueprint+Sections.swift +// LookinCore +// +// The attributes of each section. +// Translated from LookinDashboardBlueprint.m; the output of every public +// class method is pinned by Tests/LookinServerHierarchyTests/ +// BlueprintEquivalenceTests and its Fixtures/blueprint-.txt. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + extension LookinDashboardBlueprintTables { + /// `+attrIDsForSectionID:`, in display order. + static let attrIDsBySectionID: [String: [String]] = { + var table: [String: [String]] = [:] + table[LookinAttrSec_Class_Class] = [LookinAttr_Class_Class_Class] + table[LookinAttrSec_Relation_Relation] = [LookinAttr_Relation_Relation_Relation] + table[LookinAttrSec_Layout_Frame] = [LookinAttr_Layout_Frame_Frame] + table[LookinAttrSec_Layout_Bounds] = [LookinAttr_Layout_Bounds_Bounds] + table[LookinAttrSec_Layout_SafeArea] = [LookinAttr_Layout_SafeArea_SafeArea] + table[LookinAttrSec_Layout_Position] = [LookinAttr_Layout_Position_Position] + table[LookinAttrSec_Layout_AnchorPoint] = [LookinAttr_Layout_AnchorPoint_AnchorPoint] + table[LookinAttrSec_Layout_CoordinateSpace] = [LookinAttr_Layout_CoordinateSpace_CoordinateSpace] + table[LookinAttrSec_AutoLayout_Hugging] = [ + LookinAttr_AutoLayout_Hugging_Hor, + LookinAttr_AutoLayout_Hugging_Ver, + ] + table[LookinAttrSec_AutoLayout_Resistance] = [ + LookinAttr_AutoLayout_Resistance_Hor, + LookinAttr_AutoLayout_Resistance_Ver, + ] + table[LookinAttrSec_AutoLayout_Constraints] = [LookinAttr_AutoLayout_Constraints_Constraints] + table[LookinAttrSec_AutoLayout_IntrinsicSize] = [LookinAttr_AutoLayout_IntrinsicSize_Size] + table[LookinAttrSec_ViewLayer_Visibility] = [ + LookinAttr_ViewLayer_Visibility_Hidden, + LookinAttr_ViewLayer_Visibility_Opacity, + ] + table[LookinAttrSec_ViewLayer_InterationAndMasks] = { + var ids: [String] = [] + #if canImport(UIKit) + ids.append(LookinAttr_ViewLayer_InterationAndMasks_Interaction) + #endif + ids.append(LookinAttr_ViewLayer_InterationAndMasks_MasksToBounds) + return ids + }() + table[LookinAttrSec_ViewLayer_Corner] = [LookinAttr_ViewLayer_Corner_Radius] + table[LookinAttrSec_ViewLayer_BgColor] = [LookinAttr_ViewLayer_BgColor_BgColor] + table[LookinAttrSec_ViewLayer_Border] = [ + LookinAttr_ViewLayer_Border_Color, + LookinAttr_ViewLayer_Border_Width, + ] + table[LookinAttrSec_ViewLayer_Shadow] = [ + LookinAttr_ViewLayer_Shadow_Color, + LookinAttr_ViewLayer_Shadow_Opacity, + LookinAttr_ViewLayer_Shadow_Radius, + LookinAttr_ViewLayer_Shadow_OffsetW, + LookinAttr_ViewLayer_Shadow_OffsetH, + ] + #if canImport(UIKit) + table[LookinAttrSec_ViewLayer_ContentMode] = [LookinAttr_ViewLayer_ContentMode_Mode] + table[LookinAttrSec_ViewLayer_TintColor] = [ + LookinAttr_ViewLayer_TintColor_Color, + LookinAttr_ViewLayer_TintColor_Mode, + ] + #endif + table[LookinAttrSec_ViewLayer_Tag] = [LookinAttr_ViewLayer_Tag_Tag] + #if canImport(UIKit) + table[LookinAttrSec_UIStackView_Axis] = [LookinAttr_UIStackView_Axis_Axis] + table[LookinAttrSec_UIStackView_Distribution] = [LookinAttr_UIStackView_Distribution_Distribution] + table[LookinAttrSec_UIStackView_Alignment] = [LookinAttr_UIStackView_Alignment_Alignment] + table[LookinAttrSec_UIStackView_Spacing] = [LookinAttr_UIStackView_Spacing_Spacing] + table[LookinAttrSec_UIVisualEffectView_Style] = [LookinAttr_UIVisualEffectView_Style_Style] + table[LookinAttrSec_UIVisualEffectView_QMUIForegroundColor] = [LookinAttr_UIVisualEffectView_QMUIForegroundColor_Color] + table[LookinAttrSec_UIImageView_Name] = [LookinAttr_UIImageView_Name_Name] + table[LookinAttrSec_UIImageView_Open] = [LookinAttr_UIImageView_Open_Open] + table[LookinAttrSec_UILabel_Font] = [ + LookinAttr_UILabel_Font_Name, + LookinAttr_UILabel_Font_Size, + ] + table[LookinAttrSec_UILabel_NumberOfLines] = [LookinAttr_UILabel_NumberOfLines_NumberOfLines] + table[LookinAttrSec_UILabel_Text] = [LookinAttr_UILabel_Text_Text] + table[LookinAttrSec_UILabel_TextColor] = [LookinAttr_UILabel_TextColor_Color] + table[LookinAttrSec_UILabel_BreakMode] = [LookinAttr_UILabel_BreakMode_Mode] + table[LookinAttrSec_UILabel_Alignment] = [LookinAttr_UILabel_Alignment_Alignment] + table[LookinAttrSec_UILabel_CanAdjustFont] = [LookinAttr_UILabel_CanAdjustFont_CanAdjustFont] + table[LookinAttrSec_UIControl_EnabledSelected] = [ + LookinAttr_UIControl_EnabledSelected_Enabled, + LookinAttr_UIControl_EnabledSelected_Selected, + ] + table[LookinAttrSec_UIControl_QMUIOutsideEdge] = [LookinAttr_UIControl_QMUIOutsideEdge_Edge] + table[LookinAttrSec_UIControl_VerAlignment] = [LookinAttr_UIControl_VerAlignment_Alignment] + table[LookinAttrSec_UIControl_HorAlignment] = [LookinAttr_UIControl_HorAlignment_Alignment] + table[LookinAttrSec_UIButton_ContentInsets] = [LookinAttr_UIButton_ContentInsets_Insets] + table[LookinAttrSec_UIButton_TitleInsets] = [LookinAttr_UIButton_TitleInsets_Insets] + table[LookinAttrSec_UIButton_ImageInsets] = [LookinAttr_UIButton_ImageInsets_Insets] + table[LookinAttrSec_UIScrollView_ContentInset] = [LookinAttr_UIScrollView_ContentInset_Inset] + table[LookinAttrSec_UIScrollView_AdjustedInset] = [LookinAttr_UIScrollView_AdjustedInset_Inset] + table[LookinAttrSec_UIScrollView_QMUIInitialInset] = [LookinAttr_UIScrollView_QMUIInitialInset_Inset] + table[LookinAttrSec_UIScrollView_IndicatorInset] = [LookinAttr_UIScrollView_IndicatorInset_Inset] + table[LookinAttrSec_UIScrollView_Offset] = [LookinAttr_UIScrollView_Offset_Offset] + table[LookinAttrSec_UIScrollView_ContentSize] = [LookinAttr_UIScrollView_ContentSize_Size] + table[LookinAttrSec_UIScrollView_Behavior] = [LookinAttr_UIScrollView_Behavior_Behavior] + table[LookinAttrSec_UIScrollView_ShowsIndicator] = [ + LookinAttr_UIScrollView_ShowsIndicator_Hor, + LookinAttr_UIScrollView_ShowsIndicator_Ver, + ] + table[LookinAttrSec_UIScrollView_Bounce] = [ + LookinAttr_UIScrollView_Bounce_Hor, + LookinAttr_UIScrollView_Bounce_Ver, + ] + table[LookinAttrSec_UIScrollView_ScrollPaging] = [ + LookinAttr_UIScrollView_ScrollPaging_ScrollEnabled, + LookinAttr_UIScrollView_ScrollPaging_PagingEnabled, + ] + table[LookinAttrSec_UIScrollView_ContentTouches] = [ + LookinAttr_UIScrollView_ContentTouches_Delay, + LookinAttr_UIScrollView_ContentTouches_CanCancel, + ] + table[LookinAttrSec_UIScrollView_Zoom] = [ + LookinAttr_UIScrollView_Zoom_Bounce, + LookinAttr_UIScrollView_Zoom_Scale, + LookinAttr_UIScrollView_Zoom_MinScale, + LookinAttr_UIScrollView_Zoom_MaxScale, + ] + table[LookinAttrSec_UITableView_Style] = [LookinAttr_UITableView_Style_Style] + table[LookinAttrSec_UITableView_SectionsNumber] = [LookinAttr_UITableView_SectionsNumber_Number] + table[LookinAttrSec_UITableView_RowsNumber] = [LookinAttr_UITableView_RowsNumber_Number] + table[LookinAttrSec_UITableView_SeparatorInset] = [LookinAttr_UITableView_SeparatorInset_Inset] + table[LookinAttrSec_UITableView_SeparatorColor] = [LookinAttr_UITableView_SeparatorColor_Color] + table[LookinAttrSec_UITableView_SeparatorStyle] = [LookinAttr_UITableView_SeparatorStyle_Style] + table[LookinAttrSec_UITextView_Basic] = [ + LookinAttr_UITextView_Basic_Editable, + LookinAttr_UITextView_Basic_Selectable, + ] + table[LookinAttrSec_UITextView_Text] = [LookinAttr_UITextView_Text_Text] + table[LookinAttrSec_UITextView_Font] = [ + LookinAttr_UITextView_Font_Name, + LookinAttr_UITextView_Font_Size, + ] + table[LookinAttrSec_UITextView_TextColor] = [LookinAttr_UITextView_TextColor_Color] + table[LookinAttrSec_UITextView_Alignment] = [LookinAttr_UITextView_Alignment_Alignment] + table[LookinAttrSec_UITextView_ContainerInset] = [LookinAttr_UITextView_ContainerInset_Inset] + table[LookinAttrSec_UITextField_Text] = [LookinAttr_UITextField_Text_Text] + table[LookinAttrSec_UITextField_Placeholder] = [LookinAttr_UITextField_Placeholder_Placeholder] + table[LookinAttrSec_UITextField_Font] = [ + LookinAttr_UITextField_Font_Name, + LookinAttr_UITextField_Font_Size, + ] + table[LookinAttrSec_UITextField_TextColor] = [LookinAttr_UITextField_TextColor_Color] + table[LookinAttrSec_UITextField_Alignment] = [LookinAttr_UITextField_Alignment_Alignment] + table[LookinAttrSec_UITextField_Clears] = [ + LookinAttr_UITextField_Clears_ClearsOnBeginEditing, + LookinAttr_UITextField_Clears_ClearsOnInsertion, + ] + table[LookinAttrSec_UITextField_CanAdjustFont] = [ + LookinAttr_UITextField_CanAdjustFont_CanAdjustFont, + LookinAttr_UITextField_CanAdjustFont_MinSize, + ] + table[LookinAttrSec_UITextField_ClearButtonMode] = [LookinAttr_UITextField_ClearButtonMode_Mode] + table[LookinAttrSec_UIWindowScene_State] = [LookinAttr_UIWindowScene_State_ActivationState] + table[LookinAttrSec_UIWindowScene_Title] = [ + LookinAttr_UIWindowScene_Title_Title, + LookinAttr_UIWindowScene_Title_Subtitle, + ] + table[LookinAttrSec_UIWindowScene_Orientation] = [LookinAttr_UIWindowScene_Orientation_InterfaceOrientation] + table[LookinAttrSec_UIWindowScene_Windows] = [ + LookinAttr_UIWindowScene_Windows_WindowCount, + LookinAttr_UIWindowScene_Windows_KeyWindowClassName, + ] + table[LookinAttrSec_UIWindowScene_Screen] = [ + LookinAttr_UIWindowScene_Screen_ScreenBounds, + LookinAttr_UIWindowScene_Screen_ScreenScale, + ] + table[LookinAttrSec_UIWindowScene_StatusBar] = [ + LookinAttr_UIWindowScene_StatusBar_StatusBarHidden, + LookinAttr_UIWindowScene_StatusBar_StatusBarStyle, + LookinAttr_UIWindowScene_StatusBar_StatusBarFrame, + ] + table[LookinAttrSec_UIWindowScene_Traits] = [ + LookinAttr_UIWindowScene_Traits_UserInterfaceStyle, + LookinAttr_UIWindowScene_Traits_HorizontalSizeClass, + LookinAttr_UIWindowScene_Traits_VerticalSizeClass, + LookinAttr_UIWindowScene_Traits_UserInterfaceLevel, + LookinAttr_UIWindowScene_Traits_ActiveAppearance, + LookinAttr_UIWindowScene_Traits_AccessibilityContrast, + LookinAttr_UIWindowScene_Traits_LegibilityWeight, + LookinAttr_UIWindowScene_Traits_DisplayScale, + LookinAttr_UIWindowScene_Traits_DisplayGamut, + LookinAttr_UIWindowScene_Traits_UserInterfaceIdiom, + LookinAttr_UIWindowScene_Traits_LayoutDirection, + LookinAttr_UIWindowScene_Traits_PreferredContentSizeCategory, + LookinAttr_UIWindowScene_Traits_SceneCaptureState, + LookinAttr_UIWindowScene_Traits_ImageDynamicRange, + LookinAttr_UIWindowScene_Traits_TypesettingLanguage, + ] + table[LookinAttrSec_UIWindowScene_Session] = [ + LookinAttr_UIWindowScene_Session_PersistentIdentifier, + LookinAttr_UIWindowScene_Session_SessionRole, + LookinAttr_UIWindowScene_Session_StateRestorationActivityType, + LookinAttr_UIWindowScene_Session_UserInfo, + ] + table[LookinAttrSec_UIWindowScene_Configuration] = [ + LookinAttr_UIWindowScene_Configuration_Name, + LookinAttr_UIWindowScene_Configuration_SceneClass, + LookinAttr_UIWindowScene_Configuration_DelegateClass, + LookinAttr_UIWindowScene_Configuration_Storyboard, + ] + table[LookinAttrSec_UIWindowScene_Geometry] = [ + LookinAttr_UIWindowScene_Geometry_SystemFrame, + LookinAttr_UIWindowScene_Geometry_InterfaceOrientationLocked, + LookinAttr_UIWindowScene_Geometry_InteractivelyResizing, + ] + table[LookinAttrSec_UIWindowScene_ActivationConditions] = [ + LookinAttr_UIWindowScene_ActivationConditions_CanActivate, + LookinAttr_UIWindowScene_ActivationConditions_PrefersToActivate, + ] + table[LookinAttrSec_UIWindowScene_SizeRestrictions] = [ + LookinAttr_UIWindowScene_SizeRestrictions_MinimumSize, + LookinAttr_UIWindowScene_SizeRestrictions_MaximumSize, + LookinAttr_UIWindowScene_SizeRestrictions_AllowsFullScreen, + ] + table[LookinAttrSec_UIWindowScene_WindowingBehaviors] = [ + LookinAttr_UIWindowScene_WindowingBehaviors_Closable, + LookinAttr_UIWindowScene_WindowingBehaviors_Miniaturizable, + LookinAttr_UIWindowScene_WindowingBehaviors_FullScreen, + ] + table[LookinAttrSec_UIWindowScene_Pointer] = [LookinAttr_UIWindowScene_Pointer_Locked] + table[LookinAttrSec_UIWindowScene_Protection] = [LookinAttr_UIWindowScene_Protection_UserAuthenticationEnabled] + // UITraitCollection + table[LookinAttrSec_UITraitCollection_Appearance] = [ + LookinAttr_UITraitCollection_Appearance_UserInterfaceStyle, + LookinAttr_UITraitCollection_Appearance_UserInterfaceLevel, + LookinAttr_UITraitCollection_Appearance_ActiveAppearance, + LookinAttr_UITraitCollection_Appearance_AccessibilityContrast, + LookinAttr_UITraitCollection_Appearance_LegibilityWeight, + ] + table[LookinAttrSec_UITraitCollection_SizeClass] = [ + LookinAttr_UITraitCollection_SizeClass_HorizontalSizeClass, + LookinAttr_UITraitCollection_SizeClass_VerticalSizeClass, + ] + table[LookinAttrSec_UITraitCollection_Display] = [ + LookinAttr_UITraitCollection_Display_DisplayScale, + LookinAttr_UITraitCollection_Display_DisplayGamut, + LookinAttr_UITraitCollection_Display_ImageDynamicRange, + ] + table[LookinAttrSec_UITraitCollection_Device] = [ + LookinAttr_UITraitCollection_Device_UserInterfaceIdiom, + LookinAttr_UITraitCollection_Device_ForceTouchCapability, + ] + table[LookinAttrSec_UITraitCollection_Layout] = [LookinAttr_UITraitCollection_Layout_LayoutDirection] + table[LookinAttrSec_UITraitCollection_Content] = [ + LookinAttr_UITraitCollection_Content_PreferredContentSizeCategory, + LookinAttr_UITraitCollection_Content_TypesettingLanguage, + ] + #endif + #if os(macOS) + table[LookinAttrSec_NSImageView_Name] = [LookinAttr_NSImageView_Name_Name] + table[LookinAttrSec_NSImageView_Open] = [LookinAttr_NSImageView_Open_Open] + table[LookinAttrSec_NSImageView_Scaling] = [ + LookinAttr_NSImageView_Scaling_ImageScaling, + LookinAttr_NSImageView_Scaling_ImageAlignment, + LookinAttr_NSImageView_Scaling_ImageFrameStyle, + ] + table[LookinAttrSec_NSImageView_Behavior] = [ + LookinAttr_NSImageView_Behavior_Animates, + LookinAttr_NSImageView_Behavior_Editable, + ] + table[LookinAttrSec_NSImageView_ContentTintColor] = [LookinAttr_NSImageView_ContentTintColor_ContentTintColor] + table[LookinAttrSec_NSControl_State] = [ + LookinAttr_NSControl_State_Enabled, + LookinAttr_NSControl_State_Highlighted, + LookinAttr_NSControl_State_Continuous, + ] + table[LookinAttrSec_NSControl_ControlSize] = [LookinAttr_NSControl_ControlSize_Size] + table[LookinAttrSec_NSControl_Font] = [ + LookinAttr_NSControl_Font_Name, + LookinAttr_NSControl_Font_Size, + ] + table[LookinAttrSec_NSControl_Alignment] = [LookinAttr_NSControl_Alignment_Alignment] + table[LookinAttrSec_NSControl_Misc] = [ + LookinAttr_NSControl_Misc_WritingDirection, + LookinAttr_NSControl_Misc_IgnoresMultiClick, + LookinAttr_NSControl_Misc_UsesSingleLineMode, + LookinAttr_NSControl_Misc_AllowsExpansionToolTips, + ] + table[LookinAttrSec_NSControl_StringValue] = [LookinAttr_NSControl_Value_StringValue] + table[LookinAttrSec_NSControl_Value] = [ + LookinAttr_NSControl_Value_IntValue, + LookinAttr_NSControl_Value_IntegerValue, + LookinAttr_NSControl_Value_FloatValue, + LookinAttr_NSControl_Value_DoubleValue, + ] + table[LookinAttrSec_NSButton_ButtonType] = [LookinAttr_NSButton_ButtonType_ButtonType] + table[LookinAttrSec_NSButton_Title] = [ + LookinAttr_NSButton_Title_Title, + LookinAttr_NSButton_Title_AlernateTitle, + ] + table[LookinAttrSec_NSButton_BezelStyle] = [LookinAttr_NSButton_BezelStyle_BezelStyle] + table[LookinAttrSec_NSButton_Bordered] = [ + LookinAttr_NSButton_Bordered_Bordered, + LookinAttr_NSButton_Transparent_Transparent, + LookinAttr_NSButton_Misc_ShowsBorderOnlyWhileMouseInside, + LookinAttr_NSButton_Misc_SpringLoaded, + LookinAttr_NSButton_Misc_HasDestructiveAction, + ] + table[LookinAttrSec_NSButton_BezelColor] = [ + LookinAttr_NSButton_BezelColor_BezelColor, + LookinAttr_NSButton_ContentTintColor_ContentTintColor, + ] + table[LookinAttrSec_NSButton_Misc] = [LookinAttr_NSButton_Misc_MaxAcceleratorLevel] + table[LookinAttrSec_NSScrollView_ContentOffset] = [LookinAttr_NSScrollView_ContentOffset_Offset] + table[LookinAttrSec_NSScrollView_ContentSize] = [LookinAttr_NSScrollView_ContentSize_Size] + table[LookinAttrSec_NSScrollView_ContentInset] = [ + LookinAttr_NSScrollView_ContentInset_ContentInset, + LookinAttr_NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets, + ] + table[LookinAttrSec_NSScrollView_BorderType] = [LookinAttr_NSScrollView_BorderType_BorderType] + table[LookinAttrSec_NSScrollView_Scroller] = [ + LookinAttr_NSScrollView_Scroller_Horizontal, + LookinAttr_NSScrollView_Scroller_Vertical, + LookinAttr_NSScrollView_Scroller_AutohidesScrollers, + LookinAttr_NSScrollView_Scroller_ScrollerStyle, + LookinAttr_NSScrollView_Scroller_ScrollerKnobStyle, + LookinAttr_NSScrollView_Scroller_ScrollerInsets, + ] + table[LookinAttrSec_NSScrollView_Ruler] = [ + LookinAttr_NSScrollView_Ruler_Horizontal, + LookinAttr_NSScrollView_Ruler_Vertical, + LookinAttr_NSScrollView_Ruler_Visible, + ] + table[LookinAttrSec_NSScrollView_LineScroll] = [ + LookinAttr_NSScrollView_LineScroll_Horizontal, + LookinAttr_NSScrollView_LineScroll_Vertical, + LookinAttr_NSScrollView_LineScroll_LineScroll, + ] + table[LookinAttrSec_NSScrollView_PageScroll] = [ + LookinAttr_NSScrollView_PageScroll_Horizontal, + LookinAttr_NSScrollView_PageScroll_Vertical, + LookinAttr_NSScrollView_PageScroll_PageScroll, + ] + table[LookinAttrSec_NSScrollView_ScrollElasiticity] = [ + LookinAttr_NSScrollView_ScrollElasiticity_Horizontal, + LookinAttr_NSScrollView_ScrollElasiticity_Vertical, + ] + table[LookinAttrSec_NSScrollView_Misc] = [ + LookinAttr_NSScrollView_Misc_ScrollsDynamically, + LookinAttr_NSScrollView_Misc_UsesPredominantAxisScrolling, + ] + table[LookinAttrSec_NSScrollView_Magnification] = [ + LookinAttr_NSScrollView_Magnification_AllowsMagnification, + LookinAttr_NSScrollView_Magnification_Magnification, + LookinAttr_NSScrollView_Magnification_Max, + LookinAttr_NSScrollView_Magnification_Min, + ] + table[LookinAttrSec_NSTableView_RowHeight] = [LookinAttr_NSTableView_RowHeight_RowHeight] + table[LookinAttrSec_NSTableView_AutomaticRowHeights] = [LookinAttr_NSTableView_AutomaticRowHeights_AutomaticRowHeights] + table[LookinAttrSec_NSTableView_IntercellSpacing] = [LookinAttr_NSTableView_IntercellSpacing_IntercellSpacing] + table[LookinAttrSec_NSTableView_Style] = [LookinAttr_NSTableView_Style_Style] + table[LookinAttrSec_NSTableView_ColumnAutoresizingStyle] = [LookinAttr_NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle] + table[LookinAttrSec_NSTableView_GridStyleMask] = [LookinAttr_NSTableView_GridStyleMask_GridStyleMask] + table[LookinAttrSec_NSTableView_SelectionHighlightStyle] = [LookinAttr_NSTableView_SelectionHighlightStyle_SelectionHighlightStyle] + table[LookinAttrSec_NSTableView_GridColor] = [LookinAttr_NSTableView_GridColor_GridColor] + table[LookinAttrSec_NSTableView_RowSizeStyle] = [LookinAttr_NSTableView_RowSizeStyle_RowSizeStyle] + table[LookinAttrSec_NSTableView_NumberOfRows] = [LookinAttr_NSTableView_NumberOfRows_NumberOfRows] + table[LookinAttrSec_NSTableView_NumberOfColumns] = [LookinAttr_NSTableView_NumberOfColumns_NumberOfColumns] + table[LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors] = [LookinAttr_NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors] + table[LookinAttrSec_NSTableView_AllowsColumnReordering] = [LookinAttr_NSTableView_AllowsColumnReordering_AllowsColumnReordering] + table[LookinAttrSec_NSTableView_AllowsColumnResizing] = [LookinAttr_NSTableView_AllowsColumnResizing_AllowsColumnResizing] + table[LookinAttrSec_NSTableView_AllowsMultipleSelection] = [LookinAttr_NSTableView_AllowsMultipleSelection_AllowsMultipleSelection] + table[LookinAttrSec_NSTableView_AllowsEmptySelection] = [LookinAttr_NSTableView_AllowsEmptySelection_AllowsEmptySelection] + table[LookinAttrSec_NSTableView_AllowsColumnSelection] = [LookinAttr_NSTableView_AllowsColumnSelection_AllowsColumnSelection] + table[LookinAttrSec_NSTableView_AllowsTypeSelect] = [LookinAttr_NSTableView_AllowsTypeSelect_AllowsTypeSelect] + table[LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle] = [LookinAttr_NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle] + table[LookinAttrSec_NSTableView_Autosave] = [ + LookinAttr_NSTableView_AutosaveName_AutosaveName, + LookinAttr_NSTableView_AutosaveTableColumns_AutosaveTableColumns, + ] + table[LookinAttrSec_NSTableView_FloatsGroupRows] = [LookinAttr_NSTableView_FloatsGroupRows_FloatsGroupRows] + table[LookinAttrSec_NSTableView_RowActionsVisible] = [LookinAttr_NSTableView_RowActionsVisible_RowActionsVisible] + table[LookinAttrSec_NSTableView_UsesStaticContents] = [LookinAttr_NSTableView_UsesStaticContents_UsesStaticContents] + table[LookinAttrSec_NSTableView_UserInterfaceLayoutDirection] = [LookinAttr_NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection] + table[LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag] = [LookinAttr_NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag] + table[LookinAttrSec_NSTextView_Font] = [ + LookinAttr_NSTextView_Font_Name, + LookinAttr_NSTextView_Font_Size, + ] + table[LookinAttrSec_NSTextView_Basic] = [ + LookinAttr_NSTextView_Basic_Editable, + LookinAttr_NSTextView_Basic_Selectable, + LookinAttr_NSTextView_Basic_RichText, + LookinAttr_NSTextView_Basic_FieldEditor, + LookinAttr_NSTextView_Basic_ImportsGraphics, + ] + table[LookinAttrSec_NSTextView_String] = [LookinAttr_NSTextView_String_String] + table[LookinAttrSec_NSTextView_TextColor] = [LookinAttr_NSTextView_TextColor_Color] + table[LookinAttrSec_NSTextView_Alignment] = [LookinAttr_NSTextView_Alignment_Alignment] + table[LookinAttrSec_NSTextView_ContainerInset] = [LookinAttr_NSTextView_ContainerInset_Inset] + table[LookinAttrSec_NSTextView_BaseWritingDirection] = [LookinAttr_NSTextView_BaseWritingDirection_BaseWritingDirection] + table[LookinAttrSec_NSTextView_Size] = [ + LookinAttr_NSTextView_MaxSize_MaxSize, + LookinAttr_NSTextView_MinSize_MinSize, + ] + table[LookinAttrSec_NSTextView_Resizable] = [ + LookinAttr_NSTextView_Resizable_Horizontal, + LookinAttr_NSTextView_Resizable_Vertical, + ] + table[LookinAttrSec_NSTextField_BezelStyle] = [LookinAttr_NSTextField_BezelStyle_BezelStyle] + table[LookinAttrSec_NSTextField_Bordered] = [ + LookinAttr_NSTextField_Bordered_Bordered, + LookinAttr_NSTextField_Bezeled_Bezeled, + LookinAttr_NSTextField_Editable_Editable, + LookinAttr_NSTextField_Selectable_Selectable, + LookinAttr_NSTextField_DrawsBackground_DrawsBackground, + LookinAttr_NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation, + LookinAttr_NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes, + LookinAttr_NSTextField_ImportsGraphics_ImportsGraphics, + ] + table[LookinAttrSec_NSTextField_TextColor] = [ + LookinAttr_NSTextField_TextColor_Color, + LookinAttr_NSTextField_BackgroundColor_Color, + ] + table[LookinAttrSec_NSTextField_Placeholder] = [LookinAttr_NSTextField_Placeholder_Placeholder] + table[LookinAttrSec_NSTextField_LineBreakStrategy] = [LookinAttr_NSTextField_LineBreakStrategy_LineBreakStrategy] + table[LookinAttrSec_NSTextField_PreferredMaxLayoutWidth] = [ + LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth, + LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines, + ] + table[LookinAttrSec_NSVisualEffectView_Material] = [LookinAttr_NSVisualEffectView_Material_Material] + table[LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle] = [LookinAttr_NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle] + table[LookinAttrSec_NSVisualEffectView_BlendingMode] = [LookinAttr_NSVisualEffectView_BlendingMode_BlendingMode] + table[LookinAttrSec_NSVisualEffectView_State] = [LookinAttr_NSVisualEffectView_State_State] + table[LookinAttrSec_NSVisualEffectView_Emphasized] = [LookinAttr_NSVisualEffectView_Emphasized_Emphasized] + table[LookinAttrSec_NSStackView_Orientation] = [LookinAttr_NSStackView_Orientation_Orientation] + table[LookinAttrSec_NSStackView_EdgeInsets] = [LookinAttr_NSStackView_EdgeInsets_EdgeInsets] + table[LookinAttrSec_NSStackView_DetachesHiddenViews] = [LookinAttr_NSStackView_DetachesHiddenViews_DetachesHiddenViews] + table[LookinAttrSec_NSStackView_Distribution] = [LookinAttr_NSStackView_Distribution_Distribution] + table[LookinAttrSec_NSStackView_Alignment] = [LookinAttr_NSStackView_Alignment_Alignment] + table[LookinAttrSec_NSStackView_Spacing] = [LookinAttr_NSStackView_Spacing_Spacing] + table[LookinAttrSec_NSWindow_Title] = [LookinAttr_NSWindow_Title_Title] + table[LookinAttrSec_NSWindow_Subtitle] = [LookinAttr_NSWindow_Title_Subtitle] + table[LookinAttrSec_NSWindow_State] = [ + LookinAttr_NSWindow_State_KeyWindow, + LookinAttr_NSWindow_State_MainWindow, + LookinAttr_NSWindow_State_Visible, + LookinAttr_NSWindow_State_CanBecomeKeyWindow, + LookinAttr_NSWindow_State_CanBecomeMainWindow, + ] + table[LookinAttrSec_NSWindow_Style] = [ + LookinAttr_NSWindow_Style_Titled, + LookinAttr_NSWindow_Style_Closable, + LookinAttr_NSWindow_Style_Miniaturizable, + LookinAttr_NSWindow_Style_Resizable, + LookinAttr_NSWindow_Style_UnifiedTitleAndToolbar, + LookinAttr_NSWindow_Style_FullScreen, + LookinAttr_NSWindow_Style_FullSizeContentView, + LookinAttr_NSWindow_Style_UtilityWindow, + LookinAttr_NSWindow_Style_DocModalWindow, + LookinAttr_NSWindow_Style_NonactivatingPanel, + LookinAttr_NSWindow_Style_HUDWindow, + ] + table[LookinAttrSec_NSWindow_CollectionBehavior] = [ + LookinAttr_NSWindow_CollectionBehavior_CanJoinAllSpaces, + LookinAttr_NSWindow_CollectionBehavior_MoveToActiveSpace, + LookinAttr_NSWindow_CollectionBehavior_ParticipatesInCycle, + LookinAttr_NSWindow_CollectionBehavior_IgnoresCycle, + LookinAttr_NSWindow_CollectionBehavior_FullScreenPrimary, + LookinAttr_NSWindow_CollectionBehavior_FullScreenAuxiliary, + LookinAttr_NSWindow_CollectionBehavior_FullScreenNone, + LookinAttr_NSWindow_CollectionBehavior_FullScreenAllowsTiling, + LookinAttr_NSWindow_CollectionBehavior_FullScreenDisallowsTiling, + ] + table[LookinAttrSec_NSWindow_Appearance] = [ + LookinAttr_NSWindow_Appearance_TitlebarAppearsTransparent, + LookinAttr_NSWindow_Appearance_BackgroundColor, + LookinAttr_NSWindow_Appearance_AlphaValue, + LookinAttr_NSWindow_Appearance_Opaque, + LookinAttr_NSWindow_Appearance_HasShadow, + ] + table[LookinAttrSec_NSWindow_TitleVisibility] = [LookinAttr_NSWindow_Appearance_TitleVisibility] + table[LookinAttrSec_NSWindow_ToolbarStyle] = [LookinAttr_NSWindow_Appearance_ToolbarStyle] + table[LookinAttrSec_NSWindow_TitlebarSeparatorStyle] = [LookinAttr_NSWindow_Appearance_TitlebarSeparatorStyle] + table[LookinAttrSec_NSWindow_Behavior] = [ + LookinAttr_NSWindow_Behavior_Movable, + LookinAttr_NSWindow_Behavior_MovableByWindowBackground, + LookinAttr_NSWindow_Behavior_HidesOnDeactivate, + ] + table[LookinAttrSec_NSWindow_AnimationBehavior] = [LookinAttr_NSWindow_Behavior_AnimationBehavior] + table[LookinAttrSec_NSWindow_Level] = [LookinAttr_NSWindow_Behavior_Level] + table[LookinAttrSec_NSWindow_TabbingMode] = [LookinAttr_NSWindow_Behavior_TabbingMode] + table[LookinAttrSec_NSWindow_Size] = [ + LookinAttr_NSWindow_Size_MinSize, + LookinAttr_NSWindow_Size_MaxSize, + ] + table[LookinAttrSec_NSWindow_Info] = [ + LookinAttr_NSWindow_Info_WindowNumber, + LookinAttr_NSWindow_Info_BackingScaleFactor, + ] + table[LookinAttrSec_NSSlider_SliderType] = [LookinAttr_NSSlider_SliderType_SliderType] + table[LookinAttrSec_NSSlider_Range] = [ + LookinAttr_NSSlider_Range_MinValue, + LookinAttr_NSSlider_Range_MaxValue, + ] + table[LookinAttrSec_NSSlider_TickMark] = [ + LookinAttr_NSSlider_TickMark_NumberOfTickMarks, + LookinAttr_NSSlider_TickMark_TickMarkPosition, + LookinAttr_NSSlider_TickMark_AllowsTickMarkValuesOnly, + ] + table[LookinAttrSec_NSSlider_Misc] = [ + LookinAttr_NSSlider_Misc_Vertical, + LookinAttr_NSSlider_Misc_KnobThickness, + LookinAttr_NSSlider_Misc_AltIncrementValue, + LookinAttr_NSSlider_Misc_TrackFillColor, + ] + table[LookinAttrSec_NSProgressIndicator_Style] = [LookinAttr_NSProgressIndicator_Style_Style] + table[LookinAttrSec_NSProgressIndicator_Range] = [ + LookinAttr_NSProgressIndicator_Range_MinValue, + LookinAttr_NSProgressIndicator_Range_MaxValue, + LookinAttr_NSProgressIndicator_Range_DoubleValue, + ] + table[LookinAttrSec_NSProgressIndicator_Misc] = [ + LookinAttr_NSProgressIndicator_Misc_Indeterminate, + LookinAttr_NSProgressIndicator_Misc_Bezeled, + LookinAttr_NSProgressIndicator_Misc_DisplayedWhenStopped, + ] + table[LookinAttrSec_NSSegmentedControl_SegmentCount] = [LookinAttr_NSSegmentedControl_SegmentCount_SegmentCount] + table[LookinAttrSec_NSSegmentedControl_Selection] = [LookinAttr_NSSegmentedControl_Selection_SelectedSegment] + table[LookinAttrSec_NSSegmentedControl_Style] = [ + LookinAttr_NSSegmentedControl_Style_SegmentStyle, + LookinAttr_NSSegmentedControl_Style_TrackingMode, + ] + table[LookinAttrSec_NSSegmentedControl_Colors] = [LookinAttr_NSSegmentedControl_Colors_SelectedSegmentBezelColor] + table[LookinAttrSec_NSPopUpButton_Behavior] = [ + LookinAttr_NSPopUpButton_Behavior_PullsDown, + LookinAttr_NSPopUpButton_Behavior_AutoenablesItems, + LookinAttr_NSPopUpButton_Behavior_PreferredEdge, + ] + table[LookinAttrSec_NSPopUpButton_Selection] = [ + LookinAttr_NSPopUpButton_Selection_SelectedTag, + LookinAttr_NSPopUpButton_Selection_IndexOfSelectedItem, + LookinAttr_NSPopUpButton_Selection_TitleOfSelectedItem, + ] + table[LookinAttrSec_NSPopUpButton_Items] = [LookinAttr_NSPopUpButton_Items_NumberOfItems] + table[LookinAttrSec_NSComboBox_Items] = [ + LookinAttr_NSComboBox_Items_NumberOfItems, + LookinAttr_NSComboBox_Items_HasVerticalScroller, + LookinAttr_NSComboBox_Items_NumberOfVisibleItems, + LookinAttr_NSComboBox_Items_IntercellSpacing, + LookinAttr_NSComboBox_Items_ItemHeight, + ] + table[LookinAttrSec_NSComboBox_Misc] = [ + LookinAttr_NSComboBox_Misc_ButtonBordered, + LookinAttr_NSComboBox_Misc_Completes, + LookinAttr_NSComboBox_Misc_UsesDataSource, + ] + table[LookinAttrSec_NSStepper_Range] = [ + LookinAttr_NSStepper_Range_MinValue, + LookinAttr_NSStepper_Range_MaxValue, + LookinAttr_NSStepper_Range_Increment, + ] + table[LookinAttrSec_NSStepper_Misc] = [ + LookinAttr_NSStepper_Misc_ValueWraps, + LookinAttr_NSStepper_Misc_Autorepeat, + ] + table[LookinAttrSec_NSColorWell_Color] = [LookinAttr_NSColorWell_Color_Color] + table[LookinAttrSec_NSColorWell_Misc] = [ + LookinAttr_NSColorWell_Misc_Bordered, + LookinAttr_NSColorWell_Misc_Active, + LookinAttr_NSColorWell_Misc_ColorWellStyle, + ] + table[LookinAttrSec_NSSwitch_State] = [LookinAttr_NSSwitch_State_State] + table[LookinAttrSec_NSDatePicker_Style] = [ + LookinAttr_NSDatePicker_Style_DatePickerStyle, + LookinAttr_NSDatePicker_Style_DatePickerMode, + ] + table[LookinAttrSec_NSDatePicker_Range] = [ + LookinAttr_NSDatePicker_Range_DateValue, + LookinAttr_NSDatePicker_Range_MinDate, + LookinAttr_NSDatePicker_Range_MaxDate, + ] + table[LookinAttrSec_NSDatePicker_Misc] = [ + LookinAttr_NSDatePicker_Misc_Bordered, + LookinAttr_NSDatePicker_Misc_Bezeled, + LookinAttr_NSDatePicker_Misc_DrawsBackground, + ] + table[LookinAttrSec_NSLevelIndicator_Style] = [LookinAttr_NSLevelIndicator_Style_Style] + table[LookinAttrSec_NSLevelIndicator_Range] = [ + LookinAttr_NSLevelIndicator_Range_MinValue, + LookinAttr_NSLevelIndicator_Range_MaxValue, + LookinAttr_NSLevelIndicator_Range_WarningValue, + LookinAttr_NSLevelIndicator_Range_CriticalValue, + ] + table[LookinAttrSec_NSLevelIndicator_TickMark] = [ + LookinAttr_NSLevelIndicator_TickMark_NumberOfTickMarks, + LookinAttr_NSLevelIndicator_TickMark_NumberOfMajorTickMarks, + ] + table[LookinAttrSec_NSOutlineView_Indentation] = [LookinAttr_NSOutlineView_Indentation_IndentationPerLevel] + table[LookinAttrSec_NSOutlineView_Misc] = [ + LookinAttr_NSOutlineView_Misc_AutoresizesOutlineColumn, + LookinAttr_NSOutlineView_Misc_IndentationMarkerFollowsCell, + LookinAttr_NSOutlineView_Misc_AutosaveExpandedItems, + ] + table[LookinAttrSec_NSCollectionView_Selection] = [ + LookinAttr_NSCollectionView_Selection_Selectable, + LookinAttr_NSCollectionView_Selection_AllowsMultipleSelection, + LookinAttr_NSCollectionView_Selection_AllowsEmptySelection, + ] + table[LookinAttrSec_NSCollectionView_Info] = [LookinAttr_NSCollectionView_Info_NumberOfSections] + table[LookinAttrSec_NSCollectionView_Colors] = [LookinAttr_NSCollectionView_Colors_BackgroundColors] + table[LookinAttrSec_NSBox_Type] = [ + LookinAttr_NSBox_Type_BoxType, + LookinAttr_NSBox_Type_BorderType, + ] + table[LookinAttrSec_NSBox_Title] = [ + LookinAttr_NSBox_Title_Title, + LookinAttr_NSBox_Title_TitlePosition, + ] + table[LookinAttrSec_NSBox_Appearance] = [ + LookinAttr_NSBox_Appearance_Transparent, + LookinAttr_NSBox_Appearance_FillColor, + LookinAttr_NSBox_Appearance_BorderColor, + ] + table[LookinAttrSec_NSBox_Metrics] = [ + LookinAttr_NSBox_Metrics_BorderWidth, + LookinAttr_NSBox_Metrics_CornerRadius, + LookinAttr_NSBox_Metrics_ContentViewMargins, + ] + table[LookinAttrSec_NSSplitView_Orientation] = [LookinAttr_NSSplitView_Orientation_Vertical] + table[LookinAttrSec_NSSplitView_Style] = [ + LookinAttr_NSSplitView_Style_DividerStyle, + LookinAttr_NSSplitView_Style_DividerThickness, + ] + table[LookinAttrSec_NSSplitView_Misc] = [LookinAttr_NSSplitView_Misc_ArrangesAllSubviews] + table[LookinAttrSec_NSTabView_Type] = [ + LookinAttr_NSTabView_Type_TabViewType, + LookinAttr_NSTabView_Type_TabPosition, + LookinAttr_NSTabView_Type_TabViewBorderType, + ] + table[LookinAttrSec_NSTabView_Misc] = [ + LookinAttr_NSTabView_Misc_AllowsTruncatedLabels, + LookinAttr_NSTabView_Misc_DrawsBackground, + ] + table[LookinAttrSec_NSTabView_Info] = [LookinAttr_NSTabView_Info_NumberOfTabViewItems] + table[LookinAttrSec_NSGridView_Dimensions] = [ + LookinAttr_NSGridView_Dimensions_NumberOfColumns, + LookinAttr_NSGridView_Dimensions_NumberOfRows, + ] + table[LookinAttrSec_NSGridView_Spacing] = [ + LookinAttr_NSGridView_Spacing_RowSpacing, + LookinAttr_NSGridView_Spacing_ColumnSpacing, + ] + table[LookinAttrSec_NSGridView_Placement] = [ + LookinAttr_NSGridView_Placement_XPlacement, + LookinAttr_NSGridView_Placement_YPlacement, + ] + #endif + table[LookinAttrSec_LayoutGuide_Identifier] = [LookinAttr_LayoutGuide_Identifier_Identifier] + table[LookinAttrSec_LayoutGuide_LayoutFrame] = [LookinAttr_LayoutGuide_LayoutFrame_LayoutFrame] + table[LookinAttrSec_LayoutGuide_OwningView] = [LookinAttr_LayoutGuide_OwningView_OwningView] + table[LookinAttrSec_NSCell_Cell] = [ + LookinAttr_NSCell_Cell_Type, + LookinAttr_NSCell_Cell_State, + LookinAttr_NSCell_Cell_Enabled, + LookinAttr_NSCell_Cell_Bordered, + LookinAttr_NSCell_Cell_Bezeled, + LookinAttr_NSCell_Cell_Highlighted, + LookinAttr_NSCell_Cell_Editable, + LookinAttr_NSCell_Cell_Selectable, + LookinAttr_NSCell_Cell_Alignment, + LookinAttr_NSCell_Cell_ControlSize, + ] + table[LookinAttrSec_NSCell_Content] = [ + LookinAttr_NSCell_Content_Title, + LookinAttr_NSCell_Content_FontName, + LookinAttr_NSCell_Content_FontSize, + LookinAttr_NSCell_Content_LineBreakMode, + LookinAttr_NSCell_Content_Wraps, + ] + table[LookinAttrSec_NSCell_Behavior] = [ + LookinAttr_NSCell_Behavior_Tag, + LookinAttr_NSCell_Behavior_Continuous, + LookinAttr_NSCell_Behavior_AllowsMixedState, + LookinAttr_NSCell_Behavior_SendsActionOnEndEditing, + ] + table[LookinAttrSec_NSCell_ButtonCell] = [ + LookinAttr_NSCell_ButtonCell_BezelStyle, + LookinAttr_NSCell_ButtonCell_ImagePosition, + LookinAttr_NSCell_ButtonCell_ShowsBorderOnlyWhileMouseInside, + LookinAttr_NSCell_ButtonCell_KeyEquivalent, + LookinAttr_NSCell_ButtonCell_AlternateTitle, + ] + table[LookinAttrSec_NSCell_TextFieldCell] = [ + LookinAttr_NSCell_TextFieldCell_Placeholder, + LookinAttr_NSCell_TextFieldCell_DrawsBackground, + LookinAttr_NSCell_TextFieldCell_TextColor, + LookinAttr_NSCell_TextFieldCell_BackgroundColor, + LookinAttr_NSCell_TextFieldCell_BezelStyle, + ] + return table + }() + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinDashboardBlueprint.swift b/Sources/LookinCoreImpl/LookinDashboardBlueprint.swift new file mode 100644 index 00000000..d0d90745 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinDashboardBlueprint.swift @@ -0,0 +1,252 @@ +// +// LookinDashboardBlueprint.swift +// LookinCore +// +// Was LookinDashboardBlueprint.m. The tables +// live in LookinDashboardBlueprint+Groups.swift, +Sections.swift and +// +AttrInfo.swift; the output of every class method below is pinned by +// Tests/LookinServerHierarchyTests/BlueprintEquivalenceTests. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + /// Namespace for the blueprint's static tables. Each table is built once, + /// on first use, like the `dispatch_once` dictionaries it replaces. + enum LookinDashboardBlueprintTables { + // Class name of an attribute that lives on the platform view. + #if canImport(UIKit) + static let viewClassName = "UIView" + static let layoutGuideClassName = "UILayoutGuide" + #else + static let viewClassName = "NSView" + static let layoutGuideClassName = "NSLayoutGuide" + #endif + + /// What the blueprint knows about one attribute. + struct AttrInfo { + /// Required: the class that owns the attribute. + var className: String? + /// Full name. It is the search keyword and the search result + /// title; nil keeps the attribute out of search. + var fullTitle: String? + /// Short name, only for checkboxes and inputs with a built-in + /// title. Falls back to `fullTitle` when nil. + var briefTitle: String? + /// Getter selector. nil derives it from `fullTitle` (first letter + /// lowercased); "" pins the value to nil. + var getterString: String? + /// Setter selector. nil derives it from `fullTitle` + /// (`set:`); "" makes the attribute read-only. + var setterString: String? + /// The concrete object type when the value is an object. + var typeIfObj: LookinAttrType? + /// The enum's name (for example "NSTextAlignment") when the value + /// is an enum; the host looks up its cases by this name. + var enumList: String? + /// Whether the host refetches frames and screenshots after the + /// user modifies the value. nil means NO. + var patch: Bool? + /// Whether a nil value is left out of the transfer. nil means NO. + var hideIfNil: Bool? + /// Minimum OS major version; nil means no limit. + var osVersion: Int? + } + + /// `%@` of a possibly nil string, as `+[NSString stringWithFormat:]` + /// writes it. Only reached in release builds for an attribute without + /// a title, where the Objective-C implementation produced + /// "(null)(null)". + static func formatted(_ string: String?) -> String { + string ?? "(null)" + } + + static func info(_ attrID: String?) -> AttrInfo? { + guard let attrID else { + return nil + } + return attrInfo[attrID] + } + } + + /// `NSAssert(condition, @"")` from Objective-C: in debug builds a failed + /// condition raises NSInternalInconsistencyException, which Objective-C + /// callers can catch; in release builds it does nothing. + /// (NSAssertionHandler's failure methods are unavailable in Swift.) + private func blueprintAssert(_ condition: @autoclosure () -> Bool, method: Selector) { + #if DEBUG + if !condition() { + NSException( + name: .internalInconsistencyException, + reason: "Assertion failure in +[LookinDashboardBlueprint \(NSStringFromSelector(method))]", + userInfo: nil + ).raise() + } + #endif + } + + @objc(LookinDashboardBlueprint) + public class LookinDashboardBlueprint: NSObject { + @objc(groupIDs) + public class func groupIDs() -> [String]! { + LookinDashboardBlueprintTables.groupIDs + } + + @objc(sectionIDsForGroupID:) + public class func sectionIDs(forGroupID groupID: String?) -> [String]! { + guard let groupID else { + return nil + } + return LookinDashboardBlueprintTables.sectionIDsByGroupID[groupID] + } + + @objc(attrIDsForSectionID:) + public class func attrIDs(forSectionID sectionID: String?) -> [String]! { + guard let sectionID else { + return nil + } + return LookinDashboardBlueprintTables.attrIDsBySectionID[sectionID] + } + + @objc(getHostGroupID:sectionID:fromAttrID:) + public class func getHostGroupID( + _ groupID: AutoreleasingUnsafeMutablePointer?, + sectionID: AutoreleasingUnsafeMutablePointer?, + fromAttrID targetAttrID: String? + ) { + var targetGroupID: String? + var targetSectionID: String? + search: for candidateGroupID in groupIDs() ?? [] { + for candidateSectionID in sectionIDs(forGroupID: candidateGroupID) ?? [] { + for attrID in attrIDs(forSectionID: candidateSectionID) ?? [] where attrID == targetAttrID { + targetGroupID = candidateGroupID + targetSectionID = candidateSectionID + break search + } + } + } + if let groupID, let targetGroupID { + groupID.pointee = targetGroupID as NSString + } + if let sectionID, let targetSectionID { + sectionID.pointee = targetSectionID as NSString + } + } + + @objc(groupTitleWithGroupID:) + public class func groupTitle(withGroupID groupID: String?) -> String! { + let title = groupID.flatMap { LookinDashboardBlueprintTables.groupTitles[$0] } + blueprintAssert(!(title ?? "").isEmpty, method: #selector(groupTitle(withGroupID:))) + return title + } + + @objc(sectionTitleWithSectionID:) + public class func sectionTitle(withSectionID secID: String?) -> String! { + secID.flatMap { LookinDashboardBlueprintTables.sectionTitles[$0] } + } + + @objc(objectAttrTypeWithAttrID:) + public class func objectAttrType(withAttrID attrID: String?) -> LookinAttrType { + LookinDashboardBlueprintTables.info(attrID)?.typeIfObj ?? LookinAttrType.none + } + + @objc(classNameWithAttrID:) + public class func className(withAttrID attrID: String?) -> String! { + let className = LookinDashboardBlueprintTables.info(attrID)?.className + blueprintAssert(!(className ?? "").isEmpty, method: #selector(className(withAttrID:))) + return className + } + + @objc(targetKindForAttrID:) + public class func targetKind(forAttrID attrID: String?) -> LookinAttrTargetKind { + switch className(withAttrID: attrID) { + case "CALayer": + .layer + case "UIWindowScene", "NSWindow": + .window + case "NSCell", "NSButtonCell", "NSTextFieldCell": + .cell + default: + .view + } + } + + @objc(isWindowPropertyWithAttrID:) + public class func isWindowProperty(withAttrID attrID: String?) -> Bool { + targetKind(forAttrID: attrID) == .window + } + + @objc(isUIViewPropertyWithAttrID:) + public class func isUIViewProperty(withAttrID attrID: String?) -> Bool { + targetKind(forAttrID: attrID) == .view + } + + @objc(enumListNameWithAttrID:) + public class func enumListName(withAttrID attrID: String?) -> String! { + LookinDashboardBlueprintTables.info(attrID)?.enumList + } + + @objc(needPatchAfterModificationWithAttrID:) + public class func needPatchAfterModification(withAttrID attrID: String?) -> Bool { + LookinDashboardBlueprintTables.info(attrID)?.patch ?? false + } + + @objc(fullTitleWithAttrID:) + public class func fullTitle(withAttrID attrID: String?) -> String! { + LookinDashboardBlueprintTables.info(attrID)?.fullTitle + } + + @objc(briefTitleWithAttrID:) + public class func briefTitle(withAttrID attrID: String?) -> String! { + let info = LookinDashboardBlueprintTables.info(attrID) + return info?.briefTitle ?? info?.fullTitle + } + + @objc(getterWithAttrID:) + public class func getter(withAttrID attrID: String?) -> Selector! { + let info = LookinDashboardBlueprintTables.info(attrID) + if let getterString = info?.getterString { + // An empty string (for example image_open_open) pins the + // value to nil. + return getterString.isEmpty ? nil : NSSelectorFromString(getterString) + } + let fullTitle = info?.fullTitle + blueprintAssert(!(fullTitle ?? "").isEmpty, method: #selector(getter(withAttrID:))) + return NSSelectorFromString( + LookinDashboardBlueprintTables.formatted(fullTitle?.prefix(1).lowercased()) + + LookinDashboardBlueprintTables.formatted(fullTitle.map { String($0.dropFirst()) }) + ) + } + + @objc(setterWithAttrID:) + public class func setter(withAttrID attrID: String?) -> Selector! { + let info = LookinDashboardBlueprintTables.info(attrID) + if let setterString = info?.setterString { + // An empty string means the client cannot modify the + // attribute. + return setterString.isEmpty ? nil : NSSelectorFromString(setterString) + } + let fullTitle = info?.fullTitle + blueprintAssert(!(fullTitle ?? "").isEmpty, method: #selector(setter(withAttrID:))) + return NSSelectorFromString( + "set" + LookinDashboardBlueprintTables.formatted(fullTitle?.prefix(1).uppercased()) + + LookinDashboardBlueprintTables.formatted(fullTitle.map { String($0.dropFirst()) }) + ":" + ) + } + + @objc(hideIfNilWithAttrID:) + public class func hideIfNil(withAttrID attrID: String?) -> Bool { + LookinDashboardBlueprintTables.info(attrID)?.hideIfNil ?? false + } + + @objc(minAvailableOSVersionWithAttrID:) + public class func minAvailableOSVersion(withAttrID attrID: String?) -> Int { + LookinDashboardBlueprintTables.info(attrID)?.osVersion ?? 0 + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinDefines.swift b/Sources/LookinCoreImpl/LookinDefines.swift new file mode 100644 index 00000000..3773e2c0 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinDefines.swift @@ -0,0 +1,123 @@ +// +// LookinDefines.swift +// LookinCore +// +// The Swift spelling of the LookinDefines.h macros that Swift cannot import +// (type macros, error macros, string macros). Shared by every Swift +// implementation file in LookinCoreImpl and LookinServerImpl; the Host +// compiles it from DerivedSource as well. Values must stay identical to the +// macros in LookinDefines.h: the error replies are pinned by +// Tests/WireFormatGolden (Response-Error-*). +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + // `TARGET_OS_IPHONE` covers iOS, tvOS, visionOS and Mac Catalyst; in Swift + // that is `canImport(UIKit)`. `TARGET_OS_OSX` is `os(macOS)`. + #if canImport(UIKit) + import UIKit + + public typealias LookinColor = UIColor + public typealias LookinInsets = UIEdgeInsets + public typealias LookinImage = UIImage + public typealias LookinWindow = UIWindow + public typealias LookinApplication = UIApplication + public typealias LookinImageView = UIImageView + public typealias LookinView = UIView + public typealias LookinViewController = UIViewController + public typealias LookinFont = UIFont + public typealias LookinResponder = UIResponder + public typealias LookinLayoutGuide = UILayoutGuide + public typealias LookinGestureRecognizer = UIGestureRecognizer + public typealias LookinControl = UIControl + public typealias LookinCollectionView = UICollectionView + public typealias LookinTextField = UITextField + public typealias LookinTextView = UITextView + public let LookinLayoutConstraintAxisHorizontal = NSLayoutConstraint.Axis.horizontal + public let LookinLayoutConstraintAxisVertical = NSLayoutConstraint.Axis.vertical + public let LookinCollectionElementKindSectionHeader = UICollectionView.elementKindSectionHeader + public let LookinCollectionElementKindSectionFooter = UICollectionView.elementKindSectionFooter + public let LookinViewString = "UIView" + public let LookinViewControllerString = "UIViewController" + #elseif os(macOS) + import AppKit + + public typealias LookinColor = NSColor + public typealias LookinInsets = NSEdgeInsets + public typealias LookinImage = NSImage + public typealias LookinWindow = NSWindow + public typealias LookinApplication = NSApplication + public typealias LookinImageView = NSImageView + public typealias LookinView = NSView + public typealias LookinViewController = NSViewController + public typealias LookinFont = NSFont + public typealias LookinResponder = NSResponder + public typealias LookinLayoutGuide = NSLayoutGuide + public typealias LookinGestureRecognizer = NSGestureRecognizer + public typealias LookinControl = NSControl + public typealias LookinCollectionView = NSCollectionView + public typealias LookinTextField = NSTextField + public typealias LookinTextView = NSTextView + public let LookinLayoutConstraintAxisHorizontal = NSLayoutConstraint.Orientation.horizontal + public let LookinLayoutConstraintAxisVertical = NSLayoutConstraint.Orientation.vertical + public let LookinCollectionElementKindSectionHeader = NSCollectionView.elementKindSectionHeader + public let LookinCollectionElementKindSectionFooter = NSCollectionView.elementKindSectionFooter + public let LookinViewString = "NSView" + public let LookinViewControllerString = "NSViewController" + #endif + + // MARK: - Errors + + /// `LookinErr_ObjNotFound`. A new NSError on every read, like the macro. + public var LookinErr_ObjNotFound: NSError { + NSError(domain: LookinErrorDomain, code: Int(LookinErrCode_ObjectNotFound), userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("Failed to get target object in iOS app", comment: ""), + NSLocalizedRecoverySuggestionErrorKey: NSLocalizedString("Perhaps the related object was deallocated. You can reload LookInside to get newest data.", comment: ""), + ]) + } + + /// `LookinErr_NoConnect`. + public var LookinErr_NoConnect: NSError { + NSError(domain: LookinErrorDomain, code: Int(LookinErrCode_NoConnect), userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("The operation failed due to disconnection with the iOS app.", comment: ""), + ]) + } + + /// `LookinErr_Inner`. + public var LookinErr_Inner: NSError { + NSError(domain: LookinErrorDomain, code: Int(LookinErrCode_Inner), userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("The operation failed due to an inner error.", comment: ""), + ]) + } + + /// `LookinErrorMake(errorTitle, errorDetail)`. + public func LookinErrorMake(_ errorTitle: String, _ errorDetail: String) -> NSError { + NSError(domain: LookinErrorDomain, code: Int(LookinErrCode_Default), userInfo: [ + NSLocalizedDescriptionKey: errorTitle, + NSLocalizedRecoverySuggestionErrorKey: errorDetail, + ]) + } + + /// `LookinErrorText_Timeout`. + public var LookinErrorText_Timeout: String { + NSLocalizedString("Perhaps your iOS app is paused with breakpoint in Xcode, blocked by other tasks in main thread, or moved to background state.", comment: "") + } + + // MARK: - Colors + + /// `LookinColorRGBAMake(r, g, b, a)`: components in 0...255, alpha in 0...1. + public func LookinColorRGBAMake(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat) -> LookinColor { + LookinColor(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: a) + } + + /// `LookinColorMake(r, g, b)`. + public func LookinColorMake(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat) -> LookinColor { + LookinColor(red: r / 255.0, green: g / 255.0, blue: b / 255.0, alpha: 1) + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinDisplayItem.swift b/Sources/LookinCoreImpl/LookinDisplayItem.swift new file mode 100644 index 00000000..39818e85 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinDisplayItem.swift @@ -0,0 +1,834 @@ +// +// LookinDisplayItem.swift +// qmuidemo +// +// Was LookinDisplayItem.m: one +// hierarchy node on the wire, in .lookin documents, and in the host's tree. +// The coding keys, scalar encodings, decode defaults and the conditional +// screenshot keys are pinned by Tests/WireFormatGolden. The setters keep the +// original's side effects (tree bookkeeping and delegate notifications) in +// the same order. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + // The colour's RGBA components are archived as the very NSArray the + // category returns (bridging through [NSNumber] would copy it), and the + // decoded value is handed back untouched. + #if canImport(UIKit) + /// `-[UIColor lks_rgbaComponents]` and `+[UIColor + /// lks_colorFromRGBAComponents:]` live in LookinServer + /// (UIColor+LookinServer.h); LookinCore does not link against the + /// server, so they are sent dynamically, as the original's import did. + @objc private protocol LookinDisplayItemColorBridge { + func lks_rgbaComponents() -> NSArray? + func lks_colorFromRGBAComponents(_ components: AnyObject?) -> AnyObject? + } + + private func lookinRGBAComponents(of color: LookinColor?) -> NSArray? { + guard let color else { + return nil + } + return unsafeBitCast(color, to: LookinDisplayItemColorBridge.self).lks_rgbaComponents() + } + + private func lookinColor(fromRGBAComponents components: Any?) -> LookinColor? { + let colorClass: AnyObject = UIColor.self + return unsafeBitCast(colorClass, to: LookinDisplayItemColorBridge.self).lks_colorFromRGBAComponents(components as AnyObject?) as? LookinColor + } + #elseif os(macOS) + /// `-[NSColor lookin_rgbaComponents]` and `+[NSColor + /// lookin_colorFromRGBAComponents:]` (Color+Lookin.h), sent the same way + /// so the arrays are not bridged. + @objc private protocol LookinDisplayItemColorBridge { + func lookin_rgbaComponents() -> NSArray? + func lookin_colorFromRGBAComponents(_ components: AnyObject?) -> AnyObject? + } + + private func lookinRGBAComponents(of color: LookinColor?) -> NSArray? { + guard let color else { + return nil + } + return unsafeBitCast(color, to: LookinDisplayItemColorBridge.self).lookin_rgbaComponents() + } + + private func lookinColor(fromRGBAComponents components: Any?) -> LookinColor? { + let colorClass: AnyObject = NSColor.self + return unsafeBitCast(colorClass, to: LookinDisplayItemColorBridge.self).lookin_colorFromRGBAComponents(components as AnyObject?) as? LookinColor + } + #endif + + /// The screenshot decode of -initWithCoder:: PNG/TIFF data, or the image + /// object itself. + private func lookinDecodedScreenshot(_ screenshotObj: Any?) -> LookinImage?? { + guard let screenshotObj = screenshotObj as? NSObject else { + return .none + } + if screenshotObj.isKind(of: NSData.self) { + return .some(screenshotObj.lookin_decodedObject(with: .image) as? LookinImage) + } + if let image = screenshotObj as? LookinImage { + return .some(image) + } + assertionFailure() + return .none + } + + /// Was declared in LookinDisplayItem.h; same runtime name and selector. + @objc(LookinDisplayItemDelegate) + public protocol LookinDisplayItemDelegate: NSObjectProtocol { + @objc(displayItem:propertyDidChange:) + func displayItem(_ displayItem: LookinDisplayItem!, propertyDidChange property: LookinDisplayItemProperty) + } + + @objc(LookinDisplayItem) + public class LookinDisplayItem: NSObject, NSCoding, NSSecureCoding, NSCopying { + // MARK: Storage behind the setters with side effects + + private var _subitems: [LookinDisplayItem]? + private var _isHidden: Bool = false + private var _alpha: Float = 0 + private var _frame: CGRect = .zero + private var _bounds: CGRect = .zero + private var _soloScreenshot: LookinImage? + private var _groupScreenshot: LookinImage? + private var _attributesGroupList: [LookinAttributesGroup]? + private var _customAttrGroupList: [LookinAttributesGroup]? + private var _isExpanded: Bool = false + private var _isExpandable: Bool = false + private var _displayingInHierarchy: Bool = false + private var _inHiddenHierarchy: Bool = false + private var _doNotFetchScreenshotReason: LookinDoNotFetchScreenshotReason = .fetchScreenshotPermitted + private var _noPreview: Bool = false + private var _inNoPreviewHierarchy: Bool = false + private var _indentLevel: Int = 0 + private var _isInSearch: Bool = false + private var _highlightedSearchString: String? + #if os(macOS) + private var _flipped: Bool = false + #endif + + // MARK: Plain properties + + @objc(customInfo) + public var customInfo: LookinCustomDisplayItemInfo! + @objc(nodeKind) + public var nodeKind: LookinDisplayItemNodeKind = .unspecified + @objc(kindObject) + public var kindObject: LookinObject! + @objc(representsSystemManagedNode) + public var representsSystemManagedNode: Bool = false + @objc(cellObject) + public var cellObject: LookinObject! + @objc(soloScreenshotRegion) + public var soloScreenshotRegion: CGRect = .zero + @objc(groupScreenshotRegion) + public var groupScreenshotRegion: CGRect = .zero + @objc(windowObject) + public var windowObject: LookinObject! + @objc(viewObject) + public var viewObject: LookinObject! + @objc(layerObject) + public var layerObject: LookinObject! + @objc(hostViewControllerObject) + public var hostViewControllerObject: LookinObject! + @objc(hostWindowControllerObject) + public var hostWindowControllerObject: LookinObject! + @objc(eventHandlers) + public var eventHandlers: [LookinEventHandler]! + @objc(representedAsKeyWindow) + public var representedAsKeyWindow: Bool = false + @objc(backgroundColor) + public var backgroundColor: LookinColor! + @objc(shouldCaptureImage) + public var shouldCaptureImage: Bool = false + @objc(customDisplayTitle) + public var customDisplayTitle: String! + @objc(danceuiSource) + public var danceuiSource: String! + /// `superItem` in Objective-C; `super` in Swift, the name the old + /// header's import gave it. + @objc(superItem) public weak var `super`: LookinDisplayItem? + @objc(screenshotEncodeType) + public var screenshotEncodeType: LookinDisplayItemImageEncodeType = .none + // `previewLayer` and `previewNode` keep their Objective-C accessors + // (same selectors, same weak storage). Only the host app has the + // LKDisplayItemNode class, so only its build types previewNode; + // SwiftPM builds and the host's standalone test builds + // (LOOKIN_CORE_STANDALONE) store an untyped object. + // LookinPreviewItemLayer no longer exists anywhere. + #if SWIFT_PACKAGE || LOOKIN_CORE_STANDALONE + @objc private weak var previewNode: AnyObject? + #else + // Internal: LKDisplayItemNode is the host app's own class. + @objc(previewNode) + weak var previewNode: LKDisplayItemNode? + #endif + @objc private weak var previewLayer: AnyObject? + @objc(previewZIndex) + public var previewZIndex: Int = 0 + @objc(preferToBeCollapsed) + public var preferToBeCollapsed: Bool = false + @objc(hasDeterminedExpansion) + public var hasDeterminedExpansion: Bool = false + + // MARK: Init + + override public init() { + super.init() + // The server creates its display items through this initializer. + _updateDisplayingInHierarchyProperty() + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newDisplayItem = LookinDisplayItem() + newDisplayItem.subitems = subitems?.map { $0.copy() as! LookinDisplayItem } + newDisplayItem.customInfo = customInfo?.copy() as? LookinCustomDisplayItemInfo + newDisplayItem.isHidden = isHidden + newDisplayItem.alpha = alpha + newDisplayItem.frame = frame + newDisplayItem.bounds = bounds + #if os(macOS) + newDisplayItem.isFlipped = isFlipped + #endif + newDisplayItem.soloScreenshot = soloScreenshot + newDisplayItem.groupScreenshot = groupScreenshot + newDisplayItem.soloScreenshotRegion = soloScreenshotRegion + newDisplayItem.groupScreenshotRegion = groupScreenshotRegion + newDisplayItem.viewObject = viewObject?.copy() as? LookinObject + newDisplayItem.layerObject = layerObject?.copy() as? LookinObject + newDisplayItem.windowObject = windowObject?.copy() as? LookinObject + newDisplayItem.nodeKind = nodeKind + newDisplayItem.kindObject = kindObject?.copy() as? LookinObject + newDisplayItem.representsSystemManagedNode = representsSystemManagedNode + newDisplayItem.cellObject = cellObject?.copy() as? LookinObject + newDisplayItem.hostViewControllerObject = hostViewControllerObject?.copy() as? LookinObject + newDisplayItem.hostWindowControllerObject = hostWindowControllerObject?.copy() as? LookinObject + newDisplayItem.attributesGroupList = attributesGroupList?.map { $0.copy() as! LookinAttributesGroup } + newDisplayItem.customAttrGroupList = customAttrGroupList?.map { $0.copy() as! LookinAttributesGroup } + newDisplayItem.eventHandlers = eventHandlers?.map { ($0 as NSObject).copy() as! LookinEventHandler } + newDisplayItem.shouldCaptureImage = shouldCaptureImage + newDisplayItem.representedAsKeyWindow = representedAsKeyWindow + newDisplayItem.customDisplayTitle = customDisplayTitle + newDisplayItem.danceuiSource = danceuiSource + newDisplayItem._updateDisplayingInHierarchyProperty() + return newDisplayItem + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(customInfo, forKey: "customInfo") + aCoder.encode(subitems, forKey: "subitems") + aCoder.encode(isHidden, forKey: "hidden") + aCoder.encode(alpha, forKey: "alpha") + #if os(macOS) + aCoder.encode(isFlipped, forKey: "isFlipped") + #endif + aCoder.encode(viewObject, forKey: "viewObject") + aCoder.encode(layerObject, forKey: "layerObject") + aCoder.encode(windowObject, forKey: "windowObject") + aCoder.encode(nodeKind.rawValue, forKey: "nodeKind") + aCoder.encode(kindObject, forKey: "kindObject") + aCoder.encode(representsSystemManagedNode, forKey: "representsSystemManagedNode") + aCoder.encode(cellObject, forKey: "cellObject") + aCoder.encode(hostViewControllerObject, forKey: "hostViewControllerObject") + aCoder.encode(hostWindowControllerObject, forKey: "hostWindowControllerObject") + aCoder.encode(attributesGroupList, forKey: "attributesGroupList") + aCoder.encode(customAttrGroupList, forKey: "customAttrGroupList") + aCoder.encode(representedAsKeyWindow, forKey: "representedAsKeyWindow") + aCoder.encode(eventHandlers, forKey: "eventHandlers") + aCoder.encode(shouldCaptureImage, forKey: "shouldCaptureImage") + if screenshotEncodeType == .nsData { + aCoder.encode(soloScreenshot?.lookin_encodedObject(with: .image), forKey: "soloScreenshot") + aCoder.encode(groupScreenshot?.lookin_encodedObject(with: .image), forKey: "groupScreenshot") + } else if screenshotEncodeType == .image { + aCoder.encode(soloScreenshot, forKey: "soloScreenshot") + aCoder.encode(groupScreenshot, forKey: "groupScreenshot") + } + aCoder.encode(customDisplayTitle, forKey: "customDisplayTitle") + aCoder.encode(danceuiSource, forKey: "danceuiSource") + #if canImport(UIKit) + // -encodeCGRect:forKey: stores NSStringFromCGRect. Spelled out + // because on Mac Catalyst Foundation's -encodeRect:forKey: + // imports with the same Swift signature and the call is + // ambiguous. + aCoder.encode(NSCoder.string(for: frame), forKey: "frame") + aCoder.encode(NSCoder.string(for: bounds), forKey: "bounds") + aCoder.encode(NSCoder.string(for: soloScreenshotRegion), forKey: "soloScreenshotRegion") + aCoder.encode(NSCoder.string(for: groupScreenshotRegion), forKey: "groupScreenshotRegion") + #elseif os(macOS) + aCoder.encode(frame as NSRect, forKey: "frame") + aCoder.encode(bounds as NSRect, forKey: "bounds") + aCoder.encode(soloScreenshotRegion as NSRect, forKey: "soloScreenshotRegion") + aCoder.encode(groupScreenshotRegion as NSRect, forKey: "groupScreenshotRegion") + #endif + aCoder.encode(lookinRGBAComponents(of: backgroundColor), forKey: "backgroundColor") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + customInfo = aDecoder.decodeObject(forKey: "customInfo") as? LookinCustomDisplayItemInfo + subitems = aDecoder.decodeObject(forKey: "subitems") as? [LookinDisplayItem] + isHidden = aDecoder.decodeBool(forKey: "hidden") + alpha = aDecoder.decodeFloat(forKey: "alpha") + #if os(macOS) + isFlipped = aDecoder.decodeBool(forKey: "isFlipped") + #endif + windowObject = aDecoder.decodeObject(forKey: "windowObject") as? LookinObject + viewObject = aDecoder.decodeObject(forKey: "viewObject") as? LookinObject + layerObject = aDecoder.decodeObject(forKey: "layerObject") as? LookinObject + // These fields exist since the nodeKind mechanism landed; absent keys + // decode to Unspecified / nil / NO, which is exactly the legacy-data + // contract resolvedNodeKind derives from. + nodeKind = aDecoder.containsValue(forKey: "nodeKind") ? (LookinDisplayItemNodeKind(rawValue: aDecoder.decodeInteger(forKey: "nodeKind")) ?? .unspecified) : .unspecified + kindObject = aDecoder.decodeObject(forKey: "kindObject") as? LookinObject + representsSystemManagedNode = aDecoder.decodeBool(forKey: "representsSystemManagedNode") + cellObject = aDecoder.decodeObject(forKey: "cellObject") as? LookinObject + hostViewControllerObject = aDecoder.decodeObject(forKey: "hostViewControllerObject") as? LookinObject + hostWindowControllerObject = aDecoder.decodeObject(forKey: "hostWindowControllerObject") as? LookinObject + attributesGroupList = aDecoder.decodeObject(forKey: "attributesGroupList") as? [LookinAttributesGroup] + customAttrGroupList = aDecoder.decodeObject(forKey: "customAttrGroupList") as? [LookinAttributesGroup] + representedAsKeyWindow = aDecoder.decodeBool(forKey: "representedAsKeyWindow") + + if case let .some(decoded) = lookinDecodedScreenshot(aDecoder.decodeObject(forKey: "soloScreenshot")) { + soloScreenshot = decoded + } + if case let .some(decoded) = lookinDecodedScreenshot(aDecoder.decodeObject(forKey: "groupScreenshot")) { + groupScreenshot = decoded + } + + eventHandlers = aDecoder.decodeObject(forKey: "eventHandlers") as? [LookinEventHandler] + // Added in LookinServer 1.1.3. + shouldCaptureImage = aDecoder.containsValue(forKey: "shouldCaptureImage") ? aDecoder.decodeBool(forKey: "shouldCaptureImage") : true + customDisplayTitle = aDecoder.decodeObject(forKey: "customDisplayTitle") as? String + danceuiSource = aDecoder.decodeObject(forKey: "danceuiSource") as? String + #if canImport(UIKit) + frame = aDecoder.decodeCGRect(forKey: "frame") + bounds = aDecoder.decodeCGRect(forKey: "bounds") + // Absent in archives and wire data that predate the field: zero, + // the whole of bounds. + soloScreenshotRegion = aDecoder.decodeCGRect(forKey: "soloScreenshotRegion") + groupScreenshotRegion = aDecoder.decodeCGRect(forKey: "groupScreenshotRegion") + #elseif os(macOS) + frame = aDecoder.decodeRect(forKey: "frame") + bounds = aDecoder.decodeRect(forKey: "bounds") + soloScreenshotRegion = aDecoder.decodeRect(forKey: "soloScreenshotRegion") + groupScreenshotRegion = aDecoder.decodeRect(forKey: "groupScreenshotRegion") + #endif + backgroundColor = lookinColor(fromRGBAComponents: aDecoder.decodeObject(forKey: "backgroundColor")) + _updateDisplayingInHierarchyProperty() + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + // MARK: Node kind + + @objc(displayingObject) + public func displayingObject() -> LookinObject! { + kindObject ?? windowObject ?? viewObject ?? layerObject + } + + @objc(resolvedNodeKind) + public func resolvedNodeKind() -> LookinDisplayItemNodeKind { + if nodeKind != .unspecified { + return nodeKind + } + // Derivation for data that predates the field, matching how the + // maker used the legacy slots: scene and window nodes both borrowed + // windowObject (alone), custom nodes carry customInfo, view nodes + // carry viewObject, and everything else is a bare layer. + if customInfo != nil { + return .custom + } + if let windowObject, viewObject == nil, layerObject == nil { + let representsWindowScene = (windowObject.classChainList ?? []).contains { lookinStringsEqual($0, "UIWindowScene") } + return representsWindowScene ? .windowScene : .window + } + if viewObject != nil { + return .view + } + return .layer + } + + @objc(shouldRenderAsCoplanarPreviewOverlay) + public func shouldRenderAsCoplanarPreviewOverlay() -> Bool { + switch resolvedNodeKind() { + case .layoutGuide, .cell: + return true + default: + return false + } + } + + @objc(isPixelBearing) + public func isPixelBearing() -> Bool { + switch resolvedNodeKind() { + case .layoutGuide, .cell, .viewOuterLayer: + return false + default: + return true + } + } + + @objc(hasPixelBearingSubitems) + public func hasPixelBearingSubitems() -> Bool { + for subitem in subitems ?? [] where subitem.isPixelBearing() || subitem.hasPixelBearingSubitems() { + return true + } + return false + } + + // MARK: Geometry + + @objc(hasValidFrameToRoot) + public func hasValidFrameToRoot() -> Bool { + if let customInfo { + return customInfo.hasValidFrame() + } + return LookinIsUsableRect(frame) + } + + @objc(calculateFrameToRoot) + public func calculateFrameToRoot() -> CGRect { + if let customInfo { + guard hasValidFrameToRoot(), let frameInWindow = customInfo.frameInWindow else { + return .zero + } + #if canImport(UIKit) + return frameInWindow.cgRectValue + #else + return frameInWindow.rectValue + #endif + } + if !LookinIsUsableRect(frame) { + return .zero + } + guard let superItem = self.super else { + return frame + } + + let superFrameToRoot = superItem.calculateFrameToRoot() + var superBounds = superItem.bounds + if !LookinIsUsableRect(superBounds) { + superBounds = .zero + } + let selfFrame = frame + + let x = selfFrame.origin.x - superBounds.origin.x + superFrameToRoot.origin.x + let y: CGFloat + if superItem.isFlipped { + y = superFrameToRoot.origin.y + (superBounds.size.height - selfFrame.origin.y - selfFrame.size.height) + } else { + y = selfFrame.origin.y - superBounds.origin.y + superFrameToRoot.origin.y + } + + return CGRect(x: x, y: y, width: selfFrame.size.width, height: selfFrame.size.height) + } + + @objc(frame) + public var frame: CGRect { + get { _frame } + set { + _frame = newValue + recursivelyNotifyFrameToRootMayChange() + } + } + + @objc(bounds) + public var bounds: CGRect { + get { _bounds } + set { + _bounds = newValue + recursivelyNotifyFrameToRootMayChange() + } + } + + /// Meaningful on macOS only; always NO on iOS, where the setter does + /// nothing. + /// `@property(getter=isFlipped) BOOL flipped`: the accessors keep the + /// header's selectors (`-isFlipped`, `-setFlipped:`). + @objc(flipped) public var isFlipped: Bool { + @objc(isFlipped) get { + #if os(macOS) + _flipped + #else + false + #endif + } + @objc(setFlipped:) set { + #if os(macOS) + _flipped = newValue + #endif + } + } + + private func recursivelyNotifyFrameToRootMayChange() { + _notifyDelegates(with: .frameToRoot) + for obj in subitems ?? [] { + obj.recursivelyNotifyFrameToRootMayChange() + } + } + + // MARK: Attributes + + @objc(attributesGroupList) + public var attributesGroupList: [LookinAttributesGroup]! { + get { _attributesGroupList } + set { + _attributesGroupList = newValue + lookinAssignTargetDisplayItem(self, in: newValue) + } + } + + @objc(customAttrGroupList) + public var customAttrGroupList: [LookinAttributesGroup]! { + get { _customAttrGroupList } + set { + _customAttrGroupList = newValue + // Already sorted when it is passed in. + lookinAssignTargetDisplayItem(self, in: newValue) + } + } + + @objc(queryAllAttrGroupList) + public func queryAllAttrGroupList() -> [LookinAttributesGroup]! { + var array: [LookinAttributesGroup] = [] + if let attributesGroupList { + array.append(contentsOf: attributesGroupList) + } + if let customAttrGroupList { + array.append(contentsOf: customAttrGroupList) + } + return array + } + + // MARK: Tree + + @objc(subitems) + public var subitems: [LookinDisplayItem]! { + get { _subitems } + set { + for obj in _subitems ?? [] { + obj.super = nil + } + + _subitems = newValue + + _setIsExpandable((newValue?.count ?? 0) > 0) + + for obj in newValue ?? [] { + assert(obj.super == nil) + obj.super = self + + obj._updateInHiddenHierarchyProperty() + obj._updateDisplayingInHierarchyProperty() + } + } + } + + @objc(indentLevel) + public func indentLevel() -> Int { + _indentLevel + } + + @objc(isExpandable) + public var isExpandable: Bool { + _isExpandable + } + + private func _setIsExpandable(_ isExpandable: Bool) { + if _isExpandable == isExpandable { + return + } + _isExpandable = isExpandable + _notifyDelegates(with: .isExpandable) + } + + @objc(isExpanded) + public var isExpanded: Bool { + get { _isExpanded } + set { + if _isExpanded == newValue { + return + } + _isExpanded = newValue + for obj in subitems ?? [] { + obj._updateDisplayingInHierarchyProperty() + } + _notifyDelegates(with: .isExpanded) + } + } + + @objc(displayingInHierarchy) + public var displayingInHierarchy: Bool { + _displayingInHierarchy + } + + private func _setDisplayingInHierarchy(_ displayingInHierarchy: Bool) { + if _displayingInHierarchy == displayingInHierarchy { + return + } + _displayingInHierarchy = displayingInHierarchy + for obj in subitems ?? [] { + obj._updateDisplayingInHierarchyProperty() + } + + _notifyDelegates(with: .displayingInHierarchy) + } + + private func _updateDisplayingInHierarchyProperty() { + if let superItem = self.super, !superItem.displayingInHierarchy || !superItem.isExpanded { + _setDisplayingInHierarchy(false) + } else { + _setDisplayingInHierarchy(true) + } + } + + @objc(flatItemsFromHierarchicalItems:) + public class func flatItems(fromHierarchicalItems items: [LookinDisplayItem]?) -> [LookinDisplayItem]! { + var resultArray: [LookinDisplayItem] = [] + + for obj in items ?? [] { + if let superItem = obj.super { + obj._indentLevel = superItem.indentLevel() + 1 + } + resultArray.append(obj) + if let subitems = obj.subitems, !subitems.isEmpty { + resultArray.append(contentsOf: flatItems(fromHierarchicalItems: subitems) ?? []) + } + } + + return resultArray + } + + // MARK: Hidden + + @objc(isHidden) + public var isHidden: Bool { + get { _isHidden } + set { + _isHidden = newValue + _updateInHiddenHierarchyProperty() + } + } + + @objc(alpha) + public var alpha: Float { + get { _alpha } + set { + _alpha = newValue + _updateInHiddenHierarchyProperty() + } + } + + @objc(inHiddenHierarchy) + public var inHiddenHierarchy: Bool { + _inHiddenHierarchy + } + + private func _setInHiddenHierarchy(_ inHiddenHierarchy: Bool) { + if _inHiddenHierarchy == inHiddenHierarchy { + return + } + _inHiddenHierarchy = inHiddenHierarchy + for obj in subitems ?? [] { + obj._updateInHiddenHierarchyProperty() + } + + _notifyDelegates(with: .inHiddenHierarchy) + } + + private func _updateInHiddenHierarchyProperty() { + if self.super?.inHiddenHierarchy == true || isHidden || alpha <= 0 { + _setInHiddenHierarchy(true) + } else { + _setInHiddenHierarchy(false) + } + } + + // MARK: Preview + + @objc(noPreview) + public var noPreview: Bool { + get { _noPreview } + set { + _noPreview = newValue + _updateInNoPreviewHierarchy() + } + } + + @objc(inNoPreviewHierarchy) + public var inNoPreviewHierarchy: Bool { + _inNoPreviewHierarchy + } + + private func _setInNoPreviewHierarchy(_ inNoPreviewHierarchy: Bool) { + if _inNoPreviewHierarchy == inNoPreviewHierarchy { + return + } + _inNoPreviewHierarchy = inNoPreviewHierarchy + for obj in subitems ?? [] { + obj._updateInNoPreviewHierarchy() + } + _notifyDelegates(with: .inNoPreviewHierarchy) + } + + private func _updateInNoPreviewHierarchy() { + if self.super?.inNoPreviewHierarchy == true || noPreview { + _setInNoPreviewHierarchy(true) + } else { + _setInNoPreviewHierarchy(false) + } + } + + // MARK: Screenshots + + @objc(soloScreenshot) + public var soloScreenshot: LookinImage! { + get { _soloScreenshot } + set { + if _soloScreenshot === newValue { + return + } + _soloScreenshot = newValue + _notifyDelegates(with: .soloScreenshot) + } + } + + @objc(groupScreenshot) + public var groupScreenshot: LookinImage! { + get { _groupScreenshot } + set { + if _groupScreenshot === newValue { + return + } + _groupScreenshot = newValue + _notifyDelegates(with: .groupScreenshot) + } + } + + @objc(doNotFetchScreenshotReason) + public var doNotFetchScreenshotReason: LookinDoNotFetchScreenshotReason { + get { _doNotFetchScreenshotReason } + set { + if _doNotFetchScreenshotReason == newValue { + return + } + _doNotFetchScreenshotReason = newValue + _notifyDelegates(with: .avoidSyncScreenshot) + } + } + + // MARK: Delegates + + @objc(previewItemDelegate) + public weak var previewItemDelegate: (any LookinDisplayItemDelegate)? { + didSet { + guard let previewItemDelegate, previewItemDelegate.responds(to: #selector(LookinDisplayItemDelegate.displayItem(_:propertyDidChange:))) else { + assertionFailure() + previewItemDelegate = nil + return + } + previewItemDelegate.displayItem(self, propertyDidChange: .none) + } + } + + @objc(rowViewDelegate) + public weak var rowViewDelegate: (any LookinDisplayItemDelegate)? { + didSet { + // The original returned before assigning an identical delegate. + if oldValue === rowViewDelegate { + return + } + guard let rowViewDelegate, rowViewDelegate.responds(to: #selector(LookinDisplayItemDelegate.displayItem(_:propertyDidChange:))) else { + assertionFailure() + rowViewDelegate = nil + return + } + rowViewDelegate.displayItem(self, propertyDidChange: .none) + } + } + + @objc(notifySelectionChangeToDelegates) + public func notifySelectionChangeToDelegates() { + _notifyDelegates(with: .isSelected) + } + + @objc(notifyHoverChangeToDelegates) + public func notifyHoverChangeToDelegates() { + _notifyDelegates(with: .isHovered) + } + + private func _notifyDelegates(with property: LookinDisplayItemProperty) { + previewItemDelegate?.displayItem(self, propertyDidChange: property) + rowViewDelegate?.displayItem(self, propertyDidChange: property) + } + + // MARK: Search + + @objc(isInSearch) + public var isInSearch: Bool { + get { _isInSearch } + set { + _isInSearch = newValue + _notifyDelegates(with: .isInSearch) + } + } + + @objc(highlightedSearchString) + public var highlightedSearchString: String! { + get { _highlightedSearchString } + set { + _highlightedSearchString = newValue + _notifyDelegates(with: .highlightedSearchString) + } + } + + // MARK: Description + + override open var description: String { + // The original returned the raw class name, which is nil for an + // empty class chain; "%@" prints that as "(null)". + if let viewObject { + return viewObject.rawClassName() ?? "(null)" + } else if let layerObject { + return layerObject.rawClassName() ?? "(null)" + } else if let windowObject { + return windowObject.rawClassName() ?? "(null)" + } else { + return super.description + } + } + } + + /// Points every attribute of `groups` back at `item` (the original's + /// nested enumeration in the two group-list setters). + private func lookinAssignTargetDisplayItem(_ item: LookinDisplayItem, in groups: [LookinAttributesGroup]?) { + for group in groups ?? [] { + for section in group.attrSections ?? [] { + for attr in section.attributes ?? [] { + attr.targetDisplayItem = item + } + } + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinDisplayItemDetail.swift b/Sources/LookinCoreImpl/LookinDisplayItemDetail.swift new file mode 100644 index 00000000..61c894a4 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinDisplayItemDetail.swift @@ -0,0 +1,107 @@ +// +// LookinDisplayItemDetail.swift +// Lookin +// +// Was LookinDisplayItemDetail.m: the 203 reply. The coding keys, the +// screenshots-as-data encoding and the conditional subitems key are pinned by +// Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + #if canImport(UIKit) + import UIKit + #elseif os(macOS) + import AppKit + #endif + + @objc(LookinDisplayItemDetail) + public class LookinDisplayItemDetail: NSObject, NSCoding, NSSecureCoding { + @objc(displayItemOid) + public var displayItemOid: UInt = 0 + @objc(groupScreenshot) + public var groupScreenshot: LookinImage! + @objc(soloScreenshot) + public var soloScreenshot: LookinImage! + @objc(frameValue) + public var frameValue: NSValue! + @objc(boundsValue) + public var boundsValue: NSValue! + @objc(hiddenValue) + public var hiddenValue: NSNumber! + @objc(alphaValue) + public var alphaValue: NSNumber! + @objc(customDisplayTitle) + public var customDisplayTitle: String! + @objc(danceUISource) + public var danceUISource: String! + @objc(attributesGroupList) + public var attributesGroupList: [LookinAttributesGroup]! + @objc(customAttrGroupList) + public var customAttrGroupList: [LookinAttributesGroup]! + @objc(subitems) + public var subitems: [LookinDisplayItem]! + @objc(failureCode) + public var failureCode: Int = 0 + + override public init() { + super.init() + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(NSNumber(value: displayItemOid), forKey: "displayItemOid") + aCoder.encode(lookinScreenshotData(groupScreenshot), forKey: "groupScreenshot") + aCoder.encode(lookinScreenshotData(soloScreenshot), forKey: "soloScreenshot") + aCoder.encode(frameValue, forKey: "frameValue") + aCoder.encode(boundsValue, forKey: "boundsValue") + aCoder.encode(hiddenValue, forKey: "hiddenValue") + aCoder.encode(alphaValue, forKey: "alphaValue") + aCoder.encode(attributesGroupList, forKey: "attributesGroupList") + aCoder.encode(customAttrGroupList, forKey: "customAttrGroupList") + aCoder.encode(customDisplayTitle, forKey: "customDisplayTitle") + aCoder.encode(danceUISource, forKey: "danceUISource") + aCoder.encode(failureCode, forKey: "failureCode") + if let subitems { + aCoder.encode(subitems, forKey: "subitems") + } + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + displayItemOid = (aDecoder.decodeObject(forKey: "displayItemOid") as? NSNumber)?.uintValue ?? 0 + groupScreenshot = (aDecoder.decodeObject(forKey: "groupScreenshot") as? Data).flatMap { LookinImage(data: $0) } + soloScreenshot = (aDecoder.decodeObject(forKey: "soloScreenshot") as? Data).flatMap { LookinImage(data: $0) } + frameValue = aDecoder.decodeObject(forKey: "frameValue") as? NSValue + boundsValue = aDecoder.decodeObject(forKey: "boundsValue") as? NSValue + hiddenValue = aDecoder.decodeObject(forKey: "hiddenValue") as? NSNumber + alphaValue = aDecoder.decodeObject(forKey: "alphaValue") as? NSNumber + attributesGroupList = aDecoder.decodeObject(forKey: "attributesGroupList") as? [LookinAttributesGroup] + customAttrGroupList = aDecoder.decodeObject(forKey: "customAttrGroupList") as? [LookinAttributesGroup] + customDisplayTitle = aDecoder.decodeObject(forKey: "customDisplayTitle") as? String + danceUISource = aDecoder.decodeObject(forKey: "danceUISource") as? String + + if aDecoder.containsValue(forKey: "failureCode") { + failureCode = aDecoder.decodeInteger(forKey: "failureCode") + } else { + failureCode = 0 + } + + if aDecoder.containsValue(forKey: "subitems") { + subitems = aDecoder.decodeObject(forKey: "subitems") as? [LookinDisplayItem] + } + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinEventHandler.swift b/Sources/LookinCoreImpl/LookinEventHandler.swift new file mode 100644 index 00000000..03eca7ae --- /dev/null +++ b/Sources/LookinCoreImpl/LookinEventHandler.swift @@ -0,0 +1,90 @@ +// +// LookinEventHandler.swift +// Lookin +// +// Was LookinEventHandler.m. +// The coding keys and scalar encodings are pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinEventHandler) + public class LookinEventHandler: NSObject, NSCoding, NSSecureCoding { + @objc(handlerType) + public var handlerType: LookinEventHandlerType = .targetAction + @objc(eventName) + public var eventName: String! + @objc(targetActions) + public var targetActions: [LookinStringTwoTuple]! + @objc(inheritedRecognizerName) + public var inheritedRecognizerName: String! + @objc(gestureRecognizerIsEnabled) + public var gestureRecognizerIsEnabled: Bool = false + @objc(gestureRecognizerDelegator) + public var gestureRecognizerDelegator: String! + @objc(recognizerIvarTraces) + public var recognizerIvarTraces: [String]! + @objc(recognizerOid) + public var recognizerOid: UInt64 = 0 + + override public init() { + super.init() + } + + // MARK: NSCopying + + /// The class does not adopt NSCopying, but the original implemented + /// -copyWithZone: (LookinDisplayItem copies its handlers with -copy). + @objc(copyWithZone:) + final func copy(with _: NSZone? = nil) -> Any { + let newHandler = LookinEventHandler() + newHandler.handlerType = handlerType + newHandler.eventName = eventName + newHandler.targetActions = targetActions?.map { $0.copy() as! LookinStringTwoTuple } + newHandler.gestureRecognizerIsEnabled = gestureRecognizerIsEnabled + newHandler.gestureRecognizerDelegator = gestureRecognizerDelegator + newHandler.inheritedRecognizerName = inheritedRecognizerName + newHandler.recognizerIvarTraces = recognizerIvarTraces + newHandler.recognizerOid = recognizerOid + return newHandler + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(handlerType.rawValue, forKey: "handlerType") + aCoder.encode(gestureRecognizerIsEnabled, forKey: "gestureRecognizerIsEnabled") + aCoder.encode(eventName, forKey: "eventName") + aCoder.encode(gestureRecognizerDelegator, forKey: "gestureRecognizerDelegator") + aCoder.encode(targetActions, forKey: "targetActions") + aCoder.encode(inheritedRecognizerName, forKey: "inheritedRecognizerName") + aCoder.encode(recognizerIvarTraces, forKey: "recognizerIvarTraces") + aCoder.encode(NSNumber(value: recognizerOid), forKey: "recognizerOid") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + handlerType = LookinEventHandlerType(rawValue: aDecoder.decodeInteger(forKey: "handlerType")) ?? .targetAction + gestureRecognizerIsEnabled = aDecoder.decodeBool(forKey: "gestureRecognizerIsEnabled") + eventName = aDecoder.decodeObject(forKey: "eventName") as? String + gestureRecognizerDelegator = aDecoder.decodeObject(forKey: "gestureRecognizerDelegator") as? String + targetActions = aDecoder.decodeObject(forKey: "targetActions") as? [LookinStringTwoTuple] + inheritedRecognizerName = aDecoder.decodeObject(forKey: "inheritedRecognizerName") as? String + recognizerIvarTraces = aDecoder.decodeObject(forKey: "recognizerIvarTraces") as? [String] + // The original read it back through -unsignedLongValue. + recognizerOid = UInt64((aDecoder.decodeObject(forKey: "recognizerOid") as? NSNumber)?.uintValue ?? 0) + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinHierarchyFile.swift b/Sources/LookinCoreImpl/LookinHierarchyFile.swift new file mode 100644 index 00000000..a274cd10 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinHierarchyFile.swift @@ -0,0 +1,100 @@ +// +// LookinHierarchyFile.swift +// Lookin +// +// Was LookinHierarchyFile.m, +// the root object of a .lookin document. The coding keys are pinned by +// Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinHierarchyFile) + public class LookinHierarchyFile: NSObject, NSCoding, NSSecureCoding { + @objc(serverVersion) + public var serverVersion: Int32 = 0 + @objc(hierarchyInfo) + public var hierarchyInfo: LookinHierarchyInfo! + /// The screenshot dictionaries are kept as the Foundation objects they + /// were set or decoded as. Bridging them through `[NSNumber: Data]` + /// would turn the NSMutableData values a .lookin document holds into + /// Swift Data, which archives inline instead of as NSMutableData + /// objects; storing the NSDictionary re-archives a document unchanged. + private var _soloScreenshots: NSDictionary? + private var _groupScreenshots: NSDictionary? + + @objc(soloScreenshots) + public var soloScreenshots: [NSNumber: Data]! { + get { _soloScreenshots as? [NSNumber: Data] } + set { _soloScreenshots = newValue.map { $0 as NSDictionary } } + } + + @objc(groupScreenshots) + public var groupScreenshots: [NSNumber: Data]! { + get { _groupScreenshots as? [NSNumber: Data] } + set { _groupScreenshots = newValue.map { $0 as NSDictionary } } + } + + override public init() { + super.init() + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encodeCInt(serverVersion, forKey: "serverVersion") + aCoder.encode(hierarchyInfo, forKey: "hierarchyInfo") + aCoder.encode(_soloScreenshots, forKey: "soloScreenshots") + aCoder.encode(_groupScreenshots, forKey: "groupScreenshots") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + serverVersion = aDecoder.decodeCInt(forKey: "serverVersion") + hierarchyInfo = aDecoder.decodeObject(forKey: "hierarchyInfo") as? LookinHierarchyInfo + _soloScreenshots = aDecoder.decodeObject(forKey: "soloScreenshots") as? NSDictionary + _groupScreenshots = aDecoder.decodeObject(forKey: "groupScreenshots") as? NSDictionary + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + @objc(verifyHierarchyFile:) + public class func verifyHierarchyFile(_ hierarchyFile: LookinHierarchyFile?) -> (any Error)! { + guard let hierarchyFile, hierarchyFile.isKind(of: LookinHierarchyFile.self) else { + return LookinErr_Inner + } + + if hierarchyFile.serverVersion < LOOKIN_SUPPORTED_SERVER_MIN { + // The document is too old. A missing serverVersion field means + // version 6. + let fileVersion = hierarchyFile.serverVersion != 0 ? hierarchyFile.serverVersion : 6 + let detail = String(format: NSLocalizedString("The document was created by a LookInside app with too old version. Current LookInside app version is %@, but the document version is %@.", comment: ""), NSNumber(value: LOOKIN_CLIENT_VERSION), NSNumber(value: fileVersion)) + return NSError(domain: LookinErrorDomain, code: Int(LookinErrCode_ServerVersionTooLow), userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("Failed to open the document.", comment: ""), + NSLocalizedRecoverySuggestionErrorKey: detail, + ]) + } + + if hierarchyFile.serverVersion > LOOKIN_SUPPORTED_SERVER_MAX { + // The document is too new. + let detail = String(format: NSLocalizedString("Current LookInside app is too old to open this document. Current LookInside app version is %@, but the document version is %@.", comment: ""), NSNumber(value: LOOKIN_CLIENT_VERSION), NSNumber(value: hierarchyFile.serverVersion)) + return NSError(domain: LookinErrorDomain, code: Int(LookinErrCode_ServerVersionTooHigh), userInfo: [ + NSLocalizedDescriptionKey: NSLocalizedString("Failed to open the document.", comment: ""), + NSLocalizedRecoverySuggestionErrorKey: detail, + ]) + } + + return nil + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinHierarchyInfo.swift b/Sources/LookinCoreImpl/LookinHierarchyInfo.swift new file mode 100644 index 00000000..b0f76c0a --- /dev/null +++ b/Sources/LookinCoreImpl/LookinHierarchyInfo.swift @@ -0,0 +1,122 @@ +// +// LookinHierarchyInfo.swift +// WeRead +// +// Was LookinHierarchyInfo.m: +// the 202 reply and the root of a .lookin document's hierarchy. The coding +// keys are pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + private let LookinHierarchyInfoCodingKey_DisplayItems = "1" + private let LookinHierarchyInfoCodingKey_AppInfo = "2" + private let LookinHierarchyInfoCodingKey_ColorAlias = "3" + private let LookinHierarchyInfoCodingKey_CollapsedClassList = "4" + + /// `+[LKS_HierarchyDisplayItemsMaker itemsWithScreenshots:attrList:lowImageQuality:readCustomInfo:saveCustomSetter:]`. + /// LookinCore does not link against the server, so the maker is looked up + /// and called through the runtime; without it (the host) the list is empty. + private func lookinDisplayItems(screenshots hasScreenshots: Bool, attrList hasAttrList: Bool, lowImageQuality lowQuality: Bool, readCustomInfo: Bool, saveCustomSetter: Bool) -> [LookinDisplayItem] { + let selector = NSSelectorFromString("itemsWithScreenshots:attrList:lowImageQuality:readCustomInfo:saveCustomSetter:") + guard let makerClass = NSClassFromString("LKS_HierarchyDisplayItemsMaker"), (makerClass as AnyObject).responds(to: selector) else { + return [] + } + let arguments = [hasScreenshots, hasAttrList, lowQuality, readCustomInfo, saveCustomSetter].map { NSNumber(value: $0) } + let items = LookinInvoke(makerClass, selector, arguments, nil) + return (items as? [LookinDisplayItem]) ?? [] + } + + @objc(LookinHierarchyInfo) + public class LookinHierarchyInfo: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(displayItems) + public var displayItems: [LookinDisplayItem]! + @objc(colorAlias) + public var colorAlias: [String: Any]! + @objc(collapsedClassList) + public var collapsedClassList: [String]! + @objc(appInfo) + public var appInfo: LookinAppInfo! + @objc(serverVersion) + public var serverVersion: Int32 = 0 + + /// `+staticInfoWithLookinVersion:`. `version` is nil for clients older + /// than 1.0.4. An Objective-C factory must be a class method here. + @objc(staticInfoWithLookinVersion:) + public class func staticInfo(withLookinVersion version: String?) -> Self! { + var readCustomInfo = false + // Clients support customInfo from 1.0.4 on. + if let version, (version as NSString).lookin_numbericOSVersion() >= 10004 { + readCustomInfo = true + } + + let info = unsafeDowncast((self as NSObject.Type).init(), to: self) + info.serverVersion = Int32(LOOKIN_SERVER_VERSION) + info.displayItems = lookinDisplayItems(screenshots: false, attrList: false, lowImageQuality: false, readCustomInfo: readCustomInfo, saveCustomSetter: true) + info.appInfo = LookinAppInfo.currentInfo(withScreenshot: false, icon: true, localIdentifiers: nil) + info.collapsedClassList = [] + info.colorAlias = [:] + return info + } + + /// `+exportedInfo`. + @objc(exportedInfo) + public class func exported() -> Self! { + let info = unsafeDowncast((self as NSObject.Type).init(), to: self) + info.serverVersion = Int32(LOOKIN_SERVER_VERSION) + info.displayItems = lookinDisplayItems(screenshots: true, attrList: true, lowImageQuality: true, readCustomInfo: true, saveCustomSetter: false) + info.appInfo = LookinAppInfo.currentInfo(withScreenshot: false, icon: true, localIdentifiers: nil) + info.collapsedClassList = [] + info.colorAlias = [:] + return info + } + + override public init() { + super.init() + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(displayItems, forKey: LookinHierarchyInfoCodingKey_DisplayItems) + aCoder.encode(colorAlias, forKey: LookinHierarchyInfoCodingKey_ColorAlias) + aCoder.encode(collapsedClassList, forKey: LookinHierarchyInfoCodingKey_CollapsedClassList) + aCoder.encode(appInfo, forKey: LookinHierarchyInfoCodingKey_AppInfo) + aCoder.encodeCInt(serverVersion, forKey: "serverVersion") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + displayItems = aDecoder.decodeObject(forKey: LookinHierarchyInfoCodingKey_DisplayItems) as? [LookinDisplayItem] + colorAlias = aDecoder.decodeObject(forKey: LookinHierarchyInfoCodingKey_ColorAlias) as? [String: Any] + collapsedClassList = aDecoder.decodeObject(forKey: LookinHierarchyInfoCodingKey_CollapsedClassList) as? [String] + appInfo = aDecoder.decodeObject(forKey: LookinHierarchyInfoCodingKey_AppInfo) as? LookinAppInfo + serverVersion = aDecoder.decodeCInt(forKey: "serverVersion") + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newAppInfo = LookinHierarchyInfo() + newAppInfo.serverVersion = serverVersion + newAppInfo.appInfo = appInfo?.copy() as? LookinAppInfo + newAppInfo.collapsedClassList = collapsedClassList + newAppInfo.colorAlias = colorAlias + newAppInfo.displayItems = displayItems?.map { $0.copy() as! LookinDisplayItem } + return newAppInfo + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinIvarTrace.swift b/Sources/LookinCoreImpl/LookinIvarTrace.swift new file mode 100644 index 00000000..c195a0db --- /dev/null +++ b/Sources/LookinCoreImpl/LookinIvarTrace.swift @@ -0,0 +1,82 @@ +// +// LookinIvarTrace.swift +// Lookin +// +// Was LookinIvarTrace.m. The exported LookinIvarTraceRelationValue_Self stays in +// LookinServerBase/LookinIvarTraceConstants.m. The coding keys are pinned by +// Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + import LookinServerBase + #endif + + @objc(LookinIvarTrace) + public class LookinIvarTrace: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(relation) + public var relation: String? + @objc(hostClassName) + public var hostClassName: String? + @objc(ivarName) + public var ivarName: String? + @objc(hostObject) + public weak var hostObject: AnyObject? + + override public init() { + super.init() + } + + // MARK: Equality + + override open var hash: Int { + lookinStringHash(hostClassName) ^ lookinStringHash(ivarName) + } + + override open func isEqual(_ object: Any?) -> Bool { + if let object = object as? NSObject, object === self { + return true + } + guard let compared = object as? LookinIvarTrace else { + return false + } + return lookinStringsEqual(hostClassName, compared.hostClassName) && lookinStringsEqual(ivarName, compared.ivarName) + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newTrace = LookinIvarTrace() + newTrace.relation = relation + newTrace.hostClassName = hostClassName + newTrace.ivarName = ivarName + return newTrace + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(relation, forKey: "relation") + aCoder.encode(hostClassName, forKey: "hostClassName") + aCoder.encode(ivarName, forKey: "ivarName") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + relation = aDecoder.decodeObject(forKey: "relation") as? String + hostClassName = aDecoder.decodeObject(forKey: "hostClassName") as? String + ivarName = aDecoder.decodeObject(forKey: "ivarName") as? String + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinObject.swift b/Sources/LookinCoreImpl/LookinObject.swift new file mode 100644 index 00000000..066b8004 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinObject.swift @@ -0,0 +1,111 @@ +// +// LookinObject.swift +// Lookin +// +// Was LookinObject.m. The coding +// keys are pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + import LookinServerBase + #endif + + /// The LookinServer categories +instanceWithObject: reads. LookinCore does + /// not link against the server, so they are sent dynamically, exactly as + /// the original's forward declaration did: a missing method raises + /// "unrecognized selector" as before. + @objc private protocol LookinObjectServerBridge { + func lks_registerOid() -> UInt + func lks_classChainList() -> [String]? + var lks_specialTrace: String? { get } + var lks_ivarTraces: [LookinIvarTrace]? { get } + } + + @objc(LookinObject) + public class LookinObject: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(oid) + public var oid: UInt = 0 + @objc(memoryAddress) + public var memoryAddress: String! + @objc(classChainList) + public var classChainList: [String]! + @objc(specialTrace) + public var specialTrace: String! + @objc(ivarTraces) + public var ivarTraces: [LookinIvarTrace]! + + /// `+instanceWithObject:`. An Objective-C factory must be a class + /// method here. + @objc(instanceWithObject:) + public class func instance(with object: NSObject?) -> Self! { + let lookinObj = unsafeDowncast((self as NSObject.Type).init(), to: self) + guard let object else { + // Messages to nil answer 0 / nil; "%p" of nil is "0x0". + lookinObj.memoryAddress = "0x0" + return lookinObj + } + let bridged = unsafeBitCast(object, to: LookinObjectServerBridge.self) + lookinObj.oid = bridged.lks_registerOid() + + lookinObj.memoryAddress = String(format: "%p", OpaquePointer(Unmanaged.passUnretained(object).toOpaque())) + lookinObj.classChainList = bridged.lks_classChainList() + + lookinObj.specialTrace = bridged.lks_specialTrace + lookinObj.ivarTraces = bridged.lks_ivarTraces + + return lookinObj + } + + override public init() { + super.init() + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let newObject = LookinObject() + newObject.oid = oid + newObject.memoryAddress = memoryAddress + newObject.classChainList = classChainList + newObject.specialTrace = specialTrace + newObject.ivarTraces = ivarTraces?.map { $0.copy() as! LookinIvarTrace } + return newObject + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(NSNumber(value: oid), forKey: "oid") + aCoder.encode(memoryAddress, forKey: "memoryAddress") + aCoder.encode(classChainList, forKey: "classChainList") + aCoder.encode(specialTrace, forKey: "specialTrace") + aCoder.encode(ivarTraces, forKey: "ivarTraces") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + oid = (aDecoder.decodeObject(forKey: "oid") as? NSNumber)?.uintValue ?? 0 + memoryAddress = aDecoder.decodeObject(forKey: "memoryAddress") as? String + classChainList = aDecoder.decodeObject(forKey: "classChainList") as? [String] + specialTrace = aDecoder.decodeObject(forKey: "specialTrace") as? String + ivarTraces = aDecoder.decodeObject(forKey: "ivarTraces") as? [LookinIvarTrace] + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + @objc(rawClassName) + public func rawClassName() -> String! { + classChainList?.first + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinStaticAsyncUpdateTask.swift b/Sources/LookinCoreImpl/LookinStaticAsyncUpdateTask.swift new file mode 100644 index 00000000..d75286b5 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinStaticAsyncUpdateTask.swift @@ -0,0 +1,130 @@ +// +// LookinStaticAsyncUpdateTask.swift +// Lookin +// +// Was LookinStaticAsyncUpdateTask.m: the 203 request's tasks. The coding keys, +// decode defaults and range check are pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinStaticAsyncUpdateTask) + public class LookinStaticAsyncUpdateTask: NSObject, NSCoding, NSSecureCoding { + @objc(oid) + public var oid: UInt = 0 + @objc(taskType) + public var taskType: LookinStaticAsyncUpdateTaskType = .noScreenshot + @objc(attrRequest) + public var attrRequest: LookinDetailUpdateTaskAttrRequest = .automatic + @objc(needBasisVisualInfo) + public var needBasisVisualInfo: Bool = false + @objc(needSubitems) + public var needSubitems: Bool = false + @objc(clientReadableVersion) + public var clientReadableVersion: String! + /// Not archived. + @objc(frameSize) + public var frameSize: CGSize = .zero + + override public init() { + super.init() + } + + // MARK: NSSecureCoding + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(NSNumber(value: oid), forKey: "oid") + aCoder.encode(taskType.rawValue, forKey: "taskType") + aCoder.encode(clientReadableVersion, forKey: "clientReadableVersion") + aCoder.encode(attrRequest.rawValue, forKey: "attrRequest") + aCoder.encode(needBasisVisualInfo, forKey: "needBasisVisualInfo") + aCoder.encode(needSubitems, forKey: "needSubitems") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + oid = (aDecoder.decodeObject(forKey: "oid") as? NSNumber)?.uintValue ?? 0 + taskType = LookinStaticAsyncUpdateTaskType(rawValue: aDecoder.decodeInteger(forKey: "taskType")) ?? .noScreenshot + clientReadableVersion = aDecoder.decodeObject(forKey: "clientReadableVersion") as? String + if aDecoder.containsValue(forKey: "attrRequest") { + let value = aDecoder.decodeInteger(forKey: "attrRequest") + if value >= LookinDetailUpdateTaskAttrRequest.automatic.rawValue, value <= LookinDetailUpdateTaskAttrRequest.notNeed.rawValue { + attrRequest = LookinDetailUpdateTaskAttrRequest(rawValue: value) ?? .automatic + } else { + attrRequest = .automatic + } + } else { + attrRequest = .automatic + } + + if aDecoder.containsValue(forKey: "needBasisVisualInfo") { + needBasisVisualInfo = aDecoder.decodeBool(forKey: "needBasisVisualInfo") + } else { + needBasisVisualInfo = false + } + + if aDecoder.containsValue(forKey: "needSubitems") { + needSubitems = aDecoder.decodeBool(forKey: "needSubitems") + } else { + needSubitems = false + } + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + // MARK: Equality + + override open var hash: Int { + Int(bitPattern: oid) ^ taskType.rawValue ^ attrRequest.rawValue ^ (needBasisVisualInfo ? 1 : 0) ^ (needSubitems ? 1 : 0) + } + + override open func isEqual(_ object: Any?) -> Bool { + if let object = object as? NSObject, object === self { + return true + } + guard let targetTask = object as? LookinStaticAsyncUpdateTask else { + return false + } + return oid == targetTask.oid + && taskType == targetTask.taskType + && attrRequest == targetTask.attrRequest + && needBasisVisualInfo == targetTask.needBasisVisualInfo + && needSubitems == targetTask.needSubitems + } + } + + @objc(LookinStaticAsyncUpdateTasksPackage) + public class LookinStaticAsyncUpdateTasksPackage: NSObject, NSCoding, NSSecureCoding { + @objc(tasks) + public var tasks: [LookinStaticAsyncUpdateTask]! + + override public init() { + super.init() + } + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(tasks, forKey: "tasks") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + tasks = aDecoder.decodeObject(forKey: "tasks") as? [LookinStaticAsyncUpdateTask] + } + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinTuple.swift b/Sources/LookinCoreImpl/LookinTuple.swift new file mode 100644 index 00000000..885022a5 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinTuple.swift @@ -0,0 +1,129 @@ +// +// LookinTuple.swift +// LookinCore +// +// Was LookinTuple.m. The archived class names, coding keys and value types +// are pinned by Tests/WireFormatGolden. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinTwoTuple) + public class LookinTwoTuple: NSObject, NSCoding, NSSecureCoding { + @objc(first) + public var first: NSObject! + @objc(second) + public var second: NSObject! + + // MARK: NSSecureCoding + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(first, forKey: "first") + aCoder.encode(second, forKey: "second") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + first = aDecoder.decodeObject(forKey: "first") as? NSObject + second = aDecoder.decodeObject(forKey: "second") as? NSObject + } + + override public init() { + super.init() + } + + // MARK: Equality + + override open var hash: Int { + (first?.hash ?? 0) ^ (second?.hash ?? 0) + } + + override open func isEqual(_ object: Any?) -> Bool { + if let object = object as? NSObject, object === self { + return true + } + guard let compared = object as? LookinTwoTuple else { + return false + } + // [nil isEqual:] is NO in Objective-C, so two nil members never match. + guard let first, let second else { + return false + } + return first.isEqual(compared.first) && second.isEqual(compared.second) + } + } + + @objc(LookinStringTwoTuple) + public class LookinStringTwoTuple: NSObject, NSCoding, NSSecureCoding, NSCopying { + @objc(first) + public var first: String! + @objc(second) + public var second: String! + + /// `+tupleWithFirst:second:`. An Objective-C factory must be a class + /// method here: implemented as a Swift initializer it compiles but + /// registers no class method. + @objc(tupleWithFirst:second:) + public class func tuple(first firstString: String?, second secondString: String?) -> Self! { + let tuple = unsafeDowncast((self as NSObject.Type).init(), to: self) + tuple.first = firstString + tuple.second = secondString + return tuple + } + + override public init() { + super.init() + } + + // MARK: NSCopying + + @objc(copyWithZone:) + public func copy(with _: NSZone? = nil) -> Any { + let tuple = LookinStringTwoTuple() + tuple.first = first + tuple.second = second + return tuple + } + + // MARK: NSSecureCoding + + @objc(supportsSecureCoding) + public class var supportsSecureCoding: Bool { + true + } + + @objc(encodeWithCoder:) + public func encode(with aCoder: NSCoder) { + aCoder.encode(first, forKey: "first") + aCoder.encode(second, forKey: "second") + } + + public required init?(coder aDecoder: NSCoder) { + super.init() + first = aDecoder.decodeObject(forKey: "first") as? String + second = aDecoder.decodeObject(forKey: "second") as? String + } + } + + /// Swift spelling the Host has always used; the factory class method is + /// `tuple(first:second:)`. + public extension LookinStringTwoTuple { + convenience init(first firstString: String, second secondString: String) { + self.init() + first = firstString + second = secondString + } + } + +#endif diff --git a/Sources/LookinCoreImpl/LookinWeakContainer.swift b/Sources/LookinCoreImpl/LookinWeakContainer.swift new file mode 100644 index 00000000..8b5daa80 --- /dev/null +++ b/Sources/LookinCoreImpl/LookinWeakContainer.swift @@ -0,0 +1,55 @@ +// +// LookinWeakContainer.swift +// Lookin +// +// Was LookinWeakContainer.m. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + #if SWIFT_PACKAGE + import LookinCore + #endif + + @objc(LookinWeakContainer) + public class LookinWeakContainer: NSObject { + @objc(object) + public weak var object: AnyObject? + + /// `+containerWithObject:`. An Objective-C factory must be a class + /// method here: implemented as a Swift initializer it compiles but + /// registers no class method. + @objc(containerWithObject:) + public class func container(object: Any?) -> Self! { + let container = unsafeDowncast((self as NSObject.Type).init(), to: self) + container.object = object as AnyObject? + return container + } + + override public init() { + super.init() + } + + // MARK: Equality + + override open var hash: Int { + (object as? NSObjectProtocol)?.hash ?? 0 + } + + override open func isEqual(_ object: Any?) -> Bool { + if let object = object as? NSObject, object === self { + return true + } + guard let compared = object as? LookinWeakContainer else { + return false + } + // [nil isEqual:] is NO. + guard let contained = self.object as? NSObjectProtocol else { + return false + } + return contained.isEqual(compared.object) + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Transport/LookinFrame.swift b/Sources/LookinCoreImpl/Transport/LookinFrame.swift new file mode 100644 index 00000000..779a0167 --- /dev/null +++ b/Sources/LookinCoreImpl/Transport/LookinFrame.swift @@ -0,0 +1,132 @@ +// +// LookinFrame.swift +// LookinCore +// +// The frame format the Host and the Server exchange, byte for byte the one +// the vendored Peertalk used (released Hosts and Servers still speak it): +// +// offset size field byte order +// 0 4 version = 1 big-endian +// 4 4 type big-endian +// 8 4 tag big-endian +// 12 4 payloadSize big-endian +// 16 n payload n = payloadSize, absent when 0 +// +// The type is the Lookin request or push type (LookinDefines.h). A reply +// carries the tag of its request; a push and a frame that wants no reply use +// tag 0. There is no ping, close or handshake frame at this layer: ping is +// the application request 200, and a connection ends by closing the socket. +// A received frame of type 0 is Peertalk's end-of-stream marker and ends the +// stream (see LookinFrameDecoder). Pinned by Tests/LookinTransportTests, +// whose fixtures were recorded from Peertalk. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + + /// Constants of the frame protocol. + public enum LookinFrameProtocol { + /// The only protocol version; a frame with another version ends the + /// connection with an error. + public static let version: UInt32 = 1 + /// Bytes in a frame header. + public static let headerSize = 16 + /// Peertalk's `PTFrameTypeEndOfStream`. A received frame of this type + /// ends the stream; nothing sends it on purpose. + public static let endOfStreamType: UInt32 = 0 + /// Peertalk's `PTFrameNoTag`: pushes and frames that expect no reply. + public static let noTag: UInt32 = 0 + } + + /// Why a byte stream is not a valid frame stream. + public enum LookinFrameError: Error, Equatable { + /// The header's version is not `LookinFrameProtocol.version`. + case unsupportedVersion(UInt32) + /// An accepted frame announces a payload above the decoder's limit. + case payloadTooLarge(size: UInt32, limit: UInt32) + /// The payload does not fit the 32-bit size field. + case payloadSizeOverflow(Int) + /// The stream ended inside a frame header. + case truncatedHeader + } + + /// The 16-byte frame header. + public struct LookinFrameHeader: Equatable, Sendable { + public var type: UInt32 + public var tag: UInt32 + public var payloadSize: UInt32 + + public init(type: UInt32, tag: UInt32, payloadSize: UInt32) { + self.type = type + self.tag = tag + self.payloadSize = payloadSize + } + + /// The header bytes (version 1, every field big-endian). + public var encoded: Data { + var data = Data(capacity: LookinFrameProtocol.headerSize) + for field in [LookinFrameProtocol.version, type, tag, payloadSize] { + withUnsafeBytes(of: field.bigEndian) { data.append(contentsOf: $0) } + } + return data + } + + /// Reads a header from the first 16 bytes of `bytes`. + /// + /// - Throws: `LookinFrameError.truncatedHeader` for fewer than 16 + /// bytes, `.unsupportedVersion` for a version other than 1. + public static func decode(_ bytes: Bytes) throws -> LookinFrameHeader where Bytes.Element == UInt8 { + guard bytes.count >= LookinFrameProtocol.headerSize else { + throw LookinFrameError.truncatedHeader + } + var fields = [UInt32]() + fields.reserveCapacity(4) + var index = bytes.startIndex + for _ in 0 ..< 4 { + var value: UInt32 = 0 + for _ in 0 ..< 4 { + value = value << 8 | UInt32(bytes[index]) + index = bytes.index(after: index) + } + fields.append(value) + } + guard fields[0] == LookinFrameProtocol.version else { + throw LookinFrameError.unsupportedVersion(fields[0]) + } + return LookinFrameHeader(type: fields[1], tag: fields[2], payloadSize: fields[3]) + } + } + + /// One frame. An empty payload is what Peertalk delivered as a nil + /// payload: the header says 0 and no payload bytes follow. + public struct LookinFrame: Equatable, Sendable { + public var type: UInt32 + public var tag: UInt32 + public var payload: Data + + public init(type: UInt32, tag: UInt32, payload: Data = Data()) { + self.type = type + self.tag = tag + self.payload = payload + } + + public var header: LookinFrameHeader { + LookinFrameHeader(type: type, tag: tag, payloadSize: UInt32(truncatingIfNeeded: payload.count)) + } + + /// The frame's wire bytes: header, then payload. + /// + /// - Throws: `LookinFrameError.payloadSizeOverflow` for a payload of + /// 4 GiB or more (Peertalk asserted instead). + public func encoded() throws -> Data { + guard payload.count <= Int(UInt32.max) else { + throw LookinFrameError.payloadSizeOverflow(payload.count) + } + var data = header.encoded + data.append(payload) + return data + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Transport/LookinFrameChannel.swift b/Sources/LookinCoreImpl/Transport/LookinFrameChannel.swift new file mode 100644 index 00000000..182f13af --- /dev/null +++ b/Sources/LookinCoreImpl/Transport/LookinFrameChannel.swift @@ -0,0 +1,271 @@ +// +// LookinFrameChannel.swift +// LookinCore +// +// A connected stream socket that carries Lookin frames, shared by the +// Server (an accepted Host connection) and the Host (a TCP connection to a +// Simulator or Mac app, or a usbmuxd tunnel to a device). It takes the +// place of the vendored Peertalk's connected channel and keeps its behaviour: +// +// - frames are written in send order, each in one dispatch I/O write; +// - `shouldAcceptFrame` decides on each header before its payload is read; +// a rejected frame's payload is skipped; +// - a received type-0 frame ends the channel without an error, like the +// peer closing the socket at a frame boundary; +// - an invalid frame (version other than 1, payload above the limit) ends +// it with that error; the peer closing the socket ends it without an +// error, even inside a header or a payload (the partial frame is +// dropped, as Peertalk did); +// - `close()` stops at once and drops queued writes; `cancel()` stops +// reading at once and closes the socket once queued writes are written +// (Peertalk's graceful close also waited for a pending read, which could +// hold a quiet connection open); +// - `onEnd` runs exactly once, after the socket is closed, on `queue`. +// +// Every callback runs on `queue`; give the channel a serial queue. The +// channel keeps itself alive until `onEnd` has run. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Darwin + import Foundation + + public final class LookinFrameChannel: @unchecked Sendable { + /// Why the channel ended, when it did not end cleanly. + public enum EndError: Error, Equatable { + /// A POSIX error from the socket. + case posix(Int32) + /// The byte stream is not a valid frame stream. + case frame(LookinFrameError) + } + + public let queue: DispatchQueue + + /// Decides on each header before its payload is read; nil accepts + /// every frame. Set before `start()`. + public var shouldAcceptFrame: ((LookinFrameHeader) -> Bool)? + /// Each accepted frame, payload complete. Set before `start()`. + public var onFrame: ((LookinFrame) -> Void)? + /// Runs once, after the socket is closed: nil for an orderly end (the + /// peer closed, a type-0 frame, `close()` or `cancel()`). Set before + /// `start()`. + public var onEnd: ((EndError?) -> Void)? + + private let io: DispatchIO + private var decoder: LookinFrameDecoder + private var pendingBytes: Data + private var endError: EndError? + private var started = false + private var ended = false + /// Holds the channel until `onEnd` has run. + private var keepAlive: LookinFrameChannel? + + private let stateLock = NSLock() + private var open = true + /// Writes handed to dispatch I/O and not finished; under `stateLock`. + private var pendingWrites = 0 + /// `cancel()` waits for `pendingWrites` to reach 0; under `stateLock`. + private var closeAfterWrites = false + + /// True until the channel is closed, cancelled or ended. Any thread. + public var isConnected: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return open + } + + /// Takes ownership of `fileDescriptor`, a connected stream socket; it + /// is closed when the channel ends. + /// + /// - Parameters: + /// - maxPayloadSize: the largest payload an accepted frame may + /// announce (see `LookinFrameDecoder`). + /// - initialBytes: bytes already read from the socket that belong + /// to the frame stream (after a usbmuxd Connect result). + public init(fileDescriptor: Int32, queue: DispatchQueue, maxPayloadSize: UInt32 = .max, initialBytes: Data = Data()) { + self.queue = queue + decoder = LookinFrameDecoder(maxPayloadSize: maxPayloadSize) + pendingBytes = initialBytes + var on: Int32 = 1 + setsockopt(fileDescriptor, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + let flags = fcntl(fileDescriptor, F_GETFL) + if flags != -1 { + _ = fcntl(fileDescriptor, F_SETFL, flags | O_NONBLOCK) + } + // The cleanup handler cannot capture self before init completes; + // it reaches the channel through this box. + let box = WeakBox() + io = DispatchIO(type: .stream, fileDescriptor: fileDescriptor, queue: queue) { error in + Darwin.close(fileDescriptor) + box.channel?.didClose(posixError: error) + } + io.setLimit(lowWater: 1) + box.channel = self + keepAlive = self + } + + private final class WeakBox: @unchecked Sendable { + weak var channel: LookinFrameChannel? + } + + /// Starts reading. Frames and the end arrive on `queue`. + public func start() { + queue.async { [self] in + guard !started, !ended else { return } + started = true + if !pendingBytes.isEmpty { + let bytes = pendingBytes + pendingBytes = Data() + receive(bytes) + } + guard isConnected else { return } + io.read(offset: 0, length: Int.max, queue: queue) { [weak self] done, data, error in + self?.didRead(done: done, data: data, error: error) + } + } + } + + /// Sends one frame. `completion` (on `queue`) gets nil once it is + /// fully written, or the error; a closed channel fails with EPERM, as + /// Peertalk did. + public func send(_ frame: LookinFrame, completion: ((EndError?) -> Void)? = nil) { + let bytes: Data + do { + bytes = try frame.encoded() + } catch let error as LookinFrameError { + queue.async { completion?(.frame(error)) } + return + } catch { + queue.async { completion?(.posix(EINVAL)) } + return + } + stateLock.lock() + guard open else { + stateLock.unlock() + queue.async { completion?(.posix(EPERM)) } + return + } + pendingWrites += 1 + stateLock.unlock() + let data = bytes.withUnsafeBytes { DispatchData(bytes: $0) } + io.write(offset: 0, data: data, queue: queue) { [io] done, _, error in + guard done else { return } + completion?(error == 0 ? nil : .posix(error)) + self.stateLock.lock() + self.pendingWrites -= 1 + let closeNow = self.closeAfterWrites && self.pendingWrites == 0 + if closeNow { + self.closeAfterWrites = false + } + self.stateLock.unlock() + if closeNow { + io.close(flags: .stop) + } + } + } + + public func send(type: UInt32, tag: UInt32, payload: Data = Data(), completion: ((EndError?) -> Void)? = nil) { + send(LookinFrame(type: type, tag: tag, payload: payload), completion: completion) + } + + /// Ends the channel now; queued writes are dropped. + public func close() { + stateLock.lock() + // Open, or cancelled and still waiting for its writes. + let stopNow = open || closeAfterWrites + open = false + closeAfterWrites = false + stateLock.unlock() + if stopNow { + io.close(flags: .stop) + } + } + + /// Stops reading now and ends the channel once queued writes are + /// written. + public func cancel() { + stateLock.lock() + guard open else { + stateLock.unlock() + return + } + open = false + let closeNow = pendingWrites == 0 + closeAfterWrites = !closeNow + stateLock.unlock() + if closeNow { + io.close(flags: .stop) + } + } + + // MARK: - Reading + + private func markClosed() -> Bool { + stateLock.lock() + defer { stateLock.unlock() } + guard open else { return false } + open = false + return true + } + + private func didRead(done: Bool, data: DispatchData?, error: Int32) { + if let data, !data.isEmpty, isConnected { + receive(Data(data)) + } + guard done else { return } + if error != 0 { + if error != ECANCELED, endError == nil { + endError = .posix(error) + } + close() + return + } + // End of stream; a partial frame is dropped without an error. + cancel() + } + + private func receive(_ bytes: Data) { + decoder.append(bytes) + while isConnected { + let output: LookinFrameDecoder.Output? + do { + output = try decoder.next(accepting: { [self] header in shouldAcceptFrame?(header) ?? true }) + } catch let error as LookinFrameError { + endError = .frame(error) + close() + return + } catch { + close() + return + } + switch output { + case nil: + return + case .endOfStream: + cancel() + return + case let .frame(frame): + onFrame?(frame) + } + } + } + + private func didClose(posixError: Int32) { + _ = markClosed() + guard !ended else { return } + ended = true + if posixError != 0, posixError != ECANCELED, endError == nil { + endError = .posix(posixError) + } + let handler = onEnd + let error = endError + onEnd = nil + onFrame = nil + shouldAcceptFrame = nil + handler?(error) + keepAlive = nil + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Transport/LookinFrameDecoder.swift b/Sources/LookinCoreImpl/Transport/LookinFrameDecoder.swift new file mode 100644 index 00000000..c2e730a5 --- /dev/null +++ b/Sources/LookinCoreImpl/Transport/LookinFrameDecoder.swift @@ -0,0 +1,183 @@ +// +// LookinFrameDecoder.swift +// LookinCore +// +// Turns a byte stream into frames, however the bytes are split or joined +// across reads. It reproduces what Peertalk's read loop delivered +// (Fixtures/receive-behaviour.json): +// +// - a header is decided on as soon as its 16 bytes are in: the `accepting` +// closure sees it before any payload byte is buffered, like Peertalk's +// `shouldAcceptFrameOfType:tag:payloadSize:`; +// - a rejected frame's payload is skipped as it arrives, never buffered; +// - a header of type 0 is Peertalk's end-of-stream marker: the decoder +// reports `.endOfStream` without asking `accepting`, and decodes nothing +// after it; +// - a version other than 1 is an error that ends the stream. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + + public struct LookinFrameDecoder { + public enum Output: Equatable { + /// An accepted frame, payload complete. + case frame(LookinFrame) + /// A type-0 header arrived: the stream is over. + case endOfStream + } + + /// Where the stream stands between two complete frames. + public enum Position: Equatable { + /// At a frame boundary (or finished). + case betweenFrames + /// Some, but not all, of a header is buffered. + case insideHeader + /// A header is decided on and its payload is incomplete. + case insidePayload + } + + private enum State { + case header + case payload(LookinFrameHeader) + case discarding(remaining: Int) + case finished + } + + /// The largest payload an accepted frame may announce; a larger one + /// throws `LookinFrameError.payloadTooLarge`. `UInt32.max`, the + /// default, accepts every frame, as Peertalk did. + public let maxPayloadSize: UInt32 + + private var buffer = Data() + /// Index into `buffer` of the first byte not yet consumed. + private var readIndex: Int + private var state: State = .header + + public init(maxPayloadSize: UInt32 = .max) { + self.maxPayloadSize = maxPayloadSize + readIndex = buffer.startIndex + } + + /// Bytes received and not yet handed out. + public var bufferedByteCount: Int { + buffer.endIndex - readIndex + } + + public var position: Position { + switch state { + case .header: + return bufferedByteCount == 0 ? .betweenFrames : .insideHeader + case .payload, .discarding: + return .insidePayload + case .finished: + return .betweenFrames + } + } + + /// True once a type-0 header was read; later bytes are ignored. + public var isFinished: Bool { + if case .finished = state { + return true + } + return false + } + + /// Adds received bytes. Bytes after the end of the stream are dropped. + public mutating func append(_ bytes: Data) { + guard !bytes.isEmpty, !isFinished else { return } + if case let .discarding(remaining) = state, bufferedByteCount == 0 { + // Skip without buffering. + let skipped = min(remaining, bytes.count) + if skipped < bytes.count { + buffer = bytes.subdata(in: bytes.startIndex + skipped ..< bytes.endIndex) + readIndex = buffer.startIndex + } + state = remaining - skipped > 0 ? .discarding(remaining: remaining - skipped) : .header + return + } + compactIfWorthwhile() + buffer.append(bytes) + } + + /// The next accepted frame, `.endOfStream`, or nil when more bytes are + /// needed. Call it until it returns nil after every `append`. + /// + /// - Parameter accepting: decides on each header once, before its + /// payload is read; a rejected frame is skipped. Not called for a + /// type-0 header. + /// - Throws: `LookinFrameError.unsupportedVersion` or + /// `.payloadTooLarge`; the decoder is finished afterwards. + public mutating func next(accepting: (LookinFrameHeader) -> Bool = { _ in true }) throws -> Output? { + while true { + switch state { + case .finished: + return nil + + case let .discarding(remaining): + let skipped = min(remaining, bufferedByteCount) + readIndex += skipped + if remaining - skipped > 0 { + state = .discarding(remaining: remaining - skipped) + return nil + } + state = .header + + case .header: + guard bufferedByteCount >= LookinFrameProtocol.headerSize else { + return nil + } + let header: LookinFrameHeader + do { + header = try LookinFrameHeader.decode(buffer[readIndex ..< readIndex + LookinFrameProtocol.headerSize]) + } catch { + state = .finished + throw error + } + readIndex += LookinFrameProtocol.headerSize + if header.type == LookinFrameProtocol.endOfStreamType { + state = .finished + buffer = Data() + readIndex = buffer.startIndex + return .endOfStream + } + guard accepting(header) else { + state = header.payloadSize > 0 ? .discarding(remaining: Int(header.payloadSize)) : .header + continue + } + guard header.payloadSize <= maxPayloadSize else { + state = .finished + throw LookinFrameError.payloadTooLarge(size: header.payloadSize, limit: maxPayloadSize) + } + state = .payload(header) + + case let .payload(header): + let size = Int(header.payloadSize) + guard bufferedByteCount >= size else { + return nil + } + let payload = size > 0 ? Data(buffer[readIndex ..< readIndex + size]) : Data() + readIndex += size + state = .header + return .frame(LookinFrame(type: header.type, tag: header.tag, payload: payload)) + } + } + } + + /// Drops consumed bytes once they are the larger part of the buffer. + private mutating func compactIfWorthwhile() { + let consumed = readIndex - buffer.startIndex + guard consumed > 0 else { return } + if consumed == buffer.count { + buffer = Data() + } else if consumed >= 64 * 1024, consumed * 2 >= buffer.count { + buffer = buffer.subdata(in: readIndex ..< buffer.endIndex) + } else { + return + } + readIndex = buffer.startIndex + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Transport/LookinFrameListener.swift b/Sources/LookinCoreImpl/Transport/LookinFrameListener.swift new file mode 100644 index 00000000..b5999996 --- /dev/null +++ b/Sources/LookinCoreImpl/Transport/LookinFrameListener.swift @@ -0,0 +1,146 @@ +// +// LookinFrameListener.swift +// LookinCore +// +// The Server's listening socket, the way Peertalk's +// `listenOnPort:IPv4Address:callback:` made it: +// +// - socket(AF_INET, SOCK_STREAM) -> SO_REUSEADDR -> O_NONBLOCK -> bind to +// 127.0.0.1:port -> listen(fd, 512) -> a read source on `queue`; +// - on each read event it accepts until accept() fails (EAGAIN) or the +// event's pending count is used up, and hands each accepted socket +// (SO_NOSIGPIPE, O_NONBLOCK) to `onAccept`; +// - `cancel()` stops the source; the socket is closed by the source's +// cancel handler, so an accept loop already running finishes the +// connections the event announced (Peertalk behaved the same way when the +// first accepted connection cancelled its listener). +// +// A failed setup step throws `LookinSocket.Error` with its errno; EADDRINUSE +// (48) means another process (or another listener) holds the port. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Darwin + import Foundation + + public final class LookinFrameListener: @unchecked Sendable { + /// The bound port (the kernel's choice when listening on port 0). + public let port: UInt16 + + private let source: DispatchSourceRead + private let stateLock = NSLock() + private var cancelled = false + + private init(port: UInt16, source: DispatchSourceRead) { + self.port = port + self.source = source + } + + /// True until `cancel()`. Any thread. + public var isListening: Bool { + stateLock.lock() + defer { stateLock.unlock() } + return !cancelled + } + + /// Listens on 127.0.0.1:`port`; `onAccept` runs on `queue` with each + /// accepted socket, which it then owns. + public static func listen(port: UInt16, queue: DispatchQueue, onAccept: @escaping (Int32) -> Void) throws -> LookinFrameListener { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd != -1 else { throw LookinSocket.Error(code: errno) } + + func fail() -> LookinSocket.Error { + let code = errno + Darwin.close(fd) + return LookinSocket.Error(code: code) + } + + var on: Int32 = 1 + guard setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, socklen_t(MemoryLayout.size)) != -1 else { + throw fail() + } + guard fcntl(fd, F_SETFL, O_NONBLOCK) != -1 else { + throw fail() + } + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr.s_addr = INADDR_LOOPBACK.bigEndian + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { throw fail() } + guard Darwin.listen(fd, 512) == 0 else { throw fail() } + + var boundPort = port + var boundAddress = sockaddr_in() + var length = socklen_t(MemoryLayout.size) + let named = withUnsafeMutablePointer(to: &boundAddress) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + getsockname(fd, $0, &length) + } + } + if named == 0 { + boundPort = UInt16(bigEndian: boundAddress.sin_port) + } + + let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + source.setEventHandler { [weak source] in + guard let source else { return } + var pending = max(source.data, 1) + while let accepted = acceptOne(fd) { + onAccept(accepted) + pending -= 1 + if pending == 0 { + break + } + } + } + source.setCancelHandler { + Darwin.close(fd) + } + source.resume() + return LookinFrameListener(port: boundPort, source: source) + } + + /// One accept(); nil when it fails (EAGAIN included) or the socket + /// cannot be made non-blocking. + private static func acceptOne(_ listeningFD: Int32) -> Int32? { + var address = sockaddr_storage() + var length = socklen_t(MemoryLayout.size) + let fd = withUnsafeMutablePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + accept(listeningFD, $0, &length) + } + } + guard fd != -1 else { return nil } + LookinSocket.disableSIGPIPE(fd) + let flags = fcntl(fd, F_GETFL) + guard flags != -1, fcntl(fd, F_SETFL, flags | O_NONBLOCK) != -1 else { + Darwin.close(fd) + return nil + } + return fd + } + + /// Stops listening and closes the socket. Idempotent. + public func cancel() { + stateLock.lock() + let wasCancelled = cancelled + cancelled = true + stateLock.unlock() + if !wasCancelled { + source.cancel() + } + } + + deinit { + cancel() + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Transport/LookinSocket.swift b/Sources/LookinCoreImpl/Transport/LookinSocket.swift new file mode 100644 index 00000000..e39f7cd5 --- /dev/null +++ b/Sources/LookinCoreImpl/Transport/LookinSocket.swift @@ -0,0 +1,99 @@ +// +// LookinSocket.swift +// LookinCore +// +// The two blocking connects the transport needs, shared by the Host, the +// e2e probe and the activation harness so none of them carries its own +// socket code: +// +// - `connectLoopback(port:)`: a TCP connection to 127.0.0.1, the way +// Peertalk's `connectToPort:IPv4Address:` made it (blocking connect, which +// on loopback succeeds or fails with ECONNREFUSED at once; SO_NOSIGPIPE). +// Simulator and Mac apps are reached this way. +// - `connectUnix(path:)`: a stream connection to a Unix socket, for +// /var/run/usbmuxd (Peertalk's USB channel did the same). +// +// The returned descriptor is blocking; LookinFrameChannel switches it to +// non-blocking when it takes it over. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Darwin + import Foundation + + public enum LookinSocket { + /// A failed socket call, with its errno (ECONNREFUSED when nothing + /// listens on the port, ENOENT when the Unix socket does not exist). + public struct Error: Swift.Error, Equatable, CustomStringConvertible { + public var code: Int32 + + public init(code: Int32) { + self.code = code + } + + public var description: String { + "POSIX error \(code): \(String(cString: strerror(code)))" + } + } + + /// Connects to 127.0.0.1:`port` and returns the connected socket. + public static func connectLoopback(port: UInt16) throws -> Int32 { + let fd = socket(AF_INET, SOCK_STREAM, 0) + guard fd != -1 else { throw Error(code: errno) } + disableSIGPIPE(fd) + var address = sockaddr_in() + address.sin_len = UInt8(MemoryLayout.size) + address.sin_family = sa_family_t(AF_INET) + address.sin_port = port.bigEndian + address.sin_addr.s_addr = INADDR_LOOPBACK.bigEndian + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + let code = errno + close(fd) + throw Error(code: code) + } + return fd + } + + /// Connects to the Unix stream socket at `path`. + public static func connectUnix(path: String) throws -> Int32 { + var address = sockaddr_un() + let capacity = MemoryLayout.size(ofValue: address.sun_path) + let bytes = Array(path.utf8) + guard bytes.count < capacity else { throw Error(code: ENAMETOOLONG) } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd != -1 else { throw Error(code: errno) } + disableSIGPIPE(fd) + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { raw in + raw.copyBytes(from: bytes) + raw[bytes.count] = 0 + } + address.sun_len = UInt8(MemoryLayout.size) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { + connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + let code = errno + close(fd) + throw Error(code: code) + } + return fd + } + + /// A write to a peer that closed fails with EPIPE instead of + /// raising SIGPIPE. + public static func disableSIGPIPE(_ fd: Int32) { + var on: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, socklen_t(MemoryLayout.size)) + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Transport/LookinUSBMux.swift b/Sources/LookinCoreImpl/Transport/LookinUSBMux.swift new file mode 100644 index 00000000..41f20a48 --- /dev/null +++ b/Sources/LookinCoreImpl/Transport/LookinUSBMux.swift @@ -0,0 +1,239 @@ +// +// LookinUSBMux.swift +// LookinCore +// +// The usbmuxd client messages the Host sends to reach a Server on a USB +// device, byte for byte the ones Peertalk's PTUSBHub sent +// (Fixtures/usbmux-*.bin): +// +// - a 16-byte header in little-endian (host) byte order: +// size (header + payload), version 1 (plist), message type 8 (plist +// payload), tag; then an XML property list; +// - every request carries MessageType, plus ProgName and +// ClientVersionString from the main bundle's CFBundleName and +// CFBundleVersion (ProgName only when the bundle has a name, and +// ClientVersionString only when it also has a version); +// - Listen subscribes the socket to Attached / Detached broadcasts (tag 0); +// - Connect asks for DeviceID and PortNumber, the port in network byte +// order. After a Result with Number 0 the same socket is a byte stream to +// that port on the device, carrying Lookin frames. +// +// Only the encoding lives here; the socket is LookinUSBMuxClient's +// (Host-side, macOS). +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + import Foundation + + public enum LookinUSBMux { + /// The usbmuxd socket on macOS. + public static let socketPath = "/var/run/usbmuxd" + public static let headerSize = 16 + /// Header version for property-list messages. + public static let plistVersion: UInt32 = 1 + /// Header message type for property-list messages. + public static let plistMessageType: UInt32 = 8 + + /// `PortNumber` of a Connect request: the TCP port with its two bytes + /// swapped, which is network byte order read back on a little-endian + /// Mac (47175 is sent as 18360). Peertalk swapped unconditionally. + public static func portNumber(forDevicePort port: UInt16) -> Int { + Int(port.byteSwapped) + } + + /// The ProgName / ClientVersionString pair Peertalk took from the main + /// bundle. + public struct ClientIdentity: Equatable, Sendable { + public var progName: String? + public var clientVersionString: String? + + public init(progName: String?, clientVersionString: String?) { + self.progName = progName + self.clientVersionString = clientVersionString + } + + /// `CFBundleName` and the description of `CFBundleVersion` of + /// `bundle`. + public static func of(_ bundle: Bundle) -> ClientIdentity { + let info = bundle.infoDictionary + return ClientIdentity( + progName: info?["CFBundleName"] as? String, + clientVersionString: (info?["CFBundleVersion"]).map { "\($0)" } + ) + } + + public static var mainBundle: ClientIdentity { + of(Bundle.main) + } + } + + /// A request dictionary: `fields`, then MessageType and the client + /// identity on top (Peertalk let them override `fields`). + public static func request(messageType: String, fields: [String: Any] = [:], identity: ClientIdentity) -> [String: Any] { + var packet = fields + packet["MessageType"] = messageType + if let progName = identity.progName { + packet["ProgName"] = progName + if let version = identity.clientVersionString { + packet["ClientVersionString"] = version + } + } + return packet + } + + public static func listenRequest(identity: ClientIdentity) -> [String: Any] { + request(messageType: "Listen", identity: identity) + } + + public static func connectRequest(deviceID: Int, port: UInt16, identity: ClientIdentity) -> [String: Any] { + request( + messageType: "Connect", + fields: ["DeviceID": deviceID, "PortNumber": portNumber(forDevicePort: port)], + identity: identity + ) + } + } + + /// Why usbmuxd turned a request down, or why its reply is unusable. + /// `domain` and `code` are Peertalk's (`PTUSBHubError`), so log lines read + /// the same. + public struct LookinUSBMuxError: Error, Equatable, CustomStringConvertible { + public static let domain = "PTUSBHubError" + + /// usbmuxd's `Number` (1 bad command, 2 bad device, 3 connection + /// refused, 6 bad version), or 0 for a reply without one. + public var code: Int + public var message: String + + public init(code: Int, message: String) { + self.code = code + self.message = message + } + + /// The error a Result reply carries, or nil for `Number` 0. + public static func fromResult(_ reply: [String: Any]) -> LookinUSBMuxError? { + guard let number = reply["Number"] as? NSNumber else { + return LookinUSBMuxError(code: 0, message: "Result without a Number") + } + let code = number.intValue + switch code { + case 0: return nil + case 1: return LookinUSBMuxError(code: code, message: "illegal command") + case 2: return LookinUSBMuxError(code: code, message: "unknown device") + case 3: return LookinUSBMuxError(code: code, message: "connection refused") + case 6: return LookinUSBMuxError(code: code, message: "invalid version") + default: return LookinUSBMuxError(code: code, message: "Unspecified error") + } + } + + public var description: String { + "\(Self.domain) \(code): \(message)" + } + } + + /// One usbmuxd message. + public struct LookinUSBMuxPacket: Equatable { + public var version: UInt32 + public var messageType: UInt32 + public var tag: UInt32 + public var payload: Data + + public init(version: UInt32, messageType: UInt32, tag: UInt32, payload: Data) { + self.version = version + self.messageType = messageType + self.tag = tag + self.payload = payload + } + + /// A plist message: `dictionary` as an XML property list. + public init(plist dictionary: [String: Any], tag: UInt32) throws { + let payload = try PropertyListSerialization.data(fromPropertyList: dictionary, format: .xml, options: 0) + self.init(version: LookinUSBMux.plistVersion, messageType: LookinUSBMux.plistMessageType, tag: tag, payload: payload) + } + + /// The wire bytes: little-endian header, then the payload. + public var encoded: Data { + var data = Data(capacity: LookinUSBMux.headerSize + payload.count) + let size = UInt32(truncatingIfNeeded: LookinUSBMux.headerSize + payload.count) + for field in [size, version, messageType, tag] { + withUnsafeBytes(of: field.littleEndian) { data.append(contentsOf: $0) } + } + data.append(payload) + return data + } + + /// True for a broadcast (Attached / Detached), false for a reply. + public var isBroadcast: Bool { + tag == 0 + } + + /// The payload as a property-list dictionary. + /// + /// - Throws: `LookinUSBMuxError` (code 0) for a non-plist message, or + /// the property-list error. + public func plistDictionary() throws -> [String: Any] { + guard version == LookinUSBMux.plistVersion else { + throw LookinUSBMuxError(code: 0, message: "Unexpected package protocol") + } + guard messageType == LookinUSBMux.plistMessageType else { + throw LookinUSBMuxError(code: 0, message: "Unexpected package type") + } + if payload.isEmpty { + return [:] + } + let object = try PropertyListSerialization.propertyList(from: payload, options: [], format: nil) + guard let dictionary = object as? [String: Any] else { + throw LookinUSBMuxError(code: 0, message: "Unexpected property list") + } + return dictionary + } + } + + /// Splits the bytes read from a usbmuxd socket into messages. After a + /// successful Connect, `takeRemainingBytes()` hands the bytes that + /// followed the Result (the device's first frames) to the frame channel. + public struct LookinUSBMuxDecoder { + /// usbmuxd's own messages are small; anything bigger is not usbmuxd. + public static let maxPacketSize = 16 * 1024 * 1024 + + private var buffer = Data() + + public init() {} + + public mutating func append(_ bytes: Data) { + buffer.append(bytes) + } + + /// The next complete message, or nil when more bytes are needed. + /// + /// - Throws: `LookinUSBMuxError` for a size below 16 or above + /// `maxPacketSize`. + public mutating func next() throws -> LookinUSBMuxPacket? { + guard buffer.count >= LookinUSBMux.headerSize else { return nil } + let fields = (0 ..< 4).map { field -> UInt32 in + var value: UInt32 = 0 + for byte in (0 ..< 4).reversed() { + value = value << 8 | UInt32(buffer[buffer.startIndex + field * 4 + byte]) + } + return value + } + let size = Int(fields[0]) + guard size >= LookinUSBMux.headerSize, size <= Self.maxPacketSize else { + throw LookinUSBMuxError(code: 1, message: "Received a packet that is too large") + } + guard buffer.count >= size else { return nil } + let start = buffer.startIndex + let payload = Data(buffer[start + LookinUSBMux.headerSize ..< start + size]) + buffer = Data(buffer[(start + size)...]) + return LookinUSBMuxPacket(version: fields[1], messageType: fields[2], tag: fields[3], payload: payload) + } + + /// The bytes after the last decoded message; empties the decoder. + public mutating func takeRemainingBytes() -> Data { + defer { buffer = Data() } + return Data(buffer) + } + } + +#endif diff --git a/Sources/LookinCoreImpl/Transport/LookinUSBMuxClient.swift b/Sources/LookinCoreImpl/Transport/LookinUSBMuxClient.swift new file mode 100644 index 00000000..1f265a31 --- /dev/null +++ b/Sources/LookinCoreImpl/Transport/LookinUSBMuxClient.swift @@ -0,0 +1,370 @@ +// +// LookinUSBMuxClient.swift +// LookinCore +// +// The Host's usbmuxd client (macOS only), shared by the Host and +// lookin-probe. It replaces the vendored Peertalk's USB hub and sends the same +// bytes (LookinUSBMux builds them): +// +// - `startListening` keeps one socket open with a Listen request (tag 1) +// and reports usbmuxd's Attached / Detached broadcasts (tag 0). Devices +// are not filtered by ConnectionType, as in Peertalk. Unlike Peertalk, +// a Listen socket that fails or ends reports every known device as +// detached and is opened again every 2 s until `stopListening()`. +// - `connect` opens a new socket per attempt, sends Connect (tag 1) and +// reads one Result. Number 0 hands over the socket, now a byte stream to +// the device port, together with any bytes that followed the Result. +// Unlike Peertalk, an attempt fails after `timeout`. +// +// Socket I/O is blocking and runs on private queues; every callback runs +// on the queue given to `init`. +// + +#if SHOULD_COMPILE_LOOKIN_SERVER + + #if os(macOS) + + import Darwin + import Foundation + + public final class LookinUSBMuxClient: @unchecked Sendable { + public enum Event { + /// `properties` is the whole Attached message, DeviceID + /// included (what Peertalk posted as the notification's + /// userInfo). + case attached(deviceID: Int, properties: [String: Any]) + case detached(deviceID: Int) + } + + /// The pause before a failed or ended Listen socket is opened + /// again. + public static let listenRetryInterval: TimeInterval = 2 + + /// The tag of the first request on a socket; each socket counts + /// its own tags from here. + private static let firstTag: UInt32 = 1 + + public let queue: DispatchQueue + public let identity: LookinUSBMux.ClientIdentity + public let socketPath: String + + private let ioQueue = DispatchQueue(label: "lookin.usbmux.io", attributes: .concurrent) + + /// Guards the listen state below. + private let listenCondition = NSCondition() + /// Bumped by every start and stop; a listen loop whose generation + /// is no longer current stops. + private var listenGeneration = 0 + private var isListening = false + /// The open Listen socket, or -1; the listen loop owns and closes + /// it, `stopListening()` only shuts it down to wake the read. + private var listenFD: Int32 = -1 + + public init( + queue: DispatchQueue, + identity: LookinUSBMux.ClientIdentity = .mainBundle, + socketPath: String = LookinUSBMux.socketPath + ) { + self.queue = queue + self.identity = identity + self.socketPath = socketPath + } + + deinit { + stopListening() + } + + // MARK: - Listen + + /// Subscribes to device broadcasts. `onEvent` runs on `queue`. A + /// second call replaces the first subscription. + public func startListening(onEvent: @escaping (Event) -> Void) { + stopListening() + listenCondition.lock() + listenGeneration += 1 + isListening = true + let generation = listenGeneration + listenCondition.unlock() + ioQueue.async { [self] in + listenLoop(generation: generation, onEvent: onEvent) + } + } + + /// Closes the Listen socket and stops retrying. No event is + /// delivered after this returns, except one already queued. + public func stopListening() { + listenCondition.lock() + if isListening { + isListening = false + listenGeneration += 1 + if listenFD != -1 { + shutdown(listenFD, SHUT_RDWR) + } + } + listenCondition.broadcast() + listenCondition.unlock() + } + + private func isCurrent(_ generation: Int) -> Bool { + listenCondition.lock() + defer { listenCondition.unlock() } + return isListening && listenGeneration == generation + } + + private func listenLoop(generation: Int, onEvent: @escaping (Event) -> Void) { + var knownDevices = [Int]() + let deliver: (Event) -> Void = { [weak self] event in + self?.queue.async { + guard let self, self.isCurrent(generation) else { return } + onEvent(event) + } + } + while isCurrent(generation) { + do { + try listenSession(generation: generation) { event in + switch event { + case let .attached(deviceID, _): + if !knownDevices.contains(deviceID) { + knownDevices.append(deviceID) + } + case let .detached(deviceID): + knownDevices.removeAll { $0 == deviceID } + } + deliver(event) + } + if isCurrent(generation) { + NSLog("LookinUSBMux - the usbmuxd listen connection ended") + } + } catch { + if isCurrent(generation) { + NSLog("LookinUSBMux - listening to usbmuxd failed: %@", String(describing: error)) + } + } + for deviceID in knownDevices { + deliver(.detached(deviceID: deviceID)) + } + knownDevices.removeAll() + waitBeforeRetry(generation: generation) + } + } + + /// Sleeps `listenRetryInterval`, or less when the subscription + /// stops. + private func waitBeforeRetry(generation: Int) { + let deadline = Date().addingTimeInterval(Self.listenRetryInterval) + listenCondition.lock() + while isListening, listenGeneration == generation, Date() < deadline { + listenCondition.wait(until: deadline) + } + listenCondition.unlock() + } + + /// One Listen socket, from connect to its end. Returns when the + /// socket ends; throws when it cannot be opened, the Result is + /// an error or the stream is invalid. + private func listenSession(generation: Int, onEvent: (Event) -> Void) throws { + let fd = try LookinSocket.connectUnix(path: socketPath) + listenCondition.lock() + guard isListening, listenGeneration == generation else { + listenCondition.unlock() + Darwin.close(fd) + return + } + listenFD = fd + listenCondition.unlock() + defer { + listenCondition.lock() + listenFD = -1 + listenCondition.unlock() + Darwin.close(fd) + } + + let request = try LookinUSBMuxPacket(plist: LookinUSBMux.listenRequest(identity: identity), tag: Self.firstTag) + try Self.writeAll(fd, request.encoded) + + var decoder = LookinUSBMuxDecoder() + var listening = false + while true { + while let packet = try decoder.next() { + let message = try packet.plistDictionary() + if packet.isBroadcast { + handleBroadcast(message, onEvent: onEvent) + } else if !listening, packet.tag == Self.firstTag { + if let error = LookinUSBMuxError.fromResult(message) { + throw error + } + listening = true + } else { + NSLog("LookinUSBMux - ignoring a usbmuxd reply with tag %@", NSNumber(value: packet.tag)) + } + } + guard let bytes = try Self.readSome(fd) else { return } + decoder.append(bytes) + } + } + + private func handleBroadcast(_ message: [String: Any], onEvent: (Event) -> Void) { + let messageType = message["MessageType"] as? String + guard let deviceID = (message["DeviceID"] as? NSNumber)?.intValue else { + NSLog("LookinUSBMux - ignoring a usbmuxd broadcast without a DeviceID: %@", messageType ?? "(null)") + return + } + switch messageType { + case "Attached": + onEvent(.attached(deviceID: deviceID, properties: message)) + case "Detached": + onEvent(.detached(deviceID: deviceID)) + default: + NSLog("LookinUSBMux - ignoring a usbmuxd broadcast: %@", messageType ?? "(null)") + } + } + + // MARK: - Connect + + /// Opens a byte stream to `port` on the device `deviceID`. + /// `completion` runs once on `queue` with the connected socket + /// (blocking; the caller owns it) and the bytes already read past + /// the Result, or with the error: `LookinUSBMuxError` for a + /// refused Connect (code 3 when nothing listens on the port), + /// `LookinSocket.Error` for a socket failure (ETIMEDOUT after + /// `timeout`). + public func connect( + deviceID: Int, + port: UInt16, + timeout: TimeInterval = 3, + completion: @escaping (Result<(fileDescriptor: Int32, initialBytes: Data), Error>) -> Void + ) { + let attempt = ConnectAttempt() + let finish: (Result<(fileDescriptor: Int32, initialBytes: Data), Error>) -> Void = { [queue] result in + queue.async { completion(result) } + } + ioQueue.async { [self] in + let result: Result<(fileDescriptor: Int32, initialBytes: Data), Error> + var fd: Int32 = -1 + do { + fd = try LookinSocket.connectUnix(path: socketPath) + guard attempt.register(fd) else { + Darwin.close(fd) + return + } + let request = try LookinUSBMuxPacket( + plist: LookinUSBMux.connectRequest(deviceID: deviceID, port: port, identity: identity), + tag: Self.firstTag + ) + try Self.writeAll(fd, request.encoded) + result = try .success((fd, Self.readConnectResult(fd))) + } catch { + result = .failure(error) + } + guard attempt.finish() else { + // Timed out; the timer reported it. + if fd != -1 { + Darwin.close(fd) + } + return + } + if case .failure = result, fd != -1 { + Darwin.close(fd) + } + finish(result) + } + ioQueue.asyncAfter(deadline: .now() + timeout) { + guard attempt.timeOut() else { return } + finish(.failure(LookinSocket.Error(code: ETIMEDOUT))) + } + } + + /// Reads one reply and returns the bytes after it. + private static func readConnectResult(_ fd: Int32) throws -> Data { + var decoder = LookinUSBMuxDecoder() + while true { + if let packet = try decoder.next() { + if let error = try LookinUSBMuxError.fromResult(packet.plistDictionary()) { + throw error + } + return decoder.takeRemainingBytes() + } + guard let bytes = try readSome(fd) else { + throw LookinSocket.Error(code: ECONNRESET) + } + decoder.append(bytes) + } + } + + /// Finishes a Connect attempt once, from its worker or its timer. + private final class ConnectAttempt: @unchecked Sendable { + private let lock = NSLock() + private var fd: Int32 = -1 + private var finished = false + + /// False when the attempt already timed out. + func register(_ fd: Int32) -> Bool { + lock.lock() + defer { lock.unlock() } + self.fd = fd + return !finished + } + + /// Claims the result for the worker; false after a timeout. + func finish() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !finished else { return false } + finished = true + return true + } + + /// Claims the result for the timer and wakes a blocked worker, + /// which then closes the socket. + func timeOut() -> Bool { + lock.lock() + defer { lock.unlock() } + guard !finished else { return false } + finished = true + if fd != -1 { + shutdown(fd, SHUT_RDWR) + } + return true + } + } + + // MARK: - Blocking I/O + + private static func writeAll(_ fd: Int32, _ data: Data) throws { + try data.withUnsafeBytes { raw in + var offset = 0 + while offset < raw.count { + let written = Darwin.write(fd, raw.baseAddress! + offset, raw.count - offset) + if written < 0 { + if errno == EINTR { + continue + } + throw LookinSocket.Error(code: errno) + } + offset += written + } + } + } + + /// The next bytes from `fd`, or nil at the end of the stream. + private static func readSome(_ fd: Int32) throws -> Data? { + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + while true { + let count = buffer.withUnsafeMutableBytes { Darwin.read(fd, $0.baseAddress!, $0.count) } + if count > 0 { + return Data(buffer[0 ..< count]) + } + if count == 0 { + return nil + } + if errno == EINTR { + continue + } + throw LookinSocket.Error(code: errno) + } + } + } + + #endif + +#endif diff --git a/Sources/LookinServer/Server/Category/CALayer+LookinServer.h b/Sources/LookinServer/Server/Category/CALayer+LookinServer.h deleted file mode 100644 index 358d8d74..00000000 --- a/Sources/LookinServer/Server/Category/CALayer+LookinServer.h +++ /dev/null @@ -1,38 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIView+LookinMobile.h -// WeRead -// -// Created by Li Kai on 2018/11/30. -// Copyright © 2018 tencent. All rights reserved. -// - -#import "LookinDefines.h" - -@interface CALayer (LookinServer) - -/// 如果 myView.layer == myLayer,则 myLayer.lks_hostView 会返回 myView -@property(nonatomic, readonly, weak) LookinView *lks_hostView; - -- (LookinWindow *)lks_window; - -- (CGRect)lks_frameInWindow:(LookinWindow *)window; - -- (LookinImage *)lks_groupScreenshotWithLowQuality:(BOOL)lowQuality; -/// 当没有 sublayers 时,该方法返回 nil -- (LookinImage *)lks_soloScreenshotWithLowQuality:(BOOL)lowQuality; - -/// 获取和该对象有关的对象的 Class 层级树 -- (NSArray *> *)lks_relatedClassChainList; - -- (NSArray *)lks_selfRelation; - -@property(nonatomic, strong) LookinColor *lks_backgroundColor; -@property(nonatomic, strong) LookinColor *lks_borderColor; -@property(nonatomic, strong) LookinColor *lks_shadowColor; -@property(nonatomic, assign) CGFloat lks_shadowOffsetWidth; -@property(nonatomic, assign) CGFloat lks_shadowOffsetHeight; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/CALayer+LookinServer.m b/Sources/LookinServer/Server/Category/CALayer+LookinServer.m deleted file mode 100644 index cded0a2a..00000000 --- a/Sources/LookinServer/Server/Category/CALayer+LookinServer.m +++ /dev/null @@ -1,376 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIView+LookinMobile.m -// WeRead -// -// Created by Li Kai on 2018/11/30. -// Copyright © 2018 tencent. All rights reserved. -// - -#import "CALayer+LookinServer.h" -#import "UIView+LookinServer.h" -#import "LKS_HierarchyDisplayItemsMaker.h" -#import "LookinDisplayItem.h" -#import -#import "LKS_ConnectionManager.h" -#import "LookinIvarTrace.h" -#import "LookinServerDefines.h" -#import "UIColor+LookinServer.h" -#import "LKS_MultiplatformAdapter.h" -#import "NSWindow+LookinServer.h" - -@implementation CALayer (LookinServer) - -- (LookinWindow *)lks_window { - CALayer *layer = self; - while (layer) { - LookinView *hostView = layer.lks_hostView; - if (hostView.window) { - return hostView.window; -#if !TARGET_OS_OSX - } else if ([hostView isKindOfClass:[LookinWindow class]]) { - return (LookinWindow *)hostView; -#endif - } - layer = layer.superlayer; - } - return nil; -} - -- (CGRect)lks_frameInWindow:(LookinWindow *)window { - LookinWindow *selfWindow = [self lks_window]; - if (!selfWindow) { - return CGRectZero; - } - -#if TARGET_OS_IPHONE - CGRect rectInSelfWindow = [selfWindow.layer convertRect:self.frame fromLayer:self.superlayer]; - CGRect rectInWindow = [window convertRect:rectInSelfWindow fromWindow:selfWindow]; -#elif TARGET_OS_OSX - CGRect rectInSelfWindow = [selfWindow.lks_rootView.layer convertRect:self.frame fromLayer:self.superlayer]; - CGRect rectInWindow = [window.lks_rootView convertRect:rectInSelfWindow fromView:selfWindow.lks_rootView]; -#endif - return rectInWindow; - } - -#pragma mark - Host View - -- (LookinView *)lks_hostView { - if (self.delegate && [self.delegate isKindOfClass:LookinView.class]) { - LookinView *view = (LookinView *)self.delegate; - if (view.layer == self) { - return view; - } - } - return nil; -} - -#pragma mark - Screenshot - -#if TARGET_OS_OSX -+ (NSImage *)_lks_renderImageForSize:(CGSize)size contentsAreFlipped:(BOOL)contentsAreFlipped renderBlock:(void (^)(CGContextRef context))renderBlock { - if (size.width <= 0 || size.height <= 0 || size.width > 20000 || size.height > 20000) { - return nil; - } - - NSBitmapImageRep *bitmapRep = [[NSBitmapImageRep alloc] - initWithBitmapDataPlanes:NULL - pixelsWide:(NSInteger)ceil(size.width) - pixelsHigh:(NSInteger)ceil(size.height) - bitsPerSample:8 - samplesPerPixel:4 - hasAlpha:YES - isPlanar:NO - colorSpaceName:NSDeviceRGBColorSpace - bytesPerRow:0 - bitsPerPixel:0]; - if (!bitmapRep) { - return nil; - } - - NSGraphicsContext *graphicsContext = [NSGraphicsContext graphicsContextWithBitmapImageRep:bitmapRep]; - [NSGraphicsContext saveGraphicsState]; - [NSGraphicsContext setCurrentContext:graphicsContext]; - CGContextRef context = graphicsContext.CGContext; - // 与 Xcode View Debugger 行为一致:仅在 contentsAreFlipped 为 YES 时翻转 - if (contentsAreFlipped) { - CGContextTranslateCTM(context, 0, size.height); - CGContextScaleCTM(context, 1, -1); - } - renderBlock(context); - [graphicsContext flushGraphics]; - [NSGraphicsContext restoreGraphicsState]; - - NSImage *image = [[NSImage alloc] initWithSize:size]; - [image addRepresentation:bitmapRep]; - return image; -} - -/// renderInContext: silently returns a fully transparent bitmap when the -/// rendered subtree contains a visible CAPortalLayer. macOS 26's scroll-edge -/// machinery (NSScrollPocket / BackdropView) and glass materials install -/// portal and backdrop layers under plain containers, which is why a -/// collapsed NSScrollView or NSThemeFrame group screenshot came back empty. -/// Neither layer kind carries CPU-renderable content, so hiding them for the -/// duration of the offscreen render removes nothing that could ever reach -/// the bitmap. The model-tree hide takes effect without a commit, and -/// screenshots only run on the main thread. -static BOOL LKS_LayerPoisonsRenderInContext(CALayer *layer) { - static Class portalLayerClass = nil; - static Class backdropLayerClass = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // CoreAnimation-private but stable across many releases. If a rename - // ever breaks the lookup we degrade to today's empty render, no crash. - portalLayerClass = NSClassFromString(@"CAPortalLayer"); - backdropLayerClass = NSClassFromString(@"CABackdropLayer"); - }); - return (portalLayerClass && [layer isKindOfClass:portalLayerClass]) - || (backdropLayerClass && [layer isKindOfClass:backdropLayerClass]); -} - -static void LKS_CollectRenderPoisoningSublayers(CALayer *layer, NSMutableArray *collectedLayers) { - for (CALayer *sublayer in layer.sublayers) { - if (sublayer.isHidden) { - continue; - } - if (LKS_LayerPoisonsRenderInContext(sublayer)) { - // Hiding it hides its whole subtree; no need to walk deeper. - [collectedLayers addObject:sublayer]; - } else { - LKS_CollectRenderPoisoningSublayers(sublayer, collectedLayers); - } - } -} -#endif - -- (LookinImage *)lks_groupScreenshotWithLowQuality:(BOOL)lowQuality { - - CGFloat screenScale = [LKS_MultiplatformAdapter mainScreenScale]; - CGFloat pixelWidth = self.frame.size.width * screenScale; - CGFloat pixelHeight = self.frame.size.height * screenScale; - if (pixelWidth <= 0 || pixelHeight <= 0) { - return nil; - } - - CGFloat renderScale = lowQuality ? 1 : 0; - CGFloat maxLength = MAX(pixelWidth, pixelHeight); - if (maxLength > LookinNodeImageMaxLengthInPx) { - // 确保最终绘制出的图片长和宽都不能超过 LookinNodeImageMaxLengthInPx - // 如果算出的 renderScale 大于 1 则取 1,因为似乎用 1 渲染的速度要比一个别的奇怪的带小数点的数字要更快 - renderScale = MIN(screenScale * LookinNodeImageMaxLengthInPx / maxLength, 1); -} - - CGSize contextSize = self.frame.size; - if (contextSize.width <= 0 || contextSize.height <= 0 || contextSize.width > 20000 || contextSize.height > 20000) { - NSLog(@"LookinServer - Failed to capture screenshot. Invalid context size: %@ x %@", @(contextSize.width), @(contextSize.height)); - return nil; -} -#if TARGET_OS_IPHONE - UIGraphicsBeginImageContextWithOptions(contextSize, NO, renderScale); - CGContextRef context = UIGraphicsGetCurrentContext(); - // drawViewHierarchyInRect: 从屏幕合成结果渲染,对后台 UIWindowScene 中的 view 会产生错误截图。 - // 当 hostView 所属的 scene 不在前台时,改用 renderInContext:(直接从 layer model tree 渲染)。 - BOOL useDrawHierarchy = NO; - LookinView *hostView = self.lks_hostView; - if (hostView && !hostView.lks_isChildrenViewOfTabBar) { - useDrawHierarchy = YES; - if (@available(iOS 13.0, *)) { - UIWindowScene *scene = hostView.window.windowScene; - if (scene && - scene.activationState != UISceneActivationStateForegroundActive && - scene.activationState != UISceneActivationStateForegroundInactive) { - useDrawHierarchy = NO; - } - } - } - if (useDrawHierarchy) { - [hostView drawViewHierarchyInRect:CGRectMake(0, 0, self.frame.size.width, self.frame.size.height) afterScreenUpdates:YES]; - } else { - [self renderInContext:context]; - } - UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - return image; -#elif TARGET_OS_OSX - // For leaf layers (no sublayers) whose contents is not a CGImage - // (e.g. CGDisplayList on macOS 15+), renderInContext: cannot render them. - // Use drawInContext: instead, which invokes the layer's drawing code. - if (!self.sublayers.count && self.contents) { - CFTypeRef cfContents = (__bridge CFTypeRef)self.contents; - if (CFGetTypeID(cfContents) != CGImageGetTypeID()) { - return [CALayer _lks_renderImageForSize:self.bounds.size contentsAreFlipped:self.contentsAreFlipped renderBlock:^(CGContextRef context) { - [self drawInContext:context]; - }]; - } - } - NSMutableArray *poisoningLayers = [NSMutableArray array]; - LKS_CollectRenderPoisoningSublayers(self, poisoningLayers); - for (CALayer *poisoningLayer in poisoningLayers) { - poisoningLayer.hidden = YES; - } - @try { - return [CALayer _lks_renderImageForSize:self.bounds.size contentsAreFlipped:self.contentsAreFlipped renderBlock:^(CGContextRef context) { - [self renderInContext:context]; - }]; - } @finally { - for (CALayer *poisoningLayer in poisoningLayers) { - poisoningLayer.hidden = NO; - } - } -#endif -} - -- (LookinImage *)lks_soloScreenshotWithLowQuality:(BOOL)lowQuality { - if (!self.sublayers.count) { - return nil; - } - - CGFloat screenScale = [LKS_MultiplatformAdapter mainScreenScale]; - CGFloat pixelWidth = self.frame.size.width * screenScale; - CGFloat pixelHeight = self.frame.size.height * screenScale; - if (pixelWidth <= 0 || pixelHeight <= 0) { - return nil; - } - - CGFloat renderScale = lowQuality ? 1 : 0; - CGFloat maxLength = MAX(pixelWidth, pixelHeight); - if (maxLength > LookinNodeImageMaxLengthInPx) { - // 确保最终绘制出的图片长和宽都不能超过 LookinNodeImageMaxLengthInPx - // 如果算出的 renderScale 大于 1 则取 1,因为似乎用 1 渲染的速度要比一个别的奇怪的带小数点的数字要更快 - renderScale = MIN(screenScale * LookinNodeImageMaxLengthInPx / maxLength, 1); - } - CGSize contextSize = self.frame.size; - if (contextSize.width <= 0 || contextSize.height <= 0 || contextSize.width > 20000 || contextSize.height > 20000) { - NSLog(@"LookinServer - Failed to capture screenshot. Invalid context size: %@ x %@", @(contextSize.width), @(contextSize.height)); - return nil; - } - if (self.sublayers.count) { - NSArray *sublayers = [self.sublayers copy]; - NSMutableArray *visibleSublayers = [NSMutableArray arrayWithCapacity:sublayers.count]; - [sublayers enumerateObjectsUsingBlock:^(__kindof CALayer * _Nonnull sublayer, NSUInteger idx, BOOL * _Nonnull stop) { - if (!sublayer.hidden) { - sublayer.hidden = YES; - [visibleSublayers addObject:sublayer]; - } - }]; -#if TARGET_OS_IPHONE - UIGraphicsBeginImageContextWithOptions(contextSize, NO, renderScale); - CGContextRef soloContext = UIGraphicsGetCurrentContext(); - BOOL useSoloDrawHierarchy = NO; - LookinView *soloHostView = self.lks_hostView; - if (soloHostView && !soloHostView.lks_isChildrenViewOfTabBar) { - useSoloDrawHierarchy = YES; - if (@available(iOS 13.0, *)) { - UIWindowScene *soloScene = soloHostView.window.windowScene; - if (soloScene && - soloScene.activationState != UISceneActivationStateForegroundActive && - soloScene.activationState != UISceneActivationStateForegroundInactive) { - useSoloDrawHierarchy = NO; - } - } - } - if (useSoloDrawHierarchy) { - [soloHostView drawViewHierarchyInRect:CGRectMake(0, 0, self.frame.size.width, self.frame.size.height) afterScreenUpdates:YES]; - } else { - [self renderInContext:soloContext]; - } - UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); - -#elif TARGET_OS_OSX - NSImage *image = [CALayer _lks_renderImageForSize:self.bounds.size contentsAreFlipped:self.contentsAreFlipped renderBlock:^(CGContextRef context) { - [self renderInContext:context]; - }]; -#endif - [visibleSublayers enumerateObjectsUsingBlock:^(CALayer * _Nonnull sublayer, NSUInteger idx, BOOL * _Nonnull stop) { - sublayer.hidden = NO; - }]; - return image; -} - return nil; -} - -- (NSArray *> *)lks_relatedClassChainList { - NSMutableArray *array = [NSMutableArray arrayWithCapacity:2]; - if (self.lks_hostView) { - [array addObject:[CALayer lks_getClassListOfObject:self.lks_hostView endingClass:LookinViewString]]; - LookinViewController* vc = [self.lks_hostView lks_findHostViewController]; - if (vc) { - [array addObject:[CALayer lks_getClassListOfObject:vc endingClass:LookinViewControllerString]]; - } - } else { - [array addObject:[CALayer lks_getClassListOfObject:self endingClass:@"CALayer"]]; - } - return array.copy; -} - -+ (NSArray *)lks_getClassListOfObject:(id)object endingClass:(NSString *)endingClass { - NSArray *completedList = [object lks_classChainList]; - NSUInteger endingIdx = [completedList indexOfObject:endingClass]; - if (endingIdx != NSNotFound) { - completedList = [completedList subarrayWithRange:NSMakeRange(0, endingIdx + 1)]; - } - return completedList; -} - -- (NSArray *)lks_selfRelation { - NSMutableArray *array = [NSMutableArray array]; - NSMutableArray *ivarTraces = [NSMutableArray array]; - if (self.lks_hostView) { - LookinViewController* vc = [self.lks_hostView lks_findHostViewController]; - if (vc) { - [array addObject:[NSString stringWithFormat:@"(%@ *).view", NSStringFromClass(vc.class)]]; - - [ivarTraces addObjectsFromArray:vc.lks_ivarTraces]; - } - [ivarTraces addObjectsFromArray:self.lks_hostView.lks_ivarTraces]; - } else { - [ivarTraces addObjectsFromArray:self.lks_ivarTraces]; - } - if (ivarTraces.count) { - [array addObjectsFromArray:[ivarTraces lookin_map:^id(NSUInteger idx, LookinIvarTrace *value) { - return [NSString stringWithFormat:@"(%@ *) -> %@", value.hostClassName, value.ivarName]; - }]]; - } - return array.count ? array.copy : nil; -} - -- (LookinColor *)lks_backgroundColor { - return [LookinColor lks_colorWithCGColor:self.backgroundColor]; -} -- (void)setLks_backgroundColor:(LookinColor *)lks_backgroundColor { - self.backgroundColor = lks_backgroundColor.CGColor; -} - -- (LookinColor *)lks_borderColor { - return [LookinColor lks_colorWithCGColor:self.borderColor]; -} -- (void)setLks_borderColor:(LookinColor *)lks_borderColor { - self.borderColor = lks_borderColor.CGColor; -} - -- (LookinColor *)lks_shadowColor { - return [LookinColor lks_colorWithCGColor:self.shadowColor]; -} -- (void)setLks_shadowColor:(LookinColor *)lks_shadowColor { - self.shadowColor = lks_shadowColor.CGColor; -} - -- (CGFloat)lks_shadowOffsetWidth { - return self.shadowOffset.width; -} -- (void)setLks_shadowOffsetWidth:(CGFloat)lks_shadowOffsetWidth { - self.shadowOffset = CGSizeMake(lks_shadowOffsetWidth, self.shadowOffset.height); -} - -- (CGFloat)lks_shadowOffsetHeight { - return self.shadowOffset.height; -} -- (void)setLks_shadowOffsetHeight:(CGFloat)lks_shadowOffsetHeight { - self.shadowOffset = CGSizeMake(self.shadowOffset.width, lks_shadowOffsetHeight); -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/NSButton+LookinServer.h b/Sources/LookinServer/Server/Category/NSButton+LookinServer.h deleted file mode 100644 index 56e8051b..00000000 --- a/Sources/LookinServer/Server/Category/NSButton+LookinServer.h +++ /dev/null @@ -1,22 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSButton+LookinServer.h -// LookinServer -// -// Created by JH on 2024/11/7. -// - -#import "TargetConditionals.h" - - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSButton (LookinServer) -@property (nonatomic, readonly) NSButtonType lks_buttonType; -@end - -NS_ASSUME_NONNULL_END - -#endif diff --git a/Sources/LookinServer/Server/Category/NSButton+LookinServer.m b/Sources/LookinServer/Server/Category/NSButton+LookinServer.m deleted file mode 100644 index dc0b809e..00000000 --- a/Sources/LookinServer/Server/Category/NSButton+LookinServer.m +++ /dev/null @@ -1,16 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSButton+LookinServer.m -// LookinServer -// -// Created by JH on 2024/11/7. -// - -#import "NSButton+LookinServer.h" - -@implementation NSButton (LookinServer) -- (NSButtonType)lks_buttonType { - return [[self valueForKeyPath:@"cell._buttonType"] unsignedIntegerValue]; -} -@end -#endif diff --git a/Sources/LookinServer/Server/Category/NSCollectionView+LookinServer.h b/Sources/LookinServer/Server/Category/NSCollectionView+LookinServer.h deleted file mode 100644 index 1287fc5a..00000000 --- a/Sources/LookinServer/Server/Category/NSCollectionView+LookinServer.h +++ /dev/null @@ -1,22 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSCollectionView+LookinServer.h -// LookinServer -// -// Created by JH on 2026/5/4. -// - -#import "TargetConditionals.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSCollectionView (LookinServer) - -@property(nonatomic, readonly, copy) NSString *lks_backgroundColorsDescription; - -@end - -NS_ASSUME_NONNULL_END - -#endif diff --git a/Sources/LookinServer/Server/Category/NSCollectionView+LookinServer.m b/Sources/LookinServer/Server/Category/NSCollectionView+LookinServer.m deleted file mode 100644 index ef35e043..00000000 --- a/Sources/LookinServer/Server/Category/NSCollectionView+LookinServer.m +++ /dev/null @@ -1,42 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSCollectionView+LookinServer.m -// LookinServer -// -// Created by JH on 2026/5/4. -// - -#import "NSCollectionView+LookinServer.h" - -static NSString *LKSStringFromColor(NSColor *color) { - NSColor *rgbColor = [color colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; - if (!rgbColor) { - return color.description; - } - - CGFloat red = 0; - CGFloat green = 0; - CGFloat blue = 0; - CGFloat alpha = 0; - [rgbColor getRed:&red green:&green blue:&blue alpha:&alpha]; - return [NSString stringWithFormat:@"rgba(%.3f, %.3f, %.3f, %.3f)", red, green, blue, alpha]; -} - -@implementation NSCollectionView (LookinServer) - -- (NSString *)lks_backgroundColorsDescription { - NSArray *backgroundColors = self.backgroundColors; - if (backgroundColors.count == 0) { - return @"[]"; - } - - NSMutableArray *colorDescriptions = [NSMutableArray arrayWithCapacity:backgroundColors.count]; - [backgroundColors enumerateObjectsUsingBlock:^(NSColor * _Nonnull color, NSUInteger index, BOOL * _Nonnull stop) { - [colorDescriptions addObject:LKSStringFromColor(color)]; - }]; - return [colorDescriptions componentsJoinedByString:@", "]; -} - -@end - -#endif diff --git a/Sources/LookinServer/Server/Category/NSDatePicker+LookinServer.h b/Sources/LookinServer/Server/Category/NSDatePicker+LookinServer.h deleted file mode 100644 index d32c4e70..00000000 --- a/Sources/LookinServer/Server/Category/NSDatePicker+LookinServer.h +++ /dev/null @@ -1,24 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSDatePicker+LookinServer.h -// LookinServer -// -// Created by JH on 2026/5/4. -// - -#import "TargetConditionals.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSDatePicker (LookinServer) - -@property(nonatomic, readonly, copy) NSString *lks_dateValueDescription; -@property(nonatomic, readonly, copy) NSString *lks_minDateDescription; -@property(nonatomic, readonly, copy) NSString *lks_maxDateDescription; - -@end - -NS_ASSUME_NONNULL_END - -#endif diff --git a/Sources/LookinServer/Server/Category/NSDatePicker+LookinServer.m b/Sources/LookinServer/Server/Category/NSDatePicker+LookinServer.m deleted file mode 100644 index 9e11d7ce..00000000 --- a/Sources/LookinServer/Server/Category/NSDatePicker+LookinServer.m +++ /dev/null @@ -1,31 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSDatePicker+LookinServer.m -// LookinServer -// -// Created by JH on 2026/5/4. -// - -#import "NSDatePicker+LookinServer.h" - -static NSString *LKSStringFromDate(NSDate *date) { - return date.description; -} - -@implementation NSDatePicker (LookinServer) - -- (NSString *)lks_dateValueDescription { - return LKSStringFromDate(self.dateValue); -} - -- (NSString *)lks_minDateDescription { - return LKSStringFromDate(self.minDate); -} - -- (NSString *)lks_maxDateDescription { - return LKSStringFromDate(self.maxDate); -} - -@end - -#endif diff --git a/Sources/LookinServer/Server/Category/NSObject+LookinServer.h b/Sources/LookinServer/Server/Category/NSObject+LookinServer.h deleted file mode 100644 index 8bd5301c..00000000 --- a/Sources/LookinServer/Server/Category/NSObject+LookinServer.h +++ /dev/null @@ -1,40 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// NSObject+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/4/21. -// https://lookin.work -// - -#import "LookinDefines.h" -#import - -@class LookinIvarTrace; - -@interface NSObject (LookinServer) - -#pragma mark - oid - -/// 如果 oid 不存在则会创建新的 oid -- (unsigned long)lks_registerOid; - -/// 0 表示不存在 -@property(nonatomic, assign) unsigned long lks_oid; - -+ (NSObject *)lks_objectWithOid:(unsigned long)oid; - -#pragma mark - trace - -@property(nonatomic, copy) NSString *lks_specialTrace; - -+ (void)lks_clearAllObjectsTraces; - -/** - 获取当前对象的 Class 层级树,如 @[@"UIView", @"UIResponder", @"NSObject"]。未 demangle,有 Swift Module Name - */ -- (NSArray *)lks_classChainList; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/NSObject+LookinServer.m b/Sources/LookinServer/Server/Category/NSObject+LookinServer.m deleted file mode 100644 index 5c2ff06d..00000000 --- a/Sources/LookinServer/Server/Category/NSObject+LookinServer.m +++ /dev/null @@ -1,98 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// NSObject+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/4/21. -// https://lookin.work -// - -#import "NSObject+Lookin.h" -#import "NSObject+LookinServer.h" -#import "LookinServerDefines.h" -#import "LKS_ObjectRegistry.h" -#import - -@implementation NSObject (LookinServer) - -#pragma mark - oid - -- (unsigned long)lks_registerOid { - if (!self.lks_oid) { - unsigned long oid = [[LKS_ObjectRegistry sharedInstance] addObject:self]; - self.lks_oid = oid; - } - return self.lks_oid; -} - -- (void)setLks_oid:(unsigned long)lks_oid { - [self lookin_bindObject:@(lks_oid) forKey:@"lks_oid"]; -} - -- (unsigned long)lks_oid { - NSNumber *number = [self lookin_getBindObjectForKey:@"lks_oid"]; - return [number unsignedLongValue]; -} - -+ (NSObject *)lks_objectWithOid:(unsigned long)oid { - return [[LKS_ObjectRegistry sharedInstance] objectWithOid:oid]; -} - -#pragma mark - trace - -- (void)setLks_ivarTraces:(NSArray *)lks_ivarTraces { - [self lookin_bindObject:lks_ivarTraces.copy forKey:@"lks_ivarTraces"]; - - if (lks_ivarTraces) { - [[NSObject lks_allObjectsWithTraces] addPointer:(void *)self]; - } -} - -- (NSArray *)lks_ivarTraces { - return [self lookin_getBindObjectForKey:@"lks_ivarTraces"]; -} - -- (void)setLks_specialTrace:(NSString *)lks_specialTrace { - [self lookin_bindObject:lks_specialTrace forKey:@"lks_specialTrace"]; - if (lks_specialTrace) { - [[NSObject lks_allObjectsWithTraces] addPointer:(void *)self]; - } -} -- (NSString *)lks_specialTrace { - return [self lookin_getBindObjectForKey:@"lks_specialTrace"]; -} - -+ (void)lks_clearAllObjectsTraces { - [[[NSObject lks_allObjectsWithTraces] allObjects] enumerateObjectsUsingBlock:^(NSObject * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.lks_ivarTraces = nil; - obj.lks_specialTrace = nil; - }]; - [NSObject lks_allObjectsWithTraces].count = 0; -} - -+ (NSPointerArray *)lks_allObjectsWithTraces { - static dispatch_once_t onceToken; - static NSPointerArray *lks_allObjectsWithTraces = nil; - dispatch_once(&onceToken,^{ - lks_allObjectsWithTraces = [NSPointerArray weakObjectsPointerArray]; - }); - return lks_allObjectsWithTraces; -} - -- (NSArray *)lks_classChainList { - NSMutableArray *classChainList = [NSMutableArray array]; - Class currentClass = self.class; - - while (currentClass) { - NSString *currentClassName = NSStringFromClass(currentClass); - if (currentClassName) { - [classChainList addObject:currentClassName]; - } - currentClass = [currentClass superclass]; - } - return classChainList.copy; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/NSScrollView+LookinServer.h b/Sources/LookinServer/Server/Category/NSScrollView+LookinServer.h deleted file mode 100644 index f241c294..00000000 --- a/Sources/LookinServer/Server/Category/NSScrollView+LookinServer.h +++ /dev/null @@ -1,24 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSScrollView+LookinServer.h -// LookinServer -// -// Created by JH on 2024/11/7. -// - -#import "TargetConditionals.h" - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSScrollView (LookinServer) - -@property (nonatomic) CGPoint lks_contentOffset; -@property (nonatomic) CGSize lks_contentSize; - -@end - -NS_ASSUME_NONNULL_END - -#endif diff --git a/Sources/LookinServer/Server/Category/NSScrollView+LookinServer.m b/Sources/LookinServer/Server/Category/NSScrollView+LookinServer.m deleted file mode 100644 index 7d6e91f1..00000000 --- a/Sources/LookinServer/Server/Category/NSScrollView+LookinServer.m +++ /dev/null @@ -1,30 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSScrollView+LookinServer.m -// LookinServer -// -// Created by JH on 2024/11/7. -// - -#import "NSScrollView+LookinServer.h" - -@implementation NSScrollView (LookinServer) - -- (void)setLks_contentOffset:(CGPoint)lks_contentOffset { - [self.contentView scrollToPoint:lks_contentOffset]; -} - -- (CGPoint)lks_contentOffset { - return self.contentView.bounds.origin; -} - -- (void)setLks_contentSize:(CGSize)lks_contentSize { - [self.documentView setFrameSize:lks_contentSize]; -} - -- (CGSize)lks_contentSize { - return self.documentView.frame.size; -} - -@end -#endif diff --git a/Sources/LookinServer/Server/Category/NSWindow+LookinServer.h b/Sources/LookinServer/Server/Category/NSWindow+LookinServer.h deleted file mode 100644 index 4060a1d9..00000000 --- a/Sources/LookinServer/Server/Category/NSWindow+LookinServer.h +++ /dev/null @@ -1,57 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSWindow+LookinServer.h -// LookinServer -// -// Created by JH on 11/5/24. -// - -#import "TargetConditionals.h" - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSWindow (LookinServer) -// NSWindow 的 rootView 是 contentView 的 superview,例如 NSThemeFrame -@property (nonatomic, readonly, nullable) NSView *lks_rootView; - -- (NSImage *)lks_snapshotImage; - -- (CGRect)lks_bounds; - -// Returns the class chain list for this window, used by the Class attribute group -- (NSArray *> *)lks_relatedClassChainList; - -// Returns the self relation strings for this window, used by the Relation attribute group -- (NSArray *)lks_selfRelation; - -// styleMask flag accessors - individual BOOL getters/setters for each mask bit -@property (nonatomic, readonly) BOOL lks_styleMaskTitled; -@property (nonatomic, readonly) BOOL lks_styleMaskClosable; -@property (nonatomic, readonly) BOOL lks_styleMaskMiniaturizable; -@property (nonatomic, readonly) BOOL lks_styleMaskResizable; -@property (nonatomic, readonly) BOOL lks_styleMaskUnifiedTitleAndToolbar; -@property (nonatomic, readonly) BOOL lks_styleMaskFullScreen; -@property (nonatomic, readonly) BOOL lks_styleMaskFullSizeContentView; -@property (nonatomic, readonly) BOOL lks_styleMaskUtilityWindow; -@property (nonatomic, readonly) BOOL lks_styleMaskDocModalWindow; -@property (nonatomic, readonly) BOOL lks_styleMaskNonactivatingPanel; -@property (nonatomic, readonly) BOOL lks_styleMaskHUDWindow; - -// collectionBehavior flag accessors -@property (nonatomic, readonly) BOOL lks_collectionBehaviorCanJoinAllSpaces; -@property (nonatomic, readonly) BOOL lks_collectionBehaviorMoveToActiveSpace; -@property (nonatomic, readonly) BOOL lks_collectionBehaviorParticipatesInCycle; -@property (nonatomic, readonly) BOOL lks_collectionBehaviorIgnoresCycle; -@property (nonatomic, readonly) BOOL lks_collectionBehaviorFullScreenPrimary; -@property (nonatomic, readonly) BOOL lks_collectionBehaviorFullScreenAuxiliary; -@property (nonatomic, readonly) BOOL lks_collectionBehaviorFullScreenNone; -@property (nonatomic, readonly) BOOL lks_collectionBehaviorFullScreenAllowsTiling; -@property (nonatomic, readonly) BOOL lks_collectionBehaviorFullScreenDisallowsTiling; - -@end - -NS_ASSUME_NONNULL_END - -#endif diff --git a/Sources/LookinServer/Server/Category/NSWindow+LookinServer.m b/Sources/LookinServer/Server/Category/NSWindow+LookinServer.m deleted file mode 100644 index 69da210b..00000000 --- a/Sources/LookinServer/Server/Category/NSWindow+LookinServer.m +++ /dev/null @@ -1,201 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_OSX -// -// NSWindow+LookinServer.m -// LookinServer -// -// Created by JH on 11/5/24. -// - -#import "NSWindow+LookinServer.h" -#import "NSObject+LookinServer.h" -#import "LookinIvarTrace.h" -#import "NSArray+Lookin.h" - - -@implementation NSWindow (LookinServer) - -- (NSView *)lks_rootView { - return self.contentView.superview; -} - -- (NSImage *)lks_snapshotImage { - CGImageRef cgImage = CGWindowListCreateImage(CGRectZero, kCGWindowListOptionIncludingWindow, (int)self.windowNumber, kCGWindowImageBoundsIgnoreFraming); - if (!cgImage) { - return nil; - } - NSImage *image = [[NSImage alloc] initWithCGImage:cgImage size:self.frame.size]; - CGImageRelease(cgImage); - return image; -} - -- (CGRect)lks_bounds { - CGRect frame = self.frame; - frame.origin = CGPointZero; - return frame; -} - -- (NSArray *> *)lks_relatedClassChainList { - NSMutableArray *array = [NSMutableArray arrayWithCapacity:2]; - NSArray *completedList = [self lks_classChainList]; - NSUInteger endingIndex = [completedList indexOfObject:@"NSWindow"]; - if (endingIndex != NSNotFound) { - completedList = [completedList subarrayWithRange:NSMakeRange(0, endingIndex + 1)]; - } - [array addObject:completedList]; - if (self.windowController) { - NSArray *controllerList = [self.windowController lks_classChainList]; - NSUInteger controllerEndingIndex = [controllerList indexOfObject:@"NSWindowController"]; - if (controllerEndingIndex != NSNotFound) { - controllerList = [controllerList subarrayWithRange:NSMakeRange(0, controllerEndingIndex + 1)]; - } - [array addObject:controllerList]; - } - return array.copy; -} - -- (NSArray *)lks_selfRelation { - NSMutableArray *array = [NSMutableArray array]; - if (self.windowController) { - [array addObject:[NSString stringWithFormat:@"(%@ *).window", NSStringFromClass(self.windowController.class)]]; - } - if (self.delegate) { - [array addObject:[NSString stringWithFormat:@"(%@ *) delegate", NSStringFromClass([(NSObject *)self.delegate class])]]; - } - return array.copy; -} - -#pragma mark - StyleMask Flag Accessors - -- (BOOL)lks_styleMaskTitled { - return (self.styleMask & NSWindowStyleMaskTitled) != 0; -} - -- (BOOL)lks_styleMaskClosable { - return (self.styleMask & NSWindowStyleMaskClosable) != 0; -} - -- (BOOL)lks_styleMaskMiniaturizable { - return (self.styleMask & NSWindowStyleMaskMiniaturizable) != 0; -} - -- (BOOL)lks_styleMaskResizable { - return (self.styleMask & NSWindowStyleMaskResizable) != 0; -} - -- (BOOL)lks_styleMaskUnifiedTitleAndToolbar { - return (self.styleMask & NSWindowStyleMaskUnifiedTitleAndToolbar) != 0; -} - -- (BOOL)lks_styleMaskFullScreen { - return (self.styleMask & NSWindowStyleMaskFullScreen) != 0; -} - -- (BOOL)lks_styleMaskFullSizeContentView { - return (self.styleMask & NSWindowStyleMaskFullSizeContentView) != 0; -} - -- (BOOL)lks_styleMaskUtilityWindow { - return (self.styleMask & NSWindowStyleMaskUtilityWindow) != 0; -} - -- (BOOL)lks_styleMaskDocModalWindow { - return (self.styleMask & NSWindowStyleMaskDocModalWindow) != 0; -} - -- (BOOL)lks_styleMaskNonactivatingPanel { - return (self.styleMask & NSWindowStyleMaskNonactivatingPanel) != 0; -} - -- (BOOL)lks_styleMaskHUDWindow { - return (self.styleMask & NSWindowStyleMaskHUDWindow) != 0; -} - -- (void)setLks_styleMaskTitled:(BOOL)value { - if (value) { - self.styleMask |= NSWindowStyleMaskTitled; - } else { - self.styleMask &= ~NSWindowStyleMaskTitled; - } -} - -- (void)setLks_styleMaskClosable:(BOOL)value { - if (value) { - self.styleMask |= NSWindowStyleMaskClosable; - } else { - self.styleMask &= ~NSWindowStyleMaskClosable; - } -} - -- (void)setLks_styleMaskMiniaturizable:(BOOL)value { - if (value) { - self.styleMask |= NSWindowStyleMaskMiniaturizable; - } else { - self.styleMask &= ~NSWindowStyleMaskMiniaturizable; - } -} - -- (void)setLks_styleMaskResizable:(BOOL)value { - if (value) { - self.styleMask |= NSWindowStyleMaskResizable; - } else { - self.styleMask &= ~NSWindowStyleMaskResizable; - } -} - -- (void)setLks_styleMaskUnifiedTitleAndToolbar:(BOOL)value { - if (value) { - self.styleMask |= NSWindowStyleMaskUnifiedTitleAndToolbar; - } else { - self.styleMask &= ~NSWindowStyleMaskUnifiedTitleAndToolbar; - } -} - -- (void)setLks_styleMaskFullSizeContentView:(BOOL)value { - if (value) { - self.styleMask |= NSWindowStyleMaskFullSizeContentView; - } else { - self.styleMask &= ~NSWindowStyleMaskFullSizeContentView; - } -} - -#pragma mark - CollectionBehavior Flag Accessors - -- (BOOL)lks_collectionBehaviorCanJoinAllSpaces { - return (self.collectionBehavior & NSWindowCollectionBehaviorCanJoinAllSpaces) != 0; -} - -- (BOOL)lks_collectionBehaviorMoveToActiveSpace { - return (self.collectionBehavior & NSWindowCollectionBehaviorMoveToActiveSpace) != 0; -} - -- (BOOL)lks_collectionBehaviorParticipatesInCycle { - return (self.collectionBehavior & NSWindowCollectionBehaviorParticipatesInCycle) != 0; -} - -- (BOOL)lks_collectionBehaviorIgnoresCycle { - return (self.collectionBehavior & NSWindowCollectionBehaviorIgnoresCycle) != 0; -} - -- (BOOL)lks_collectionBehaviorFullScreenPrimary { - return (self.collectionBehavior & NSWindowCollectionBehaviorFullScreenPrimary) != 0; -} - -- (BOOL)lks_collectionBehaviorFullScreenAuxiliary { - return (self.collectionBehavior & NSWindowCollectionBehaviorFullScreenAuxiliary) != 0; -} - -- (BOOL)lks_collectionBehaviorFullScreenNone { - return (self.collectionBehavior & NSWindowCollectionBehaviorFullScreenNone) != 0; -} - -- (BOOL)lks_collectionBehaviorFullScreenAllowsTiling { - return (self.collectionBehavior & NSWindowCollectionBehaviorFullScreenAllowsTiling) != 0; -} - -- (BOOL)lks_collectionBehaviorFullScreenDisallowsTiling { - return (self.collectionBehavior & NSWindowCollectionBehaviorFullScreenDisallowsTiling) != 0; -} - -@end - -#endif diff --git a/Sources/LookinServer/Server/Category/UIBlurEffect+LookinServer.h b/Sources/LookinServer/Server/Category/UIBlurEffect+LookinServer.h deleted file mode 100644 index 00305bc6..00000000 --- a/Sources/LookinServer/Server/Category/UIBlurEffect+LookinServer.h +++ /dev/null @@ -1,20 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UIBlurEffect+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/10/8. -// https://lookin.work -// - -#import - -@interface UIBlurEffect (LookinServer) - -/// 该 number 包装的对象是 UIBlurEffectStyle,之所以用 NSNumber 是因为想把 0 和 nil 区分开,毕竟这里是在 hook 系统,稳一点好。 -/// 该方法的实现需要 Hook,因此若定义了 LOOKIN_SERVER_DISABLE_HOOK 宏,则属性会返回 nil -@property(nonatomic, strong) NSNumber *lks_effectStyleNumber; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIBlurEffect+LookinServer.m b/Sources/LookinServer/Server/Category/UIBlurEffect+LookinServer.m deleted file mode 100644 index 0a9f297f..00000000 --- a/Sources/LookinServer/Server/Category/UIBlurEffect+LookinServer.m +++ /dev/null @@ -1,56 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UIBlurEffect+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/10/8. -// https://lookin.work -// - -#import "UIBlurEffect+LookinServer.h" -#import "NSObject+Lookin.h" -#import - -@implementation UIBlurEffect (LookinServer) - -#ifdef LOOKIN_SERVER_DISABLE_HOOK - -- (void)setLks_effectStyleNumber:(NSNumber *)lks_effectStyleNumber { -} - -- (NSNumber *)lks_effectStyleNumber { - return nil; -} - -#else - -+ (void)load { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - Method oriMethod = class_getClassMethod([self class], @selector(effectWithStyle:)); - Method newMethod = class_getClassMethod([self class], @selector(lks_effectWithStyle:)); - method_exchangeImplementations(oriMethod, newMethod); - }); -} - -+ (UIBlurEffect *)lks_effectWithStyle:(UIBlurEffectStyle)style { - id effect = [self lks_effectWithStyle:style]; - if ([effect respondsToSelector:@selector(setLks_effectStyleNumber:)]) { - [effect setLks_effectStyleNumber:@(style)]; - } - return effect; -} - -- (void)setLks_effectStyleNumber:(NSNumber *)lks_effectStyleNumber { - [self lookin_bindObject:lks_effectStyleNumber forKey:@"lks_effectStyleNumber"]; -} - -- (NSNumber *)lks_effectStyleNumber { - return [self lookin_getBindObjectForKey:@"lks_effectStyleNumber"]; -} - -#endif /* LOOKIN_SERVER_DISABLE_HOOK */ - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIColor+LookinServer.h b/Sources/LookinServer/Server/Category/UIColor+LookinServer.h deleted file mode 100644 index 7c7b4d06..00000000 --- a/Sources/LookinServer/Server/Category/UIColor+LookinServer.h +++ /dev/null @@ -1,25 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIColor+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/6/5. -// https://lookin.work -// - -#import "LookinDefines.h" - -@interface LookinColor (LookinServer) - -- (NSArray *)lks_rgbaComponents; -+ (instancetype)lks_colorFromRGBAComponents:(NSArray *)components; - -- (NSString *)lks_rgbaString; -- (NSString *)lks_hexString; - -/// 会检查参数是否是真实的 CGColor -+ (LookinColor *)lks_colorWithCGColor:(CGColorRef)cgColor; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIColor+LookinServer.m b/Sources/LookinServer/Server/Category/UIColor+LookinServer.m deleted file mode 100644 index faaf0715..00000000 --- a/Sources/LookinServer/Server/Category/UIColor+LookinServer.m +++ /dev/null @@ -1,183 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// UIColor+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/6/5. -// https://lookin.work -// - -#import "UIColor+LookinServer.h" - -@implementation LookinColor (LookinServer) - -- (NSArray *)lks_rgbaComponents { - CGFloat r, g, b, a; - CGColorRef cgColor = [self CGColor]; - const CGFloat *components = CGColorGetComponents(cgColor); - if (CGColorGetNumberOfComponents(cgColor) == 4) { - r = components[0]; - g = components[1]; - b = components[2]; - a = components[3]; - } else if (CGColorGetNumberOfComponents(cgColor) == 2) { - r = components[0]; - g = components[0]; - b = components[0]; - a = components[1]; - } else if (CGColorGetNumberOfComponents(cgColor) == 1) { - r = components[0]; - g = components[0]; - b = components[0]; - a = components[0]; - } else { - r = 0; - g = 0; - b = 0; - a = 0; - NSAssert(NO, @""); - } - NSArray *rgba = @[@(r), @(g), @(b), @(a)]; - return rgba; -} - -+ (instancetype)lks_colorFromRGBAComponents:(NSArray *)components { - if (!components) { - return nil; - } - if (components.count != 4) { - NSAssert(NO, @""); - return nil; - } - LookinColor *color = [LookinColor colorWithRed:components[0].doubleValue green:components[1].doubleValue blue:components[2].doubleValue alpha:components[3].doubleValue]; - return color; -} - -- (NSString *)lks_rgbaString { - CGFloat r, g, b, a; - CGColorRef cgColor = [self CGColor]; - const CGFloat *components = CGColorGetComponents(cgColor); - if (CGColorGetNumberOfComponents(cgColor) == 4) { - r = components[0]; - g = components[1]; - b = components[2]; - a = components[3]; - } else if (CGColorGetNumberOfComponents(cgColor) == 2) { - r = components[0]; - g = components[0]; - b = components[0]; - a = components[1]; - } else { - r = 0; - g = 0; - b = 0; - a = 0; - NSAssert(NO, @""); - } - - if (a >= 1) { - return [NSString stringWithFormat:@"(%.0f, %.0f, %.0f)", r * 255, g * 255, b * 255]; - } else { - return [NSString stringWithFormat:@"(%.0f, %.0f, %.0f, %.2f)", r * 255, g * 255, b * 255, a]; - } -} - -- (NSString *)lks_hexString { - CGFloat r, g, b, a; - CGColorRef cgColor = [self CGColor]; - const CGFloat *components = CGColorGetComponents(cgColor); - if (CGColorGetNumberOfComponents(cgColor) == 4) { - r = components[0]; - g = components[1]; - b = components[2]; - a = components[3]; - } else if (CGColorGetNumberOfComponents(cgColor) == 2) { - r = components[0]; - g = components[0]; - b = components[0]; - a = components[1]; - } else { - r = 0; - g = 0; - b = 0; - a = 0; - NSAssert(NO, @""); - } - - NSInteger red = r * 255; - NSInteger green = g * 255; - NSInteger blue = b * 255; - NSInteger alpha = a * 255; - - return [[NSString stringWithFormat:@"#%@%@%@%@", - [LookinColor _alignColorHexStringLength:[LookinColor _hexStringWithInteger:alpha]], - [LookinColor _alignColorHexStringLength:[LookinColor _hexStringWithInteger:red]], - [LookinColor _alignColorHexStringLength:[LookinColor _hexStringWithInteger:green]], - [LookinColor _alignColorHexStringLength:[LookinColor _hexStringWithInteger:blue]]] lowercaseString]; -} - -// 对于色值只有单位数的,在前面补一个0,例如“F”会补齐为“0F” -+ (NSString *)_alignColorHexStringLength:(NSString *)hexString { - return hexString.length < 2 ? [@"0" stringByAppendingString:hexString] : hexString; -} - -+ (NSString *)_hexStringWithInteger:(NSInteger)integer { - NSString *hexString = @""; - NSInteger remainder = 0; - for (NSInteger i = 0; i < 9; i++) { - remainder = integer % 16; - integer = integer / 16; - NSString *letter = [self _hexLetterStringWithInteger:remainder]; - hexString = [letter stringByAppendingString:hexString]; - if (integer == 0) { - break; - } - - } - return hexString; -} - -+ (NSString *)_hexLetterStringWithInteger:(NSInteger)integer { - NSAssert(integer < 16, @"要转换的数必须是16进制里的个位数,也即小于16,但你传给我是%@", @(integer)); - - NSString *letter = nil; - switch (integer) { - case 10: - letter = @"A"; - break; - case 11: - letter = @"B"; - break; - case 12: - letter = @"C"; - break; - case 13: - letter = @"D"; - break; - case 14: - letter = @"E"; - break; - case 15: - letter = @"F"; - break; - default: - letter = [[NSString alloc]initWithFormat:@"%@", @(integer)]; - break; - } - return letter; -} - -+ (LookinColor *)lks_colorWithCGColor:(CGColorRef)cgColor { - if (!cgColor) { - return nil; - } - if (CFGetTypeID(cgColor) != CGColorGetTypeID()) { - return nil; - } - return [LookinColor colorWithCGColor:cgColor]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIImage+LookinServer.h b/Sources/LookinServer/Server/Category/UIImage+LookinServer.h deleted file mode 100644 index 4404bc73..00000000 --- a/Sources/LookinServer/Server/Category/UIImage+LookinServer.h +++ /dev/null @@ -1,21 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIImage+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/5/14. -// https://lookin.work -// - -#import "LookinDefines.h" - -@interface LookinImage (LookinServer) - -/// 该方法的实现需要 Hook,因此若定义了 LOOKIN_SERVER_DISABLE_HOOK 宏,则属性会返回 nil -@property(nonatomic, copy) NSString *lks_imageSourceName; - -- (NSData *)lookin_data; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIImage+LookinServer.m b/Sources/LookinServer/Server/Category/UIImage+LookinServer.m deleted file mode 100644 index 115c5439..00000000 --- a/Sources/LookinServer/Server/Category/UIImage+LookinServer.m +++ /dev/null @@ -1,106 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIImage+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/5/14. -// https://lookin.work -// - -#import -#import "UIImage+LookinServer.h" -#import "LookinServerDefines.h" - -@implementation LookinImage (LookinServer) - -#ifdef LOOKIN_SERVER_DISABLE_HOOK - -- (void)setLks_imageSourceName:(NSString *)lks_imageSourceName { -} - -- (NSString *)lks_imageSourceName { - return nil; -} - -#else - -+ (void)load { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - Method oriMethod = class_getClassMethod([self class], @selector(imageNamed:)); - Method newMethod = class_getClassMethod([self class], @selector(lks_imageNamed:)); - method_exchangeImplementations(oriMethod, newMethod); - -#if TARGET_OS_IPHONE - oriMethod = class_getClassMethod([self class], @selector(imageWithContentsOfFile:)); - newMethod = class_getClassMethod([self class], @selector(lks_imageWithContentsOfFile:)); - method_exchangeImplementations(oriMethod, newMethod); - - oriMethod = class_getClassMethod([self class], @selector(imageNamed:inBundle:compatibleWithTraitCollection:)); - newMethod = class_getClassMethod([self class], @selector(lks_imageNamed:inBundle:compatibleWithTraitCollection:)); - method_exchangeImplementations(oriMethod, newMethod); - - if (@available(iOS 13.0, tvOS 13.0, watchOS 6.0, *)) { - oriMethod = class_getClassMethod([self class], @selector(imageNamed:inBundle:withConfiguration:)); - newMethod = class_getClassMethod([self class], @selector(lks_imageNamed:inBundle:withConfiguration:)); - method_exchangeImplementations(oriMethod, newMethod); - } -#endif - -#if TARGET_OS_OSX -#endif - }); -} -+ (LookinImage *)lks_imageNamed:(NSString *)name { - LookinImage *image = [self lks_imageNamed:name]; - image.lks_imageSourceName = name; - return image; -} - -#if TARGET_OS_IPHONE -+ (nullable UIImage *)lks_imageNamed:(NSString *)name inBundle:(nullable NSBundle *)bundle withConfiguration:(nullable UIImageConfiguration *)configuration API_AVAILABLE(ios(13.0),tvos(13.0),watchos(6.0)) -{ - UIImage *image = [self lks_imageNamed:name inBundle:bundle withConfiguration:configuration]; - image.lks_imageSourceName = name; - return image; -} - -+ (nullable UIImage *)lks_imageNamed:(NSString *)name inBundle:(nullable NSBundle *)bundle compatibleWithTraitCollection:(nullable UITraitCollection *)traitCollection API_AVAILABLE(ios(8.0)) -{ - UIImage *image = [self lks_imageNamed:name inBundle:bundle compatibleWithTraitCollection:traitCollection]; - image.lks_imageSourceName = name; - return image; -} - -+ (UIImage *)lks_imageWithContentsOfFile:(NSString *)path { - UIImage *image = [self lks_imageWithContentsOfFile:path]; - - NSString *fileName = [[path componentsSeparatedByString:@"/"].lastObject componentsSeparatedByString:@"."].firstObject; - image.lks_imageSourceName = fileName; - return image; -} -#endif - -#if TARGET_OS_OSX -#endif - -- (void)setLks_imageSourceName:(NSString *)lks_imageSourceName { - [self lookin_bindObject:lks_imageSourceName.copy forKey:@"lks_imageSourceName"]; -} - -- (NSString *)lks_imageSourceName { - return [self lookin_getBindObjectForKey:@"lks_imageSourceName"]; -} - -#endif /* LOOKIN_SERVER_DISABLE_HOOK */ - -#if TARGET_OS_IPHONE -- (NSData *)lookin_data { - return UIImagePNGRepresentation(self); -} -#endif -// macOS 上 lookin_data 由 Image+Lookin.m(NSImage (LookinClient) 分类)提供。 - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIImageView+LookinServer.h b/Sources/LookinServer/Server/Category/UIImageView+LookinServer.h deleted file mode 100644 index dbf831ca..00000000 --- a/Sources/LookinServer/Server/Category/UIImageView+LookinServer.h +++ /dev/null @@ -1,19 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIImageView+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/9/18. -// https://lookin.work -// - -#import "LookinDefines.h" - -@interface LookinImageView (LookinServer) - -- (NSString *)lks_imageSourceName; -- (NSNumber *)lks_imageViewOidIfHasImage; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIImageView+LookinServer.m b/Sources/LookinServer/Server/Category/UIImageView+LookinServer.m deleted file mode 100644 index 030d3009..00000000 --- a/Sources/LookinServer/Server/Category/UIImageView+LookinServer.m +++ /dev/null @@ -1,30 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIImageView+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/9/18. -// https://lookin.work -// - -#import "UIImageView+LookinServer.h" -#import "UIImage+LookinServer.h" -#import "NSObject+LookinServer.h" - -@implementation LookinImageView (LookinServer) - -- (NSString *)lks_imageSourceName { - return self.image.lks_imageSourceName; -} - -- (NSNumber *)lks_imageViewOidIfHasImage { - if (!self.image) { - return nil; - } - unsigned long oid = [self lks_registerOid]; - return @(oid); -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UILabel+LookinServer.h b/Sources/LookinServer/Server/Category/UILabel+LookinServer.h deleted file mode 100644 index a98943c3..00000000 --- a/Sources/LookinServer/Server/Category/UILabel+LookinServer.h +++ /dev/null @@ -1,20 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UILabel+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/2/26. -// https://lookin.work -// - -#import - -@interface UILabel (LookinServer) - -@property(nonatomic, assign) CGFloat lks_fontSize; - -- (NSString *)lks_fontName; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UILabel+LookinServer.m b/Sources/LookinServer/Server/Category/UILabel+LookinServer.m deleted file mode 100644 index 257aef67..00000000 --- a/Sources/LookinServer/Server/Category/UILabel+LookinServer.m +++ /dev/null @@ -1,28 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UILabel+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/2/26. -// https://lookin.work -// - -#import "UILabel+LookinServer.h" - -@implementation UILabel (LookinServer) - -- (CGFloat)lks_fontSize { - return self.font.pointSize; -} -- (void)setLks_fontSize:(CGFloat)lks_fontSize { - UIFont *font = [self.font fontWithSize:lks_fontSize]; - self.font = font; -} - -- (NSString *)lks_fontName { - return self.font.fontName; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UITableView+LookinServer.h b/Sources/LookinServer/Server/Category/UITableView+LookinServer.h deleted file mode 100644 index f6fce7a1..00000000 --- a/Sources/LookinServer/Server/Category/UITableView+LookinServer.h +++ /dev/null @@ -1,18 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UITableView+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/9/5. -// https://lookin.work -// - -#import - -@interface UITableView (LookinServer) - -- (NSArray *)lks_numberOfRows; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UITableView+LookinServer.m b/Sources/LookinServer/Server/Category/UITableView+LookinServer.m deleted file mode 100644 index bbea222d..00000000 --- a/Sources/LookinServer/Server/Category/UITableView+LookinServer.m +++ /dev/null @@ -1,28 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UITableView+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/9/5. -// https://lookin.work -// - -#import "UITableView+LookinServer.h" -#import "LookinServerDefines.h" - -@implementation UITableView (LookinServer) - -- (NSArray *)lks_numberOfRows { - NSUInteger sectionsCount = MIN(self.numberOfSections, 10); - NSArray *rowsCount = [NSArray lookin_arrayWithCount:sectionsCount block:^id(NSUInteger idx) { - return @([self numberOfRowsInSection:idx]); - }]; - if (rowsCount.count == 0) { - return nil; - } - return rowsCount; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UITextField+LookinServer.h b/Sources/LookinServer/Server/Category/UITextField+LookinServer.h deleted file mode 100644 index 04754a1e..00000000 --- a/Sources/LookinServer/Server/Category/UITextField+LookinServer.h +++ /dev/null @@ -1,20 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UITextField+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/2/26. -// https://lookin.work -// - -#import - -@interface UITextField (LookinServer) - -@property(nonatomic, assign) CGFloat lks_fontSize; - -- (NSString *)lks_fontName; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UITextField+LookinServer.m b/Sources/LookinServer/Server/Category/UITextField+LookinServer.m deleted file mode 100644 index c01a5382..00000000 --- a/Sources/LookinServer/Server/Category/UITextField+LookinServer.m +++ /dev/null @@ -1,28 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UITextField+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/2/26. -// https://lookin.work -// - -#import "UITextField+LookinServer.h" - -@implementation UITextField (LookinServer) - -- (CGFloat)lks_fontSize { - return self.font.pointSize; -} -- (void)setLks_fontSize:(CGFloat)lks_fontSize { - UIFont *font = [self.font fontWithSize:lks_fontSize]; - self.font = font; -} - -- (NSString *)lks_fontName { - return self.font.fontName; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UITextView+LookinServer.h b/Sources/LookinServer/Server/Category/UITextView+LookinServer.h deleted file mode 100644 index 936964b5..00000000 --- a/Sources/LookinServer/Server/Category/UITextView+LookinServer.h +++ /dev/null @@ -1,20 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UITextView+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/2/26. -// https://lookin.work -// - -#import - -@interface UITextView (LookinServer) - -@property(nonatomic, assign) CGFloat lks_fontSize; - -- (NSString *)lks_fontName; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UITextView+LookinServer.m b/Sources/LookinServer/Server/Category/UITextView+LookinServer.m deleted file mode 100644 index e2ca6bc9..00000000 --- a/Sources/LookinServer/Server/Category/UITextView+LookinServer.m +++ /dev/null @@ -1,28 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UITextView+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/2/26. -// https://lookin.work -// - -#import "UITextView+LookinServer.h" - -@implementation UITextView (LookinServer) - -- (CGFloat)lks_fontSize { - return self.font.pointSize; -} -- (void)setLks_fontSize:(CGFloat)lks_fontSize { - UIFont *font = [self.font fontWithSize:lks_fontSize]; - self.font = font; -} - -- (NSString *)lks_fontName { - return self.font.fontName; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIView+LookinServer.h b/Sources/LookinServer/Server/Category/UIView+LookinServer.h deleted file mode 100644 index 50c712d8..00000000 --- a/Sources/LookinServer/Server/Category/UIView+LookinServer.h +++ /dev/null @@ -1,67 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIView+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/3/19. -// https://lookin.work -// - -#import "LookinDefines.h" - -@interface LookinView (LookinServer) - -/// 如果 myViewController.view = myView,则可以通过 myView 的 lks_findHostViewController 方法找到 myViewController -- (LookinViewController *)lks_findHostViewController; - -/// 是否是 UITabBar 的 childrenView,如果是的话,则截图时需要强制使用 renderInContext: 的方式而非 drawViewHierarchyInRect:afterScreenUpdates: 否则在 iOS 13 上获取到的图像是空的不知道为什么 -@property(nonatomic, assign) BOOL lks_isChildrenViewOfTabBar; - -/// point 是相对于 receiver 自身的坐标系 -- (LookinView *)lks_subviewAtPoint:(CGPoint)point preferredClasses:(NSArray *)preferredClasses; - -- (CGFloat)lks_bestWidth; -- (CGFloat)lks_bestHeight; -- (CGSize)lks_bestSize; - -@property(nonatomic, assign) float lks_horizontalContentHuggingPriority; -@property(nonatomic, assign) float lks_verticalContentHuggingPriority; - -@property(nonatomic, assign) float lks_horizontalContentCompressionResistancePriority; -@property(nonatomic, assign) float lks_verticalContentCompressionResistancePriority; - -/// 遍历全局的 view 并给他们设置 lks_involvedRawConstraints 属性 -+ (void)lks_rebuildGlobalInvolvedRawConstraints; -/// 该属性保存了牵扯到当前 view 的所有 constraints,包括那些没有生效的 -@property(nonatomic, strong) NSMutableArray *lks_involvedRawConstraints; - -- (NSArray *> *)lks_constraints; - -#if TARGET_OS_OSX -- (LookinImage *)lks_groupScreenshotWithLowQuality:(BOOL)lowQuality; -/// 当没有 sublayers 时,该方法返回 nil -- (LookinImage *)lks_soloScreenshotWithLowQuality:(BOOL)lowQuality; -#endif - -#if TARGET_OS_IPHONE -// UITraitCollection getters -@property (nonatomic, readonly) NSInteger lks_traitCollection_userInterfaceStyle; -@property (nonatomic, readonly) NSInteger lks_traitCollection_userInterfaceLevel; -@property (nonatomic, readonly) NSInteger lks_traitCollection_activeAppearance; -@property (nonatomic, readonly) NSInteger lks_traitCollection_accessibilityContrast; -@property (nonatomic, readonly) NSInteger lks_traitCollection_legibilityWeight; -@property (nonatomic, readonly) NSInteger lks_traitCollection_horizontalSizeClass; -@property (nonatomic, readonly) NSInteger lks_traitCollection_verticalSizeClass; -@property (nonatomic, readonly) CGFloat lks_traitCollection_displayScale; -@property (nonatomic, readonly) NSInteger lks_traitCollection_displayGamut; -@property (nonatomic, readonly) NSInteger lks_traitCollection_imageDynamicRange; -@property (nonatomic, readonly) NSInteger lks_traitCollection_userInterfaceIdiom; -@property (nonatomic, readonly) NSInteger lks_traitCollection_forceTouchCapability; -@property (nonatomic, readonly) NSInteger lks_traitCollection_layoutDirection; -@property (nonatomic, readonly, nullable) NSString *lks_traitCollection_preferredContentSizeCategory; -@property (nonatomic, readonly, nullable) NSString *lks_traitCollection_typesettingLanguage; -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIView+LookinServer.m b/Sources/LookinServer/Server/Category/UIView+LookinServer.m deleted file mode 100644 index 3086798f..00000000 --- a/Sources/LookinServer/Server/Category/UIView+LookinServer.m +++ /dev/null @@ -1,447 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIView+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/3/19. -// https://lookin.work -// - -#import "UIView+LookinServer.h" -#import -#import "LookinObject.h" -#import "LookinAutoLayoutConstraint.h" -#import "LookinServerDefines.h" -#import "LKS_MultiplatformAdapter.h" -#import "NSWindow+LookinServer.h" -@implementation LookinView (LookinServer) - -#if TARGET_OS_OSX -- (CGFloat)alpha { - return self.alphaValue; -} -- (void)setContentCompressionResistancePriority:(NSLayoutPriority)priority - forAxis:(NSLayoutConstraintOrientation)axis { - [self setContentCompressionResistancePriority:priority forOrientation:axis]; -} -- (void)setContentHuggingPriority:(NSLayoutPriority)priority - forAxis:(NSLayoutConstraintOrientation)axis { - [self setContentHuggingPriority:priority forOrientation:axis]; -} - -- (float)contentHuggingPriorityForAxis:(NSLayoutConstraintOrientation)axis { - return [self contentHuggingPriorityForOrientation:axis]; -} - -- (float)contentCompressionResistancePriorityForAxis:(NSLayoutConstraintOrientation)axis { - return [self contentCompressionResistancePriorityForOrientation:axis]; -} -- (NSArray *)constraintsAffectingLayoutForAxis:(NSLayoutConstraintOrientation)orientation { - return [self constraintsAffectingLayoutForOrientation:orientation]; -} -#endif - -- (LookinViewController *)lks_findHostViewController { - LookinResponder *responder = [self nextResponder]; - if (!responder) { - return nil; - } - if (![responder isKindOfClass:[LookinViewController class]]) { - return nil; - } - LookinViewController *viewController = (LookinViewController *)responder; - if (viewController.view != self) { - return nil; - } - return viewController; -} - -- (LookinView *)lks_subviewAtPoint:(CGPoint)point preferredClasses:(NSArray *)preferredClasses { - BOOL isPreferredClassForSelf = [preferredClasses lookin_any:^BOOL(Class obj) { - return [self isKindOfClass:obj]; - }]; - if (isPreferredClassForSelf) { - return self; - } - - LookinView *targetView = [self.subviews lookin_lastFiltered:^BOOL(__kindof LookinView *obj) { - if (obj.hidden || obj.alpha <= 0.01) { - return NO; - } - BOOL contains = CGRectContainsPoint(obj.frame, point); - return contains; - }]; - - if (!targetView) { - return self; - } - - CGPoint newPoint = [targetView convertPoint:point fromView:self]; - targetView = [targetView lks_subviewAtPoint:newPoint preferredClasses:preferredClasses]; - return targetView; -} - -- (CGSize)lks_bestSize { -#if TARGET_OS_IPHONE - return [self sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)]; -#elif TARGET_OS_OSX - if ([self isKindOfClass:[NSControl class]]) { - return [((NSControl *)self) sizeThatFits:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX)]; - } else { - return self.fittingSize; - } -#endif -} - -- (CGFloat)lks_bestWidth { - return self.lks_bestSize.width; -} - -- (CGFloat)lks_bestHeight { - return self.lks_bestSize.height; -} - -- (void)setLks_isChildrenViewOfTabBar:(BOOL)lks_isChildrenViewOfTabBar { - [self lookin_bindBOOL:lks_isChildrenViewOfTabBar forKey:@"lks_isChildrenViewOfTabBar"]; -} -- (BOOL)lks_isChildrenViewOfTabBar { - return [self lookin_getBindBOOLForKey:@"lks_isChildrenViewOfTabBar"]; -} - -- (void)setLks_verticalContentHuggingPriority:(float)lks_verticalContentHuggingPriority { - [self setContentHuggingPriority:lks_verticalContentHuggingPriority forAxis:LookinLayoutConstraintAxisVertical]; -} -- (float)lks_verticalContentHuggingPriority { - return [self contentHuggingPriorityForAxis:LookinLayoutConstraintAxisVertical]; -} - -- (void)setLks_horizontalContentHuggingPriority:(float)lks_horizontalContentHuggingPriority { - [self setContentHuggingPriority:lks_horizontalContentHuggingPriority forAxis:LookinLayoutConstraintAxisHorizontal]; -} -- (float)lks_horizontalContentHuggingPriority { - return [self contentHuggingPriorityForAxis:LookinLayoutConstraintAxisHorizontal]; -} - -- (void)setLks_verticalContentCompressionResistancePriority:(float)lks_verticalContentCompressionResistancePriority { - [self setContentCompressionResistancePriority:lks_verticalContentCompressionResistancePriority forAxis:LookinLayoutConstraintAxisVertical]; -} -- (float)lks_verticalContentCompressionResistancePriority { - return [self contentCompressionResistancePriorityForAxis:LookinLayoutConstraintAxisVertical]; -} - -- (void)setLks_horizontalContentCompressionResistancePriority:(float)lks_horizontalContentCompressionResistancePriority { - [self setContentCompressionResistancePriority:lks_horizontalContentCompressionResistancePriority forAxis:LookinLayoutConstraintAxisHorizontal]; -} -- (float)lks_horizontalContentCompressionResistancePriority { - return [self contentCompressionResistancePriorityForAxis:LookinLayoutConstraintAxisHorizontal]; -} - -+ (void)lks_rebuildGlobalInvolvedRawConstraints { - [[LKS_MultiplatformAdapter allWindows] enumerateObjectsUsingBlock:^(__kindof LookinWindow * _Nonnull window, NSUInteger idx, BOOL * _Nonnull stop) { -#if TARGET_OS_IPHONE - [self lks_removeInvolvedRawConstraintsForViewsRootedByView:window]; -#elif TARGET_OS_OSX - [self lks_removeInvolvedRawConstraintsForViewsRootedByView:window.lks_rootView]; -#endif - }]; - [[LKS_MultiplatformAdapter allWindows] enumerateObjectsUsingBlock:^(__kindof LookinWindow * _Nonnull window, NSUInteger idx, BOOL * _Nonnull stop) { -#if TARGET_OS_IPHONE - [self lks_addInvolvedRawConstraintsForViewsRootedByView:window]; -#elif TARGET_OS_OSX - [self lks_addInvolvedRawConstraintsForViewsRootedByView:window.lks_rootView]; -#endif - }]; -} - -+ (void)lks_addInvolvedRawConstraintsForViewsRootedByView:(LookinView *)rootView { - [rootView.constraints enumerateObjectsUsingBlock:^(__kindof NSLayoutConstraint * _Nonnull constraint, NSUInteger idx, BOOL * _Nonnull stop) { - LookinView *firstView = constraint.firstItem; - if ([firstView isKindOfClass:[LookinView class]] && ![firstView.lks_involvedRawConstraints containsObject:constraint]) { - if (!firstView.lks_involvedRawConstraints) { - firstView.lks_involvedRawConstraints = [NSMutableArray array]; - } - [firstView.lks_involvedRawConstraints addObject:constraint]; - } - - LookinView *secondView = constraint.secondItem; - if ([secondView isKindOfClass:[LookinView class]] && ![secondView.lks_involvedRawConstraints containsObject:constraint]) { - if (!secondView.lks_involvedRawConstraints) { - secondView.lks_involvedRawConstraints = [NSMutableArray array]; - } - [secondView.lks_involvedRawConstraints addObject:constraint]; - } - }]; - - [rootView.subviews enumerateObjectsUsingBlock:^(__kindof LookinView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) { - [self lks_addInvolvedRawConstraintsForViewsRootedByView:subview]; - }]; -} - -+ (void)lks_removeInvolvedRawConstraintsForViewsRootedByView:(LookinView *)rootView { - [rootView.lks_involvedRawConstraints removeAllObjects]; - [rootView.subviews enumerateObjectsUsingBlock:^(__kindof LookinView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) { - [self lks_removeInvolvedRawConstraintsForViewsRootedByView:subview]; - }]; -} - -- (void)setLks_involvedRawConstraints:(NSMutableArray *)lks_involvedRawConstraints { - [self lookin_bindObject:lks_involvedRawConstraints forKey:@"lks_involvedRawConstraints"]; -} - -- (NSMutableArray *)lks_involvedRawConstraints { - return [self lookin_getBindObjectForKey:@"lks_involvedRawConstraints"]; -} - -- (NSArray *)lks_constraints { - /** - - lks_involvedRawConstraints 保存了牵扯到了 self 的所有的 constraints(包括未生效的,但不包括 inactive 的,整个产品逻辑都是直接忽略 inactive 的 constraints) - - 通过 constraintsAffectingLayoutForAxis 可以拿到会影响 self 布局的所有已生效的 constraints(这里称之为 effectiveConstraints) - - 很多情况下,一条 constraint 会出现在 effectiveConstraints 里但不会出现在 lks_involvedRawConstraints 里,比如: - · UIWindow 拥有 minX, minY, width, height 四个 effectiveConstraints,但 lks_involvedRawConstraints 为空,因为它的 constraints 属性为空(这一点不知道为啥,但 Xcode Inspector 和 Reveal 确实也不会显示这四个 constraints) - · 如果设置了 View1 的 center 和 superview 的 center 保持一致,则 superview 的 width 和 height 也会出现在 effectiveConstraints 里,但不会出现在 lks_involvedRawConstraints 里(这点可以理解,毕竟这种场景下 superview 的 width 和 height 确实会影响到 View1) - */ - NSMutableArray *effectiveConstraints = [NSMutableArray array]; - [effectiveConstraints addObjectsFromArray:[self constraintsAffectingLayoutForAxis:LookinLayoutConstraintAxisHorizontal]]; - [effectiveConstraints addObjectsFromArray:[self constraintsAffectingLayoutForAxis:LookinLayoutConstraintAxisVertical]]; - - NSArray *lookinConstraints = [self.lks_involvedRawConstraints lookin_map:^id(NSUInteger idx, __kindof NSLayoutConstraint *constraint) { - BOOL isEffective = [effectiveConstraints containsObject:constraint]; - if ([constraint isActive]) { - // 尝试获取未激活约束的 firstItem 或 secondItem 可能导致野指针崩溃 - // https://github.com/QMUI/LookinServer/issues/86 - LookinConstraintItemType firstItemType = [self _lks_constraintItemTypeForItem:constraint.firstItem]; - LookinConstraintItemType secondItemType = [self _lks_constraintItemTypeForItem:constraint.secondItem]; - LookinAutoLayoutConstraint *lookinConstraint = [LookinAutoLayoutConstraint instanceFromNSConstraint:constraint isEffective:isEffective firstItemType:firstItemType secondItemType:secondItemType]; - return lookinConstraint; - } - return nil; - }]; - return lookinConstraints.count ? lookinConstraints : nil; -} - -- (LookinConstraintItemType)_lks_constraintItemTypeForItem:(id)item { - if (!item) { - return LookinConstraintItemTypeNil; - } - if (item == self) { - return LookinConstraintItemTypeSelf; - } - if (item == self.superview) { - return LookinConstraintItemTypeSuper; - } - - // 在 runtime 时,这里会遇到的 UILayoutGuide 和 _UILayoutGuide 居然是 UIView 的子类,不知道是看错了还是有什么玄机,所以在判断是否是 UIView 之前要先判断这个 - if (@available(iOS 9.0, *)) { - if ([item isKindOfClass:[LookinLayoutGuide class]]) { - return LookinConstraintItemTypeLayoutGuide; - } - } - - -#if TARGET_OS_IPHONE - NSString *className = NSStringFromClass([item class]); - if ([className hasSuffix:@"_UILayoutGuide"]) { - return LookinConstraintItemTypeLayoutGuide; - } -#endif - - if ([item isKindOfClass:[LookinView class]]) { - return LookinConstraintItemTypeView; - } - - NSAssert(NO, @""); - return LookinConstraintItemTypeUnknown; -} - -#pragma mark - Screenshot - -#if TARGET_OS_IPHONE - -// MARK: - UITraitCollection getters - -- (NSInteger)lks_traitCollection_userInterfaceStyle { - if (@available(iOS 12.0, *)) { - return (NSInteger)self.traitCollection.userInterfaceStyle; - } - return 0; -} - -- (NSInteger)lks_traitCollection_userInterfaceLevel { - if (@available(iOS 13.0, *)) { - return (NSInteger)self.traitCollection.userInterfaceLevel; - } - return -1; -} - -- (NSInteger)lks_traitCollection_activeAppearance { - if (@available(iOS 14.0, *)) { - return (NSInteger)self.traitCollection.activeAppearance; - } - return -1; -} - -- (NSInteger)lks_traitCollection_accessibilityContrast { - if (@available(iOS 13.0, *)) { - return (NSInteger)self.traitCollection.accessibilityContrast; - } - return -1; -} - -- (NSInteger)lks_traitCollection_legibilityWeight { - if (@available(iOS 13.0, *)) { - return (NSInteger)self.traitCollection.legibilityWeight; - } - return -1; -} - -- (NSInteger)lks_traitCollection_horizontalSizeClass { - return (NSInteger)self.traitCollection.horizontalSizeClass; -} - -- (NSInteger)lks_traitCollection_verticalSizeClass { - return (NSInteger)self.traitCollection.verticalSizeClass; -} - -- (CGFloat)lks_traitCollection_displayScale { - return self.traitCollection.displayScale; -} - -- (NSInteger)lks_traitCollection_displayGamut { - if (@available(iOS 10.0, *)) { - return (NSInteger)self.traitCollection.displayGamut; - } - return -1; -} - -- (NSInteger)lks_traitCollection_imageDynamicRange { - if (@available(iOS 17.0, *)) { - return (NSInteger)self.traitCollection.imageDynamicRange; - } - return -1; -} - -- (NSInteger)lks_traitCollection_userInterfaceIdiom { - return (NSInteger)self.traitCollection.userInterfaceIdiom; -} - -- (NSInteger)lks_traitCollection_forceTouchCapability { - return (NSInteger)self.traitCollection.forceTouchCapability; -} - -- (NSInteger)lks_traitCollection_layoutDirection { - if (@available(iOS 10.0, *)) { - return (NSInteger)self.traitCollection.layoutDirection; - } - return -1; -} - -- (NSString *)lks_traitCollection_preferredContentSizeCategory { - if (@available(iOS 10.0, *)) { - return self.traitCollection.preferredContentSizeCategory; - } - return nil; -} - -- (NSString *)lks_traitCollection_typesettingLanguage { - if (@available(iOS 17.0, *)) { - return self.traitCollection.typesettingLanguage; - } - return nil; -} - -#endif - -#if TARGET_OS_OSX -- (LookinImage *)lks_groupScreenshotWithLowQuality:(BOOL)lowQuality { - - CGFloat screenScale = [LKS_MultiplatformAdapter mainScreenScale]; - CGFloat pixelWidth = self.frame.size.width * screenScale; - CGFloat pixelHeight = self.frame.size.height * screenScale; - if (pixelWidth <= 0 || pixelHeight <= 0) { - return nil; - } - - CGFloat renderScale = lowQuality ? 1 : 0; - CGFloat maxLength = MAX(pixelWidth, pixelHeight); - if (maxLength > LookinNodeImageMaxLengthInPx) { - // 确保最终绘制出的图片长和宽都不能超过 LookinNodeImageMaxLengthInPx - // 如果算出的 renderScale 大于 1 则取 1,因为似乎用 1 渲染的速度要比一个别的奇怪的带小数点的数字要更快 - renderScale = MIN(screenScale * LookinNodeImageMaxLengthInPx / maxLength, 1); - } - - CGSize contextSize = self.frame.size; - if (contextSize.width <= 0 || contextSize.height <= 0 || contextSize.width > 20000 || contextSize.height > 20000) { - NSLog(@"LookinServer - Failed to capture screenshot. Invalid context size: %@ x %@", @(contextSize.width), @(contextSize.height)); - return nil; - } - - NSBitmapImageRep *rep = [self bitmapImageRepForCachingDisplayInRect:self.bounds]; - if (!rep) { - return nil; - } - [self cacheDisplayInRect:self.bounds toBitmapImageRep:rep]; - - NSImage *image = [[NSImage alloc] initWithSize:rep.size]; - - [image addRepresentation:rep]; - - return image; -} - -- (LookinImage *)lks_soloScreenshotWithLowQuality:(BOOL)lowQuality { - if (!self.subviews.count) { - return nil; - } - - CGFloat screenScale = [LKS_MultiplatformAdapter mainScreenScale]; - CGFloat pixelWidth = self.frame.size.width * screenScale; - CGFloat pixelHeight = self.frame.size.height * screenScale; - if (pixelWidth <= 0 || pixelHeight <= 0) { - return nil; - } - - CGFloat renderScale = lowQuality ? 1 : 0; - CGFloat maxLength = MAX(pixelWidth, pixelHeight); - if (maxLength > LookinNodeImageMaxLengthInPx) { - // 确保最终绘制出的图片长和宽都不能超过 LookinNodeImageMaxLengthInPx - // 如果算出的 renderScale 大于 1 则取 1,因为似乎用 1 渲染的速度要比一个别的奇怪的带小数点的数字要更快 - renderScale = MIN(screenScale * LookinNodeImageMaxLengthInPx / maxLength, 1); - } - - if (self.subviews.count) { - NSArray *subviews = [self.subviews copy]; - NSMutableArray *visibleSubviews = [NSMutableArray arrayWithCapacity:subviews.count]; - [subviews enumerateObjectsUsingBlock:^(__kindof NSView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) { - if (!subview.hidden) { - subview.hidden = YES; - [visibleSubviews addObject:subview]; - } - }]; - - CGSize contextSize = self.frame.size; - if (contextSize.width <= 0 || contextSize.height <= 0 || contextSize.width > 20000 || contextSize.height > 20000) { - NSLog(@"LookinServer - Failed to capture screenshot. Invalid context size: %@ x %@", @(contextSize.width), @(contextSize.height)); - return nil; - } - - NSBitmapImageRep *rep = [self bitmapImageRepForCachingDisplayInRect:self.bounds]; - if (!rep) { - return nil; - } - [self cacheDisplayInRect:self.bounds toBitmapImageRep:rep]; - - NSImage *image = [[NSImage alloc] initWithSize:rep.size]; - - [image addRepresentation:rep]; - [visibleSubviews enumerateObjectsUsingBlock:^(NSView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) { - subview.hidden = NO; - }]; - return image; - } - return nil; -} -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIViewController+LookinServer.h b/Sources/LookinServer/Server/Category/UIViewController+LookinServer.h deleted file mode 100644 index 7ddf22a7..00000000 --- a/Sources/LookinServer/Server/Category/UIViewController+LookinServer.h +++ /dev/null @@ -1,18 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIViewController+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/4/22. -// https://lookin.work -// - -#import "LookinDefines.h" - -@interface LookinViewController (LookinServer) - -+ (LookinViewController *)lks_visibleViewController; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIViewController+LookinServer.m b/Sources/LookinServer/Server/Category/UIViewController+LookinServer.m deleted file mode 100644 index 63a7c66c..00000000 --- a/Sources/LookinServer/Server/Category/UIViewController+LookinServer.m +++ /dev/null @@ -1,71 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// UIViewController+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/4/22. -// https://lookin.work -// - -#import "UIViewController+LookinServer.h" -#import "UIView+LookinServer.h" -#import -#import "LKS_MultiplatformAdapter.h" - -@implementation LookinViewController (LookinServer) - -+ (nullable LookinViewController *)lks_visibleViewController { -#if TARGET_OS_IPHONE - LookinViewController *rootViewController = [LKS_MultiplatformAdapter keyWindow].rootViewController; - LookinViewController *visibleViewController = [rootViewController lks_visibleViewControllerIfExist]; - return visibleViewController; -#endif - -#if TARGET_OS_OSX - - LookinViewController *rootViewController = [LKS_MultiplatformAdapter keyWindow].contentViewController; - LookinViewController *visibleViewController = [rootViewController lks_visibleViewControllerIfExist]; - return visibleViewController; -#endif -} - -- (LookinViewController *)lks_visibleViewControllerIfExist { - -#if TARGET_OS_IPHONE - if (self.presentedViewController) { - return [self.presentedViewController lks_visibleViewControllerIfExist]; - } - - if ([self isKindOfClass:[UINavigationController class]]) { - return [((UINavigationController *)self).visibleViewController lks_visibleViewControllerIfExist]; - } - - if ([self isKindOfClass:[UITabBarController class]]) { - return [((UITabBarController *)self).selectedViewController lks_visibleViewControllerIfExist]; - } - - if (self.isViewLoaded && !self.view.hidden && self.view.alpha > 0.01) { - return self; - } else { - return nil; - } -#endif - -#if TARGET_OS_OSX - if (self.presentedViewControllers) { - for (NSViewController *presentedViewController in self.presentedViewControllers) { - return [presentedViewController lks_visibleViewControllerIfExist]; - } - } - - if (self.isViewLoaded && !self.view.hidden && self.view.alphaValue > 0.01) { - return self; - } else { - return nil; - } -#endif -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIVisualEffectView+LookinServer.h b/Sources/LookinServer/Server/Category/UIVisualEffectView+LookinServer.h deleted file mode 100644 index 7eeac574..00000000 --- a/Sources/LookinServer/Server/Category/UIVisualEffectView+LookinServer.h +++ /dev/null @@ -1,20 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UIVisualEffectView+LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/10/8. -// https://lookin.work -// - -#import - -@interface UIVisualEffectView (LookinServer) - -- (void)setLks_blurEffectStyleNumber:(NSNumber *)lks_blurEffectStyleNumber; - -- (NSNumber *)lks_blurEffectStyleNumber; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIVisualEffectView+LookinServer.m b/Sources/LookinServer/Server/Category/UIVisualEffectView+LookinServer.m deleted file mode 100644 index 71cc4168..00000000 --- a/Sources/LookinServer/Server/Category/UIVisualEffectView+LookinServer.m +++ /dev/null @@ -1,32 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION) -// -// UIVisualEffectView+LookinServer.m -// LookinServer -// -// Created by Li Kai on 2019/10/8. -// https://lookin.work -// - -#import "UIVisualEffectView+LookinServer.h" -#import "UIBlurEffect+LookinServer.h" - -@implementation UIVisualEffectView (LookinServer) - -- (void)setLks_blurEffectStyleNumber:(NSNumber *)lks_blurEffectStyleNumber { - UIBlurEffectStyle style = [lks_blurEffectStyleNumber integerValue]; - UIBlurEffect *effect = [UIBlurEffect effectWithStyle:style]; - self.effect = effect; -} - -- (NSNumber *)lks_blurEffectStyleNumber { - UIVisualEffect *effect = self.effect; - if (![effect isKindOfClass:[UIBlurEffect class]]) { - return nil; - } - UIBlurEffect *blurEffect = (UIBlurEffect *)effect; - return blurEffect.lks_effectStyleNumber; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.h b/Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.h deleted file mode 100644 index 310b5f6c..00000000 --- a/Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.h +++ /dev/null @@ -1,93 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_IPHONE -// -// UIWindowScene+LookinServer.h -// LookinServer -// - -#import "TargetConditionals.h" -#import - -NS_ASSUME_NONNULL_BEGIN - -API_AVAILABLE(ios(13.0)) -@interface UIWindowScene (LookinServer) - -// Returns the class chain list for this scene, truncated at UIScene -- (NSArray *> *)lks_relatedClassChainList; - -// Returns the self relation strings (delegate class name) -- (NSArray *)lks_selfRelation; - -// Pass-through getters for sub-object properties -@property (nonatomic, readonly) CGRect lks_coordinateSpaceBounds; -@property (nonatomic, readonly) NSInteger lks_windowCount; -@property (nonatomic, readonly, nullable) NSString *lks_keyWindowClassName; -@property (nonatomic, readonly) CGRect lks_screenBounds; -@property (nonatomic, readonly) CGFloat lks_screenScale; -@property (nonatomic, readonly) BOOL lks_statusBarHidden; -@property (nonatomic, readonly) NSInteger lks_statusBarStyle; -@property (nonatomic, readonly) CGRect lks_statusBarFrame; -@property (nonatomic, readonly) NSInteger lks_userInterfaceStyle; -@property (nonatomic, readonly) NSInteger lks_horizontalSizeClass; -@property (nonatomic, readonly) NSInteger lks_verticalSizeClass; -@property (nonatomic, readonly, nullable) NSString *lks_sessionPersistentIdentifier; -@property (nonatomic, readonly, nullable) NSString *lks_sessionRole; - -// Additional trait getters -@property (nonatomic, readonly) NSInteger lks_userInterfaceLevel; -@property (nonatomic, readonly) NSInteger lks_activeAppearance; -@property (nonatomic, readonly) NSInteger lks_accessibilityContrast; -@property (nonatomic, readonly) NSInteger lks_legibilityWeight; -@property (nonatomic, readonly) CGFloat lks_traitDisplayScale; -@property (nonatomic, readonly) NSInteger lks_displayGamut; -@property (nonatomic, readonly) NSInteger lks_userInterfaceIdiom; -@property (nonatomic, readonly) NSInteger lks_layoutDirection; -@property (nonatomic, readonly, nullable) NSString *lks_preferredContentSizeCategory; -@property (nonatomic, readonly) NSInteger lks_sceneCaptureState; -@property (nonatomic, readonly) NSInteger lks_imageDynamicRange; -@property (nonatomic, readonly, nullable) NSString *lks_typesettingLanguage; - -#if !TARGET_OS_TV -// Declared only where UIInterfaceOrientation exists. On tvOS the selector is -// absent, so the dashboard drops the row instead of showing a fabricated value. -@property (nonatomic, readonly) NSInteger lks_interfaceOrientation; -#endif - -// UISceneSession getters -@property (nonatomic, readonly, nullable) NSString *lks_sessionStateRestorationActivityType; -@property (nonatomic, readonly, nullable) NSString *lks_sessionUserInfoJSONString; - -// UISceneConfiguration getters -@property (nonatomic, readonly, nullable) NSString *lks_configurationName; -@property (nonatomic, readonly, nullable) NSString *lks_configurationSceneClassName; -@property (nonatomic, readonly, nullable) NSString *lks_configurationDelegateClassName; -@property (nonatomic, readonly, nullable) NSString *lks_configurationStoryboardDescription; - -// UIWindowSceneGeometry getters -@property (nonatomic, readonly, nullable) NSValue *lks_geometrySystemFrame; -@property (nonatomic, readonly, nullable) NSNumber *lks_geometryInterfaceOrientationLocked; -@property (nonatomic, readonly, nullable) NSNumber *lks_geometryInteractivelyResizing; - -// UISceneActivationConditions getters -@property (nonatomic, readonly, nullable) NSString *lks_canActivateForTargetContentIdentifierPredicateFormat; -@property (nonatomic, readonly, nullable) NSString *lks_prefersToActivateForTargetContentIdentifierPredicateFormat; - -// UISceneSizeRestrictions getters -@property (nonatomic, readonly, nullable) NSValue *lks_sizeRestrictionsMinimumSize; -@property (nonatomic, readonly, nullable) NSValue *lks_sizeRestrictionsMaximumSize; -@property (nonatomic, readonly, nullable) NSNumber *lks_sizeRestrictionsAllowsFullScreen; - -// UISceneWindowingBehaviors getters -@property (nonatomic, readonly, nullable) NSNumber *lks_windowingBehaviorsClosable; -@property (nonatomic, readonly, nullable) NSNumber *lks_windowingBehaviorsMiniaturizable; -@property (nonatomic, readonly, nullable) NSNumber *lks_fullScreen; - -// UIPointerLockState / UISceneSystemProtectionManager getters -@property (nonatomic, readonly, nullable) NSNumber *lks_pointerLocked; -@property (nonatomic, readonly, nullable) NSNumber *lks_systemProtectionUserAuthenticationEnabled; - -@end - -NS_ASSUME_NONNULL_END - -#endif diff --git a/Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.m b/Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.m deleted file mode 100644 index 9281bc40..00000000 --- a/Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.m +++ /dev/null @@ -1,396 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && TARGET_OS_IPHONE -// -// UIWindowScene+LookinServer.m -// LookinServer -// - -#import "UIWindowScene+LookinServer.h" -#import "NSObject+LookinServer.h" -#import "NSArray+Lookin.h" -#import - -@implementation UIWindowScene (LookinServer) - -- (NSArray *> *)lks_relatedClassChainList { - NSMutableArray *array = [NSMutableArray arrayWithCapacity:1]; - NSArray *completedList = [self lks_classChainList]; - NSUInteger endingIndex = [completedList indexOfObject:@"UIScene"]; - if (endingIndex != NSNotFound) { - completedList = [completedList subarrayWithRange:NSMakeRange(0, endingIndex + 1)]; - } - [array addObject:completedList]; - return array.copy; -} - -- (NSArray *)lks_selfRelation { - NSMutableArray *array = [NSMutableArray array]; - if (self.delegate) { - [array addObject:[NSString stringWithFormat:@"(%@ *) delegate", NSStringFromClass([(NSObject *)self.delegate class])]]; - } - return array.copy; -} - -// -coordinateSpace and -interfaceOrientation were both deprecated in iOS 26 in -// favour of -effectiveGeometry. Prefer the new source where it exists and fall -// back to the old property below it, so a single row keeps working across -// versions. effectiveGeometry itself arrived in iOS 16, but only exposes -// coordinateSpace from iOS 26 onwards — hence the two different thresholds. -- (CGRect)lks_coordinateSpaceBounds { -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26.0, tvOS 26.0, *)) { - return self.effectiveGeometry.coordinateSpace.bounds; - } -#endif -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - return self.coordinateSpace.bounds; -#pragma clang diagnostic pop -} - -#if !TARGET_OS_TV -- (NSInteger)lks_interfaceOrientation { - if (@available(iOS 16.0, *)) { - return (NSInteger)self.effectiveGeometry.interfaceOrientation; - } -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - return (NSInteger)self.interfaceOrientation; -#pragma clang diagnostic pop -} -#endif - -- (NSInteger)lks_windowCount { - return (NSInteger)self.windows.count; -} - -- (NSString *)lks_keyWindowClassName { - UIWindow *keyWindow = nil; - if (@available(iOS 15.0, tvOS 15.0, *)) { - keyWindow = self.keyWindow; - } else { - for (UIWindow *window in self.windows) { - if (window.isKeyWindow) { - keyWindow = window; - break; - } - } - } - return keyWindow ? NSStringFromClass(keyWindow.class) : nil; -} - -- (CGRect)lks_screenBounds { -#if TARGET_OS_VISION - return self.coordinateSpace.bounds; -#else - return self.screen ? self.screen.bounds : CGRectZero; -#endif -} - -- (CGFloat)lks_screenScale { -#if TARGET_OS_VISION - return 2.0; -#else - return self.screen ? self.screen.scale : 1.0; -#endif -} - -- (BOOL)lks_statusBarHidden { -#if TARGET_OS_TV - return YES; -#else - return self.statusBarManager ? self.statusBarManager.isStatusBarHidden : YES; -#endif -} - -- (NSInteger)lks_statusBarStyle { -#if TARGET_OS_TV - return 0; -#else - return self.statusBarManager ? (NSInteger)self.statusBarManager.statusBarStyle : 0; -#endif -} - -- (CGRect)lks_statusBarFrame { -#if TARGET_OS_TV - return CGRectZero; -#else - return self.statusBarManager ? self.statusBarManager.statusBarFrame : CGRectZero; -#endif -} - -- (NSInteger)lks_userInterfaceStyle { - return (NSInteger)self.traitCollection.userInterfaceStyle; -} - -- (NSInteger)lks_horizontalSizeClass { - return (NSInteger)self.traitCollection.horizontalSizeClass; -} - -- (NSInteger)lks_verticalSizeClass { - return (NSInteger)self.traitCollection.verticalSizeClass; -} - -- (NSString *)lks_sessionPersistentIdentifier { - return self.session.persistentIdentifier; -} - -- (NSString *)lks_sessionRole { - return self.session.role; -} - -- (NSInteger)lks_userInterfaceLevel { -#if TARGET_OS_TV - return 0; -#else - return (NSInteger)self.traitCollection.userInterfaceLevel; -#endif -} - -- (NSInteger)lks_activeAppearance { - if (@available(iOS 14.0, tvOS 14.0, *)) { - return (NSInteger)self.traitCollection.activeAppearance; - } - return -1; -} - -- (NSInteger)lks_accessibilityContrast { - return (NSInteger)self.traitCollection.accessibilityContrast; -} - -- (NSInteger)lks_legibilityWeight { - return (NSInteger)self.traitCollection.legibilityWeight; -} - -- (CGFloat)lks_traitDisplayScale { - return self.traitCollection.displayScale; -} - -- (NSInteger)lks_displayGamut { - return (NSInteger)self.traitCollection.displayGamut; -} - -- (NSInteger)lks_userInterfaceIdiom { - return (NSInteger)self.traitCollection.userInterfaceIdiom; -} - -- (NSInteger)lks_layoutDirection { - return (NSInteger)self.traitCollection.layoutDirection; -} - -- (NSString *)lks_preferredContentSizeCategory { - return self.traitCollection.preferredContentSizeCategory; -} - -- (NSInteger)lks_sceneCaptureState { - if (@available(iOS 17.0, tvOS 17.0, *)) { - return (NSInteger)self.traitCollection.sceneCaptureState; - } - return -1; -} - -- (NSInteger)lks_imageDynamicRange { - if (@available(iOS 17.0, tvOS 17.0, *)) { - return (NSInteger)self.traitCollection.imageDynamicRange; - } - return -1; -} - -- (NSString *)lks_typesettingLanguage { - if (@available(iOS 17.0, tvOS 17.0, *)) { - return self.traitCollection.typesettingLanguage; - } - return nil; -} - -#pragma mark - UISceneSession - -- (NSString *)lks_sessionStateRestorationActivityType { - return self.session.stateRestorationActivity.activityType; -} - -- (NSString *)lks_sessionUserInfoJSONString { - NSDictionary *userInfo = self.session.userInfo; - if (userInfo.count == 0) { - return nil; - } - // userInfo holds plist types, which is a wider set than JSON: dates and data - // are legal there and would make serialization fail. Fall back to the plain - // description rather than dropping the row. - if (![NSJSONSerialization isValidJSONObject:userInfo]) { - return userInfo.description; - } - NSData *jsonData = [NSJSONSerialization dataWithJSONObject:userInfo options:NSJSONWritingPrettyPrinted error:NULL]; - if (!jsonData) { - return userInfo.description; - } - return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; -} - -#pragma mark - UISceneConfiguration - -- (NSString *)lks_configurationName { - return self.session.configuration.name; -} - -- (NSString *)lks_configurationSceneClassName { - Class sceneClass = self.session.configuration.sceneClass; - return sceneClass ? NSStringFromClass(sceneClass) : nil; -} - -- (NSString *)lks_configurationDelegateClassName { - Class delegateClass = self.session.configuration.delegateClass; - return delegateClass ? NSStringFromClass(delegateClass) : nil; -} - -- (NSString *)lks_configurationStoryboardDescription { - UIStoryboard *storyboard = self.session.configuration.storyboard; - if (!storyboard) { - return nil; - } - // UIStoryboard exposes no public name, and its description is just the - // pointer. Ask for the private -name and fall back when it is gone. - SEL nameSelector = NSSelectorFromString(@"name"); - if ([storyboard respondsToSelector:nameSelector]) { - id name = ((id (*)(id, SEL))objc_msgSend)(storyboard, nameSelector); - if ([name isKindOfClass:[NSString class]] && ((NSString *)name).length > 0) { - return name; - } - } - return storyboard.description; -} - -#pragma mark - UIWindowSceneGeometry - -- (NSValue *)lks_geometrySystemFrame { -#if TARGET_OS_MACCATALYST - if (@available(macCatalyst 16.0, *)) { - return [NSValue valueWithCGRect:self.effectiveGeometry.systemFrame]; - } -#endif - return nil; -} - -- (NSNumber *)lks_geometryInterfaceOrientationLocked { -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 -#if !TARGET_OS_TV && !TARGET_OS_VISION - if (@available(iOS 26.0, *)) { - return @(self.effectiveGeometry.isInterfaceOrientationLocked); - } -#endif -#endif - return nil; -} - -- (NSNumber *)lks_geometryInteractivelyResizing { -#if defined(__IPHONE_26_0) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_26_0 - if (@available(iOS 26.0, tvOS 26.0, *)) { - return @(self.effectiveGeometry.isInteractivelyResizing); - } -#endif - return nil; -} - -#pragma mark - UISceneActivationConditions - -- (NSString *)lks_canActivateForTargetContentIdentifierPredicateFormat { - return self.activationConditions.canActivateForTargetContentIdentifierPredicate.predicateFormat; -} - -- (NSString *)lks_prefersToActivateForTargetContentIdentifierPredicateFormat { - return self.activationConditions.prefersToActivateForTargetContentIdentifierPredicate.predicateFormat; -} - -#pragma mark - UISceneSizeRestrictions - -- (NSValue *)lks_sizeRestrictionsMinimumSize { -#if !TARGET_OS_TV - UISceneSizeRestrictions *sizeRestrictions = self.sizeRestrictions; - if (sizeRestrictions) { - return [NSValue valueWithCGSize:sizeRestrictions.minimumSize]; - } -#endif - return nil; -} - -- (NSValue *)lks_sizeRestrictionsMaximumSize { -#if !TARGET_OS_TV - UISceneSizeRestrictions *sizeRestrictions = self.sizeRestrictions; - if (sizeRestrictions) { - return [NSValue valueWithCGSize:sizeRestrictions.maximumSize]; - } -#endif - return nil; -} - -- (NSNumber *)lks_sizeRestrictionsAllowsFullScreen { -#if TARGET_OS_MACCATALYST - if (@available(macCatalyst 16.0, *)) { - UISceneSizeRestrictions *sizeRestrictions = self.sizeRestrictions; - if (sizeRestrictions) { - return @(sizeRestrictions.allowsFullScreen); - } - } -#endif - return nil; -} - -#pragma mark - UISceneWindowingBehaviors - -- (NSNumber *)lks_windowingBehaviorsClosable { - if (@available(iOS 16.0, tvOS 16.0, *)) { - UISceneWindowingBehaviors *windowingBehaviors = self.windowingBehaviors; - if (windowingBehaviors) { - return @(windowingBehaviors.isClosable); - } - } - return nil; -} - -- (NSNumber *)lks_windowingBehaviorsMiniaturizable { - if (@available(iOS 16.0, tvOS 16.0, *)) { - UISceneWindowingBehaviors *windowingBehaviors = self.windowingBehaviors; - if (windowingBehaviors) { - return @(windowingBehaviors.isMiniaturizable); - } - } - return nil; -} - -- (NSNumber *)lks_fullScreen { -#if TARGET_OS_MACCATALYST - if (@available(macCatalyst 16.0, *)) { - return @(self.isFullScreen); - } -#endif - return nil; -} - -#pragma mark - Pointer lock and system protection - -- (NSNumber *)lks_pointerLocked { -#if !TARGET_OS_TV - if (@available(iOS 14.0, *)) { - UIPointerLockState *pointerLockState = self.pointerLockState; - if (pointerLockState) { - return @(pointerLockState.isLocked); - } - } -#endif - return nil; -} - -- (NSNumber *)lks_systemProtectionUserAuthenticationEnabled { -#if !TARGET_OS_TV && !TARGET_OS_VISION - if (@available(iOS 18.0, *)) { - UISceneSystemProtectionManager *systemProtectionManager = self.systemProtectionManager; - if (systemProtectionManager) { - return @(systemProtectionManager.isUserAuthenticationEnabled); - } - } -#endif - return nil; -} - -@end - -#endif diff --git a/Sources/LookinServer/Server/Connection/LKS_ConnectionManager.h b/Sources/LookinServer/Server/Connection/LKS_ConnectionManager.h deleted file mode 100644 index 534cfe88..00000000 --- a/Sources/LookinServer/Server/Connection/LKS_ConnectionManager.h +++ /dev/null @@ -1,34 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// Lookin.h -// Lookin -// -// Created by Li Kai on 2018/8/5. -// https://lookin.work -// - -#import "TargetConditionals.h" - -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_OSX -#import -#endif - -extern NSString *const LKS_ConnectionDidEndNotificationName; - -@class LookinConnectionResponseAttachment; - -@interface LKS_ConnectionManager : NSObject - -+ (instancetype)sharedInstance; - -@property(nonatomic, assign) BOOL applicationIsActive; - -- (void)respond:(LookinConnectionResponseAttachment *)data requestType:(uint32_t)requestType tag:(uint32_t)tag; - -- (void)pushData:(NSObject *)data type:(uint32_t)type; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/LKS_ConnectionManager.m b/Sources/LookinServer/Server/Connection/LKS_ConnectionManager.m deleted file mode 100644 index 96a4c7e5..00000000 --- a/Sources/LookinServer/Server/Connection/LKS_ConnectionManager.m +++ /dev/null @@ -1,292 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LookinServer.m -// LookinServer -// -// Created by Li Kai on 2018/8/5. -// https://lookin.work -// - -#import "LKS_ConnectionManager.h" -#import "Lookin_PTChannel.h" -#import "LKS_RequestHandler.h" -#import "LookinConnectionResponseAttachment.h" -#import "LKS_ExportManager.h" -#import "LookinServerDefines.h" -#import "LKS_TraceManager.h" -#import "LKS_MultiplatformAdapter.h" - -NSString *const LKS_ConnectionDidEndNotificationName = @"LKS_ConnectionDidEndNotificationName"; - -@interface LKS_ConnectionManager () - -@property(nonatomic, strong) Lookin_PTChannel *peerChannel_; - -@property(nonatomic, strong) LKS_RequestHandler *requestHandler; - -@end - -@implementation LKS_ConnectionManager - -+ (instancetype)sharedInstance { - static LKS_ConnectionManager *sharedInstance; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sharedInstance = [[LKS_ConnectionManager alloc] init]; - }); - return sharedInstance; -} - -+ (void)load { - // 触发 init 方法 - [LKS_ConnectionManager sharedInstance]; -} - -- (instancetype)init { - if (self = [super init]) { - NSLog(@"LookinServer - Will launch. Framework version: %@", LOOKIN_SERVER_READABLE_VERSION); - -#if TARGET_OS_IPHONE - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_handleApplicationDidBecomeActive) name:UIApplicationDidBecomeActiveNotification object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_handleWillResignActiveNotification) name:UIApplicationWillResignActiveNotification object:nil]; -#endif - -#if TARGET_OS_OSX - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_handleApplicationDidBecomeActive) name:NSApplicationDidBecomeActiveNotification object:nil]; -// [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_handleWillResignActiveNotification) name:NSApplicationWillResignActiveNotification object:nil]; -#endif - - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_handleLocalInspect:) name:@"Lookin_2D" object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_handleLocalInspect:) name:@"Lookin_3D" object:nil]; - [[NSNotificationCenter defaultCenter] addObserverForName:@"Lookin_Export" object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) { - [[LKS_ExportManager sharedInstance] exportAndShare]; - }]; - [[NSNotificationCenter defaultCenter] addObserverForName:@"Lookin_RelationSearch" object:nil queue:[NSOperationQueue mainQueue] usingBlock:^(NSNotification * _Nonnull note) { - [[LKS_TraceManager sharedInstance] addSearchTarger:note.object]; - }]; - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleGetLookinInfo:) name:@"GetLookinInfo" object:nil]; - - self.requestHandler = [LKS_RequestHandler new]; -#if TARGET_OS_OSX - self.applicationIsActive = YES; - [self searchPortToListenIfNoConnection]; -#endif - } - return self; -} - -- (void)_handleWillResignActiveNotification { - self.applicationIsActive = NO; - - if (self.peerChannel_ && ![self.peerChannel_ isConnected]) { - [self.peerChannel_ close]; - self.peerChannel_ = nil; - } -} - -- (void)_handleApplicationDidBecomeActive { - self.applicationIsActive = YES; - [self searchPortToListenIfNoConnection]; -} - -- (void)searchPortToListenIfNoConnection { - if (self.peerChannel_ && (self.peerChannel_.isConnected || self.peerChannel_.isListening)) { - NSLog(@"LookinServer - Abort to search ports. Already has active channel %@.", self.peerChannel_.debugTag); - return; - } - NSLog(@"LookinServer - Searching port to listen..."); - [self.peerChannel_ close]; - self.peerChannel_ = nil; - -#if TARGET_OS_OSX - [self _tryToListenOnPortFrom:LookinMacIPv4PortNumberStart to:LookinMacIPv4PortNumberEnd current:LookinMacIPv4PortNumberStart]; -#else - if ([self isiOSAppOnMac]) { - [self _tryToListenOnPortFrom:LookinSimulatorIPv4PortNumberStart to:LookinSimulatorIPv4PortNumberEnd current:LookinSimulatorIPv4PortNumberStart]; - } else { - [self _tryToListenOnPortFrom:LookinUSBDeviceIPv4PortNumberStart to:LookinUSBDeviceIPv4PortNumberEnd current:LookinUSBDeviceIPv4PortNumberStart]; - } -#endif -} - -- (BOOL)isiOSAppOnMac { -#if TARGET_OS_SIMULATOR - return YES; -#elif TARGET_OS_OSX - return YES; -#else - if (@available(iOS 14.0, *)) { - // isiOSAppOnMac 这个 API 看似在 iOS 14.0 上可用,但其实在 iOS 14 beta 上是不存在的、有 unrecognized selector 问题,因此这里要用 respondsToSelector 做一下保护 - NSProcessInfo *info = [NSProcessInfo processInfo]; - if ([info respondsToSelector:@selector(isiOSAppOnMac)] && [info isiOSAppOnMac]) { - return YES; - } else if ([info respondsToSelector:@selector(isMacCatalystApp)] && [info isMacCatalystApp]) { - return YES; - } else { - return NO; - } - } else if (@available(iOS 13.0, tvOS 13.0, *)) { - return [NSProcessInfo processInfo].isMacCatalystApp; - } - return NO; -#endif -} - -- (void)_tryToListenOnPortFrom:(int)fromPort to:(int)toPort current:(int)currentPort { - Lookin_PTChannel *channel = [Lookin_PTChannel channelWithDelegate:self]; - channel.targetPort = currentPort; - [channel listenOnPort:currentPort IPv4Address:INADDR_LOOPBACK callback:^(NSError *error) { - if (error) { - if (error.code == 48) { - // 该地址已被占用 - } else { - // 未知失败 - } - - if (currentPort < toPort) { - // 尝试下一个端口 - NSLog(@"LookinServer - 127.0.0.1:%d is unavailable(%@). Will try anothor address ...", currentPort, error); - [self _tryToListenOnPortFrom:fromPort to:toPort current:(currentPort + 1)]; - } else { - // 所有端口都尝试完毕,全部失败 - NSLog(@"LookinServer - 127.0.0.1:%d is unavailable(%@).", currentPort, error); - NSLog(@"LookinServer - Connect failed in the end."); - } - - } else { - // 成功 - NSLog(@"LookinServer - Listening on 127.0.0.1:%d with channel %@.", currentPort, channel.debugTag); - // 此时 peerChannel_ 状态为 listening - self.peerChannel_ = channel; - } - }]; -} - -- (void)dealloc { - if (self.peerChannel_) { - [self.peerChannel_ close]; - } - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -- (void)respond:(LookinConnectionResponseAttachment *)data requestType:(uint32_t)requestType tag:(uint32_t)tag { - [self _sendData:data frameOfType:requestType tag:tag]; -} - -- (void)pushData:(NSObject *)data type:(uint32_t)type { - [self _sendData:data frameOfType:type tag:0]; -} - -- (void)_sendData:(NSObject *)data frameOfType:(uint32_t)frameOfType tag:(uint32_t)tag { - if (self.peerChannel_) { - NSData *archivedData = [NSKeyedArchiver archivedDataWithRootObject:data]; - dispatch_data_t payload = [archivedData createReferencingDispatchData]; - - [self.peerChannel_ sendFrameOfType:frameOfType tag:tag withPayload:payload callback:^(NSError *error) { - if (error) { - } - }]; - } -} - -#pragma mark - Lookin_PTChannelDelegate - -- (BOOL)ioFrameChannel:(Lookin_PTChannel*)channel shouldAcceptFrameOfType:(uint32_t)type tag:(uint32_t)tag payloadSize:(uint32_t)payloadSize { - if (channel != self.peerChannel_) { - return NO; - } else if ([self.requestHandler canHandleRequestType:type]) { - return YES; - } else { - [channel close]; - return NO; - } -} - -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didReceiveFrameOfType:(uint32_t)type tag:(uint32_t)tag payload:(Lookin_PTData*)payload { - id object = nil; - if (payload) { - id unarchivedObject = [NSKeyedUnarchiver unarchiveObjectWithData:[NSData dataWithContentsOfDispatchData:payload.dispatchData]]; - if ([unarchivedObject isKindOfClass:[LookinConnectionAttachment class]]) { - LookinConnectionAttachment *attachment = (LookinConnectionAttachment *)unarchivedObject; - object = attachment.data; - } else { - object = unarchivedObject; - } - } - [self.requestHandler handleRequestType:type tag:tag object:object]; -} - -/// 当 Client 端链接成功时,该方法会被调用,然后 channel 的状态会变成 connected -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didAcceptConnection:(Lookin_PTChannel*)otherChannel fromAddress:(Lookin_PTAddress*)address { - NSLog(@"LookinServer - channel:%@, acceptConnection:%@", channel.debugTag, otherChannel.debugTag); - - Lookin_PTChannel *previousChannel = self.peerChannel_; - - otherChannel.targetPort = channel.targetPort; - self.peerChannel_ = otherChannel; - - [previousChannel cancel]; -} - -/// 当连接过 Lookin 客户端,然后 Lookin 客户端又被关闭时,会走到这里 -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didEndWithError:(NSError*)error { - if (self.peerChannel_ != channel) { - // Client 端第一次连接上时,之前 listen 的 port 会被 Peertalk 内部 cancel(并在 didAcceptConnection 方法里给业务抛一个新建的 connected 状态的 channel),那个被 cancel 的 channel 会走到这里 - NSLog(@"LookinServer - Ignore channel%@ end.", channel.debugTag); - return; - } - // Client 端关闭时,会走到这里 - NSLog(@"LookinServer - channel%@ DidEndWithError:%@", channel.debugTag, error); - self.peerChannel_ = nil; - - [[NSNotificationCenter defaultCenter] postNotificationName:LKS_ConnectionDidEndNotificationName object:self]; - [self searchPortToListenIfNoConnection]; -} - -#pragma mark - Handler - -- (void)_handleLocalInspect:(NSNotification *)note { -#if TARGET_OS_IPHONE - UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"Lookin" message:@"Failed to run local inspection. The feature has been removed. Please use the computer version of Lookin or consider SDKs like FLEX for similar functionality." preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *okAction = [UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]; - [alertController addAction:okAction]; - UIWindow *keyWindow = [LKS_MultiplatformAdapter keyWindow]; - UIViewController *rootViewController = [keyWindow rootViewController]; - [rootViewController presentViewController:alertController animated:YES completion:nil]; - -#elif TARGET_OS_OSX - NSAlert *alert = [[NSAlert alloc] init]; - alert.messageText = @"Lookin"; - alert.informativeText = @"Failed to run local inspection. The feature has been removed. Please use the computer version of Lookin or consider SDKs like FLEX for similar functionality."; - [alert addButtonWithTitle:@"OK"]; - [alert runModal]; -#endif - NSLog(@"LookinServer - Failed to run local inspection. The feature has been removed. Please use the computer version of Lookin or consider SDKs like FLEX for similar functionality."); -} - -- (void)handleGetLookinInfo:(NSNotification *)note { - NSDictionary* userInfo = note.userInfo; - if (!userInfo) { - return; - } - NSMutableDictionary* infoWrapper = userInfo[@"infos"]; - if (![infoWrapper isKindOfClass:[NSMutableDictionary class]]) { - NSLog(@"LookinServer - GetLookinInfo failed. Params invalid."); - return; - } - infoWrapper[@"lookinServerVersion"] = LOOKIN_SERVER_READABLE_VERSION; -} - -@end - -/// 这个类使得用户可以通过 NSClassFromString(@"Lookin") 来判断 LookinServer 是否被编译进了项目里 - -@interface Lookin : NSObject - -@end - -@implementation Lookin - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/LKS_RequestHandler.h b/Sources/LookinServer/Server/Connection/LKS_RequestHandler.h deleted file mode 100644 index c999ba77..00000000 --- a/Sources/LookinServer/Server/Connection/LKS_RequestHandler.h +++ /dev/null @@ -1,20 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_MAC) -// -// LKS_RequestHandler.h -// LookinServer -// -// Created by Li Kai on 2019/1/15. -// https://lookin.work -// - -#import - -@interface LKS_RequestHandler : NSObject - -- (BOOL)canHandleRequestType:(uint32_t)requestType; - -- (void)handleRequestType:(uint32_t)requestType tag:(uint32_t)tag object:(id)object; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/LKS_RequestHandler.m b/Sources/LookinServer/Server/Connection/LKS_RequestHandler.m deleted file mode 100644 index 33b47ef1..00000000 --- a/Sources/LookinServer/Server/Connection/LKS_RequestHandler.m +++ /dev/null @@ -1,261 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LKS_RequestHandler.m -// LookinServer -// -// Created by Li Kai on 2019/1/15. -// https://lookin.work -// - -#import "LKS_RequestHandler.h" -#import "LKS_ConnectionManager.h" -#import "LookinConnectionResponseAttachment.h" -#import "LookinHierarchyInfo.h" -#import "LookinAppInfo.h" -#import "LookinObject.h" -#import "LookinDisplayItemDetail.h" -#import "LookinStaticAsyncUpdateTask.h" -#import "NSObject+LookinServer.h" -#import "LKS_AttrGroupsMaker.h" -#import "LKS_InbuiltAttrModificationHandler.h" -#import "LKS_CustomAttrModificationHandler.h" -#import "LKS_AttrModificationPatchHandler.h" -#import "LKS_HierarchyDetailsHandler.h" -#import "LookinServerDefines.h" -#import "LookinHierarchyInfo+LookinServer.h" -#import "UIImage+LookinServer.h" -#import "NSValue+Lookin.h" -#import - -@interface LKS_RequestHandler () - -@property(nonatomic, strong) NSMutableSet *activeDetailHandlers; -@property(nonatomic, strong) NSSet *validRequestTypes; - -@end - -@implementation LKS_RequestHandler - -- (instancetype)init { - if (self = [super init]) { - _validRequestTypes = [NSSet setWithArray:@[ - @(LookinRequestTypePing), - @(LookinRequestTypeApp), - @(LookinRequestTypeHierarchy), - @(LookinRequestTypeHierarchyDetails), - @(LookinRequestTypeInbuiltAttrModification), - @(LookinRequestTypeCustomAttrModification), - @(LookinRequestTypeAttrModificationPatch), - @(LookinRequestTypeFetchObject), - @(LookinRequestTypeAllAttrGroups), - @(LookinRequestTypeAllSelectorNames), - @(LookinRequestTypeInvokeMethod), - @(LookinRequestTypeFetchImageViewImage), - @(LookinRequestTypeModifyRecognizerEnable), - @(LookinPush_CanceHierarchyDetails), - ]]; - _activeDetailHandlers = [NSMutableSet set]; - } - return self; -} - -- (BOOL)canHandleRequestType:(uint32_t)requestType { - return [self.validRequestTypes containsObject:@(requestType)]; -} - -- (void)_respondWithData:(id)data requestType:(uint32_t)requestType tag:(uint32_t)tag { - LookinConnectionResponseAttachment *attachment = [LookinConnectionResponseAttachment new]; - attachment.data = data; - [[LKS_ConnectionManager sharedInstance] respond:attachment requestType:requestType tag:tag]; -} - -- (void)_respondWithError:(NSError *)error requestType:(uint32_t)requestType tag:(uint32_t)tag { - LookinConnectionResponseAttachment *attachment = [LookinConnectionResponseAttachment new]; - attachment.error = error ?: LookinErr_Inner; - [[LKS_ConnectionManager sharedInstance] respond:attachment requestType:requestType tag:tag]; -} - -- (void)handleRequestType:(uint32_t)requestType tag:(uint32_t)tag object:(id)object { - if (requestType == LookinRequestTypePing) { - LookinConnectionResponseAttachment *attachment = [LookinConnectionResponseAttachment new]; -#if TARGET_OS_OSX - attachment.appIsInBackground = NO; -#else - attachment.appIsInBackground = ![LKS_ConnectionManager sharedInstance].applicationIsActive; -#endif - [[LKS_ConnectionManager sharedInstance] respond:attachment requestType:requestType tag:tag]; - return; - } - - if (requestType == LookinRequestTypeApp) { - NSDictionary *params = [object isKindOfClass:[NSDictionary class]] ? object : @{}; - BOOL needImages = [params[@"needImages"] boolValue]; - NSArray *localIdentifiers = [params[@"local"] isKindOfClass:[NSArray class]] ? params[@"local"] : @[]; - [self _respondWithData:[LookinAppInfo currentInfoWithScreenshot:needImages icon:needImages localIdentifiers:localIdentifiers] requestType:requestType tag:tag]; - return; - } - - if (requestType == LookinRequestTypeHierarchy) { - NSString *clientVersion = [object isKindOfClass:[NSDictionary class]] ? object[@"clientVersion"] : nil; - [self _respondWithData:[LookinHierarchyInfo staticInfoWithLookinVersion:clientVersion] requestType:requestType tag:tag]; - return; - } - - if (requestType == LookinRequestTypeHierarchyDetails) { - NSArray *packages = [object isKindOfClass:[NSArray class]] ? object : @[]; - NSUInteger responsesDataTotalCount = 0; - for (LookinStaticAsyncUpdateTasksPackage *package in packages) { - responsesDataTotalCount += package.tasks.count; - } - LKS_HierarchyDetailsHandler *handler = [LKS_HierarchyDetailsHandler new]; - [self.activeDetailHandlers addObject:handler]; - [handler startWithPackages:packages block:^(NSArray *details) { - LookinConnectionResponseAttachment *attachment = [LookinConnectionResponseAttachment new]; - attachment.data = details; - attachment.dataTotalCount = responsesDataTotalCount; - attachment.currentDataCount = details.count; - [[LKS_ConnectionManager sharedInstance] respond:attachment requestType:requestType tag:tag]; - } finishedBlock:^{ - [self.activeDetailHandlers removeObject:handler]; - }]; - return; - } - - if (requestType == LookinRequestTypeInbuiltAttrModification) { - [LKS_InbuiltAttrModificationHandler handleModification:object completion:^(LookinDisplayItemDetail *data, NSError *error) { - if (error) { - [self _respondWithError:error requestType:requestType tag:tag]; - } else { - [self _respondWithData:data requestType:requestType tag:tag]; - } - }]; - return; - } - - if (requestType == LookinRequestTypeAttrModificationPatch) { - [LKS_InbuiltAttrModificationHandler handlePatchWithTasks:[object isKindOfClass:[NSArray class]] ? object : @[] block:^(LookinDisplayItemDetail *data) { - [self _respondWithData:data requestType:requestType tag:tag]; - }]; - return; - } - - if (requestType == LookinRequestTypeFetchObject) { - NSObject *resolvedObject = [NSObject lks_objectWithOid:[(NSNumber *)object unsignedLongValue]]; - [self _respondWithData:[LookinObject instanceWithObject:resolvedObject] requestType:requestType tag:tag]; - return; - } - - if (requestType == LookinRequestTypeAllAttrGroups) { - CALayer *layer = (CALayer *)[NSObject lks_objectWithOid:[(NSNumber *)object unsignedLongValue]]; - if (![layer isKindOfClass:[CALayer class]]) { - [self _respondWithError:LookinErr_ObjNotFound requestType:requestType tag:tag]; - return; - } - [self _respondWithData:[LKS_AttrGroupsMaker attrGroupsForLayer:layer] requestType:requestType tag:tag]; - return; - } - - if (requestType == LookinRequestTypeAllSelectorNames) { - NSDictionary *params = [object isKindOfClass:[NSDictionary class]] ? object : nil; - Class targetClass = NSClassFromString(params[@"className"]); - if (!targetClass) { - [self _respondWithError:LookinErr_Inner requestType:requestType tag:tag]; - return; - } - BOOL hasArg = [params[@"hasArg"] boolValue]; - NSMutableArray *selectors = [NSMutableArray array]; - Class currentClass = targetClass; - while (currentClass) { - unsigned int methodCount = 0; - Method *methods = class_copyMethodList(currentClass, &methodCount); - for (unsigned int i = 0; i < methodCount; i++) { - NSString *selName = NSStringFromSelector(method_getName(methods[i])); - if (!hasArg && [selName containsString:@":"]) { - continue; - } - if (selName.length && ![selectors containsObject:selName]) { - [selectors addObject:selName]; - } - } - free(methods); - currentClass = currentClass.superclass; - } - [self _respondWithData:selectors requestType:requestType tag:tag]; - return; - } - - if (requestType == LookinRequestTypeInvokeMethod) { - NSDictionary *params = [object isKindOfClass:[NSDictionary class]] ? object : nil; - NSObject *targetObject = [NSObject lks_objectWithOid:[params[@"oid"] unsignedLongValue]]; - SEL selector = NSSelectorFromString(params[@"text"]); - if (!targetObject || !selector || ![targetObject respondsToSelector:selector]) { - [self _respondWithError:LookinErr_ObjNotFound requestType:requestType tag:tag]; - return; - } - NSMethodSignature *signature = [targetObject methodSignatureForSelector:selector]; - if (!signature || signature.numberOfArguments > 2) { - [self _respondWithError:LookinErr_Inner requestType:requestType tag:tag]; - return; - } - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; - invocation.target = targetObject; - invocation.selector = selector; - [invocation invoke]; - - NSMutableDictionary *response = [NSMutableDictionary dictionary]; - if (strcmp(signature.methodReturnType, @encode(void)) == 0) { - response[@"description"] = LookinStringFlag_VoidReturn; - } else if (signature.methodReturnLength == sizeof(id)) { - __unsafe_unretained id returnValue = nil; - [invocation getReturnValue:&returnValue]; - if (returnValue) { - response[@"description"] = [returnValue description] ?: @""; - if ([returnValue isKindOfClass:[NSObject class]]) { - response[@"object"] = [LookinObject instanceWithObject:returnValue]; - } - } - } else { - response[@"description"] = @"Method invoked."; - } - [self _respondWithData:response requestType:requestType tag:tag]; - return; - } - - if (requestType == LookinRequestTypeFetchImageViewImage) { -#if TARGET_OS_OSX - NSImageView *imageView = (NSImageView *)[NSObject lks_objectWithOid:[(NSNumber *)object unsignedLongValue]]; - if (![imageView isKindOfClass:[NSImageView class]] || !imageView.image) { - [self _respondWithError:LookinErr_ObjNotFound requestType:requestType tag:tag]; - return; - } - [self _respondWithData:imageView.image.lookin_data requestType:requestType tag:tag]; -#else - UIImageView *imageView = (UIImageView *)[NSObject lks_objectWithOid:[(NSNumber *)object unsignedLongValue]]; - if (![imageView isKindOfClass:[UIImageView class]] || !imageView.image) { - [self _respondWithError:LookinErr_ObjNotFound requestType:requestType tag:tag]; - return; - } - [self _respondWithData:[imageView.image lookin_data] requestType:requestType tag:tag]; -#endif - return; - } - - if (requestType == LookinRequestTypeModifyRecognizerEnable) { - [self _respondWithError:LookinErr_Inner requestType:requestType tag:tag]; - return; - } - - if (requestType == LookinPush_CanceHierarchyDetails) { - for (LKS_HierarchyDetailsHandler *handler in self.activeDetailHandlers.copy) { - [handler cancel]; - } - [self.activeDetailHandlers removeAllObjects]; - return; - } - - [self _respondWithError:LookinErr_Inner requestType:requestType tag:tag]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_AttrModificationPatchHandler.h b/Sources/LookinServer/Server/Connection/RequestHandler/LKS_AttrModificationPatchHandler.h deleted file mode 100644 index 60eca2c4..00000000 --- a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_AttrModificationPatchHandler.h +++ /dev/null @@ -1,25 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LKS_AttrModificationPatchHandler.h -// LookinServer -// -// Created by Li Kai on 2019/6/12. -// https://lookin.work -// - -#import - -@class LookinDisplayItemDetail; - -@interface LKS_AttrModificationPatchHandler : NSObject - -/** - @param oids 数组内 idx 较小的应该为 displayItems 里的 subItem,idx 较大的应该为 superItem - @param lowImageQuality 是否采用低图像质量 - @param block 该 block 会被多次调用,其中 tasksTotalCount 是总的调用次数(即可被用来作为 TotalResponseCount) - */ -+ (void)handleLayerOids:(NSArray *)oids lowImageQuality:(BOOL)lowImageQuality block:(void (^)(LookinDisplayItemDetail *detail, NSUInteger tasksTotalCount, NSError *error))block; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_AttrModificationPatchHandler.m b/Sources/LookinServer/Server/Connection/RequestHandler/LKS_AttrModificationPatchHandler.m deleted file mode 100644 index 9be1b396..00000000 --- a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_AttrModificationPatchHandler.m +++ /dev/null @@ -1,83 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LKS_AttrModificationPatchHandler.m -// LookinServer -// -// Created by Li Kai on 2019/6/12. -// https://lookin.work -// - -#import "LKS_AttrModificationPatchHandler.h" -#import "LookinDisplayItemDetail.h" -#import "LookinServerDefines.h" -#import "UIView+LookinServer.h" - -@implementation LKS_AttrModificationPatchHandler - -+ (void)handleLayerOids:(NSArray *)oids lowImageQuality:(BOOL)lowImageQuality block:(void (^)(LookinDisplayItemDetail *detail, NSUInteger tasksTotalCount, NSError *error))block { - if (!block) { - NSAssert(NO, @""); - return; - } - if (![oids isKindOfClass:[NSArray class]]) { - block(nil, 1, LookinErr_Inner); - return; - } - - [oids enumerateObjectsUsingBlock:^(NSNumber * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - unsigned long oid = [obj unsignedLongValue]; - LookinDisplayItemDetail *detail = [LookinDisplayItemDetail new]; - detail.displayItemOid = oid; - - id object = [NSObject lks_objectWithOid:oid]; -#if TARGET_OS_OSX - if ([object isKindOfClass:[NSWindow class]]) { - NSWindow *window = (NSWindow *)object; - CGImageRef cgImage = CGWindowListCreateImage(CGRectZero, - kCGWindowListOptionIncludingWindow, - (int)window.windowNumber, - kCGWindowImageBoundsIgnoreFraming); - if (cgImage) { - NSImage *screenshot = [[NSImage alloc] initWithCGImage:cgImage size:window.frame.size]; - CGImageRelease(cgImage); - detail.groupScreenshot = screenshot; - if (idx == 0) { - detail.soloScreenshot = screenshot; - } - } - block(detail, oids.count, nil); - return; - } - NSView *view = object; - if (view && [view isKindOfClass:[NSView class]] && !view.layer) { - if (idx == 0) { - detail.soloScreenshot = [view lks_soloScreenshotWithLowQuality:lowImageQuality]; - detail.groupScreenshot = [view lks_groupScreenshotWithLowQuality:lowImageQuality]; - } else { - detail.groupScreenshot = [view lks_groupScreenshotWithLowQuality:lowImageQuality]; - } - block(detail, oids.count, nil); - return; - } -#endif - - CALayer *layer = object; - if (![layer isKindOfClass:[CALayer class]]) { - block(nil, idx + 1, LookinErr_ObjNotFound); - *stop = YES; - return; - } - - if (idx == 0) { - detail.soloScreenshot = [layer lks_soloScreenshotWithLowQuality:lowImageQuality]; - detail.groupScreenshot = [layer lks_groupScreenshotWithLowQuality:lowImageQuality]; - } else { - detail.groupScreenshot = [layer lks_groupScreenshotWithLowQuality:lowImageQuality]; - } - block(detail, oids.count, nil); - }]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_CustomAttrModificationHandler.h b/Sources/LookinServer/Server/Connection/RequestHandler/LKS_CustomAttrModificationHandler.h deleted file mode 100644 index 717edcc2..00000000 --- a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_CustomAttrModificationHandler.h +++ /dev/null @@ -1,19 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LKS_CustomAttrModificationHandler.h -// LookinServer -// -// Created by likaimacbookhome on 2023/11/4. -// - -#import -#import "LookinCustomAttrModification.h" - -@interface LKS_CustomAttrModificationHandler : NSObject - -/// 返回值表示是否修改成功(有成功调用 setter block 就算成功) -+ (BOOL)handleModification:(LookinCustomAttrModification *)modification; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_CustomAttrModificationHandler.m b/Sources/LookinServer/Server/Connection/RequestHandler/LKS_CustomAttrModificationHandler.m deleted file mode 100644 index daf63a40..00000000 --- a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_CustomAttrModificationHandler.m +++ /dev/null @@ -1,155 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LKS_CustomAttrModificationHandler.m -// LookinServer -// -// Created by likaimacbookhome on 2023/11/4. -// - -#import "LKS_CustomAttrModificationHandler.h" -#import "LKS_CustomAttrSetterManager.h" -#import "UIColor+LookinServer.h" -#import "NSValue+Lookin.h" -@implementation LKS_CustomAttrModificationHandler - -+ (BOOL)handleModification:(LookinCustomAttrModification *)modification { - if (!modification || modification.customSetterID.length == 0) { - return NO; - } - switch (modification.attrType) { - case LookinAttrTypeNSString: { - NSString *newValue = modification.value; - if (newValue != nil && ![newValue isKindOfClass:[NSString class]]) { - // nil 是合法的 - return NO; - } - LKS_StringSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getStringSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - setter(newValue); - return YES; - } - - case LookinAttrTypeDouble: { - NSNumber *newValue = modification.value; - if (![newValue isKindOfClass:[NSNumber class]]) { - return NO; - } - LKS_NumberSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getNumberSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - setter(newValue); - return YES; - } - - case LookinAttrTypeBOOL: { - NSNumber *newValue = modification.value; - if (![newValue isKindOfClass:[NSNumber class]]) { - return NO; - } - LKS_BoolSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getBoolSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - setter(newValue.boolValue); - return YES; - } - - case LookinAttrTypeUIColor: { - LKS_ColorSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getColorSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - - NSArray *newValue = modification.value; - if (newValue == nil) { - // nil 是合法的 - setter(nil); - return YES; - } - if (![newValue isKindOfClass:[NSArray class]]) { - return NO; - } - LookinColor *color = [LookinColor lks_colorFromRGBAComponents:newValue]; - if (!color) { - return NO; - } - setter(color); - return YES; - } - - case LookinAttrTypeEnumString: { - NSString *newValue = modification.value; - if (![newValue isKindOfClass:[NSString class]]) { - return NO; - } - LKS_EnumSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getEnumSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - setter(newValue); - return YES; - } - - case LookinAttrTypeCGRect: { - NSValue *newValue = modification.value; - if (![newValue isKindOfClass:[NSValue class]]) { - return NO; - } - LKS_RectSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getRectSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - setter(newValue.CGRectValue); - return YES; - } - - case LookinAttrTypeCGSize: { - NSValue *newValue = modification.value; - if (![newValue isKindOfClass:[NSValue class]]) { - return NO; - } - LKS_SizeSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getSizeSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - setter(newValue.CGSizeValue); - return YES; - } - - case LookinAttrTypeCGPoint: { - NSValue *newValue = modification.value; - if (![newValue isKindOfClass:[NSValue class]]) { - return NO; - } - LKS_PointSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getPointSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - setter(newValue.CGPointValue); - return YES; - } - - case LookinAttrTypeUIEdgeInsets: { - NSValue *newValue = modification.value; - if (![newValue isKindOfClass:[NSValue class]]) { - return NO; - } - LKS_InsetsSetter setter = [[LKS_CustomAttrSetterManager sharedInstance] getInsetsSetterWithID:modification.customSetterID]; - if (!setter) { - return NO; - } - setter(newValue.InsetsValue); - return YES; - } - - default: - return NO; - } -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_HierarchyDetailsHandler.h b/Sources/LookinServer/Server/Connection/RequestHandler/LKS_HierarchyDetailsHandler.h deleted file mode 100644 index 43407e5c..00000000 --- a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_HierarchyDetailsHandler.h +++ /dev/null @@ -1,29 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_MAC) -// -// LKS_HierarchyDetailsHandler.h -// LookinServer -// -// Created by Li Kai on 2019/6/20. -// https://lookin.work -// - -#import - -@class LookinDisplayItemDetail, LookinStaticAsyncUpdateTasksPackage; - -typedef void (^LKS_HierarchyDetailsHandler_ProgressBlock)(NSArray *details); -typedef void (^LKS_HierarchyDetailsHandler_FinishBlock)(void); - -@interface LKS_HierarchyDetailsHandler : NSObject - -/// packages 会按照 idx 从小到大的顺序被执行 -/// 全部任务完成时,finishBlock 会被调用 -/// 如果调用了 cancel,则 finishBlock 不会被执行 -- (void)startWithPackages:(NSArray *)packages block:(LKS_HierarchyDetailsHandler_ProgressBlock)progressBlock finishedBlock:(LKS_HierarchyDetailsHandler_FinishBlock)finishBlock; - -/// 取消所有任务 -- (void)cancel; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_HierarchyDetailsHandler.m b/Sources/LookinServer/Server/Connection/RequestHandler/LKS_HierarchyDetailsHandler.m deleted file mode 100644 index 7398553e..00000000 --- a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_HierarchyDetailsHandler.m +++ /dev/null @@ -1,266 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LKS_HierarchyDetailsHandler.m -// LookinServer -// -// Created by Li Kai on 2019/6/20. -// https://lookin.work -// - -#import "LKS_HierarchyDetailsHandler.h" -#import "LookinDisplayItemDetail.h" -#import "LKS_AttrGroupsMaker.h" -#import "LookinStaticAsyncUpdateTask.h" -#import "LKS_ConnectionManager.h" -#import "LookinServerDefines.h" -#import "LKS_CustomAttrGroupsMaker.h" -#import "LKS_HierarchyDisplayItemsMaker.h" -#import "UIView+LookinServer.h" -#import "NSValue+Lookin.h" -#import "NSObject+LookinServer.h" -#import "CALayer+LookinServer.h" -#if TARGET_OS_IPHONE -#import "UIWindowScene+LookinServer.h" -#endif -@interface LKS_HierarchyDetailsHandler () - -@property(nonatomic, strong) NSMutableArray *taskPackages; -/// 标识哪些 oid 已经拉取过 attrGroups 了 -@property(nonatomic, strong) NSMutableSet *attrGroupsSyncedOids; - -@property(nonatomic, copy) LKS_HierarchyDetailsHandler_ProgressBlock progressBlock; -@property(nonatomic, copy) LKS_HierarchyDetailsHandler_FinishBlock finishBlock; - -@end - -@implementation LKS_HierarchyDetailsHandler - -- (instancetype)init { - if (self = [super init]) { - [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_handleConnectionDidEnd:) name:LKS_ConnectionDidEndNotificationName object:nil]; - - self.attrGroupsSyncedOids = [NSMutableSet set]; - } - return self; -} - -- (void)startWithPackages:(NSArray *)packages block:(LKS_HierarchyDetailsHandler_ProgressBlock)progressBlock finishedBlock:(LKS_HierarchyDetailsHandler_FinishBlock)finishBlock { - if (!progressBlock || !finishBlock) { - NSAssert(NO, @""); - return; - } - if (!packages.count) { - finishBlock(); - return; - } - self.taskPackages = [packages mutableCopy]; - self.progressBlock = progressBlock; - self.finishBlock = finishBlock; - - [LookinView lks_rebuildGlobalInvolvedRawConstraints]; - - [self _dequeueAndHandlePackage]; -} - -- (void)cancel { - [self.taskPackages removeAllObjects]; -} - -- (void)_dequeueAndHandlePackage { - dispatch_async(dispatch_get_main_queue(), ^{ - LookinStaticAsyncUpdateTasksPackage *package = self.taskPackages.firstObject; - if (!package) { - self.finishBlock(); - return; - } - NSArray *details = [package.tasks lookin_map:^id(NSUInteger idx, LookinStaticAsyncUpdateTask *task) { - LookinDisplayItemDetail *itemDetail = [LookinDisplayItemDetail new]; - itemDetail.displayItemOid = task.oid; - - id object = [NSObject lks_objectWithOid:task.oid]; - -#if TARGET_OS_OSX - // 处理所有 NSView 对象(包括 layer-backed 的 SwiftUI 视图) - if ([object isKindOfClass:[NSView class]]) { - NSView *view = (NSView *)object; - // 对于 layer-backed 视图,委托给 CALayer 截图路径, - // 该路径使用 renderInContext:(在现代 macOS 上有效)。 - // cacheDisplayInRect: 只能捕获 drawRect: 的内容, - // 对 layer-backed 视图会产生空白图片。 - if (view.layer) { - CALayer *layer = view.layer; - if (task.taskType == LookinStaticAsyncUpdateTaskTypeSoloScreenshot) { - itemDetail.soloScreenshot = [layer lks_soloScreenshotWithLowQuality:NO]; - } else if (task.taskType == LookinStaticAsyncUpdateTaskTypeGroupScreenshot) { - itemDetail.groupScreenshot = [layer lks_groupScreenshotWithLowQuality:NO]; - } - } else { - // 非 layer-backed 视图:使用 cacheDisplayInRect: - if (task.taskType == LookinStaticAsyncUpdateTaskTypeSoloScreenshot && view.subviews.count) { - NSMutableArray *hiddenSubviews = [NSMutableArray array]; - for (NSView *subview in view.subviews.copy) { - if (!subview.isHidden) { - subview.hidden = YES; - [hiddenSubviews addObject:subview]; - } - } - @try { - itemDetail.soloScreenshot = [view lks_groupScreenshotWithLowQuality:NO]; - } @finally { - for (NSView *subview in hiddenSubviews) { - subview.hidden = NO; - } - } - } else if (task.taskType == LookinStaticAsyncUpdateTaskTypeGroupScreenshot) { - itemDetail.groupScreenshot = [view lks_groupScreenshotWithLowQuality:NO]; - } -} - - BOOL shouldMakeAttr = [self queryIfShouldMakeAttrsFromTask:task]; - if (shouldMakeAttr) { - itemDetail.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForView:view]; - - NSString *version = task.clientReadableVersion; - if (version.length > 0 && [version lookin_numbericOSVersion] >= 10004) { - LKS_CustomAttrGroupsMaker *maker = [[LKS_CustomAttrGroupsMaker alloc] initWithView:view]; - [maker execute]; - itemDetail.customAttrGroupList = [maker getGroups]; - itemDetail.customDisplayTitle = [maker getCustomDisplayTitle]; - itemDetail.danceUISource = [maker getDanceUISource]; - } - [self.attrGroupsSyncedOids addObject:@(task.oid)]; - } - if (task.needBasisVisualInfo) { - itemDetail.frameValue = [NSValue valueWithCGRect:view.frame]; - itemDetail.boundsValue = [NSValue valueWithCGRect:view.bounds]; - itemDetail.hiddenValue = @(view.hidden); - itemDetail.alphaValue = @(view.layer ? view.layer.opacity : 1); - } - if (task.needSubitems) { - itemDetail.subitems = [LKS_HierarchyDisplayItemsMaker subitemsOfView:view]; - } - return itemDetail; - - } else if ([object isKindOfClass:[NSWindow class]]) { - NSWindow *window = (NSWindow *)object; - if (task.taskType == LookinStaticAsyncUpdateTaskTypeGroupScreenshot) { - CGImageRef cgImage = CGWindowListCreateImage(CGRectZero, - kCGWindowListOptionIncludingWindow, - (int)window.windowNumber, - kCGWindowImageBoundsIgnoreFraming); - if (cgImage) { - itemDetail.groupScreenshot = [[NSImage alloc] initWithCGImage:cgImage size:window.frame.size]; - CGImageRelease(cgImage); - } - } - BOOL shouldMakeAttr = [self queryIfShouldMakeAttrsFromTask:task]; - if (shouldMakeAttr) { - itemDetail.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForWindow:window]; - [self.attrGroupsSyncedOids addObject:@(task.oid)]; - } - if (task.needBasisVisualInfo) { - CGRect windowBounds = window.frame; - windowBounds.origin = CGPointZero; - itemDetail.frameValue = [NSValue valueWithCGRect:windowBounds]; - itemDetail.boundsValue = [NSValue valueWithCGRect:windowBounds]; - itemDetail.hiddenValue = @(!window.visible); - itemDetail.alphaValue = @(window.alphaValue); - } - return itemDetail; - } -#endif -#if TARGET_OS_IPHONE - if (@available(iOS 13.0, *)) { - if ([object isKindOfClass:[UIWindowScene class]]) { - UIWindowScene *windowScene = (UIWindowScene *)object; - // UIWindowScene has no screenshots - BOOL shouldMakeAttr = [self queryIfShouldMakeAttrsFromTask:task]; - if (shouldMakeAttr) { - itemDetail.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForWindowScene:windowScene]; - [self.attrGroupsSyncedOids addObject:@(task.oid)]; - } - if (task.needBasisVisualInfo) { - itemDetail.hiddenValue = @(NO); - itemDetail.alphaValue = @(1.0); - } - return itemDetail; - } - } -#endif - if (!object) { - // Routine, not a fault: the registry holds weakly and the - // inspected app's short-lived internals go away on their own. - // Hosts log this without alerting. - itemDetail.failureCode = LookinDisplayItemDetailFailureCodeObjectGone; - return itemDetail; - } - if (![object isKindOfClass:[CALayer class]]) { - itemDetail.failureCode = LookinDisplayItemDetailFailureCodeUnhandledObject; - return itemDetail; - } - CALayer *layer = object; - - if (task.taskType == LookinStaticAsyncUpdateTaskTypeSoloScreenshot) { - LookinImage *image = [layer lks_soloScreenshotWithLowQuality:NO]; - itemDetail.soloScreenshot = image; - } else if (task.taskType == LookinStaticAsyncUpdateTaskTypeGroupScreenshot) { - LookinImage *image = [layer lks_groupScreenshotWithLowQuality:NO]; - itemDetail.groupScreenshot = image; - } - - BOOL shouldMakeAttr = [self queryIfShouldMakeAttrsFromTask:task]; - if (shouldMakeAttr) { - itemDetail.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForLayer:layer]; - - NSString *version = task.clientReadableVersion; - if (version.length > 0 && [version lookin_numbericOSVersion] >= 10004) { - LKS_CustomAttrGroupsMaker *maker = [[LKS_CustomAttrGroupsMaker alloc] initWithLayer:layer]; - [maker execute]; - itemDetail.customAttrGroupList = [maker getGroups]; - itemDetail.customDisplayTitle = [maker getCustomDisplayTitle]; - itemDetail.danceUISource = [maker getDanceUISource]; - } - [self.attrGroupsSyncedOids addObject:@(task.oid)]; - } - if (task.needBasisVisualInfo) { - itemDetail.frameValue = [NSValue valueWithCGRect:layer.frame]; - itemDetail.boundsValue = [NSValue valueWithCGRect:layer.bounds]; - itemDetail.hiddenValue = [NSNumber numberWithBool:layer.isHidden]; - itemDetail.alphaValue = @(layer.opacity); - } - - if (task.needSubitems) { - itemDetail.subitems = [LKS_HierarchyDisplayItemsMaker subitemsOfLayer:layer]; - } - - return itemDetail; - }]; - self.progressBlock(details); - - [self.taskPackages removeObjectAtIndex:0]; - [self _dequeueAndHandlePackage]; - }); -} - -- (BOOL)queryIfShouldMakeAttrsFromTask:(LookinStaticAsyncUpdateTask *)task { - switch (task.attrRequest) { - case LookinDetailUpdateTaskAttrRequest_Automatic: { - BOOL alreadyMadeBefore = [self.attrGroupsSyncedOids containsObject:@(task.oid)]; - return !alreadyMadeBefore; - } - case LookinDetailUpdateTaskAttrRequest_Need: - return YES; - case LookinDetailUpdateTaskAttrRequest_NotNeed: - return NO; - } - NSAssert(NO, @""); - return YES; -} - -- (void)_handleConnectionDidEnd:(id)obj { - [self cancel]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_InbuiltAttrModificationHandler.h b/Sources/LookinServer/Server/Connection/RequestHandler/LKS_InbuiltAttrModificationHandler.h deleted file mode 100644 index 4c1426bb..00000000 --- a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_InbuiltAttrModificationHandler.h +++ /dev/null @@ -1,22 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_MAC) -// -// LKS_InbuiltAttrModificationHandler.h -// LookinServer -// -// Created by Li Kai on 2019/6/12. -// https://lookin.work -// - -#import - -@class LookinAttributeModification, LookinDisplayItemDetail, LookinStaticAsyncUpdateTask; - -@interface LKS_InbuiltAttrModificationHandler : NSObject - -+ (void)handleModification:(LookinAttributeModification *)modification completion:(void (^)(LookinDisplayItemDetail *data, NSError *error))completion; - -+ (void)handlePatchWithTasks:(NSArray *)tasks block:(void (^)(LookinDisplayItemDetail *data))block; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_InbuiltAttrModificationHandler.m b/Sources/LookinServer/Server/Connection/RequestHandler/LKS_InbuiltAttrModificationHandler.m deleted file mode 100644 index 57a129be..00000000 --- a/Sources/LookinServer/Server/Connection/RequestHandler/LKS_InbuiltAttrModificationHandler.m +++ /dev/null @@ -1,292 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LKS_InbuiltAttrModificationHandler.m -// LookinServer -// -// Created by Li Kai on 2019/6/12. -// https://lookin.work -// - -#import "LKS_InbuiltAttrModificationHandler.h" -#import "UIColor+LookinServer.h" -#import "LookinAttributeModification.h" -#import "LKS_AttrGroupsMaker.h" -#import "LookinDisplayItemDetail.h" -#import "LookinStaticAsyncUpdateTask.h" -#import "LookinServerDefines.h" -#import "LKS_CustomAttrGroupsMaker.h" -#import "UIView+LookinServer.h" -#import "NSValue+Lookin.h" -#import "NSWindow+LookinServer.h" -@implementation LKS_InbuiltAttrModificationHandler - -+ (void)handleModification:(LookinAttributeModification *)modification completion:(void (^)(LookinDisplayItemDetail *data, NSError *error))completion { - if (!completion) { - NSAssert(NO, @""); - return; - } - if (!modification || ![modification isKindOfClass:[LookinAttributeModification class]]) { - completion(nil, LookinErr_Inner); - return; - } - - NSObject *receiver = [NSObject lks_objectWithOid:modification.targetOid]; - if (!receiver) { - completion(nil, LookinErr_ObjNotFound); - return; - } - - NSMethodSignature *setterSignature = [receiver methodSignatureForSelector:modification.setterSelector]; - if (!setterSignature || setterSignature.numberOfArguments != 3 || ![receiver respondsToSelector:modification.setterSelector]) { - completion(nil, LookinErr_Inner); - return; - } - - NSInvocation *setterInvocation = [NSInvocation invocationWithMethodSignature:setterSignature]; - setterInvocation.target = receiver; - setterInvocation.selector = modification.setterSelector; - - switch (modification.attrType) { - case LookinAttrTypeNone: - case LookinAttrTypeVoid: { - completion(nil, LookinErr_Inner); - return; - } - case LookinAttrTypeChar: { - char expectedValue = [(NSNumber *)modification.value charValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeInt: - case LookinAttrTypeEnumInt: { - int expectedValue = [(NSNumber *)modification.value intValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeShort: { - short expectedValue = [(NSNumber *)modification.value shortValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeLong: - case LookinAttrTypeEnumLong: { - long expectedValue = [(NSNumber *)modification.value longValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeLongLong: { - long long expectedValue = [(NSNumber *)modification.value longLongValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeUnsignedChar: { - unsigned char expectedValue = [(NSNumber *)modification.value unsignedCharValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeUnsignedInt: { - unsigned int expectedValue = [(NSNumber *)modification.value unsignedIntValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeUnsignedShort: { - unsigned short expectedValue = [(NSNumber *)modification.value unsignedShortValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeUnsignedLong: { - unsigned long expectedValue = [(NSNumber *)modification.value unsignedLongValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeUnsignedLongLong: { - unsigned long long expectedValue = [(NSNumber *)modification.value unsignedLongLongValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeFloat: { - float expectedValue = [(NSNumber *)modification.value floatValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeDouble: { - double expectedValue = [(NSNumber *)modification.value doubleValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeBOOL: { - BOOL expectedValue = [(NSNumber *)modification.value boolValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeSel: { - SEL expectedValue = NSSelectorFromString(modification.value); - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeClass: { - Class expectedValue = NSClassFromString(modification.value); - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeCGPoint: { - CGPoint expectedValue = [(NSValue *)modification.value CGPointValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeCGVector: { - CGVector expectedValue = [(NSValue *)modification.value CGVectorValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeCGSize: { - CGSize expectedValue = [(NSValue *)modification.value CGSizeValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeCGRect: { - CGRect expectedValue = [(NSValue *)modification.value CGRectValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeCGAffineTransform: { - CGAffineTransform expectedValue = [(NSValue *)modification.value CGAffineTransformValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } - case LookinAttrTypeUIEdgeInsets: { - LookinInsets expectedValue = [(NSValue *)modification.value InsetsValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } -#if !TARGET_OS_OSX - case LookinAttrTypeUIOffset: { - UIOffset expectedValue = [(NSValue *)modification.value UIOffsetValue]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - break; - } -#endif - case LookinAttrTypeCustomObj: - case LookinAttrTypeNSString: { - NSObject *expectedValue = modification.value; - [setterInvocation setArgument:&expectedValue atIndex:2]; - [setterInvocation retainArguments]; - break; - } - case LookinAttrTypeUIColor: { - NSArray *rgba = modification.value; - LookinColor *expectedValue = [LookinColor lks_colorFromRGBAComponents:rgba]; - [setterInvocation setArgument:&expectedValue atIndex:2]; - [setterInvocation retainArguments]; - break; - } - default: { - completion(nil, LookinErr_Inner); - return; - } - } - - NSError *error = nil; - @try { - [setterInvocation invoke]; - } @catch (NSException *exception) { - NSString *errorMsg = [NSString stringWithFormat:LKS_Localized(@"<%@: %p>: an exception was raised when invoking %@. (%@)"), NSStringFromClass(receiver.class), receiver, NSStringFromSelector(modification.setterSelector), exception.reason]; - error = [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_Exception userInfo:@{NSLocalizedDescriptionKey:LKS_Localized(@"The modification may failed."), NSLocalizedRecoverySuggestionErrorKey:errorMsg}]; - } @finally { - - } - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ -#if TARGET_OS_OSX - if ([receiver isKindOfClass:[NSWindow class]]) { - NSWindow *window = (NSWindow *)receiver; - LookinDisplayItemDetail *detail = [LookinDisplayItemDetail new]; - detail.displayItemOid = modification.targetOid; - detail.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForWindow:window]; - CGRect windowBounds = window.frame; - windowBounds.origin = CGPointZero; - detail.frameValue = [NSValue valueWithCGRect:windowBounds]; - detail.boundsValue = [NSValue valueWithCGRect:windowBounds]; - detail.hiddenValue = @(!window.isVisible); - detail.alphaValue = @(window.alphaValue); - completion(detail, error); - return; - } -#endif - CALayer *layer = nil; - if ([receiver isKindOfClass:[CALayer class]]) { - layer = (CALayer *)receiver; - } else if ([receiver isKindOfClass:[LookinView class]]) { - layer = ((LookinView *)receiver).layer; - } else { - completion(nil, LookinErr_ObjNotFound); - return; - } - // 比如试图更改 frame 时,这个改动很有可能触发用户业务的 relayout,因此这时 dispatch 一下以确保拿到的 attrGroups 数据是最新的 - LookinDisplayItemDetail *detail = [LookinDisplayItemDetail new]; - detail.displayItemOid = modification.targetOid; - detail.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForLayer:layer]; - - NSString *version = modification.clientReadableVersion; - if (version.length > 0 && [version lookin_numbericOSVersion] >= 10004) { - LKS_CustomAttrGroupsMaker *maker = [[LKS_CustomAttrGroupsMaker alloc] initWithLayer:layer]; - [maker execute]; - detail.customAttrGroupList = [maker getGroups]; - } - - detail.frameValue = [NSValue valueWithCGRect:layer.frame]; - detail.boundsValue = [NSValue valueWithCGRect:layer.bounds]; - detail.hiddenValue = [NSNumber numberWithBool:layer.isHidden]; - detail.alphaValue = @(layer.opacity); - - completion(detail, error); - }); -} - - -+ (void)handlePatchWithTasks:(NSArray *)tasks block:(void (^)(LookinDisplayItemDetail *data))block { - if (!block) { - NSAssert(NO, @""); - return; - } - [tasks enumerateObjectsUsingBlock:^(LookinStaticAsyncUpdateTask * _Nonnull task, NSUInteger idx, BOOL * _Nonnull stop) { - LookinDisplayItemDetail *itemDetail = [LookinDisplayItemDetail new]; - itemDetail.displayItemOid = task.oid; - id object = [NSObject lks_objectWithOid:task.oid]; - -#if TARGET_OS_OSX - NSView *view = object; - if (view && [view isKindOfClass:[NSView class]] && !view.layer) { - if (task.taskType == LookinStaticAsyncUpdateTaskTypeSoloScreenshot) { - LookinImage *image = [view lks_soloScreenshotWithLowQuality:NO]; - itemDetail.soloScreenshot = image; - } else if (task.taskType == LookinStaticAsyncUpdateTaskTypeGroupScreenshot) { - LookinImage *image = [view lks_groupScreenshotWithLowQuality:NO]; - itemDetail.groupScreenshot = image; - } - block(itemDetail); - return; - } -#endif - - - if (!object || ![object isKindOfClass:[CALayer class]]) { - block(itemDetail); - return; - } - - CALayer *layer = object; - if (task.taskType == LookinStaticAsyncUpdateTaskTypeSoloScreenshot) { - LookinImage *image = [layer lks_soloScreenshotWithLowQuality:NO]; - itemDetail.soloScreenshot = image; - } else if (task.taskType == LookinStaticAsyncUpdateTaskTypeGroupScreenshot) { - LookinImage *image = [layer lks_groupScreenshotWithLowQuality:NO]; - itemDetail.groupScreenshot = image; - } - block(itemDetail); - }]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/LookinServer.h b/Sources/LookinServer/Server/LookinServer.h deleted file mode 100644 index 1110e857..00000000 --- a/Sources/LookinServer/Server/LookinServer.h +++ /dev/null @@ -1,19 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_MAC) -// -// LookinServer.h -// LookinServer -// -// Created by Li Kai on 2019/7/20. -// https://lookin.work -// - -#ifndef LookinServer_h -#define LookinServer_h - -#import - -FOUNDATION_EXPORT void LookinServerStart(void); - -#endif /* LookinServer_h */ - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/LookinServer.m b/Sources/LookinServer/Server/LookinServer.m deleted file mode 100644 index 117e3e65..00000000 --- a/Sources/LookinServer/Server/LookinServer.m +++ /dev/null @@ -1,10 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_MAC) - -#import "LookinServer.h" -#import "LKS_ConnectionManager.h" - -void LookinServerStart(void) { - [LKS_ConnectionManager sharedInstance]; -} - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKSConfigManager.h b/Sources/LookinServer/Server/Others/LKSConfigManager.h deleted file mode 100644 index 766de881..00000000 --- a/Sources/LookinServer/Server/Others/LKSConfigManager.h +++ /dev/null @@ -1,38 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKSConfigManager.h -// LookinServer -// -// Created by likai.123 on 2023/1/10. -// - -#import - -#if TARGET_OS_IPHONE -#import -#endif - -#if TARGET_OS_OSX -#import -#endif - -#import "LookinDefines.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface LKSConfigManager : NSObject - -+ (NSArray *)collapsedClassList; - -+ (NSDictionary *)colorAlias; - -+ (BOOL)shouldCaptureScreenshotOfLayer:(CALayer *)layer; -#if TARGET_OS_OSX -+ (BOOL)shouldCaptureScreenshotOfView:(NSView *)view; -#endif -@end - -NS_ASSUME_NONNULL_END - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKSConfigManager.m b/Sources/LookinServer/Server/Others/LKSConfigManager.m deleted file mode 100644 index 5f75360d..00000000 --- a/Sources/LookinServer/Server/Others/LKSConfigManager.m +++ /dev/null @@ -1,214 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKSConfigManager.m -// LookinServer -// -// Created by likai.123 on 2023/1/10. -// - -#import "LKSConfigManager.h" -#import "NSArray+Lookin.h" -#import "CALayer+LookinServer.h" - -@implementation LKSConfigManager - -+ (NSArray *)collapsedClassList { - NSArray *result = [self queryCollapsedClassListWithClass:[NSObject class] selector:@"lookin_collapsedClassList"]; - if (result) { - return result; - } - - // Legacy logic. Deprecated. - Class configClass = NSClassFromString(@"LookinConfig"); - if (!configClass) { - return nil; - } - NSArray *legacyCodeResult = [self queryCollapsedClassListWithClass:configClass selector:@"collapsedClasses"]; - return legacyCodeResult; -} - -+ (NSArray *)queryCollapsedClassListWithClass:(Class)class selector:(NSString *)selectorName { - SEL selector = NSSelectorFromString(selectorName); - if (![class respondsToSelector:selector]) { - return nil; - } - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[class methodSignatureForSelector:selector]]; - [invocation setTarget:class]; - [invocation setSelector:selector]; - [invocation invoke]; - void *arrayValue; - [invocation getReturnValue:&arrayValue]; - id classList = (__bridge id)(arrayValue); - - if ([classList isKindOfClass:[NSArray class]]) { - NSArray *validClassList = [((NSArray *)classList) lookin_filter:^BOOL(id obj) { - return [obj isKindOfClass:[NSString class]]; - }]; - return [validClassList copy]; - } - return nil; -} - -+ (NSDictionary *)colorAlias { - NSDictionary *result = [self queryColorAliasWithClass:[NSObject class] selector:@"lookin_colorAlias"]; - if (result) { - return result; - } - - // Legacy logic. Deprecated. - Class configClass = NSClassFromString(@"LookinConfig"); - if (!configClass) { - return nil; - } - NSDictionary *legacyCodeResult = [self queryColorAliasWithClass:configClass selector:@"colors"]; - return legacyCodeResult; -} - -+ (NSDictionary *)queryColorAliasWithClass:(Class)class selector:(NSString *)selectorName { - SEL selector = NSSelectorFromString(selectorName); - if (![class respondsToSelector:selector]) { - return nil; - } - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[class methodSignatureForSelector:selector]]; - [invocation setTarget:class]; - [invocation setSelector:selector]; - [invocation invoke]; - void *dictValue; - [invocation getReturnValue:&dictValue]; - id colorAlias = (__bridge id)(dictValue); - - if ([colorAlias isKindOfClass:[NSDictionary class]]) { - NSMutableDictionary *validDictionary = [NSMutableDictionary dictionary]; - [(NSDictionary *)colorAlias enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { - if ([key isKindOfClass:[NSString class]]) { - if ([obj isKindOfClass:[LookinColor class]]) { - [validDictionary setObject:obj forKey:key]; - - } else if ([obj isKindOfClass:[NSDictionary class]]) { - __block BOOL isValidSubDict = YES; - [((NSDictionary *)obj) enumerateKeysAndObjectsUsingBlock:^(id _Nonnull subKey, id _Nonnull subObj, BOOL * _Nonnull stop) { - if (![subKey isKindOfClass:[NSString class]] || ![subObj isKindOfClass:[LookinColor class]]) { - isValidSubDict = NO; - *stop = YES; - } - }]; - if (isValidSubDict) { - [validDictionary setObject:obj forKey:key]; - } - } - } - }]; - return [validDictionary copy]; - } - return nil; -} - -+ (BOOL)shouldCaptureScreenshotOfLayer:(CALayer *)layer { - if (!layer) { - return YES; - } - if (![self shouldCaptureImageOfLayer:layer]) { - return NO; - } - LookinView *view = layer.lks_hostView; - if (!view) { - return YES; - } - if (![self shouldCaptureImageOfView:view]) { - return NO; - } - return YES; -} - -#if TARGET_OS_OSX -+ (BOOL)shouldCaptureScreenshotOfView:(NSView *)view { - if (!view) { - return YES; - } - if (![self shouldCaptureImageOfView:view]) { - return NO; - } - CALayer *layer = view.layer; - if (!layer) { - return YES; - } - if (![self shouldCaptureImageOfLayer:layer]) { - return NO; - } - return YES; -} -#endif - -+ (BOOL)shouldCaptureImageOfLayer:(CALayer *)layer { - if (!layer) { - return YES; - } - SEL selector = NSSelectorFromString(@"lookin_shouldCaptureImageOfLayer:"); - if ([NSObject respondsToSelector:selector]) { - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[NSObject methodSignatureForSelector:selector]]; - [invocation setTarget:[NSObject class]]; - [invocation setSelector:selector]; - [invocation setArgument:&layer atIndex:2]; - [invocation invoke]; - BOOL resultValue = YES; - [invocation getReturnValue:&resultValue]; - if (!resultValue) { - return NO; - } - } - - SEL selector2 = NSSelectorFromString(@"lookin_shouldCaptureImage"); - if ([layer respondsToSelector:selector2]) { - NSInvocation *invocation2 = [NSInvocation invocationWithMethodSignature:[layer methodSignatureForSelector:selector2]]; - [invocation2 setTarget:layer]; - [invocation2 setSelector:selector2]; - [invocation2 invoke]; - BOOL resultValue2 = YES; - [invocation2 getReturnValue:&resultValue2]; - if (!resultValue2) { - return NO; - } - } - - return YES; -} - -+ (BOOL)shouldCaptureImageOfView:(LookinView *)view { - if (!view) { - return YES; - } - - SEL selector = NSSelectorFromString(@"lookin_shouldCaptureImageOfView:"); - if ([NSObject respondsToSelector:selector]) { - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:[NSObject methodSignatureForSelector:selector]]; - [invocation setTarget:[NSObject class]]; - [invocation setSelector:selector]; - [invocation setArgument:&view atIndex:2]; - [invocation invoke]; - BOOL resultValue = YES; - [invocation getReturnValue:&resultValue]; - if (!resultValue) { - return NO; - } - } - - SEL selector2 = NSSelectorFromString(@"lookin_shouldCaptureImage"); - if ([view respondsToSelector:selector2]) { - NSInvocation *invocation2 = [NSInvocation invocationWithMethodSignature:[view methodSignatureForSelector:selector2]]; - [invocation2 setTarget:view]; - [invocation2 setSelector:selector2]; - [invocation2 invoke]; - BOOL resultValue2 = YES; - [invocation2 getReturnValue:&resultValue2]; - if (!resultValue2) { - return NO; - } - } - - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_AttrGroupsMaker.h b/Sources/LookinServer/Server/Others/LKS_AttrGroupsMaker.h deleted file mode 100644 index 49973b45..00000000 --- a/Sources/LookinServer/Server/Others/LKS_AttrGroupsMaker.h +++ /dev/null @@ -1,38 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) -// -// LKS_AttrGroupsMaker.h -// LookinServer -// -// Created by Li Kai on 2019/6/6. -// https://lookin.work -// - -#import "LookinDefines.h" - -@class LookinAttributesGroup; -#if TARGET_OS_OSX -@class NSView, NSWindow; -#endif -#if TARGET_OS_IPHONE -@class UIWindowScene; -#endif - -@interface LKS_AttrGroupsMaker : NSObject - -#if TARGET_OS_OSX - -+ (NSArray *)attrGroupsForView:(NSView *)view; - -+ (NSArray *)attrGroupsForWindow:(NSWindow *)window; - -#endif - -+ (NSArray *)attrGroupsForLayer:(CALayer *)layer; - -#if TARGET_OS_IPHONE -+ (NSArray *)attrGroupsForWindowScene:(UIWindowScene *)windowScene API_AVAILABLE(ios(13.0)); -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_AttrGroupsMaker.m b/Sources/LookinServer/Server/Others/LKS_AttrGroupsMaker.m deleted file mode 100644 index a005565d..00000000 --- a/Sources/LookinServer/Server/Others/LKS_AttrGroupsMaker.m +++ /dev/null @@ -1,616 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_AttrGroupsMaker.m -// LookinServer -// -// Created by Li Kai on 2019/6/6. -// https://lookin.work -// - -#import "LKS_AttrGroupsMaker.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LookinAttribute.h" -#import "LookinDashboardBlueprint.h" -#import "LookinIvarTrace.h" -#import "UIColor+LookinServer.h" -#import "LookinServerDefines.h" -#import "NSValue+Lookin.h" -#if TARGET_OS_OSX -#import "NSWindow+LookinServer.h" -#endif -#if TARGET_OS_IPHONE -#import "UIWindowScene+LookinServer.h" -#endif -@implementation LKS_AttrGroupsMaker - -+ (NSArray *)attrGroupsForLayer:(CALayer *)layer { - if (!layer) { - NSAssert(NO, @""); - return nil; - } - NSArray *groups = [[LookinDashboardBlueprint groupIDs] lookin_map:^id(NSUInteger idx, LookinAttrGroupIdentifier groupID) { - LookinAttributesGroup *group = [LookinAttributesGroup new]; - group.identifier = groupID; - - NSArray *secIDs = [LookinDashboardBlueprint sectionIDsForGroupID:groupID]; - group.attrSections = [secIDs lookin_map:^id(NSUInteger idx, LookinAttrSectionIdentifier secID) { - LookinAttributesSection *sec = [LookinAttributesSection new]; - sec.identifier = secID; - - NSArray *attrIDs = [LookinDashboardBlueprint attrIDsForSectionID:secID]; - sec.attributes = [attrIDs lookin_map:^id(NSUInteger idx, LookinAttrIdentifier attrID) { - NSInteger minAvailableVersion = [LookinDashboardBlueprint minAvailableOSVersionWithAttrID:attrID]; - if (minAvailableVersion > 0 && (NSProcessInfo.processInfo.operatingSystemVersion.majorVersion < minAvailableVersion)) { - // iOS 版本过低不支持该属性 - return nil; - } - - id targetObj = nil; - if ([LookinDashboardBlueprint isUIViewPropertyWithAttrID:attrID]) { - targetObj = layer.lks_hostView; - } else { - targetObj = layer; - } - - if (targetObj) { - Class targetClass = NSClassFromString([LookinDashboardBlueprint classNameWithAttrID:attrID]); - if (![targetObj isKindOfClass:targetClass]) { - return nil; - } - - LookinAttribute *attr = [self _attributeWithIdentifer:attrID targetObject:targetObj]; - return attr; - } else { - return nil; - } - }]; - - if (sec.attributes.count) { - return sec; - } else { - return nil; - } - }]; - - if ([groupID isEqualToString:LookinAttrGroup_AutoLayout]) { - // 这里特殊处理一下,如果 AutoLayout 里面不包含 Constraints 的话(只有 Hugging 和 Resistance),就丢弃掉这整个 AutoLayout 不显示 - BOOL hasConstraits = [group.attrSections lookin_any:^BOOL(LookinAttributesSection *obj) { - return [obj.identifier isEqualToString:LookinAttrSec_AutoLayout_Constraints]; - }]; - if (!hasConstraits) { - return nil; - } - } - - if (group.attrSections.count) { - return group; - } else { - return nil; - } - }]; - - return groups; -} - -#if TARGET_OS_OSX -+ (NSArray *)attrGroupsForView:(NSView *)view { - if (!view) { - NSAssert(NO, @""); - return nil; - } - NSArray *groups = [[LookinDashboardBlueprint groupIDs] lookin_map:^id(NSUInteger idx, LookinAttrGroupIdentifier groupID) { - LookinAttributesGroup *group = [LookinAttributesGroup new]; - group.identifier = groupID; - - NSArray *secIDs = [LookinDashboardBlueprint sectionIDsForGroupID:groupID]; - group.attrSections = [secIDs lookin_map:^id(NSUInteger idx, LookinAttrSectionIdentifier secID) { - LookinAttributesSection *sec = [LookinAttributesSection new]; - sec.identifier = secID; - - NSArray *attrIDs = [LookinDashboardBlueprint attrIDsForSectionID:secID]; - sec.attributes = [attrIDs lookin_map:^id(NSUInteger idx, LookinAttrIdentifier attrID) { - NSInteger minAvailableVersion = [LookinDashboardBlueprint minAvailableOSVersionWithAttrID:attrID]; - if (minAvailableVersion > 0 && (NSProcessInfo.processInfo.operatingSystemVersion.majorVersion < minAvailableVersion)) { - // iOS 版本过低不支持该属性 - return nil; - } - - id targetObj = nil; - if ([LookinDashboardBlueprint isUIViewPropertyWithAttrID:attrID]) { - targetObj = view; - } else { - // Skip CALayer attributes when the backing layer's delegate - // doesn't match this view (e.g. NSRemoteView + NSVBCALayerHost). - // The backing layer is exposed as a separate child node. - CALayer *viewLayer = view.layer; - if (viewLayer && viewLayer.delegate != (id)view) { - return nil; - } - targetObj = viewLayer; - } - - if (targetObj) { - Class targetClass = NSClassFromString([LookinDashboardBlueprint classNameWithAttrID:attrID]); - if (![targetObj isKindOfClass:targetClass]) { - return nil; - } - - LookinAttribute *attr = [self _attributeWithIdentifer:attrID targetObject:targetObj]; - return attr; - } else { - return nil; - } - }]; - - if (sec.attributes.count) { - return sec; - } else { - return nil; - } - }]; - - if ([groupID isEqualToString:LookinAttrGroup_AutoLayout]) { - // 这里特殊处理一下,如果 AutoLayout 里面不包含 Constraints 的话(只有 Hugging 和 Resistance),就丢弃掉这整个 AutoLayout 不显示 - BOOL hasConstraits = [group.attrSections lookin_any:^BOOL(LookinAttributesSection *obj) { - return [obj.identifier isEqualToString:LookinAttrSec_AutoLayout_Constraints]; - }]; - if (!hasConstraits) { - return nil; - } - } - - if (group.attrSections.count) { - return group; - } else { - return nil; - } - }]; - - return groups; -} - -+ (NSArray *)attrGroupsForWindow:(NSWindow *)window { - if (!window) { - NSAssert(NO, @""); - return nil; - } - - NSMutableArray *result = [NSMutableArray array]; - - // Class group - show window class hierarchy and optional window controller - { - LookinAttribute *classAttr = [LookinAttribute new]; - classAttr.identifier = LookinAttr_Class_Class_Class; - classAttr.attrType = LookinAttrTypeCustomObj; - classAttr.value = [window lks_relatedClassChainList]; - - LookinAttributesSection *classSec = [LookinAttributesSection new]; - classSec.identifier = LookinAttrSec_Class_Class; - classSec.attributes = @[classAttr]; - - LookinAttributesGroup *classGroup = [LookinAttributesGroup new]; - classGroup.identifier = LookinAttrGroup_Class; - classGroup.attrSections = @[classSec]; - [result addObject:classGroup]; - } - - // Relation group - show window controller / delegate relation - { - NSArray *selfRelation = [window lks_selfRelation]; - if (selfRelation.count > 0) { - LookinAttribute *relationAttr = [LookinAttribute new]; - relationAttr.identifier = LookinAttr_Relation_Relation_Relation; - relationAttr.attrType = LookinAttrTypeCustomObj; - relationAttr.value = selfRelation; - - LookinAttributesSection *relationSec = [LookinAttributesSection new]; - relationSec.identifier = LookinAttrSec_Relation_Relation; - relationSec.attributes = @[relationAttr]; - - LookinAttributesGroup *relationGroup = [LookinAttributesGroup new]; - relationGroup.identifier = LookinAttrGroup_Relation; - relationGroup.attrSections = @[relationSec]; - [result addObject:relationGroup]; - } - } - - // Layout group - show frame and bounds - { - LookinAttribute *frameAttr = [LookinAttribute new]; - frameAttr.identifier = LookinAttr_Layout_Frame_Frame; - frameAttr.attrType = LookinAttrTypeCGRect; - frameAttr.value = [NSValue valueWithCGRect:window.frame]; - - LookinAttributesSection *frameSec = [LookinAttributesSection new]; - frameSec.identifier = LookinAttrSec_Layout_Frame; - frameSec.attributes = @[frameAttr]; - - LookinAttributesGroup *layoutGroup = [LookinAttributesGroup new]; - layoutGroup.identifier = LookinAttrGroup_Layout; - layoutGroup.attrSections = @[frameSec]; - [result addObject:layoutGroup]; - } - - // NSWindow-specific groups from blueprint - NSArray *blueprintGroups = [[LookinDashboardBlueprint groupIDs] lookin_map:^id(NSUInteger idx, LookinAttrGroupIdentifier groupID) { - LookinAttributesGroup *group = [LookinAttributesGroup new]; - group.identifier = groupID; - - NSArray *secIDs = [LookinDashboardBlueprint sectionIDsForGroupID:groupID]; - group.attrSections = [secIDs lookin_map:^id(NSUInteger idx, LookinAttrSectionIdentifier secID) { - LookinAttributesSection *sec = [LookinAttributesSection new]; - sec.identifier = secID; - - NSArray *attrIDs = [LookinDashboardBlueprint attrIDsForSectionID:secID]; - sec.attributes = [attrIDs lookin_map:^id(NSUInteger idx, LookinAttrIdentifier attrID) { - NSInteger minAvailableVersion = [LookinDashboardBlueprint minAvailableOSVersionWithAttrID:attrID]; - if (minAvailableVersion > 0 && (NSProcessInfo.processInfo.operatingSystemVersion.majorVersion < minAvailableVersion)) { - return nil; - } - - if (![LookinDashboardBlueprint isWindowPropertyWithAttrID:attrID]) { - return nil; - } - - Class targetClass = NSClassFromString([LookinDashboardBlueprint classNameWithAttrID:attrID]); - if (![window isKindOfClass:targetClass]) { - return nil; - } - - LookinAttribute *attr = [self _attributeWithIdentifer:attrID targetObject:window]; - return attr; - }]; - - if (sec.attributes.count) { - return sec; - } else { - return nil; - } - }]; - - if (group.attrSections.count) { - return group; - } else { - return nil; - } - }]; - - [result addObjectsFromArray:blueprintGroups]; - return result; -} -#endif - -#if TARGET_OS_IPHONE -+ (NSArray *)attrGroupsForWindowScene:(UIWindowScene *)windowScene API_AVAILABLE(ios(13.0)) { - if (!windowScene) { - NSAssert(NO, @""); - return nil; - } - - NSMutableArray *result = [NSMutableArray array]; - - // Class group - show scene class hierarchy - { - LookinAttribute *classAttr = [LookinAttribute new]; - classAttr.identifier = LookinAttr_Class_Class_Class; - classAttr.attrType = LookinAttrTypeCustomObj; - classAttr.value = [windowScene lks_relatedClassChainList]; - - LookinAttributesSection *classSec = [LookinAttributesSection new]; - classSec.identifier = LookinAttrSec_Class_Class; - classSec.attributes = @[classAttr]; - - LookinAttributesGroup *classGroup = [LookinAttributesGroup new]; - classGroup.identifier = LookinAttrGroup_Class; - classGroup.attrSections = @[classSec]; - [result addObject:classGroup]; - } - - // Relation group - show delegate relation - { - NSArray *selfRelation = [windowScene lks_selfRelation]; - if (selfRelation.count > 0) { - LookinAttribute *relationAttr = [LookinAttribute new]; - relationAttr.identifier = LookinAttr_Relation_Relation_Relation; - relationAttr.attrType = LookinAttrTypeCustomObj; - relationAttr.value = selfRelation; - - LookinAttributesSection *relationSec = [LookinAttributesSection new]; - relationSec.identifier = LookinAttrSec_Relation_Relation; - relationSec.attributes = @[relationAttr]; - - LookinAttributesGroup *relationGroup = [LookinAttributesGroup new]; - relationGroup.identifier = LookinAttrGroup_Relation; - relationGroup.attrSections = @[relationSec]; - [result addObject:relationGroup]; - } - } - - // Layout group - show coordinateSpace bounds. A scene has no frame of its - // own, so this must not reuse LookinAttr_Layout_Frame_Frame: that attribute - // resolves its setter to -setFrame:, which UIWindowScene does not respond to. - { - LookinAttribute *coordinateSpaceAttribute = [self _attributeWithIdentifer:LookinAttr_Layout_CoordinateSpace_CoordinateSpace targetObject:windowScene]; - if (coordinateSpaceAttribute) { - LookinAttributesSection *coordinateSpaceSection = [LookinAttributesSection new]; - coordinateSpaceSection.identifier = LookinAttrSec_Layout_CoordinateSpace; - coordinateSpaceSection.attributes = @[coordinateSpaceAttribute]; - - LookinAttributesGroup *layoutGroup = [LookinAttributesGroup new]; - layoutGroup.identifier = LookinAttrGroup_Layout; - layoutGroup.attrSections = @[coordinateSpaceSection]; - [result addObject:layoutGroup]; - } - } - - // UIWindowScene-specific groups from blueprint - NSArray *blueprintGroups = [[LookinDashboardBlueprint groupIDs] lookin_map:^id(NSUInteger idx, LookinAttrGroupIdentifier groupID) { - LookinAttributesGroup *group = [LookinAttributesGroup new]; - group.identifier = groupID; - - NSArray *secIDs = [LookinDashboardBlueprint sectionIDsForGroupID:groupID]; - group.attrSections = [secIDs lookin_map:^id(NSUInteger idx, LookinAttrSectionIdentifier secID) { - LookinAttributesSection *sec = [LookinAttributesSection new]; - sec.identifier = secID; - - NSArray *attrIDs = [LookinDashboardBlueprint attrIDsForSectionID:secID]; - sec.attributes = [attrIDs lookin_map:^id(NSUInteger idx, LookinAttrIdentifier attrID) { - NSInteger minAvailableVersion = [LookinDashboardBlueprint minAvailableOSVersionWithAttrID:attrID]; - if (minAvailableVersion > 0 && (NSProcessInfo.processInfo.operatingSystemVersion.majorVersion < minAvailableVersion)) { - return nil; - } - - if (![LookinDashboardBlueprint isWindowPropertyWithAttrID:attrID]) { - return nil; - } - - Class targetClass = NSClassFromString([LookinDashboardBlueprint classNameWithAttrID:attrID]); - if (![windowScene isKindOfClass:targetClass]) { - return nil; - } - - LookinAttribute *attr = [self _attributeWithIdentifer:attrID targetObject:windowScene]; - return attr; - }]; - - if (sec.attributes.count) { - return sec; - } else { - return nil; - } - }]; - - if (group.attrSections.count) { - return group; - } else { - return nil; - } - }]; - - [result addObjectsFromArray:blueprintGroups]; - return result; -} -#endif - -+ (LookinAttribute *)_attributeWithIdentifer:(LookinAttrIdentifier)identifier targetObject:(id)target { - if (!target) { - NSAssert(NO, @""); - return nil; - } - - LookinAttribute *attribute = [LookinAttribute new]; - attribute.identifier = identifier; - - SEL getter = [LookinDashboardBlueprint getterWithAttrID:identifier]; - if (!getter) { - NSAssert(NO, @""); - return nil; - } - if (![target respondsToSelector:getter]) { - // 比如某些 QMUI 的属性,不引入 QMUI 就会走到这个分支里 - return nil; - } - NSMethodSignature *signature = [target methodSignatureForSelector:getter]; - if (signature.numberOfArguments > 2) { - NSAssert(NO, @"getter 不可以有参数"); - return nil; - } - if (strcmp([signature methodReturnType], @encode(void)) == 0) { - NSAssert(NO, @"getter 返回值不能为 void"); - return nil; - } - - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; - invocation.target = target; - invocation.selector = getter; - [invocation invoke]; - - const char *returnType = [signature methodReturnType]; - - if (strcmp(returnType, @encode(char)) == 0) { - char targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeChar; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(int)) == 0) { - int targetValue; - [invocation getReturnValue:&targetValue]; - attribute.value = @(targetValue); - if ([LookinDashboardBlueprint enumListNameWithAttrID:identifier]) { - attribute.attrType = LookinAttrTypeEnumInt; - } else { - attribute.attrType = LookinAttrTypeInt; - } - - } else if (strcmp(returnType, @encode(short)) == 0) { - short targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeShort; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(long)) == 0) { - long targetValue; - [invocation getReturnValue:&targetValue]; - attribute.value = @(targetValue); - if ([LookinDashboardBlueprint enumListNameWithAttrID:identifier]) { - attribute.attrType = LookinAttrTypeEnumLong; - } else { - attribute.attrType = LookinAttrTypeLong; - } - - } else if (strcmp(returnType, @encode(long long)) == 0) { - long long targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeLongLong; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(unsigned char)) == 0) { - unsigned char targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeUnsignedChar; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(unsigned int)) == 0) { - unsigned int targetValue; - [invocation getReturnValue:&targetValue]; - attribute.value = @(targetValue); - if ([LookinDashboardBlueprint enumListNameWithAttrID:identifier]) { - attribute.attrType = LookinAttrTypeEnumInt; - } else { - attribute.attrType = LookinAttrTypeUnsignedInt; - } - - } else if (strcmp(returnType, @encode(unsigned short)) == 0) { - unsigned short targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeUnsignedShort; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(unsigned long)) == 0) { - unsigned long targetValue; - [invocation getReturnValue:&targetValue]; - attribute.value = @(targetValue); - if ([LookinDashboardBlueprint enumListNameWithAttrID:identifier]) { - attribute.attrType = LookinAttrTypeEnumLong; - } else { - attribute.attrType = LookinAttrTypeUnsignedLong; - } - - } else if (strcmp(returnType, @encode(unsigned long long)) == 0) { - unsigned long long targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeUnsignedLongLong; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(float)) == 0) { - float targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeFloat; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(double)) == 0) { - double targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeDouble; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(BOOL)) == 0) { - BOOL targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeBOOL; - attribute.value = @(targetValue); - - } else if (strcmp(returnType, @encode(SEL)) == 0) { - SEL targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeSel; - attribute.value = NSStringFromSelector(targetValue); - - } else if (strcmp(returnType, @encode(Class)) == 0) { - Class targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeClass; - attribute.value = NSStringFromClass(targetValue); - - } else if (strcmp(returnType, @encode(CGPoint)) == 0) { - CGPoint targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeCGPoint; - attribute.value = [NSValue valueWithCGPoint:targetValue]; - - } else if (strcmp(returnType, @encode(CGVector)) == 0) { - CGVector targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeCGVector; - attribute.value = [NSValue valueWithCGVector:targetValue]; - - } else if (strcmp(returnType, @encode(CGSize)) == 0) { - CGSize targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeCGSize; - attribute.value = [NSValue valueWithCGSize:targetValue]; - - } else if (strcmp(returnType, @encode(CGRect)) == 0) { - CGRect targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeCGRect; - attribute.value = [NSValue valueWithCGRect:targetValue]; - - } else if (strcmp(returnType, @encode(CGAffineTransform)) == 0) { - CGAffineTransform targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeCGAffineTransform; - attribute.value = [NSValue valueWithCGAffineTransform:targetValue]; - - } else if (strcmp(returnType, @encode(LookinInsets)) == 0) { - LookinInsets targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeUIEdgeInsets; - attribute.value = [NSValue valueWithInsets:targetValue]; -#if !TARGET_OS_OSX - } else if (strcmp(returnType, @encode(UIOffset)) == 0) { - UIOffset targetValue; - [invocation getReturnValue:&targetValue]; - attribute.attrType = LookinAttrTypeUIOffset; - attribute.value = [NSValue valueWithUIOffset:targetValue]; -#endif - } else { - NSString *argType_string = [[NSString alloc] lookin_safeInitWithUTF8String:returnType]; - if ([argType_string hasPrefix:@"@"]) { - __unsafe_unretained id returnObjValue; - [invocation getReturnValue:&returnObjValue]; - - if (!returnObjValue && [LookinDashboardBlueprint hideIfNilWithAttrID:identifier]) { - // 对于某些属性,若 value 为 nil 则不显示 - return nil; - } - - attribute.attrType = [LookinDashboardBlueprint objectAttrTypeWithAttrID:identifier]; - if (attribute.attrType == LookinAttrTypeUIColor) { - if (returnObjValue == nil) { - attribute.value = nil; - } else if ([returnObjValue isKindOfClass:[LookinColor class]] && [returnObjValue respondsToSelector:@selector(lks_rgbaComponents)]) { - attribute.value = [returnObjValue lks_rgbaComponents]; - } else { - // https://github.com/QMUI/LookinServer/issues/124 - return nil; - } - } else { - attribute.value = returnObjValue; - } - - } else { - NSAssert(NO, @"不支持解析该类型的返回值"); - return nil; - } - } - - return attribute; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_CustomAttrGroupsMaker.h b/Sources/LookinServer/Server/Others/LKS_CustomAttrGroupsMaker.h deleted file mode 100644 index 05eb2952..00000000 --- a/Sources/LookinServer/Server/Others/LKS_CustomAttrGroupsMaker.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LKS_CustomAttrGroupsMaker.h -// LookinServer -// -// Created by LikaiMacStudioWork on 2023/10/31. -// - -#import "LookinDefines.h" - -@class LookinAttributesGroup; - -@interface LKS_CustomAttrGroupsMaker : NSObject - -- (instancetype)initWithLayer:(CALayer *)layer; -#if TARGET_OS_OSX -- (instancetype)initWithView:(NSView *)view; -#endif - -- (void)execute; - -- (NSArray *)getGroups; -- (NSString *)getCustomDisplayTitle; -- (NSString *)getDanceUISource; - -+ (NSArray *)makeGroupsFromRawProperties:(NSArray *)rawProperties saveCustomSetter:(BOOL)saveCustomSetter; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_CustomAttrGroupsMaker.m b/Sources/LookinServer/Server/Others/LKS_CustomAttrGroupsMaker.m deleted file mode 100644 index 03a07b31..00000000 --- a/Sources/LookinServer/Server/Others/LKS_CustomAttrGroupsMaker.m +++ /dev/null @@ -1,517 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LKS_CustomAttrGroupsMaker.m -// LookinServer -// -// Created by LikaiMacStudioWork on 2023/10/31. -// - -#import "LKS_CustomAttrGroupsMaker.h" -#import "LKS_AttrGroupsMaker.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LookinAttribute.h" -#import "LookinDashboardBlueprint.h" -#import "LookinIvarTrace.h" -#import "UIColor+LookinServer.h" -#import "LookinServerDefines.h" -#import "LKS_CustomAttrSetterManager.h" - -@interface LKS_CustomAttrGroupsMaker () - -/// key 是 section title -@property(nonatomic, strong) NSMutableDictionary *> *sectionAndAttrs; - -@property(nonatomic, copy) NSString *resolvedCustomDisplayTitle; -@property(nonatomic, copy) NSString *resolvedDanceUISource; -@property(nonatomic, strong) NSMutableArray *resolvedGroups; - -#if TARGET_OS_OSX -@property(nonatomic, weak) NSView *view; -#endif -@property(nonatomic, weak) CALayer *layer; -@end - -@implementation LKS_CustomAttrGroupsMaker - -#if TARGET_OS_OSX -- (instancetype)initWithView:(NSView *)view { - if (self = [super init]) { - self.sectionAndAttrs = [NSMutableDictionary dictionary]; - self.view = view; - } - return self; -} -#endif - -- (instancetype)initWithLayer:(CALayer *)layer { - if (self = [super init]) { - self.sectionAndAttrs = [NSMutableDictionary dictionary]; - self.layer = layer; - } - return self; -} - -- (void)execute { -#if TARGET_OS_IPHONE - if (!self.layer) { - NSAssert(NO, @""); - return; - } -#elif TARGET_OS_OSX - if (!self.view && !self.layer) { - NSAssert(NO, @""); - return; - } -#endif - NSMutableArray *selectors = [NSMutableArray array]; - [selectors addObject:@"lookin_customDebugInfos"]; - for (int i = 0; i < 5; i++) { - [selectors addObject:[NSString stringWithFormat:@"lookin_customDebugInfos_%@", @(i)]]; - } - - for (NSString *name in selectors) { -#if TARGET_OS_OSX - if (self.view && !self.view.layer) { - [self makeAttrsForViewOrLayer:self.view selectorName:name]; - CALayer *layer = self.view.layer; - if (layer) { - [self makeAttrsForViewOrLayer:layer selectorName:name]; - } - continue; - } -#endif - if (self.layer) { - [self makeAttrsForViewOrLayer:self.layer selectorName:name]; - - LookinView *view = self.layer.lks_hostView; - if (view) { - [self makeAttrsForViewOrLayer:view selectorName:name]; - } - } - } - - if ([self.sectionAndAttrs count] == 0) { - return; - } - NSMutableArray *groups = [NSMutableArray array]; - [self.sectionAndAttrs enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull groupTitle, NSMutableArray * _Nonnull attrs, BOOL * _Nonnull stop) { - LookinAttributesGroup *group = [LookinAttributesGroup new]; - group.userCustomTitle = groupTitle; - group.identifier = LookinAttrGroup_UserCustom; - - NSMutableArray *sections = [NSMutableArray array]; - [attrs enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop) { - LookinAttributesSection *sec = [LookinAttributesSection new]; - sec.identifier = LookinAttrSec_UserCustom; - sec.attributes = @[attr]; - [sections addObject:sec]; - }]; - - group.attrSections = sections; - [groups addObject:group]; - }]; - [groups sortedArrayUsingComparator:^NSComparisonResult(LookinAttributesGroup *obj1, LookinAttributesGroup *obj2) { - return [obj1.userCustomTitle compare:obj2.userCustomTitle]; - }]; - - self.resolvedGroups = groups; -} - -- (void)makeAttrsForViewOrLayer:(id)viewOrLayer selectorName:(NSString *)selectorName { - if (!viewOrLayer || !selectorName.length) { - return; - } - if (![viewOrLayer isKindOfClass:[LookinView class]] && ![viewOrLayer isKindOfClass:[CALayer class]]) { - return; - } - SEL selector = NSSelectorFromString(selectorName); - if (![viewOrLayer respondsToSelector:selector]) { - return; - } - NSMethodSignature *signature = [viewOrLayer methodSignatureForSelector:selector]; - if (signature.numberOfArguments > 2) { - NSAssert(NO, @"LookinServer - There should be no explicit parameters."); - return; - } - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; - [invocation setTarget:viewOrLayer]; - [invocation setSelector:selector]; - [invocation invoke]; - - // 小心这里的内存管理 - NSDictionary * __unsafe_unretained tempRawData; - [invocation getReturnValue:&tempRawData]; - if (!tempRawData || ![tempRawData isKindOfClass:[NSDictionary class]]) { - return; - } - - NSDictionary *rawData = tempRawData; - NSArray *rawProperties = rawData[@"properties"]; - - NSString *customTitle = rawData[@"title"]; - if (customTitle && [customTitle isKindOfClass:[NSString class]] && customTitle.length > 0) { - self.resolvedCustomDisplayTitle = customTitle; - } - - NSString *danceSource = rawData[@"lookin_source"]; - if (danceSource && [danceSource isKindOfClass:[NSString class]] && danceSource.length > 0) { - self.resolvedDanceUISource = danceSource; - } - - [self makeAttrsFromRawProperties:rawProperties]; -} - -- (void)makeAttrsFromRawProperties:(NSArray *)rawProperties { - if (!rawProperties || ![rawProperties isKindOfClass:[NSArray class]]) { - return; - } - - for (NSDictionary *dict in rawProperties) { - NSString *groupTitle; - LookinAttribute *attr = [LKS_CustomAttrGroupsMaker attrFromRawDict:dict saveCustomSetter:YES groupTitle:&groupTitle]; - if (!attr) { - continue; - } - if (!self.sectionAndAttrs[groupTitle]) { - self.sectionAndAttrs[groupTitle] = [NSMutableArray array]; - } - [self.sectionAndAttrs[groupTitle] addObject:attr]; - } -} - -+ (LookinAttribute *)attrFromRawDict:(NSDictionary *)dict saveCustomSetter:(BOOL)saveCustomSetter groupTitle:(inout NSString **)inoutGroupTitle { - LookinAttribute *attr = [LookinAttribute new]; - attr.identifier = LookinAttr_UserCustom; - - NSString *title = dict[@"title"]; - NSString *type = dict[@"valueType"]; - NSString *section = dict[@"section"]; - id value = dict[@"value"]; - - if (!title || ![title isKindOfClass:[NSString class]]) { - NSLog(@"LookinServer - Wrong title"); - return nil; - } - if (!type || ![type isKindOfClass:[NSString class]]) { - NSLog(@"LookinServer - Wrong valueType"); - return nil; - } - if (!section || ![section isKindOfClass:[NSString class]] || section.length == 0) { - *inoutGroupTitle = @"Custom"; - } else { - *inoutGroupTitle = section; - } - - attr.displayTitle = title; - - NSString *fixedType = type.lowercaseString; - if ([fixedType isEqualToString:@"string"]) { - if (value != nil && ![value isKindOfClass:[NSString class]]) { - // nil 是合法的 - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeNSString; - attr.value = value; - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_StringSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] saveStringSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"number"]) { - if (value == nil) { - NSLog(@"LookinServer - No value."); - return nil; - } - if (![value isKindOfClass:[NSNumber class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeDouble; - attr.value = value; - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_NumberSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] saveNumberSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"bool"]) { - if (value == nil) { - NSLog(@"LookinServer - No value."); - return nil; - } - if (![value isKindOfClass:[NSNumber class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeBOOL; - attr.value = value; - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_BoolSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] saveBoolSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"color"]) { - if (value != nil && ![value isKindOfClass:[LookinColor class]]) { - // nil 是合法的 - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeUIColor; - attr.value = [(LookinColor *)value lks_rgbaComponents]; - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_ColorSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] saveColorSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"rect"]) { - if (value == nil) { - NSLog(@"LookinServer - No value."); - return nil; - } - if (![value isKindOfClass:[NSValue class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeCGRect; - attr.value = value; - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_RectSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] saveRectSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"size"]) { - if (value == nil) { - NSLog(@"LookinServer - No value."); - return nil; - } - if (![value isKindOfClass:[NSValue class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeCGSize; - attr.value = value; - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_SizeSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] saveSizeSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"point"]) { - if (value == nil) { - NSLog(@"LookinServer - No value."); - return nil; - } - if (![value isKindOfClass:[NSValue class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeCGPoint; - attr.value = value; - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_PointSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] savePointSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"insets"]) { - if (value == nil) { - NSLog(@"LookinServer - No value."); - return nil; - } - if (![value isKindOfClass:[NSValue class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeUIEdgeInsets; - attr.value = value; - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_InsetsSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] saveInsetsSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"shadow"]) { - if (value == nil) { - NSLog(@"LookinServer - No value."); - return nil; - } - if (![value isKindOfClass:[NSDictionary class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - NSDictionary *shadowInfo = value; - if (![shadowInfo[@"offset"] isKindOfClass:[NSValue class]]) { - NSLog(@"LookinServer - Wrong value. No offset."); - return nil; - } - if (![shadowInfo[@"opacity"] isKindOfClass:[NSNumber class]]) { - NSLog(@"LookinServer - Wrong value. No opacity."); - return nil; - } - if (![shadowInfo[@"radius"] isKindOfClass:[NSNumber class]]) { - NSLog(@"LookinServer - Wrong value. No radius."); - return nil; - } - NSMutableDictionary *checkedShadowInfo = [@{ - @"offset": shadowInfo[@"offset"], - @"opacity": shadowInfo[@"opacity"], - @"radius": shadowInfo[@"radius"] - } mutableCopy]; - if ([shadowInfo[@"color"] isKindOfClass:[LookinColor class]]) { - checkedShadowInfo[@"color"] = [(LookinColor *)shadowInfo[@"color"] lks_rgbaComponents]; - } - - attr.attrType = LookinAttrTypeShadow; - attr.value = checkedShadowInfo; - - return attr; - } - - if ([fixedType isEqualToString:@"enum"]) { - if (value == nil) { - NSLog(@"LookinServer - No value."); - return nil; - } - if (![value isKindOfClass:[NSString class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeEnumString; - attr.value = value; - - NSArray *allEnumCases = dict[@"allEnumCases"]; - if ([allEnumCases isKindOfClass:[NSArray class]]) { - attr.extraValue = allEnumCases; - } - - if (saveCustomSetter && dict[@"retainedSetter"]) { - NSString *uniqueID = [[NSUUID new] UUIDString]; - LKS_EnumSetter setter = dict[@"retainedSetter"]; - [[LKS_CustomAttrSetterManager sharedInstance] saveEnumSetter:setter uniqueID:uniqueID]; - attr.customSetterID = uniqueID; - } - - return attr; - } - - if ([fixedType isEqualToString:@"json"]) { - if (![value isKindOfClass:[NSString class]]) { - NSLog(@"LookinServer - Wrong value type."); - return nil; - } - attr.attrType = LookinAttrTypeJson; - attr.value = value; - - return attr; - } - - NSLog(@"LookinServer - Unsupported value type."); - return nil; -} - -- (NSArray *)getGroups { - return self.resolvedGroups; -} - -- (NSString *)getCustomDisplayTitle { - return self.resolvedCustomDisplayTitle; -} - -- (NSString *)getDanceUISource { - return self.resolvedDanceUISource; -} - -+ (NSArray *)makeGroupsFromRawProperties:(NSArray *)rawProperties saveCustomSetter:(BOOL)saveCustomSetter { - if (!rawProperties || ![rawProperties isKindOfClass:[NSArray class]]) { - return nil; - } - // key 是 group title - NSMutableDictionary *> *groupTitleAndAttrs = [NSMutableDictionary dictionary]; - - for (NSDictionary *dict in rawProperties) { - NSString *groupTitle; - LookinAttribute *attr = [LKS_CustomAttrGroupsMaker attrFromRawDict:dict saveCustomSetter:saveCustomSetter groupTitle:&groupTitle]; - if (!attr) { - continue; - } - if (!groupTitleAndAttrs[groupTitle]) { - groupTitleAndAttrs[groupTitle] = [NSMutableArray array]; - } - [groupTitleAndAttrs[groupTitle] addObject:attr]; - } - - if ([groupTitleAndAttrs count] == 0) { - return nil; - } - NSMutableArray *groups = [NSMutableArray array]; - [groupTitleAndAttrs enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull groupTitle, NSMutableArray * _Nonnull attrs, BOOL * _Nonnull stop) { - LookinAttributesGroup *group = [LookinAttributesGroup new]; - group.userCustomTitle = groupTitle; - group.identifier = LookinAttrGroup_UserCustom; - - NSMutableArray *sections = [NSMutableArray array]; - [attrs enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop) { - LookinAttributesSection *sec = [LookinAttributesSection new]; - sec.identifier = LookinAttrSec_UserCustom; - sec.attributes = @[attr]; - [sections addObject:sec]; - }]; - - group.attrSections = sections; - [groups addObject:group]; - }]; - [groups sortedArrayUsingComparator:^NSComparisonResult(LookinAttributesGroup *obj1, LookinAttributesGroup *obj2) { - return [obj1.userCustomTitle compare:obj2.userCustomTitle]; - }]; - return [groups copy]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_CustomAttrSetterManager.h b/Sources/LookinServer/Server/Others/LKS_CustomAttrSetterManager.h deleted file mode 100644 index 2f0589b7..00000000 --- a/Sources/LookinServer/Server/Others/LKS_CustomAttrSetterManager.h +++ /dev/null @@ -1,62 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LKS_CustomAttrSetterManager.h -// LookinServer -// -// Created by likai.123 on 2023/11/4. -// -#import -#if TARGET_OS_IPHONE -#import -#endif - -#if TARGET_OS_OSX -#import -#endif -#import "LookinDefines.h" -typedef void(^LKS_StringSetter)(NSString *); -typedef void(^LKS_NumberSetter)(NSNumber *); -typedef void(^LKS_BoolSetter)(BOOL); -typedef void(^LKS_ColorSetter)(LookinColor *); -typedef void(^LKS_EnumSetter)(NSString *); -typedef void(^LKS_RectSetter)(CGRect); -typedef void(^LKS_SizeSetter)(CGSize); -typedef void(^LKS_PointSetter)(CGPoint); -typedef void(^LKS_InsetsSetter)(LookinInsets); - -@interface LKS_CustomAttrSetterManager : NSObject - -+ (instancetype)sharedInstance; - -- (void)removeAll; - -- (void)saveStringSetter:(LKS_StringSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_StringSetter)getStringSetterWithID:(NSString *)uniqueID; - -- (void)saveNumberSetter:(LKS_NumberSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_NumberSetter)getNumberSetterWithID:(NSString *)uniqueID; - -- (void)saveBoolSetter:(LKS_BoolSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_BoolSetter)getBoolSetterWithID:(NSString *)uniqueID; - -- (void)saveColorSetter:(LKS_ColorSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_ColorSetter)getColorSetterWithID:(NSString *)uniqueID; - -- (void)saveEnumSetter:(LKS_EnumSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_EnumSetter)getEnumSetterWithID:(NSString *)uniqueID; - -- (void)saveRectSetter:(LKS_RectSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_RectSetter)getRectSetterWithID:(NSString *)uniqueID; - -- (void)saveSizeSetter:(LKS_SizeSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_SizeSetter)getSizeSetterWithID:(NSString *)uniqueID; - -- (void)savePointSetter:(LKS_PointSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_PointSetter)getPointSetterWithID:(NSString *)uniqueID; - -- (void)saveInsetsSetter:(LKS_InsetsSetter)setter uniqueID:(NSString *)uniqueID; -- (LKS_InsetsSetter)getInsetsSetterWithID:(NSString *)uniqueID; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_CustomAttrSetterManager.m b/Sources/LookinServer/Server/Others/LKS_CustomAttrSetterManager.m deleted file mode 100644 index 20b73c9b..00000000 --- a/Sources/LookinServer/Server/Others/LKS_CustomAttrSetterManager.m +++ /dev/null @@ -1,117 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LKS_CustomAttrSetterManager.m -// LookinServer -// -// Created by likai.123 on 2023/11/4. -// - -#import "LKS_CustomAttrSetterManager.h" - -@interface LKS_CustomAttrSetterManager () - -@property(nonatomic, strong) NSMutableDictionary *settersMap; - -@end - -@implementation LKS_CustomAttrSetterManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKS_CustomAttrSetterManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone { - return [self sharedInstance]; -} - -- (instancetype)init { - self = [super init]; - if (self) { - self.settersMap = [NSMutableDictionary new]; - } - return self; -} - -- (void)removeAll { - [self.settersMap removeAllObjects]; -} - -- (void)saveStringSetter:(nonnull LKS_StringSetter)setter uniqueID:(nonnull NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (nullable LKS_StringSetter)getStringSetterWithID:(nonnull NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -- (void)saveNumberSetter:(LKS_NumberSetter)setter uniqueID:(NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (nullable LKS_NumberSetter)getNumberSetterWithID:(NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -- (void)saveBoolSetter:(LKS_BoolSetter)setter uniqueID:(NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (LKS_BoolSetter)getBoolSetterWithID:(NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -- (void)saveColorSetter:(LKS_ColorSetter)setter uniqueID:(NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (LKS_ColorSetter)getColorSetterWithID:(NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -- (void)saveEnumSetter:(LKS_EnumSetter)setter uniqueID:(NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (LKS_EnumSetter)getEnumSetterWithID:(NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -- (void)saveRectSetter:(LKS_RectSetter)setter uniqueID:(NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (LKS_RectSetter)getRectSetterWithID:(NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -- (void)saveSizeSetter:(LKS_SizeSetter)setter uniqueID:(NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (LKS_SizeSetter)getSizeSetterWithID:(NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -- (void)savePointSetter:(LKS_PointSetter)setter uniqueID:(NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (LKS_PointSetter)getPointSetterWithID:(NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -- (void)saveInsetsSetter:(LKS_InsetsSetter)setter uniqueID:(NSString *)uniqueID { - self.settersMap[uniqueID] = setter; -} - -- (LKS_InsetsSetter)getInsetsSetterWithID:(NSString *)uniqueID { - return self.settersMap[uniqueID]; -} - -@end -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_CustomDisplayItemsMaker.h b/Sources/LookinServer/Server/Others/LKS_CustomDisplayItemsMaker.h deleted file mode 100644 index 888beb3c..00000000 --- a/Sources/LookinServer/Server/Others/LKS_CustomDisplayItemsMaker.h +++ /dev/null @@ -1,35 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_CustomDisplayItemsMaker.h -// LookinServer -// -// Created by likai.123 on 2023/11/1. -// - -#import - -#if TARGET_OS_IPHONE -#import -#endif - -#if TARGET_OS_OSX -#import -#endif - -#import "LookinDefines.h" - -@class LookinDisplayItem; - -@interface LKS_CustomDisplayItemsMaker : NSObject - -- (instancetype)initWithLayer:(CALayer *)layer saveAttrSetter:(BOOL)saveAttrSetter; -#if TARGET_OS_OSX -- (instancetype)initWithView:(NSView *)view saveAttrSetter:(BOOL)saveAttrSetter; -#endif - -- (NSArray *)make; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_CustomDisplayItemsMaker.m b/Sources/LookinServer/Server/Others/LKS_CustomDisplayItemsMaker.m deleted file mode 100644 index f46bfaf9..00000000 --- a/Sources/LookinServer/Server/Others/LKS_CustomDisplayItemsMaker.m +++ /dev/null @@ -1,185 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_CustomDisplayItemsMaker.m -// LookinServer -// -// Created by likai.123 on 2023/11/1. -// - -#import "LKS_CustomDisplayItemsMaker.h" -#import "CALayer+LookinServer.h" -#import "LookinDisplayItem.h" -#import "NSArray+Lookin.h" -#import "LKS_CustomAttrGroupsMaker.h" - -@interface LKS_CustomDisplayItemsMaker () -#if TARGET_OS_OSX -@property(nonatomic, weak) NSView *view; -#endif -@property(nonatomic, weak) CALayer *layer; -@property(nonatomic, assign) BOOL saveAttrSetter; -@property(nonatomic, strong) NSMutableArray *allSubitems; - -@end - - - -@implementation LKS_CustomDisplayItemsMaker - - - -#if TARGET_OS_OSX -- (instancetype)initWithView:(NSView *)view saveAttrSetter:(BOOL)saveAttrSetter { - if (self = [super init]) { - self.view = view; - self.saveAttrSetter = saveAttrSetter; - self.allSubitems = [NSMutableArray array]; - } - return self; -} -#endif - -- (instancetype)initWithLayer:(CALayer *)layer saveAttrSetter:(BOOL)saveAttrSetter { - if (self = [super init]) { - self.layer = layer; - self.saveAttrSetter = saveAttrSetter; - self.allSubitems = [NSMutableArray array]; - } - return self; -} - -- (NSArray *)make { -#if TARGET_OS_OSX - if (self.view && !self.view.layer) { - NSMutableArray *selectors = [NSMutableArray array]; - [selectors addObject:@"lookin_customDebugInfos"]; - for (int i = 0; i < 5; i++) { - [selectors addObject:[NSString stringWithFormat:@"lookin_customDebugInfos_%@", @(i)]]; - } - - for (NSString *name in selectors) { - [self makeSubitemsForViewOrLayer:self.view selectorName:name]; - - CALayer *layer = self.view.layer; - if (layer) { - [self makeSubitemsForViewOrLayer:layer selectorName:name]; - } - } - - if (self.allSubitems.count) { - return self.allSubitems; - } else { - return nil; - } - } -#endif - if (!self.layer) { - NSAssert(NO, @""); - return nil; - } - NSMutableArray *selectors = [NSMutableArray array]; - [selectors addObject:@"lookin_customDebugInfos"]; - for (int i = 0; i < 5; i++) { - [selectors addObject:[NSString stringWithFormat:@"lookin_customDebugInfos_%@", @(i)]]; - } - - for (NSString *name in selectors) { - [self makeSubitemsForViewOrLayer:self.layer selectorName:name]; - - LookinView *view = self.layer.lks_hostView; - if (view) { - [self makeSubitemsForViewOrLayer:view selectorName:name]; - } - } - - if (self.allSubitems.count) { - return self.allSubitems; - } else { - return nil; - } -} - -- (void)makeSubitemsForViewOrLayer:(id)viewOrLayer selectorName:(NSString *)selectorName { - if (!viewOrLayer || !selectorName.length) { - return; - } - if (![viewOrLayer isKindOfClass:[LookinView class]] && ![viewOrLayer isKindOfClass:[CALayer class]]) { - return; - } - SEL selector = NSSelectorFromString(selectorName); - if (![viewOrLayer respondsToSelector:selector]) { - return; - } - NSMethodSignature *signature = [viewOrLayer methodSignatureForSelector:selector]; - if (signature.numberOfArguments > 2) { - NSAssert(NO, @"LookinServer - There should be no explicit parameters."); - return; - } - NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; - [invocation setTarget:viewOrLayer]; - [invocation setSelector:selector]; - [invocation invoke]; - - // 小心这里的内存管理 - NSDictionary * __unsafe_unretained tempRawData; - [invocation getReturnValue:&tempRawData]; - NSDictionary *rawData = tempRawData; - - [self makeSubitemsFromRawData:rawData]; -} - -- (void)makeSubitemsFromRawData:(NSDictionary *)data { - if (!data || ![data isKindOfClass:[NSDictionary class]]) { - return; - } - NSArray *rawSubviews = data[@"subviews"]; - NSArray *newSubitems = [self displayItemsFromRawArray:rawSubviews]; - if (newSubitems) { - [self.allSubitems addObjectsFromArray:newSubitems]; - } -} - -- (NSArray *)displayItemsFromRawArray:(NSArray *)rawArray { - if (!rawArray || ![rawArray isKindOfClass:[NSArray class]]) { - return nil; - } - NSArray *items = [rawArray lookin_map:^id(NSUInteger idx, NSDictionary *rawDict) { - if (![rawDict isKindOfClass:[NSDictionary class]]) { - return nil; - } - return [self displayItemFromRawDict:rawDict]; - }]; - return items; -} - -- (LookinDisplayItem *)displayItemFromRawDict:(NSDictionary *)dict { - NSString *title = dict[@"title"]; - NSString *subtitle = dict[@"subtitle"]; - NSValue *frameValue = dict[@"frameInWindow"]; - NSArray *properties = dict[@"properties"]; - NSArray *subviews = dict[@"subviews"]; - NSString *danceSource = dict[@"lookin_source"]; - - if (![title isKindOfClass:[NSString class]]) { - return nil; - } - LookinDisplayItem *newItem = [LookinDisplayItem new]; - if (subviews && [subviews isKindOfClass:[NSArray class]]) { - newItem.subitems = [self displayItemsFromRawArray:subviews]; - } - newItem.isHidden = NO; - newItem.alpha = 1.0; - newItem.customInfo = [LookinCustomDisplayItemInfo new]; - newItem.customInfo.title = title; - newItem.customInfo.subtitle = subtitle; - newItem.customInfo.frameInWindow = frameValue; - newItem.customInfo.danceuiSource = danceSource; - newItem.customAttrGroupList = [LKS_CustomAttrGroupsMaker makeGroupsFromRawProperties:properties saveCustomSetter:self.saveAttrSetter]; - - return newItem; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_EventHandlerMaker.h b/Sources/LookinServer/Server/Others/LKS_EventHandlerMaker.h deleted file mode 100644 index a8d036f6..00000000 --- a/Sources/LookinServer/Server/Others/LKS_EventHandlerMaker.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_EventHandlerMaker.h -// LookinServer -// -// Created by Li Kai on 2019/8/7. -// https://lookin.work -// - -#import "LookinDefines.h" - -@class LookinEventHandler; - -@interface LKS_EventHandlerMaker : NSObject - -+ (NSArray *)makeForView:(LookinView *)view; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_EventHandlerMaker.m b/Sources/LookinServer/Server/Others/LKS_EventHandlerMaker.m deleted file mode 100644 index 608c7806..00000000 --- a/Sources/LookinServer/Server/Others/LKS_EventHandlerMaker.m +++ /dev/null @@ -1,337 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_EventHandlerMaker.m -// LookinServer -// -// Created by Li Kai on 2019/8/7. -// https://lookin.work -// - -#import "LKS_EventHandlerMaker.h" -#import "LookinTuple.h" -#import "LookinEventHandler.h" -#import "LookinObject.h" -#import "LookinWeakContainer.h" -#import "LookinIvarTrace.h" -#import "LookinServerDefines.h" -#import "LKS_GestureTargetActionsSearcher.h" -#import "LKS_MultiplatformAdapter.h" - -@implementation LKS_EventHandlerMaker - -+ (NSArray *)makeForView:(LookinView *)view { - if (!view) { - return nil; - } - - NSMutableArray *allHandlers = nil; - if ([view isKindOfClass:[LookinControl class]]) { - NSArray *targetActionHandlers = [self _targetActionHandlersForControl:(LookinControl *)view]; - if (targetActionHandlers.count) { - if (!allHandlers) { - allHandlers = [NSMutableArray array]; - } - [allHandlers addObjectsFromArray:targetActionHandlers]; - } - } - - NSArray *gestureHandlers = [self _gestureHandlersForView:view]; - if (gestureHandlers.count) { - if (!allHandlers) { - allHandlers = [NSMutableArray array]; - } - [allHandlers addObjectsFromArray:gestureHandlers]; - } - - return allHandlers.copy; -} - -+ (NSArray *)_gestureHandlersForView:(LookinView *)view { - if (view.gestureRecognizers.count == 0) { - return nil; - } - NSArray *handlers = [view.gestureRecognizers lookin_map:^id(NSUInteger idx, __kindof LookinGestureRecognizer *recognizer) { - LookinEventHandler *handler = [LookinEventHandler new]; - handler.handlerType = LookinEventHandlerTypeGesture; - handler.eventName = NSStringFromClass([recognizer class]); - - NSArray *targetActionInfos = [LKS_GestureTargetActionsSearcher getTargetActionsFromRecognizer:recognizer]; - handler.targetActions = [targetActionInfos lookin_map:^id(NSUInteger idx, LookinTwoTuple *rawTuple) { - NSObject *target = ((LookinWeakContainer *)rawTuple.first).object; - if (!target) { - // 该 target 已被释放 - return nil; - } - LookinStringTwoTuple *newTuple = [LookinStringTwoTuple new]; - newTuple.first = [LKS_Helper descriptionOfObject:target]; - newTuple.second = (NSString *)rawTuple.second; - return newTuple; - }]; - handler.inheritedRecognizerName = [self _inheritedRecognizerNameForRecognizer:recognizer]; - handler.gestureRecognizerIsEnabled = recognizer.enabled; - if (recognizer.delegate) { - handler.gestureRecognizerDelegator = [LKS_Helper descriptionOfObject:recognizer.delegate]; - } - handler.recognizerIvarTraces = [recognizer.lks_ivarTraces lookin_map:^id(NSUInteger idx, LookinIvarTrace *trace) { - return [NSString stringWithFormat:@"(%@ *) -> %@", trace.hostClassName, trace.ivarName]; - }]; - - handler.recognizerOid = [recognizer lks_registerOid]; - return handler; - }]; - return handlers; -} - -+ (NSString *)_inheritedRecognizerNameForRecognizer:(LookinGestureRecognizer *)recognizer { - if (!recognizer) { - NSAssert(NO, @""); - return nil; - } - - static NSArray *baseRecognizers; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - // 注意这里 UIScreenEdgePanGestureRecognizer 在 UIPanGestureRecognizer 前面,因为 UIScreenEdgePanGestureRecognizer 继承于 UIPanGestureRecognizer -#if TARGET_OS_TV - baseRecognizers = @[[UILongPressGestureRecognizer class], - [UIPanGestureRecognizer class], - [UISwipeGestureRecognizer class], - [UITapGestureRecognizer class]]; -#elif TARGET_OS_VISION - baseRecognizers = @[[UILongPressGestureRecognizer class], - [UIPanGestureRecognizer class], - [UISwipeGestureRecognizer class], - [UIRotationGestureRecognizer class], - [UIPinchGestureRecognizer class], - [UITapGestureRecognizer class]]; -#elif TARGET_OS_IPHONE - baseRecognizers = @[[UILongPressGestureRecognizer class], - [UIScreenEdgePanGestureRecognizer class], - [UIPanGestureRecognizer class], - [UISwipeGestureRecognizer class], - [UIRotationGestureRecognizer class], - [UIPinchGestureRecognizer class], - [UITapGestureRecognizer class]]; -#elif TARGET_OS_OSX - baseRecognizers = @[[NSClickGestureRecognizer class], - [NSMagnificationGestureRecognizer class], - [NSPanGestureRecognizer class], - [NSPressGestureRecognizer class], - [NSRotationGestureRecognizer class]]; -#else - baseRecognizers = @[]; -#endif - - }); - -#if TARGET_OS_IPHONE - __block NSString *result = @"UIGestureRecognizer"; -#elif TARGET_OS_OSX - __block NSString *result = @"NSGestureRecognizer"; -#endif - - - [baseRecognizers enumerateObjectsUsingBlock:^(Class _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if ([recognizer isMemberOfClass:obj]) { - // 自身就是基本款,则直接置为 nil - result = nil; - *stop = YES; - return; - } - if ([recognizer isKindOfClass:obj]) { - result = NSStringFromClass(obj); - *stop = YES; - return; - } - }]; - return result; -} - -+ (NSArray *)_targetActionHandlersForControl:(LookinControl *)control { - static dispatch_once_t onceToken; - static NSArray *allEvents = nil; - dispatch_once(&onceToken,^{ -#if TARGET_OS_IPHONE - allEvents = @[@(UIControlEventTouchDown), @(UIControlEventTouchDownRepeat), @(UIControlEventTouchDragInside), @(UIControlEventTouchDragOutside), @(UIControlEventTouchDragEnter), @(UIControlEventTouchDragExit), @(UIControlEventTouchUpInside), @(UIControlEventTouchUpOutside), @(UIControlEventTouchCancel), @(UIControlEventValueChanged), @(UIControlEventEditingDidBegin), @(UIControlEventEditingChanged), @(UIControlEventEditingDidEnd), @(UIControlEventEditingDidEndOnExit)]; - if (@available(iOS 9.0, *)) { - allEvents = [allEvents arrayByAddingObject:@(UIControlEventPrimaryActionTriggered)]; - } -#elif TARGET_OS_OSX - allEvents = @[ - @(NSEventMaskLeftMouseDown), - @(NSEventMaskLeftMouseUp), - @(NSEventMaskRightMouseDown), - @(NSEventMaskRightMouseUp), - @(NSEventMaskMouseMoved), - @(NSEventMaskLeftMouseDragged), - @(NSEventMaskRightMouseDragged), - @(NSEventMaskMouseEntered), - @(NSEventMaskMouseExited), - @(NSEventMaskKeyDown), - @(NSEventMaskKeyUp), - @(NSEventMaskFlagsChanged), - @(NSEventMaskAppKitDefined), - @(NSEventMaskSystemDefined), - @(NSEventMaskApplicationDefined), - @(NSEventMaskPeriodic), - @(NSEventMaskCursorUpdate), - @(NSEventMaskScrollWheel), - @(NSEventMaskTabletPoint), - @(NSEventMaskTabletProximity), - @(NSEventMaskOtherMouseDown), - @(NSEventMaskOtherMouseUp), - @(NSEventMaskOtherMouseDragged), - @(NSEventMaskGesture), - @(NSEventMaskMagnify), - @(NSEventMaskSwipe), - @(NSEventMaskRotate), - @(NSEventMaskBeginGesture), - @(NSEventMaskEndGesture), - @(NSEventMaskSmartMagnify), - @(NSEventMaskPressure), - @(NSEventMaskDirectTouch), - @(NSEventMaskAny), - ]; - if (@available(macOS 10.15, *)) { - allEvents = [allEvents arrayByAddingObject:@(NSEventMaskChangeMode)]; - } -#endif - }); - -#if TARGET_OS_IPHONE - NSSet *allTargets = control.allTargets; -#elif TARGET_OS_OSX - NSMutableSet *allTargets = [NSMutableSet set]; - if (control.target) { - [allTargets addObject:control.target]; - } -#endif - - if (!allTargets.count) { - return nil; - } - - NSMutableArray *handlers = [NSMutableArray array]; - -#if TARGET_OS_IPHONE - [allEvents enumerateObjectsUsingBlock:^(NSNumber * _Nonnull eventNum, NSUInteger idx, BOOL * _Nonnull stop) { - UIControlEvents event = [eventNum unsignedIntegerValue]; - NSMutableArray *targetActions = [NSMutableArray array]; - - [allTargets enumerateObjectsUsingBlock:^(id _Nonnull target, BOOL * _Nonnull stop) { - NSArray *actions = [control actionsForTarget:target forControlEvent:event]; - [actions enumerateObjectsUsingBlock:^(NSString * _Nonnull action, NSUInteger idx, BOOL * _Nonnull stop) { - LookinStringTwoTuple *tuple = [LookinStringTwoTuple new]; - tuple.first = [LKS_Helper descriptionOfObject:target]; - tuple.second = action; - [targetActions addObject:tuple]; - }]; - }]; - - if (targetActions.count) { - LookinEventHandler *handler = [LookinEventHandler new]; - handler.handlerType = LookinEventHandlerTypeTargetAction; - handler.eventName = [self _nameFromControlEvent:event]; - handler.targetActions = targetActions.copy; - [handlers addObject:handler]; - } - }]; -#elif TARGET_OS_OSX - if (control.target && control.action) { - NSEventMask eventMask = [[control valueForKeyPath:@"cell.sendActionOnMask"] unsignedIntegerValue]; - LookinEventHandler *handler = [LookinEventHandler new]; - handler.handlerType = LookinEventHandlerTypeTargetAction; - handler.eventName = [self _nameFromEventMask:eventMask]; - handler.targetActions = @[[LookinStringTwoTuple tupleWithFirst:[LKS_Helper descriptionOfObject:control.target] second:NSStringFromSelector(control.action)]]; - [handlers addObject:handler]; - } -#endif - - return handlers; -} - -#if TARGET_OS_IPHONE -+ (NSString *)_nameFromControlEvent:(UIControlEvents)event { - static dispatch_once_t onceToken; - static NSDictionary *eventsAndNames = nil; - dispatch_once(&onceToken,^{ - NSMutableDictionary *eventsAndNames_m = @{ - @(UIControlEventTouchDown): @"UIControlEventTouchDown", - @(UIControlEventTouchDownRepeat): @"UIControlEventTouchDownRepeat", - @(UIControlEventTouchDragInside): @"UIControlEventTouchDragInside", - @(UIControlEventTouchDragOutside): @"UIControlEventTouchDragOutside", - @(UIControlEventTouchDragEnter): @"UIControlEventTouchDragEnter", - @(UIControlEventTouchDragExit): @"UIControlEventTouchDragExit", - @(UIControlEventTouchUpInside): @"UIControlEventTouchUpInside", - @(UIControlEventTouchUpOutside): @"UIControlEventTouchUpOutside", - @(UIControlEventTouchCancel): @"UIControlEventTouchCancel", - @(UIControlEventValueChanged): @"UIControlEventValueChanged", - @(UIControlEventEditingDidBegin): @"UIControlEventEditingDidBegin", - @(UIControlEventEditingChanged): @"UIControlEventEditingChanged", - @(UIControlEventEditingDidEnd): @"UIControlEventEditingDidEnd", - @(UIControlEventEditingDidEndOnExit): @"UIControlEventEditingDidEndOnExit", - }.mutableCopy; - if (@available(iOS 9.0, *)) { - eventsAndNames_m[@(UIControlEventPrimaryActionTriggered)] = @"UIControlEventPrimaryActionTriggered"; - } - eventsAndNames = eventsAndNames_m.copy; - }); - - NSString *name = eventsAndNames[@(event)]; - return name; -} -#elif TARGET_OS_OSX -+ (NSString *)_nameFromEventMask:(NSEventMask)eventMask { - static dispatch_once_t onceToken; - static NSDictionary *eventsAndNames = nil; - dispatch_once(&onceToken,^{ - NSMutableDictionary *eventsAndNames_m = @{ - @(NSEventMaskLeftMouseDown): @"NSEventMaskLeftMouseDown", - @(NSEventMaskLeftMouseUp): @"NSEventMaskLeftMouseUp", - @(NSEventMaskRightMouseDown): @"NSEventMaskRightMouseDown", - @(NSEventMaskRightMouseUp): @"NSEventMaskRightMouseUp", - @(NSEventMaskMouseMoved): @"NSEventMaskMouseMoved", - @(NSEventMaskLeftMouseDragged): @"NSEventMaskLeftMouseDragged", - @(NSEventMaskRightMouseDragged): @"NSEventMaskRightMouseDragged", - @(NSEventMaskMouseEntered): @"NSEventMaskMouseEntered", - @(NSEventMaskMouseExited): @"NSEventMaskMouseExited", - @(NSEventMaskKeyDown): @"NSEventMaskKeyDown", - @(NSEventMaskKeyUp): @"NSEventMaskKeyUp", - @(NSEventMaskFlagsChanged): @"NSEventMaskFlagsChanged", - @(NSEventMaskAppKitDefined): @"NSEventMaskAppKitDefined", - @(NSEventMaskSystemDefined): @"NSEventMaskSystemDefined", - @(NSEventMaskApplicationDefined): @"NSEventMaskApplicationDefined", - @(NSEventMaskPeriodic): @"NSEventMaskPeriodic", - @(NSEventMaskCursorUpdate): @"NSEventMaskCursorUpdate", - @(NSEventMaskScrollWheel): @"NSEventMaskScrollWheel", - @(NSEventMaskTabletPoint): @"NSEventMaskTabletPoint", - @(NSEventMaskTabletProximity): @"NSEventMaskTabletProximity", - @(NSEventMaskOtherMouseDown): @"NSEventMaskOtherMouseDown", - @(NSEventMaskOtherMouseUp): @"NSEventMaskOtherMouseUp", - @(NSEventMaskOtherMouseDragged): @"NSEventMaskOtherMouseDragged", - @(NSEventMaskGesture): @"NSEventMaskGesture", - @(NSEventMaskMagnify): @"NSEventMaskMagnify", - @(NSEventMaskSwipe): @"NSEventMaskSwipe", - @(NSEventMaskRotate): @"NSEventMaskRotate", - @(NSEventMaskBeginGesture): @"NSEventMaskBeginGesture", - @(NSEventMaskEndGesture): @"NSEventMaskEndGesture", - @(NSEventMaskSmartMagnify): @"NSEventMaskSmartMagnify", - @(NSEventMaskPressure): @"NSEventMaskPressure", - @(NSEventMaskDirectTouch): @"NSEventMaskDirectTouch", - @(NSEventMaskAny): @"NSEventMaskAny", - }.mutableCopy; - if (@available(macOS 10.15, *)) { - eventsAndNames_m[@(NSEventMaskChangeMode)] = @"NSEventMaskChangeMode"; - } - eventsAndNames = eventsAndNames_m.copy; - }); - - NSString *name = eventsAndNames[@(eventMask)]; - return name; -} -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_ExportManager.h b/Sources/LookinServer/Server/Others/LKS_ExportManager.h deleted file mode 100644 index db693418..00000000 --- a/Sources/LookinServer/Server/Others/LKS_ExportManager.h +++ /dev/null @@ -1,20 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_MAC) -// -// LKS_ExportManager.h -// LookinServer -// -// Created by Li Kai on 2019/5/13. -// https://lookin.work -// - -#import - -@interface LKS_ExportManager : NSObject - -+ (instancetype)sharedInstance; - -- (void)exportAndShare; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_ExportManager.m b/Sources/LookinServer/Server/Others/LKS_ExportManager.m deleted file mode 100644 index dc49f79d..00000000 --- a/Sources/LookinServer/Server/Others/LKS_ExportManager.m +++ /dev/null @@ -1,199 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_ExportManager.m -// LookinServer -// -// Created by Li Kai on 2019/5/13. -// https://lookin.work -// - -#import "LKS_ExportManager.h" -#import "UIViewController+LookinServer.h" -#import "LookinHierarchyInfo.h" -#import "LookinHierarchyFile.h" -#import "LookinAppInfo.h" -#import "LookinServerDefines.h" -#import "LKS_MultiplatformAdapter.h" -#import "LookinHierarchyInfo+LookinServer.h" - -#if TARGET_OS_IPHONE -@interface LKS_ExportManagerMaskView : UIView - -@property(nonatomic, strong) UIView *tipsView; -@property(nonatomic, strong) UILabel *firstLabel; -@property(nonatomic, strong) UILabel *secondLabel; -@property(nonatomic, strong) UILabel *thirdLabel; - -@end - -@implementation LKS_ExportManagerMaskView - -- (instancetype)initWithFrame:(CGRect)frame { - if (self = [super initWithFrame:frame]) { - self.backgroundColor = [UIColor colorWithRed:0 green:0 blue:0 alpha:.35]; - - self.tipsView = [UIView new]; - self.tipsView.backgroundColor = [UIColor colorWithRed:0 green:0 blue:0 alpha:.88]; - self.tipsView.layer.cornerRadius = 6; - self.tipsView.layer.masksToBounds = YES; - [self addSubview:self.tipsView]; - - self.firstLabel = [UILabel new]; - self.firstLabel.text = LKS_Localized(@"Creating File…"); - self.firstLabel.textColor = [UIColor whiteColor]; - self.firstLabel.font = [UIFont boldSystemFontOfSize:14]; - self.firstLabel.textAlignment = NSTextAlignmentCenter; - self.firstLabel.numberOfLines = 0; - [self.tipsView addSubview:self.firstLabel]; - - self.secondLabel = [UILabel new]; - self.secondLabel.text = LKS_Localized(@"May take 8 or more seconds according to the UI complexity."); - self.secondLabel.textColor = [UIColor colorWithRed:173/255.0 green:180/255.0 blue:190/255.0 alpha:1]; - self.secondLabel.font = [UIFont systemFontOfSize:12]; - self.secondLabel.textAlignment = NSTextAlignmentLeft; - self.secondLabel.numberOfLines = 0; - [self.tipsView addSubview:self.secondLabel]; - - self.thirdLabel = [UILabel new]; - self.thirdLabel.text = LKS_Localized(@"The file can be opened by Lookin.app on macOS."); - self.thirdLabel.textColor = [UIColor colorWithRed:173/255.0 green:180/255.0 blue:190/255.0 alpha:1]; - self.thirdLabel.font = [UIFont systemFontOfSize:12]; - self.thirdLabel.textAlignment = NSTextAlignmentCenter; - self.thirdLabel.numberOfLines = 0; - [self.tipsView addSubview:self.thirdLabel]; - } - return self; -} - -- (void)layoutSubviews { - [super layoutSubviews]; - - UIEdgeInsets insets = UIEdgeInsetsMake(8, 10, 8, 10); - CGFloat maxLabelWidth = self.bounds.size.width * .8 - insets.left - insets.right; - - CGSize firstSize = [self.firstLabel sizeThatFits:CGSizeMake(maxLabelWidth, CGFLOAT_MAX)]; - CGSize secondSize = [self.secondLabel sizeThatFits:CGSizeMake(maxLabelWidth, CGFLOAT_MAX)]; - CGSize thirdSize = [self.thirdLabel sizeThatFits:CGSizeMake(maxLabelWidth, CGFLOAT_MAX)]; - - CGFloat tipsWidth = MAX(MAX(firstSize.width, secondSize.width), thirdSize.width) + insets.left + insets.right; - - self.firstLabel.frame = CGRectMake(tipsWidth / 2.0 - firstSize.width / 2.0, insets.top, firstSize.width, firstSize.height); - self.secondLabel.frame = CGRectMake(tipsWidth / 2.0 - secondSize.width / 2.0, CGRectGetMaxY(self.firstLabel.frame) + 10, secondSize.width, secondSize.height); - self.thirdLabel.frame = CGRectMake(tipsWidth / 2.0 - thirdSize.width / 2.0, CGRectGetMaxY(self.secondLabel.frame) + 5, thirdSize.width, thirdSize.height); - - self.tipsView.frame = ({ - CGFloat height = CGRectGetMaxY(self.thirdLabel.frame) + insets.bottom; - CGRectMake(self.bounds.size.width / 2.0 - tipsWidth / 2.0, self.bounds.size.height / 2.0 - height / 2.0, tipsWidth, height); - }); -} - -@end -#endif - -#if TARGET_OS_OSX -#endif - -@interface LKS_ExportManager () - -#if TARGET_OS_TV -#elif TARGET_OS_IPHONE -@property(nonatomic, strong) UIDocumentInteractionController *documentController; -@property(nonatomic, strong) LKS_ExportManagerMaskView *maskView; -#endif - - -@end - -@implementation LKS_ExportManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKS_ExportManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -#if TARGET_OS_TV || TARGET_OS_OSX -- (void)exportAndShare { - NSAssert(NO, @"not supported"); -} -#else -- (void)exportAndShare { - - UIViewController *visibleVc = [UIViewController lks_visibleViewController]; - if (!visibleVc) { - NSLog(@"LookinServer - Failed to export because we didn't find any visible view controller."); - return; - } - - [[NSNotificationCenter defaultCenter] postNotificationName:@"Lookin_WillExport" object:nil]; - - if (!self.maskView) { - self.maskView = [LKS_ExportManagerMaskView new]; - } - [visibleVc.view.window addSubview:self.maskView]; - self.maskView.frame = visibleVc.view.window.bounds; - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - LookinHierarchyInfo *info = [LookinHierarchyInfo exportedInfo]; - LookinHierarchyFile *file = [LookinHierarchyFile new]; - file.serverVersion = info.serverVersion; - file.hierarchyInfo = info; - NSData *data = [NSKeyedArchiver archivedDataWithRootObject:file]; - if (!data) { - return; - } - - NSString *fileName = ({ - NSString *timeString = ({ - NSDate *date = [NSDate date]; - NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; - [formatter setDateFormat:@"MMddHHmm"]; - [formatter stringFromDate:date]; - }); - NSString *iOSVersion = ({ - NSString *str = info.appInfo.osDescription; - NSUInteger dotIdx = [str rangeOfString:@"."].location; - if (dotIdx != NSNotFound) { - str = [str substringToIndex:dotIdx]; - } - str; - }); - [NSString stringWithFormat:@"%@_ios%@_%@.lookin", info.appInfo.appName, iOSVersion, timeString]; - }); - NSString *path = [NSString stringWithFormat:@"%@%@", NSTemporaryDirectory(), fileName]; - [data writeToFile:path atomically:YES]; - - [self.maskView removeFromSuperview]; - - if (!self.documentController) { - self.documentController = [UIDocumentInteractionController new]; - } - self.documentController.URL = [NSURL fileURLWithPath:path]; - if ([LKS_MultiplatformAdapter isiPad]) { - [self.documentController presentOpenInMenuFromRect:CGRectMake(0, 0, 1, 1) inView:visibleVc.view animated:YES]; - } else { - [self.documentController presentOpenInMenuFromRect:visibleVc.view.bounds inView:visibleVc.view animated:YES]; - } - - [[NSNotificationCenter defaultCenter] postNotificationName:@"Lookin_DidFinishExport" object:nil]; - -// [self.documentController presentOptionsMenuFromRect:visibleVc.view.bounds inView:visibleVc.view animated:YES]; - -// CFTimeInterval endTime = CACurrentMediaTime(); -// CFTimeInterval consumingTime = endTime - startTime; -// NSLog(@"LookinServer - 导出 UI 结构耗时:%@", @(consumingTime)); - }); -} -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_GestureTargetActionsSearcher.h b/Sources/LookinServer/Server/Others/LKS_GestureTargetActionsSearcher.h deleted file mode 100644 index 37c9461e..00000000 --- a/Sources/LookinServer/Server/Others/LKS_GestureTargetActionsSearcher.h +++ /dev/null @@ -1,36 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_GestureTargetActionsSearcher.h -// LookinServer -// -// Created by likai.123 on 2023/9/11. -// - -#import - -#if TARGET_OS_IPHONE -#import -#endif - -#if TARGET_OS_OSX -#import -#endif - -#import "LookinDefines.h" - -@class LookinTwoTuple; - -NS_ASSUME_NONNULL_BEGIN - -@interface LKS_GestureTargetActionsSearcher : NSObject - -/// 返回一个 UIGestureRecognizer 实例身上绑定的 target & action 信息 -/// tuple.first => LookinWeakContainer(包裹着 target),tuple.second => action(方法名字符串) -+ (NSArray *)getTargetActionsFromRecognizer:(LookinGestureRecognizer *)recognizer; - -@end - -NS_ASSUME_NONNULL_END - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_GestureTargetActionsSearcher.m b/Sources/LookinServer/Server/Others/LKS_GestureTargetActionsSearcher.m deleted file mode 100644 index d1f5115f..00000000 --- a/Sources/LookinServer/Server/Others/LKS_GestureTargetActionsSearcher.m +++ /dev/null @@ -1,52 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_GestureTargetActionsSearcher.m -// LookinServer -// -// Created by likai.123 on 2023/9/11. -// - -#import "LKS_GestureTargetActionsSearcher.h" -#import -#import "NSArray+Lookin.h" -#import "LookinTuple.h" -#import "LookinWeakContainer.h" - -@implementation LKS_GestureTargetActionsSearcher - -+ (NSArray *)getTargetActionsFromRecognizer:(LookinGestureRecognizer *)recognizer { - if (!recognizer) { - return @[]; - } - // KVC 要放到 try catch 里面防止 Crash - @try { - NSArray* targetsList = [recognizer valueForKey:@"_targets"]; - if (!targetsList || targetsList.count == 0) { - return @[]; - } - // 数组元素对象是 UIGestureRecognizerTarget* - // 这个元素有两个属性,一个是名为 _target 的属性指向某个实例,另一个是名为 _action 的属性保存一个 SEL - NSArray* ret = [targetsList lookin_map:^id(NSUInteger idx, id targetBox) { - id targetObj = [targetBox valueForKey:@"_target"]; - if (!targetObj) { - return nil; - } - SEL action = ((SEL (*)(id, Ivar))object_getIvar)(targetBox, class_getInstanceVariable([targetBox class], "_action")); - - LookinTwoTuple* tuple = [LookinTwoTuple new]; - tuple.first = [LookinWeakContainer containerWithObject:targetObj]; - tuple.second = (action == NULL ? @"NULL" : NSStringFromSelector(action)); - return tuple; - }]; - return ret; - } - @catch (NSException * e) { - NSLog(@"LookinServer - %@", e); - return @[]; - } -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_Helper.h b/Sources/LookinServer/Server/Others/LKS_Helper.h deleted file mode 100644 index 36994877..00000000 --- a/Sources/LookinServer/Server/Others/LKS_Helper.h +++ /dev/null @@ -1,28 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_OSX) -// -// LKS_Helper.h -// LookinServer -// -// Created by Li Kai on 2019/7/20. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import - -#define LKS_Localized(stringKey) NSLocalizedStringFromTableInBundle(stringKey, nil, [NSBundle bundleForClass:self.class], nil) - -@interface LKS_Helper : NSObject - -/// 如果 object 为 nil 则返回字符串 “nil”,否则返回字符串格式类似于 (UIView *) -+ (NSString *)descriptionOfObject:(id)object; - -/// 返回当前的bundle -+ (NSBundle *)bundle; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_Helper.m b/Sources/LookinServer/Server/Others/LKS_Helper.m deleted file mode 100644 index dab81655..00000000 --- a/Sources/LookinServer/Server/Others/LKS_Helper.m +++ /dev/null @@ -1,37 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_OSX) -// -// LKS_Helper.m -// LookinServer -// -// Created by Li Kai on 2019/7/20. -// https://lookin.work -// - -#import "LKS_Helper.h" -#import "NSObject+LookinServer.h" - -@implementation LKS_Helper - -+ (NSString *)descriptionOfObject:(id)object { - if (!object) { - return @"nil"; - } - NSString *className = NSStringFromClass([object class]); - return [NSString stringWithFormat:@"(%@ *)", className]; -} - -+ (NSBundle *)bundle { - static id bundle = nil; - if (bundle != nil) { -#ifdef SPM_RESOURCE_BUNDLE_IDENTIFITER - bundle = [NSBundle bundleWithIdentifier:SPM_RESOURCE_BUNDLE_IDENTIFITER]; -#else - bundle = [NSBundle bundleForClass:self.class]; -#endif - } - return bundle; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_HierarchyDisplayItemsMaker.h b/Sources/LookinServer/Server/Others/LKS_HierarchyDisplayItemsMaker.h deleted file mode 100644 index 0c81b58b..00000000 --- a/Sources/LookinServer/Server/Others/LKS_HierarchyDisplayItemsMaker.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_HierarchyDisplayItemsMaker.h -// LookinServer -// -// Created by Li Kai on 2019/2/19. -// https://lookin.work -// - - - -#import "LookinDefines.h" - -@class LookinDisplayItem; - -@interface LKS_HierarchyDisplayItemsMaker : NSObject - -/// @param hasScreenshots 是否包含 soloScreenshots 和 groupScreenshot 属性 -/// @param hasAttrList 是否包含 attributesGroupList 属性 -/// @param lowQuality screenshots 是否为低质量(当 hasScreenshots 为 NO 时,该属性无意义) -/// @param readCustomInfo 是否读取 lookin_customDebugInfos,比如低版本的 Lookin 发请求时,就无需读取(因为 Lookin 解析不了、还可能出 Bug) -/// @param saveCustomSetter 是否要读取并保存用户给 attribute 配置的 custom setter -+ (NSArray *)itemsWithScreenshots:(BOOL)hasScreenshots attrList:(BOOL)hasAttrList lowImageQuality:(BOOL)lowQuality readCustomInfo:(BOOL)readCustomInfo saveCustomSetter:(BOOL)saveCustomSetter; - -/// 把 layer 的 sublayers 转换为 displayItem 数组并返回 -+ (NSArray *)subitemsOfLayer:(CALayer *)layer; -#if TARGET_OS_OSX -+ (NSArray *)subitemsOfView:(NSView *)view; -#endif -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_HierarchyDisplayItemsMaker.m b/Sources/LookinServer/Server/Others/LKS_HierarchyDisplayItemsMaker.m deleted file mode 100644 index 6f37feed..00000000 --- a/Sources/LookinServer/Server/Others/LKS_HierarchyDisplayItemsMaker.m +++ /dev/null @@ -1,554 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_HierarchyDisplayItemsMaker.m -// LookinServer -// -// Created by Li Kai on 2019/2/19. -// https://lookin.work -// - -#import "LKS_HierarchyDisplayItemsMaker.h" -#import "LookinDisplayItem.h" -#import "LKS_TraceManager.h" -#import "LKS_AttrGroupsMaker.h" -#import "LKS_EventHandlerMaker.h" -#import "LookinServerDefines.h" -#import "UIColor+LookinServer.h" -#import "LKSConfigManager.h" -#import "LKS_CustomAttrGroupsMaker.h" -#import "LKS_CustomDisplayItemsMaker.h" -#import "LKS_CustomAttrSetterManager.h" -#import "LKS_MultiplatformAdapter.h" -#import "NSValue+Lookin.h" -//#import "LookinObject+LookinServer.h" -#import "UIView+LookinServer.h" -#import "NSWindow+LookinServer.h" -#if TARGET_OS_IPHONE -#import "UIWindowScene+LookinServer.h" -#endif -@implementation LKS_HierarchyDisplayItemsMaker - -+ (NSArray *)itemsWithScreenshots:(BOOL)hasScreenshots attrList:(BOOL)hasAttrList lowImageQuality:(BOOL)lowQuality readCustomInfo:(BOOL)readCustomInfo saveCustomSetter:(BOOL)saveCustomSetter { - - [[LKS_TraceManager sharedInstance] reload]; - -#if TARGET_OS_IPHONE - NSMutableArray *resultArray = [NSMutableArray array]; - if (@available(iOS 13.0, *)) { - // iOS 13+: group windows by UIWindowScene, scene is the top-level container - for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) { - continue; - } - UIWindowScene *windowScene = (UIWindowScene *)scene; - - // Create the scene display item - LookinDisplayItem *sceneItem = [LookinDisplayItem new]; - sceneItem.windowObject = [LookinObject instanceWithObject:windowScene]; - sceneItem.shouldCaptureImage = YES; - sceneItem.alpha = 1.0; - - // Build display title: "UIWindowScene – Title (State)" - NSString *sceneTitle = windowScene.title.length > 0 ? windowScene.title : nil; - NSString *stateString = nil; - switch (windowScene.activationState) { - case UISceneActivationStateForegroundActive: stateString = @"Foreground Active"; break; - case UISceneActivationStateForegroundInactive: stateString = @"Foreground Inactive"; break; - case UISceneActivationStateBackground: stateString = @"Background"; break; - case UISceneActivationStateUnattached: stateString = @"Unattached"; break; - default: stateString = @"Unknown"; break; - } - NSMutableString *displayTitle = [NSMutableString stringWithString:@"UIWindowScene"]; - if (sceneTitle) { - [displayTitle appendFormat:@" – %@", sceneTitle]; - } - [displayTitle appendFormat:@" (%@)", stateString]; - sceneItem.customDisplayTitle = displayTitle; - - if (hasAttrList) { - sceneItem.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForWindowScene:windowScene]; - } - - // Build child items for each window in this scene - NSMutableArray *windowItems = [NSMutableArray array]; - NSArray *windows = windowScene.windows; - for (UIWindow *window in windows) { - LookinDisplayItem *windowItem = [self _displayItemWithLayer:window.layer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (windowItem) { - windowItem.representedAsKeyWindow = window.isKeyWindow; - [windowItems addObject:windowItem]; - } - } - - // Also check for keyWindow not in windows list (e.g. form sheet presentations) - UIWindow *keyWindow = nil; - if (@available(iOS 15.0, *)) { - keyWindow = windowScene.keyWindow; - } else { - for (UIWindow *window in windows) { - if (window.isKeyWindow) { - keyWindow = window; - break; - } - } - } - if (keyWindow && ![windows containsObject:keyWindow]) { - if (![NSStringFromClass(keyWindow.class) containsString:@"HUD"]) { - LookinDisplayItem *keyWindowItem = [self _displayItemWithLayer:keyWindow.layer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (keyWindowItem) { - keyWindowItem.representedAsKeyWindow = YES; - [windowItems addObject:keyWindowItem]; - } - } - } - - // 如果 scene 包含 key window,则将 scene 也标记为 key - for (LookinDisplayItem *windowItem in windowItems) { - if (windowItem.representedAsKeyWindow) { - sceneItem.representedAsKeyWindow = YES; - break; - } - } - - sceneItem.subitems = windowItems; - [resultArray addObject:sceneItem]; - } - } else { - // iOS 12 fallback: flat window list without scene grouping - NSArray *windows = [LKS_MultiplatformAdapter allWindows]; - for (LookinWindow *window in windows) { - LookinDisplayItem *item = [self _displayItemWithLayer:window.layer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (item) { - item.representedAsKeyWindow = window.isKeyWindow; - [resultArray addObject:item]; - } - } - } - - return [resultArray copy]; -#elif TARGET_OS_OSX - NSArray *windows = [LKS_MultiplatformAdapter allWindows]; - NSMutableArray *resultArray = [NSMutableArray arrayWithCapacity:windows.count]; - [windows enumerateObjectsUsingBlock:^(__kindof LookinWindow * _Nonnull window, NSUInteger idx, BOOL * _Nonnull stop) { - // macOS: view is the source of truth — always start from the root view - NSView *rootView = window.lks_rootView; - LookinDisplayItem *item = [LookinDisplayItem new]; - item.windowObject = [LookinObject instanceWithObject:window]; - item.frame = window.lks_bounds; - item.bounds = window.lks_bounds; - item.backgroundColor = window.backgroundColor; - item.shouldCaptureImage = YES; - item.alpha = window.alphaValue; - if (rootView.layer) { - item.groupScreenshot = [rootView.layer lks_groupScreenshotWithLowQuality:lowQuality]; - item.soloScreenshot = [rootView.layer lks_soloScreenshotWithLowQuality:lowQuality]; - } else { - item.groupScreenshot = [rootView lks_groupScreenshotWithLowQuality:lowQuality]; - item.soloScreenshot = [rootView lks_soloScreenshotWithLowQuality:lowQuality]; - } - item.screenshotEncodeType = LookinDisplayItemImageEncodeTypeNSData; - if (window.windowController) { - item.hostWindowControllerObject = [LookinObject instanceWithObject:window.windowController]; - } - if (hasAttrList) { - item.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForWindow:window]; - } - if (rootView) { - item.subitems = @[[self _displayItemWithView:rootView screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]]; - } - item.representedAsKeyWindow = window.isKeyWindow; - if (item) { - [resultArray addObject:item]; - } - }]; - - return [resultArray copy]; -#endif -} - -+ (LookinDisplayItem *)_displayItemWithLayer:(CALayer *)layer screenshots:(BOOL)hasScreenshots attrList:(BOOL)hasAttrList lowImageQuality:(BOOL)lowQuality readCustomInfo:(BOOL)readCustomInfo saveCustomSetter:(BOOL)saveCustomSetter { - if (!layer) { - return nil; - } - -#if TARGET_OS_OSX - // macOS: if this layer has a host view, delegate to the view-based path - // to preserve the view hierarchy as the source of truth. - LookinView *earlyHostView = layer.lks_hostView; - if (earlyHostView) { - return [self _displayItemWithView:earlyHostView screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - } -#endif - - LookinDisplayItem *item = [LookinDisplayItem new]; - CGRect layerFrame = layer.frame; - LookinView *hostView = layer.lks_hostView; - if (hostView && hostView.superview) { - layerFrame = [hostView.superview convertRect:layerFrame toView:nil]; - } - if ([self validateFrame:layerFrame]) { - item.frame = layer.frame; - } else { - NSLog(@"LookinServer - The layer frame(%@) seems really weird. Lookin will ignore it to avoid potential render error in Lookin.", NSStringFromCGRect(layer.frame)); - item.frame = CGRectZero; - } - item.bounds = layer.bounds; - if (hasScreenshots) { - item.soloScreenshot = [layer lks_soloScreenshotWithLowQuality:lowQuality]; - item.groupScreenshot = [layer lks_groupScreenshotWithLowQuality:lowQuality]; - item.screenshotEncodeType = LookinDisplayItemImageEncodeTypeNSData; - } - - if (hasAttrList) { - item.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForLayer:layer]; - LKS_CustomAttrGroupsMaker *maker = [[LKS_CustomAttrGroupsMaker alloc] initWithLayer:layer]; - [maker execute]; - item.customAttrGroupList = [maker getGroups]; - item.customDisplayTitle = [maker getCustomDisplayTitle]; - item.danceuiSource = [maker getDanceUISource]; - } - - item.isHidden = layer.isHidden; - item.alpha = layer.opacity; - item.layerObject = [LookinObject instanceWithObject:layer]; - item.shouldCaptureImage = [LKSConfigManager shouldCaptureScreenshotOfLayer:layer]; - - LookinView *view = layer.lks_hostView; - if (view) { -#if TARGET_OS_OSX - item.flipped = view.isFlipped; -#endif - item.viewObject = [LookinObject instanceWithObject:view]; - item.eventHandlers = [LKS_EventHandlerMaker makeForView:view]; - item.backgroundColor = [view valueForKeyPath:@"backgroundColor"]; - - LookinViewController* vc = [view lks_findHostViewController]; - if (vc) { - item.hostViewControllerObject = [LookinObject instanceWithObject:vc]; - } - } else { -#if TARGET_OS_OSX - item.flipped = layer.isGeometryFlipped; -#endif - item.backgroundColor = [LookinColor lks_colorWithCGColor:layer.backgroundColor]; - } - -#if TARGET_OS_OSX - // macOS 上优先使用视图层级遍历,因为它是权威的。 - // macOS 26+ 上 layer.sublayers 可能不包含所有子视图的 backing layer。 - if (view && view.subviews.count > 0) { - NSMutableArray *allSubitems = [NSMutableArray array]; - NSMutableSet *processedLayers = [NSMutableSet set]; - - for (NSView *subview in view.subviews.copy) { - if (subview.layer) { - [processedLayers addObject:subview.layer]; - LookinDisplayItem *subviewItem = [self _displayItemWithLayer:subview.layer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (subviewItem) { - [allSubitems addObject:subviewItem]; - } - } else { - LookinDisplayItem *subviewItem = [self _displayItemWithView:subview screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (subviewItem) { - [allSubitems addObject:subviewItem]; - } - } - } - - // 添加未关联到任何子视图的孤立 sublayer - for (CALayer *sublayer in layer.sublayers.copy) { - if (![processedLayers containsObject:sublayer]) { - LookinDisplayItem *sublayerItem = [self _displayItemWithLayer:sublayer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (sublayerItem) { - [allSubitems addObject:sublayerItem]; - } - } - } - - item.subitems = allSubitems.copy; - } else if (layer.sublayers.count) { - NSArray *sublayers = [layer.sublayers copy]; - NSMutableArray *allSubitems = [NSMutableArray arrayWithCapacity:sublayers.count]; - [sublayers enumerateObjectsUsingBlock:^(__kindof CALayer * _Nonnull sublayer, NSUInteger idx, BOOL * _Nonnull stop) { - LookinDisplayItem *sublayer_item = [self _displayItemWithLayer:sublayer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (sublayer_item) { - [allSubitems addObject:sublayer_item]; - } - }]; - item.subitems = [allSubitems copy]; - } -#else - if (layer.sublayers.count) { - NSArray *sublayers = [layer.sublayers copy]; - NSMutableArray *allSubitems = [NSMutableArray arrayWithCapacity:sublayers.count]; - [sublayers enumerateObjectsUsingBlock:^(__kindof CALayer * _Nonnull sublayer, NSUInteger idx, BOOL * _Nonnull stop) { - LookinDisplayItem *sublayer_item = [self _displayItemWithLayer:sublayer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (sublayer_item) { - [allSubitems addObject:sublayer_item]; - } - }]; - item.subitems = [allSubitems copy]; - } -#endif - if (readCustomInfo) { - NSArray *customSubitems = [[[LKS_CustomDisplayItemsMaker alloc] initWithLayer:layer saveAttrSetter:saveCustomSetter] make]; - if (customSubitems.count > 0) { - if (item.subitems) { - item.subitems = [item.subitems arrayByAddingObjectsFromArray:customSubitems]; - } else { - item.subitems = customSubitems; - } - } - } - - return item; -} - -+ (NSArray *)subitemsOfLayer:(CALayer *)layer { - if (!layer) { - return @[]; - } -#if TARGET_OS_OSX - // macOS: if this layer has a host view, delegate to the view-based path - LookinView *hostView = layer.lks_hostView; - if (hostView) { - return [self subitemsOfView:hostView]; - } - if (layer.sublayers.count == 0) { - return @[]; - } -#else - if (layer.sublayers.count == 0) { - return @[]; - } -#endif - [[LKS_TraceManager sharedInstance] reload]; - - NSMutableArray *resultSubitems = [NSMutableArray array]; - - NSArray *sublayers = [layer.sublayers copy]; - [sublayers enumerateObjectsUsingBlock:^(__kindof CALayer * _Nonnull sublayer, NSUInteger idx, BOOL * _Nonnull stop) { - LookinDisplayItem *sublayerItem = [self _displayItemWithLayer:sublayer screenshots:NO attrList:NO lowImageQuality:NO readCustomInfo:YES saveCustomSetter:YES]; - if (sublayerItem) { - [resultSubitems addObject:sublayerItem]; - } - }]; - - NSArray *customSubitems = [[[LKS_CustomDisplayItemsMaker alloc] initWithLayer:layer saveAttrSetter:YES] make]; - if (customSubitems.count > 0) { - [resultSubitems addObjectsFromArray:customSubitems]; - } - - return resultSubitems; -} - -#if TARGET_OS_OSX -+ (LookinDisplayItem *)_displayItemWithView:(NSView *)view screenshots:(BOOL)hasScreenshots attrList:(BOOL)hasAttrList lowImageQuality:(BOOL)lowQuality readCustomInfo:(BOOL)readCustomInfo saveCustomSetter:(BOOL)saveCustomSetter { - if (!view) { - return nil; - } - - LookinDisplayItem *item = [LookinDisplayItem new]; - CGRect viewFrame = view.frame; - - if (view.superview) { - viewFrame = [view.superview convertRect:viewFrame toView:nil]; - } - if ([self validateFrame:viewFrame]) { - item.frame = view.frame; - } else { - NSLog(@"LookinServer - The layer frame(%@) seems really weird. Lookin will ignore it to avoid potential render error in Lookin.", NSStringFromCGRect(view.frame)); - item.frame = CGRectZero; - } - item.bounds = view.bounds; - item.flipped = view.isFlipped; - if (hasScreenshots) { - item.soloScreenshot = [view lks_soloScreenshotWithLowQuality:lowQuality]; - item.groupScreenshot = [view lks_groupScreenshotWithLowQuality:lowQuality]; - item.screenshotEncodeType = LookinDisplayItemImageEncodeTypeNSData; - } - - if (hasAttrList) { - item.attributesGroupList = [LKS_AttrGroupsMaker attrGroupsForView:view]; - LKS_CustomAttrGroupsMaker *maker = [[LKS_CustomAttrGroupsMaker alloc] initWithView:view]; - [maker execute]; - item.customAttrGroupList = [maker getGroups]; - item.customDisplayTitle = [maker getCustomDisplayTitle]; - item.danceuiSource = [maker getDanceUISource]; - } - - item.isHidden = view.isHidden; - item.alpha = view.alphaValue; - item.viewObject = [LookinObject instanceWithObject:view]; - - // Check if the backing layer's delegate matches this view. - // For special layers like NSVBCALayerHost (used by NSRemoteView), the - // delegate is not the host view, so lks_hostView returns nil. - // In that case, expose the backing layer as a separate child node - // instead of merging it into this view node. - CALayer *viewLayer = view.layer; - // Direct delegate check — lks_hostView has an additional view.layer==self - // guard that can fail for some standard AppKit views, so check delegate directly - BOOL backingLayerOwnedByView = (viewLayer != nil && viewLayer.delegate == (id)view); - if (viewLayer && backingLayerOwnedByView) { - item.layerObject = [LookinObject instanceWithObject:viewLayer]; - } - item.shouldCaptureImage = [LKSConfigManager shouldCaptureScreenshotOfView:view]; - - item.eventHandlers = [LKS_EventHandlerMaker makeForView:view]; - item.backgroundColor = [view valueForKeyPath:@"backgroundColor"]; - - LookinViewController* vc = [view lks_findHostViewController]; - if (vc) { - item.hostViewControllerObject = [LookinObject instanceWithObject:vc]; - } - - // View is the source of truth: always recurse through view.subviews - { - NSMutableArray *allSubitems = [NSMutableArray array]; - NSMutableSet *processedLayers = [NSMutableSet set]; - - for (NSView *subview in view.subviews.copy) { - LookinDisplayItem *subviewItem = [self _displayItemWithView:subview screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (subviewItem) { - [allSubitems addObject:subviewItem]; - } - if (subview.layer) { - [processedLayers addObject:subview.layer]; - } - } - - // Expose backing layer as separate child when delegate doesn't match - if (viewLayer && !backingLayerOwnedByView) { - [processedLayers addObject:viewLayer]; - LookinDisplayItem *backingLayerItem = [self _displayItemWithLayer:viewLayer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (backingLayerItem) { - [allSubitems addObject:backingLayerItem]; - } - } - - // Add orphan sublayers (sublayers not associated with any subview) - if (viewLayer && backingLayerOwnedByView) { - for (CALayer *sublayer in viewLayer.sublayers.copy) { - if (![processedLayers containsObject:sublayer]) { - LookinDisplayItem *sublayerItem = [self _displayItemWithLayer:sublayer screenshots:hasScreenshots attrList:hasAttrList lowImageQuality:lowQuality readCustomInfo:readCustomInfo saveCustomSetter:saveCustomSetter]; - if (sublayerItem) { - [allSubitems addObject:sublayerItem]; - } - } - } - } - - if (allSubitems.count > 0) { - item.subitems = allSubitems.copy; - } - } - if (readCustomInfo) { - NSArray *customSubitems; - if (viewLayer) { - // Always use initWithLayer: when view has a layer, regardless of delegate match. - // LKS_CustomDisplayItemsMaker.initWithView: only handles layerless views. - customSubitems = [[[LKS_CustomDisplayItemsMaker alloc] initWithLayer:viewLayer saveAttrSetter:saveCustomSetter] make]; - } else { - customSubitems = [[[LKS_CustomDisplayItemsMaker alloc] initWithView:view saveAttrSetter:saveCustomSetter] make]; - } - if (customSubitems.count > 0) { - if (item.subitems) { - item.subitems = [item.subitems arrayByAddingObjectsFromArray:customSubitems]; - } else { - item.subitems = customSubitems; - } - } - } - - return item; -} - -+ (NSArray *)subitemsOfView:(NSView *)view { - if (!view) { - return @[]; - } - - CALayer *viewLayer = view.layer; - BOOL backingLayerOwnedByView = (viewLayer != nil && viewLayer.delegate == (id)view); - - BOOL hasExposedBackingLayer = (viewLayer != nil && !backingLayerOwnedByView); - BOOL hasOrphanSublayers = (viewLayer != nil && backingLayerOwnedByView && viewLayer.sublayers.count > 0); - BOOL hasSubviews = view.subviews.count > 0; - - if (!hasSubviews && !hasExposedBackingLayer && !hasOrphanSublayers) { - return @[]; - } - - [[LKS_TraceManager sharedInstance] reload]; - - NSMutableArray *resultSubitems = [NSMutableArray array]; - NSMutableSet *processedLayers = [NSMutableSet set]; - - // Always recurse through view.subviews - for (NSView *subview in view.subviews.copy) { - LookinDisplayItem *subviewItem = [self _displayItemWithView:subview screenshots:NO attrList:NO lowImageQuality:NO readCustomInfo:YES saveCustomSetter:YES]; - if (subviewItem) { - [resultSubitems addObject:subviewItem]; - } - if (subview.layer) { - [processedLayers addObject:subview.layer]; - } - } - - // Expose backing layer as separate child when delegate doesn't match - if (hasExposedBackingLayer) { - [processedLayers addObject:viewLayer]; - LookinDisplayItem *backingLayerItem = [self _displayItemWithLayer:viewLayer screenshots:NO attrList:NO lowImageQuality:NO readCustomInfo:YES saveCustomSetter:YES]; - if (backingLayerItem) { - [resultSubitems addObject:backingLayerItem]; - } - } - - // Add orphan sublayers - if (viewLayer && backingLayerOwnedByView) { - for (CALayer *sublayer in viewLayer.sublayers.copy) { - if (![processedLayers containsObject:sublayer]) { - LookinDisplayItem *sublayerItem = [self _displayItemWithLayer:sublayer screenshots:NO attrList:NO lowImageQuality:NO readCustomInfo:YES saveCustomSetter:YES]; - if (sublayerItem) { - [resultSubitems addObject:sublayerItem]; - } - } - } - } - - NSArray *customSubitems; - if (viewLayer) { - customSubitems = [[[LKS_CustomDisplayItemsMaker alloc] initWithLayer:viewLayer saveAttrSetter:YES] make]; - } else { - customSubitems = [[[LKS_CustomDisplayItemsMaker alloc] initWithView:view saveAttrSetter:YES] make]; - } - if (customSubitems.count > 0) { - [resultSubitems addObjectsFromArray:customSubitems]; - } - - return resultSubitems; -} -#endif - - -+ (BOOL)validateFrame:(CGRect)frame { - return !CGRectIsNull(frame) && !CGRectIsInfinite(frame) && ![self cgRectIsNaN:frame] && ![self cgRectIsInf:frame] && ![self cgRectIsUnreasonable:frame]; -} - -+ (BOOL)cgRectIsNaN:(CGRect)rect { - return isnan(rect.origin.x) || isnan(rect.origin.y) || isnan(rect.size.width) || isnan(rect.size.height); -} - -+ (BOOL)cgRectIsInf:(CGRect)rect { - return isinf(rect.origin.x) || isinf(rect.origin.y) || isinf(rect.size.width) || isinf(rect.size.height); -} - -+ (BOOL)cgRectIsUnreasonable:(CGRect)rect { - return ABS(rect.origin.x) > 100000 || ABS(rect.origin.y) > 100000 || rect.size.width < 0 || rect.size.height < 0 || rect.size.width > 100000 || rect.size.height > 100000; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_ObjectRegistry.h b/Sources/LookinServer/Server/Others/LKS_ObjectRegistry.h deleted file mode 100644 index 65e1acbd..00000000 --- a/Sources/LookinServer/Server/Others/LKS_ObjectRegistry.h +++ /dev/null @@ -1,22 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_MAC) -// -// LKS_ObjectRegistry.h -// LookinServer -// -// Created by Li Kai on 2019/4/21. -// https://lookin.work -// - -#import - -@interface LKS_ObjectRegistry : NSObject - -+ (instancetype)sharedInstance; - -- (unsigned long)addObject:(NSObject *)object; - -- (NSObject *)objectWithOid:(unsigned long)oid; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_ObjectRegistry.m b/Sources/LookinServer/Server/Others/LKS_ObjectRegistry.m deleted file mode 100644 index d15dba4e..00000000 --- a/Sources/LookinServer/Server/Others/LKS_ObjectRegistry.m +++ /dev/null @@ -1,61 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_MAC) -// -// LKS_ObjectRegistry.m -// LookinServer -// -// Created by Li Kai on 2019/4/21. -// https://lookin.work -// - -#import "LKS_ObjectRegistry.h" -#import - -@interface LKS_ObjectRegistry () - -@property(nonatomic, strong) NSPointerArray *data; - -@end - -@implementation LKS_ObjectRegistry - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKS_ObjectRegistry *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone{ - return [self sharedInstance]; -} - -- (instancetype)init { - if (self = [super init]) { - self.data = [NSPointerArray weakObjectsPointerArray]; - // index 为 0 用 Null 填充 - self.data.count = 1; - } - return self; -} - -- (unsigned long)addObject:(NSObject *)object { - if (!object) { - return 0; - } - [self.data addPointer:(void *)object]; - return self.data.count - 1; -} - -- (NSObject *)objectWithOid:(unsigned long)oid { - if (self.data.count <= oid) { - return nil; - } - id object = [self.data pointerAtIndex:oid]; - return object; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_TraceManager.h b/Sources/LookinServer/Server/Others/LKS_TraceManager.h deleted file mode 100644 index 509ddea2..00000000 --- a/Sources/LookinServer/Server/Others/LKS_TraceManager.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_TraceManager.h -// LookinServer -// -// Created by Li Kai on 2019/5/5. -// https://lookin.work -// - - - -#import - -@class LookinIvarTrace; - -@interface LKS_TraceManager : NSObject - -+ (instancetype)sharedInstance; - -- (void)reload; - -- (void)addSearchTarger:(id)target; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LKS_TraceManager.m b/Sources/LookinServer/Server/Others/LKS_TraceManager.m deleted file mode 100644 index 763c61c9..00000000 --- a/Sources/LookinServer/Server/Others/LKS_TraceManager.m +++ /dev/null @@ -1,393 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LKS_TraceManager.m -// LookinServer -// -// Created by Li Kai on 2019/5/5. -// https://lookin.work -// - -#import "LKS_TraceManager.h" -#import -#import "LookinIvarTrace.h" -#import "LookinServerDefines.h" -#import "LookinWeakContainer.h" -#import "LKS_MultiplatformAdapter.h" -#import "NSWindow+LookinServer.h" -#import "UIView+LookinServer.h" -#ifdef LOOKIN_SERVER_SWIFT_ENABLED - -#if __has_include() - #import - #define LOOKIN_SERVER_SWIFT_ENABLED_SUCCESSFULLY -#elif __has_include("LookinServer-Swift.h") - #import "LookinServer-Swift.h" - #define LOOKIN_SERVER_SWIFT_ENABLED_SUCCESSFULLY -#endif - -#endif - -#ifdef SPM_LOOKIN_SERVER_ENABLED -@import LookinServerSwift; -#define LOOKIN_SERVER_SWIFT_ENABLED_SUCCESSFULLY -#endif - -@interface LKS_TraceManager () - -@property(nonatomic, strong) NSMutableArray *searchTargets; - -@end - -@implementation LKS_TraceManager - -+ (instancetype)sharedInstance { - static dispatch_once_t onceToken; - static LKS_TraceManager *instance = nil; - dispatch_once(&onceToken,^{ - instance = [[super allocWithZone:NULL] init]; - }); - return instance; -} - -+ (id)allocWithZone:(struct _NSZone *)zone { - return [self sharedInstance]; -} - -- (void)addSearchTarger:(id)target { - if (!target) { - return; - } - if (!self.searchTargets) { - self.searchTargets = [NSMutableArray array]; - } - LookinWeakContainer *container = [LookinWeakContainer containerWithObject:target]; - [self.searchTargets addObject:container]; -} - -- (void)reload { - // 把旧的先都清理掉 - [NSObject lks_clearAllObjectsTraces]; - - [self.searchTargets enumerateObjectsUsingBlock:^(LookinWeakContainer * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (!obj.object) { - return; - } - [self _markIVarsInAllClassLevelsOfObject:obj.object]; - }]; - - [[LKS_MultiplatformAdapter allWindows] enumerateObjectsUsingBlock:^(__kindof LookinWindow * _Nonnull window, NSUInteger idx, BOOL * _Nonnull stop) { -#if TARGET_OS_IPHONE - [self _addTraceForLayersRootedByLayer:window.layer]; -#elif TARGET_OS_OSX - [self _addTraceForLayersRootedByLayer:window.lks_rootView.layer]; -#endif - }]; -} - -- (void)_addTraceForLayersRootedByLayer:(CALayer *)layer { - LookinView *view = layer.lks_hostView; - -#if TARGET_OS_IPHONE - if ([view.superview lks_isChildrenViewOfTabBar]) { - view.lks_isChildrenViewOfTabBar = YES; - } else if ([view isKindOfClass:[UITabBar class]]) { - view.lks_isChildrenViewOfTabBar = YES; - } - -#endif - if (view) { - [self _markIVarsInAllClassLevelsOfObject:view]; - LookinViewController* vc = [view lks_findHostViewController]; - if (vc) { - [self _markIVarsInAllClassLevelsOfObject:vc]; - } - - [self _buildSpecialTraceForView:view]; - } else { - [self _markIVarsInAllClassLevelsOfObject:layer]; - } - - [[layer.sublayers copy] enumerateObjectsUsingBlock:^(__kindof CALayer * _Nonnull sublayer, NSUInteger idx, BOOL * _Nonnull stop) { - [self _addTraceForLayersRootedByLayer:sublayer]; - }]; -} - -#if TARGET_OS_OSX -- (void)_addTraceForInterfaceObject:(id)interfaceObject { -// LookinView *view = window.contentView; - - if ([interfaceObject isKindOfClass:[NSWindow class]]) { - [self _markIVarsInAllClassLevelsOfObject:interfaceObject]; - NSWindow *window = interfaceObject; - if (window.contentView) { - [self _addTraceForInterfaceObject:window.contentView]; - } - } else if ([interfaceObject isKindOfClass:[NSView class]]) { - NSView *view = interfaceObject; - [self _markIVarsInAllClassLevelsOfObject:view]; - LookinViewController* vc = [view lks_findHostViewController]; - if (vc) { - [self _markIVarsInAllClassLevelsOfObject:vc]; - } - [self _buildSpecialTraceForView:view]; - if (view.layer) { - [self _markIVarsInAllClassLevelsOfObject:view.layer]; - } - [[view.subviews copy] enumerateObjectsUsingBlock:^(__kindof NSView * _Nonnull subview, NSUInteger idx, BOOL * _Nonnull stop) { - [self _addTraceForInterfaceObject:subview]; - }]; - } -} -#endif - -- (void)_buildSpecialTraceForView:(LookinView *)view { - LookinViewController* vc = [view lks_findHostViewController]; - if (vc) { - view.lks_specialTrace = [NSString stringWithFormat:@"%@.view", NSStringFromClass(vc.class)]; - -#if TARGET_OS_IPHONE - } else if ([view isKindOfClass:[LookinWindow class]]) { - CGFloat currentWindowLevel = ((LookinWindow *)view).windowLevel; - - if (((UIWindow *)view).isKeyWindow) { - view.lks_specialTrace = [NSString stringWithFormat:@"KeyWindow ( Level: %@ )", @(currentWindowLevel)]; - } else { - view.lks_specialTrace = [NSString stringWithFormat:@"WindowLevel: %@", @(currentWindowLevel)]; - } - } else if ([view isKindOfClass:[UITableViewCell class]]) { - ((UITableViewCell *)view).backgroundView.lks_specialTrace = @"cell.backgroundView"; - ((UITableViewCell *)view).accessoryView.lks_specialTrace = @"cell.accessoryView"; - - } else if ([view isKindOfClass:[UITableView class]]) { - UITableView *tableView = (UITableView *)view; - - NSMutableArray *relatedSectionIdx = [NSMutableArray array]; - [[tableView visibleCells] enumerateObjectsUsingBlock:^(__kindof UITableViewCell * _Nonnull cell, NSUInteger idx, BOOL * _Nonnull stop) { - NSIndexPath *indexPath = [tableView indexPathForCell:cell]; - cell.lks_specialTrace = [NSString stringWithFormat:@"{ sec:%@, row:%@ }", @(indexPath.section), @(indexPath.row)]; - - if (![relatedSectionIdx containsObject:@(indexPath.section)]) { - [relatedSectionIdx addObject:@(indexPath.section)]; - } - }]; - - [relatedSectionIdx enumerateObjectsUsingBlock:^(NSNumber * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSUInteger secIdx = [obj unsignedIntegerValue]; - UIView *secHeaderView = [tableView headerViewForSection:secIdx]; - secHeaderView.lks_specialTrace = [NSString stringWithFormat:@"sectionHeader { sec: %@ }", @(secIdx)]; - - UIView *secFooterView = [tableView footerViewForSection:secIdx]; - secFooterView.lks_specialTrace = [NSString stringWithFormat:@"sectionFooter { sec: %@ }", @(secIdx)]; - }]; - } else if ([view isKindOfClass:[UITableViewHeaderFooterView class]]) { - UITableViewHeaderFooterView *headerFooterView = (UITableViewHeaderFooterView *)view; - headerFooterView.textLabel.lks_specialTrace = @"sectionHeaderFooter.textLabel"; - headerFooterView.detailTextLabel.lks_specialTrace = @"sectionHeaderFooter.detailTextLabel"; -#elif TARGET_OS_OSX - } else if ([view isKindOfClass:[NSTableView class]]) { - NSTableView *tableView = (NSTableView *)view; - tableView.headerView.lks_specialTrace = @"tableView.headerView"; - NSRect visibleRect = tableView.visibleRect; - NSIndexSet *visibleRowIndexes = [NSIndexSet indexSetWithIndexesInRange:[tableView rowsInRect:visibleRect]]; - [visibleRowIndexes enumerateIndexesUsingBlock:^(NSUInteger row, BOOL * _Nonnull stop) { - NSTableRowView *rowView = [tableView rowViewAtRow:row makeIfNecessary:NO]; - NSInteger level = NSNotFound; - if ([tableView isKindOfClass:[NSOutlineView class]]) { - NSOutlineView *outlineView = (NSOutlineView *)tableView; - level = [outlineView levelForRow:row]; - } - - if (level != NSNotFound) { - rowView.lks_specialTrace = [NSString stringWithFormat:@"{ level: %@, row: %@ }", @(level), @(row)]; - } else { - rowView.lks_specialTrace = [NSString stringWithFormat:@"{ row: %@ }", @(row)]; - } - - NSInteger numberOfColumns = [tableView numberOfColumns]; - for (NSInteger column = 0; column < numberOfColumns; column++) { - NSView *cellView = [rowView viewAtColumn:column]; - if (cellView && [cellView isKindOfClass:[NSView class]]) { - if (level != NSNotFound) { - cellView.lks_specialTrace = [NSString stringWithFormat:@"{ level: %@, row: %@, column: %@ }", @(level), @(row), @(column)]; - } else { - cellView.lks_specialTrace = [NSString stringWithFormat:@"{ row: %@, column: %@ }", @(row), @(column)]; - } - } - } - }]; -#endif - } else if ([view isKindOfClass:[LookinCollectionView class]]) { - LookinCollectionView *collectionView = (LookinCollectionView *)view; - collectionView.backgroundView.lks_specialTrace = @"collectionView.backgroundView"; -#if TARGET_OS_IPHONE - if (@available(iOS 9.0, *)) { - [[collectionView indexPathsForVisibleSupplementaryElementsOfKind:LookinCollectionElementKindSectionHeader] enumerateObjectsUsingBlock:^(NSIndexPath * _Nonnull indexPath, NSUInteger idx, BOOL * _Nonnull stop) { - LookinView *headerView = [collectionView supplementaryViewForElementKind:LookinCollectionElementKindSectionHeader atIndexPath:indexPath]; - headerView.lks_specialTrace = [NSString stringWithFormat:@"sectionHeader { sec:%@ }", @(indexPath.section)]; - }]; - [[collectionView indexPathsForVisibleSupplementaryElementsOfKind:LookinCollectionElementKindSectionFooter] enumerateObjectsUsingBlock:^(NSIndexPath * _Nonnull indexPath, NSUInteger idx, BOOL * _Nonnull stop) { - LookinView *footerView = [collectionView supplementaryViewForElementKind:LookinCollectionElementKindSectionFooter atIndexPath:indexPath]; - footerView.lks_specialTrace = [NSString stringWithFormat:@"sectionFooter { sec:%@ }", @(indexPath.section)]; - }]; - } - [[collectionView visibleCells] enumerateObjectsUsingBlock:^(__kindof UICollectionViewCell * _Nonnull cell, NSUInteger idx, BOOL * _Nonnull stop) { - NSIndexPath *indexPath = [collectionView indexPathForCell:cell]; - cell.lks_specialTrace = [NSString stringWithFormat:@"{ item:%@, sec:%@ }", @(indexPath.item), @(indexPath.section)]; - }]; -#elif TARGET_OS_OSX - [[collectionView indexPathsForVisibleSupplementaryElementsOfKind:LookinCollectionElementKindSectionHeader] enumerateObjectsUsingBlock:^(NSIndexPath * _Nonnull indexPath, BOOL * _Nonnull stop) { - LookinView *headerView = [collectionView supplementaryViewForElementKind:LookinCollectionElementKindSectionHeader atIndexPath:indexPath]; - headerView.lks_specialTrace = [NSString stringWithFormat:@"sectionHeader { sec:%@ }", @(indexPath.section)]; - }]; - [[collectionView indexPathsForVisibleSupplementaryElementsOfKind:LookinCollectionElementKindSectionFooter] enumerateObjectsUsingBlock:^(NSIndexPath * _Nonnull indexPath, BOOL * _Nonnull stop) { - LookinView *footerView = [collectionView supplementaryViewForElementKind:LookinCollectionElementKindSectionFooter atIndexPath:indexPath]; - footerView.lks_specialTrace = [NSString stringWithFormat:@"sectionFooter { sec:%@ }", @(indexPath.section)]; - }]; - [[collectionView visibleItems] enumerateObjectsUsingBlock:^(NSCollectionViewItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { - NSIndexPath *indexPath = [collectionView indexPathForItem:item]; - item.lks_specialTrace = [NSString stringWithFormat:@"{ item:%@, sec:%@ }", @(indexPath.item), @(indexPath.section)]; - }]; -#endif - } -} - -- (void)_markIVarsInAllClassLevelsOfObject:(NSObject *)object { - if (!object) { - return; - } - [self _markIVarsOfObject:object class:object.class]; -#ifdef LOOKIN_SERVER_SWIFT_ENABLED_SUCCESSFULLY - [LKS_SwiftTraceManager swiftMarkIVarsOfObject:object]; -#endif -} - -- (void)_markIVarsOfObject:(NSObject *)hostObject class:(Class)targetClass { - if (!targetClass) { - return; - } - - NSArray *prefixesToTerminateRecursion = @[@"NSObject", @"UIResponder", @"UIButton", @"UIButtonLabel", @"NSResponder"]; - BOOL hasPrefix = [prefixesToTerminateRecursion lookin_any:^BOOL(NSString *prefix) { - return [NSStringFromClass(targetClass) hasPrefix:prefix]; - }]; - if (hasPrefix) { - return; - } - - unsigned int outCount = 0; - Ivar *ivars = class_copyIvarList(targetClass, &outCount); - for (unsigned int i = 0; i < outCount; i ++) { - Ivar ivar = ivars[i]; - NSString *ivarType = [[NSString alloc] lookin_safeInitWithUTF8String:ivar_getTypeEncoding(ivar)]; - if (![ivarType hasPrefix:@"@"] || ivarType.length <= 3) { - continue; - } - NSString *ivarClassName = [ivarType substringWithRange:NSMakeRange(2, ivarType.length - 3)]; - Class ivarClass = NSClassFromString(ivarClassName); - if (![ivarClass isSubclassOfClass:[LookinView class]] - && ![ivarClass isSubclassOfClass:[CALayer class]] - && ![ivarClass isSubclassOfClass:[LookinViewController class]] - && ![ivarClass isSubclassOfClass:[LookinGestureRecognizer class]]) { - continue; - } - const char * ivarNameChar = ivar_getName(ivar); - if (!ivarNameChar) { - continue; - } - // 这个 ivarObject 可能的类型:UIView, CALayer, UIViewController, UIGestureRecognizer - NSObject *ivarObject = object_getIvar(hostObject, ivar); - if (!ivarObject || ![ivarObject isKindOfClass:[NSObject class]]) { - continue; - } - - LookinIvarTrace *ivarTrace = [LookinIvarTrace new]; - ivarTrace.hostObject = hostObject; - ivarTrace.hostClassName = [self makeDisplayClassNameWithSuper:targetClass childClass:hostObject.class]; - ivarTrace.ivarName = [[NSString alloc] lookin_safeInitWithUTF8String:ivarNameChar]; - - if (hostObject == ivarObject) { - ivarTrace.relation = LookinIvarTraceRelationValue_Self; - } else if ([hostObject isKindOfClass:[LookinView class]]) { - CALayer *ivarLayer = nil; - if ([ivarObject isKindOfClass:[CALayer class]]) { - ivarLayer = (CALayer *)ivarObject; - } else if ([ivarObject isKindOfClass:[LookinView class]]) { - ivarLayer = ((LookinView *)ivarObject).layer; - } - if (ivarLayer && (ivarLayer.superlayer == ((LookinView *)hostObject).layer)) { - ivarTrace.relation = @"superview"; - } - } - - if ([LKS_InvalidIvarTraces() containsObject:ivarTrace]) { - continue; - } - - if (![ivarObject respondsToSelector:@selector(lks_ivarTraces)] || ![ivarObject respondsToSelector:@selector(setLks_ivarTraces:)]) { - continue; - } - if (!ivarObject.lks_ivarTraces) { - ivarObject.lks_ivarTraces = [NSArray array]; - } - if (![ivarObject.lks_ivarTraces containsObject:ivarTrace]) { - ivarObject.lks_ivarTraces = [ivarObject.lks_ivarTraces arrayByAddingObject:ivarTrace]; - } - } - free(ivars); - - Class superClass = [targetClass superclass]; - [self _markIVarsOfObject:hostObject class:superClass]; -} - -// 比如 superClass 可能是 UIView,而 childClass 可能是 UIButton -- (NSString *)makeDisplayClassNameWithSuper:(Class)superClass childClass:(Class)childClass { - NSString *superName = NSStringFromClass(superClass); - if (!childClass) { - return superName; - } - NSString *childName = NSStringFromClass(childClass); - if ([childName isEqualToString:superName]) { - return superName; - } - return [NSString stringWithFormat:@"%@ : %@", childName, superName]; -} - -static NSSet *LKS_InvalidIvarTraces(void) { - static NSSet *list; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSMutableSet *set = [NSMutableSet set]; - - [set addObject:({ - LookinIvarTrace *trace = [LookinIvarTrace new]; - trace.hostClassName = LookinViewString; - trace.ivarName = @"_window"; - trace; - })]; - [set addObject:({ - LookinIvarTrace *trace = [LookinIvarTrace new]; - trace.hostClassName = LookinViewControllerString; - trace.ivarName = @"_view"; - trace; - })]; - [set addObject:({ - LookinIvarTrace *trace = [LookinIvarTrace new]; - trace.hostClassName = @"UIView"; - trace.ivarName = @"_viewDelegate"; - trace; - })]; - [set addObject:({ - LookinIvarTrace *trace = [LookinIvarTrace new]; - trace.hostClassName = LookinViewControllerString; - trace.ivarName = @"_parentViewController"; - trace; - })]; - list = set.copy; - }); - return list; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Server/Others/LookinHierarchyInfo+LookinServer.h b/Sources/LookinServer/Server/Others/LookinHierarchyInfo+LookinServer.h deleted file mode 100644 index 5a3c3f6d..00000000 --- a/Sources/LookinServer/Server/Others/LookinHierarchyInfo+LookinServer.h +++ /dev/null @@ -1,8 +0,0 @@ -// -// LookinHierarchyInfo+LookinServer.h -// LookinServer -// -// Created by JH on 2024/11/5. -// - -#import "LookinHierarchyInfo.h" diff --git a/Sources/LookinServer/Server/Others/LookinHierarchyInfo+LookinServer.m b/Sources/LookinServer/Server/Others/LookinHierarchyInfo+LookinServer.m deleted file mode 100644 index 50dce814..00000000 --- a/Sources/LookinServer/Server/Others/LookinHierarchyInfo+LookinServer.m +++ /dev/null @@ -1,8 +0,0 @@ -// -// LookinHierarchyInfo+LookinServer.m -// LookinServer -// -// Created by JH on 2024/11/5. -// - -#import "LookinHierarchyInfo+LookinServer.h" diff --git a/Sources/LookinServer/Server/Others/LookinServerDefines.h b/Sources/LookinServer/Server/Others/LookinServerDefines.h deleted file mode 100644 index 3db5b08e..00000000 --- a/Sources/LookinServer/Server/Others/LookinServerDefines.h +++ /dev/null @@ -1,24 +0,0 @@ -#if defined(SHOULD_COMPILE_LOOKIN_SERVER) && (TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION || TARGET_OS_OSX) -// -// LookinServer_PrefixHeader.pch -// LookinServer -// -// Created by Li Kai on 2018/12/21. -// https://lookin.work -// - -#import "TargetConditionals.h" -#import "LookinDefines.h" -#import "LKS_Helper.h" -#import "NSObject+LookinServer.h" -#import "NSArray+Lookin.h" -#import "NSSet+Lookin.h" -#import "CALayer+Lookin.h" -#if TARGET_OS_IPHONE || TARGET_OS_TV || TARGET_OS_VISION -#import "UIView+LookinServer.h" -#endif -#import "CALayer+LookinServer.h" -#import "NSObject+Lookin.h" -#import "NSString+Lookin.h" - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/CALayer+Lookin.h b/Sources/LookinServer/Shared/Category/CALayer+Lookin.h deleted file mode 100644 index 815459a7..00000000 --- a/Sources/LookinServer/Shared/Category/CALayer+Lookin.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// CALayer+Lookin.h -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import - -@interface CALayer (Lookin) - -- (void)lookin_removeImplicitAnimations; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/CALayer+Lookin.m b/Sources/LookinServer/Shared/Category/CALayer+Lookin.m deleted file mode 100644 index e756fc49..00000000 --- a/Sources/LookinServer/Shared/Category/CALayer+Lookin.m +++ /dev/null @@ -1,70 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// CALayer+Lookin.m -// Lookin -// -// Created by Li Kai on 2018/8/4. -// https://lookin.work -// - - - -#import "CALayer+Lookin.h" - -@implementation CALayer (Lookin) - -- (void)lookin_removeImplicitAnimations { - NSMutableDictionary> *actions = @{NSStringFromSelector(@selector(bounds)): [NSNull null], - NSStringFromSelector(@selector(position)): [NSNull null], - NSStringFromSelector(@selector(zPosition)): [NSNull null], - NSStringFromSelector(@selector(anchorPoint)): [NSNull null], - NSStringFromSelector(@selector(anchorPointZ)): [NSNull null], - NSStringFromSelector(@selector(transform)): [NSNull null], - NSStringFromSelector(@selector(sublayerTransform)): [NSNull null], - NSStringFromSelector(@selector(masksToBounds)): [NSNull null], - NSStringFromSelector(@selector(contents)): [NSNull null], - NSStringFromSelector(@selector(contentsRect)): [NSNull null], - NSStringFromSelector(@selector(contentsScale)): [NSNull null], - NSStringFromSelector(@selector(contentsCenter)): [NSNull null], - NSStringFromSelector(@selector(minificationFilterBias)): [NSNull null], - NSStringFromSelector(@selector(backgroundColor)): [NSNull null], - NSStringFromSelector(@selector(cornerRadius)): [NSNull null], - NSStringFromSelector(@selector(borderWidth)): [NSNull null], - NSStringFromSelector(@selector(borderColor)): [NSNull null], - NSStringFromSelector(@selector(opacity)): [NSNull null], - NSStringFromSelector(@selector(compositingFilter)): [NSNull null], - NSStringFromSelector(@selector(filters)): [NSNull null], - NSStringFromSelector(@selector(backgroundFilters)): [NSNull null], - NSStringFromSelector(@selector(shouldRasterize)): [NSNull null], - NSStringFromSelector(@selector(rasterizationScale)): [NSNull null], - NSStringFromSelector(@selector(shadowColor)): [NSNull null], - NSStringFromSelector(@selector(shadowOpacity)): [NSNull null], - NSStringFromSelector(@selector(shadowOffset)): [NSNull null], - NSStringFromSelector(@selector(shadowRadius)): [NSNull null], - NSStringFromSelector(@selector(shadowPath)): [NSNull null]}.mutableCopy; - - if ([self isKindOfClass:[CAShapeLayer class]]) { - [actions addEntriesFromDictionary:@{NSStringFromSelector(@selector(path)): [NSNull null], - NSStringFromSelector(@selector(fillColor)): [NSNull null], - NSStringFromSelector(@selector(strokeColor)): [NSNull null], - NSStringFromSelector(@selector(strokeStart)): [NSNull null], - NSStringFromSelector(@selector(strokeEnd)): [NSNull null], - NSStringFromSelector(@selector(lineWidth)): [NSNull null], - NSStringFromSelector(@selector(miterLimit)): [NSNull null], - NSStringFromSelector(@selector(lineDashPhase)): [NSNull null]}]; - } - - if ([self isKindOfClass:[CAGradientLayer class]]) { - [actions addEntriesFromDictionary:@{NSStringFromSelector(@selector(colors)): [NSNull null], - NSStringFromSelector(@selector(locations)): [NSNull null], - NSStringFromSelector(@selector(startPoint)): [NSNull null], - NSStringFromSelector(@selector(endPoint)): [NSNull null]}]; - } - - self.actions = actions; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/Color+Lookin.h b/Sources/LookinServer/Shared/Category/Color+Lookin.h deleted file mode 100644 index b0422469..00000000 --- a/Sources/LookinServer/Shared/Category/Color+Lookin.h +++ /dev/null @@ -1,27 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Color+Lookin.h -// LookinShared -// -// Created by 李凯 on 2022/4/2. -// - -#import - -#if TARGET_OS_IPHONE - -#elif TARGET_OS_OSX - -@interface NSColor (Lookin) - -+ (instancetype)lookin_colorFromRGBAComponents:(NSArray *)components; - -- (NSArray *)lookin_rgbaComponents; - -@end - -#endif - - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/Color+Lookin.m b/Sources/LookinServer/Shared/Category/Color+Lookin.m deleted file mode 100644 index eda9e651..00000000 --- a/Sources/LookinServer/Shared/Category/Color+Lookin.m +++ /dev/null @@ -1,42 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Color+Lookin.m -// LookinShared -// -// Created by 李凯 on 2022/4/2. -// - -#import "Image+Lookin.h" - -#if TARGET_OS_IPHONE - -#elif TARGET_OS_OSX - -@implementation NSColor (Lookin) - -+ (instancetype)lookin_colorFromRGBAComponents:(NSArray *)components { - if (!components) { - return nil; - } - if (components.count != 4) { - NSAssert(NO, @""); - return nil; - } - NSColor *color = [NSColor colorWithRed:components[0].doubleValue green:components[1].doubleValue blue:components[2].doubleValue alpha:components[3].doubleValue]; - return color; -} - -- (NSArray *)lookin_rgbaComponents { - NSColor *rgbColor = [self colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; - CGFloat r, g, b, a; - [rgbColor getRed:&r green:&g blue:&b alpha:&a]; - NSArray *rgba = @[@(r), @(g), @(b), @(a)]; - return rgba; -} - -@end - -#endif - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/Image+Lookin.h b/Sources/LookinServer/Shared/Category/Image+Lookin.h deleted file mode 100644 index bf8ea3cf..00000000 --- a/Sources/LookinServer/Shared/Category/Image+Lookin.h +++ /dev/null @@ -1,25 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Image+Lookin.h -// LookinShared -// -// Created by 李凯 on 2022/4/2. -// - -#import - -#if TARGET_OS_IPHONE - -#elif TARGET_OS_OSX - -@interface NSImage (LookinClient) - -- (NSData *)lookin_data; - -@end - -#endif - - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/Image+Lookin.m b/Sources/LookinServer/Shared/Category/Image+Lookin.m deleted file mode 100644 index 4f651687..00000000 --- a/Sources/LookinServer/Shared/Category/Image+Lookin.m +++ /dev/null @@ -1,41 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Image+Lookin.m -// LookinShared -// -// Created by 李凯 on 2022/4/2. -// - -#import "Image+Lookin.h" - -#if TARGET_OS_IPHONE - -#elif TARGET_OS_OSX - -@implementation NSImage (LookinClient) - -- (NSData *)lookin_data { - if (self.representations.count == 0) { - return nil; - } - // 优先尝试直接使用 bitmap representations(适用于光栅图像)。 - NSData *data = [NSBitmapImageRep representationOfImageRepsInArray:self.representations usingType:NSBitmapImageFileTypePNG properties:@{}]; - if (data) { - return data; - } - // 兜底方案:渲染到 bitmap context(处理矢量图、SF Symbols 等)。 - CGRect proposedRect = CGRectMake(0, 0, self.size.width, self.size.height); - CGImageRef cgImage = [self CGImageForProposedRect:&proposedRect context:nil hints:nil]; - if (!cgImage) { - return nil; - } - NSBitmapImageRep *bitmapRep = [[NSBitmapImageRep alloc] initWithCGImage:cgImage]; - return [bitmapRep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; -} - -@end - -#endif - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSArray+Lookin.h b/Sources/LookinServer/Shared/Category/NSArray+Lookin.h deleted file mode 100644 index a66e02a9..00000000 --- a/Sources/LookinServer/Shared/Category/NSArray+Lookin.h +++ /dev/null @@ -1,72 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSArray+Lookin.h -// Lookin -// -// Created by Li Kai on 2018/9/3. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import -#import - -@interface NSArray<__covariant ValueType> (Lookin) - -/** - 初始化一个新的 NSArray 并返回,新数组的长度为 count,如果当前数组长度比 count 小则会补充新元素(被补充的元素由 addBlock 返回),如果当前数组长度比 count 大则会舍弃多余的元素,被舍弃的元素会作为参数传入 removeBlock。最终,新数组的所有元素均会作为参数被传入 doBlock。 - */ -- (NSArray *)lookin_resizeWithCount:(NSUInteger)count add:(ValueType (^)(NSUInteger idx))addBlock remove:(void (^)(NSUInteger idx, ValueType obj))removeBlock doNext:(void (^)(NSUInteger idx, ValueType obj))doBlock __attribute__((warn_unused_result)); - -+ (NSArray *)lookin_arrayWithCount:(NSUInteger)count block:(id (^)(NSUInteger idx))block; - -/** - 检查 index 位置是否有元素存在 - */ -- (BOOL)lookin_hasIndex:(NSInteger)index; - -- (NSArray *)lookin_map:(id (^)(NSUInteger idx, ValueType value))block; - -- (NSArray *)lookin_filter:(BOOL (^)( ValueType obj))block; - -- (ValueType)lookin_firstFiltered:(BOOL (^)(ValueType obj))block; - -/// 返回最后一个 block 返回 YES 的元素 -- (ValueType)lookin_lastFiltered:(BOOL (^)(ValueType obj))block; - -- (id)lookin_reduce:(id (^)(id accumulator, NSUInteger idx, ValueType obj))block; - -- (CGFloat)lookin_reduceCGFloat:(CGFloat (^)(CGFloat accumulator, NSUInteger idx, ValueType obj))block initialAccumlator:(CGFloat)initialAccumlator; -- (NSInteger)lookin_reduceInteger:(NSInteger (^)(NSInteger accumulator, NSUInteger idx, ValueType obj))block initialAccumlator:(NSInteger)initialAccumlator; - -- (BOOL)lookin_all:(BOOL (^)(ValueType obj))block; - -- (BOOL)lookin_any:(BOOL (^)(ValueType obj))block; - -- (NSArray *)lookin_arrayByRemovingObject:(ValueType)obj; - -- (NSArray *)lookin_nonredundantArray; - -- (ValueType)lookin_safeObjectAtIndex:(NSInteger)idx; - -/// 字符串长度从短到长,即 length 小的字符串的 idx 更小 -- (NSArray *)lookin_sortedArrayByStringLength; - -@end - -@interface NSMutableArray (Lookin) - -/** - 如果当前数组长度比 count 小则会补充新元素(被补充的元素由 addBlock 返回),如果当前数组长度比 count 大则多余的元素会被作为参数传入 notDequeued。然后从 idx 为 0 算起,前 count 个元素会被作为参数传入 doBlock - */ -- (void)lookin_dequeueWithCount:(NSUInteger)count add:(ValueType (^)(NSUInteger idx))addBlock notDequeued:(void (^)(NSUInteger idx, ValueType obj))notDequeuedBlock doNext:(void (^)(NSUInteger idx, ValueType obj))doBlock; - -- (void)lookin_removeObjectsPassingTest:(BOOL (^)(NSUInteger idx, ValueType obj))block; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSArray+Lookin.m b/Sources/LookinServer/Shared/Category/NSArray+Lookin.m deleted file mode 100644 index 6df614b7..00000000 --- a/Sources/LookinServer/Shared/Category/NSArray+Lookin.m +++ /dev/null @@ -1,300 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSArray+Lookin.m -// Lookin -// -// Created by Li Kai on 2018/9/3. -// https://lookin.work -// - - - -#import "NSArray+Lookin.h" - -@implementation NSArray (Lookin) - -- (NSArray *)lookin_resizeWithCount:(NSUInteger)count add:(id (^)(NSUInteger idx))addBlock remove:(void (^)(NSUInteger idx, id obj))removeBlock doNext:(void (^)(NSUInteger idx, id obj))doBlock { - NSMutableArray *resultArray = [NSMutableArray arrayWithCapacity:count]; - - for (NSUInteger i = 0; i < count; i++) { - if (self.count > i) { - id obj = [self objectAtIndex:i]; - [resultArray addObject:obj]; - if (doBlock) { - doBlock(i, obj); - } - } else { - if (addBlock) { - id newObj = addBlock(i); - if (newObj) { - [resultArray addObject:newObj]; - if (doBlock) { - doBlock(i, newObj); - } - } else { - NSAssert(NO, @""); - } - } else { - NSAssert(NO, @""); - } - } - } - - if (removeBlock) { - if (self.count > count) { - for (NSUInteger i = count; i < self.count; i++) { - id obj = [self objectAtIndex:i]; - removeBlock(i, obj); - } - } - } - - return [resultArray copy]; -} - -+ (NSArray *)lookin_arrayWithCount:(NSUInteger)count block:(id (^)(NSUInteger idx))block { - NSMutableArray *array = [NSMutableArray arrayWithCapacity:count]; - for (NSUInteger i = 0; i < count; i++) { - id obj = block(i); - if (obj) { - [array addObject:obj]; - } - } - return [array copy]; -} - -- (BOOL)lookin_hasIndex:(NSInteger)index { - if (index == NSNotFound || index < 0) { - return NO; - } - return self.count > index; -} - -- (NSArray *)lookin_map:(id (^)(NSUInteger , id))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - NSMutableArray *array = [[NSMutableArray alloc] initWithCapacity:self.count]; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - id newObj = block(idx, obj); - if (newObj) { - [array addObject:newObj]; - } - }]; - return [array copy]; -} - -- (NSArray *)lookin_filter:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - NSMutableArray *mArray = [NSMutableArray array]; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (block(obj)) { - [mArray addObject:obj]; - } - }]; - return [mArray copy]; -} - -- (id)lookin_firstFiltered:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - __block id targetObj = nil; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (block(obj)) { - targetObj = obj; - *stop = YES; - } - }]; - return targetObj; -} - -- (id)lookin_lastFiltered:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - __block id targetObj = nil; - [self enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (block(obj)) { - targetObj = obj; - *stop = YES; - } - }]; - return targetObj; -} - -- (id)lookin_reduce:(id (^)(id accumulator, NSUInteger idx, id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - __block id accumulator = nil; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - accumulator = block(accumulator, idx, obj); - }]; - return accumulator; -} - -- (CGFloat)lookin_reduceCGFloat:(CGFloat (^)(CGFloat accumulator, NSUInteger idx, id obj))block initialAccumlator:(CGFloat)initialAccumlator { - if (!block) { - NSAssert(NO, @""); - return initialAccumlator; - } - - __block CGFloat accumulator = initialAccumlator; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - accumulator = block(accumulator, idx, obj); - }]; - return accumulator; -} - -- (NSInteger)lookin_reduceInteger:(NSInteger (^)(NSInteger, NSUInteger, id))block initialAccumlator:(NSInteger)initialAccumlator { - if (!block) { - NSAssert(NO, @""); - return initialAccumlator; - } - - __block NSInteger accumulator = initialAccumlator; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - accumulator = block(accumulator, idx, obj); - }]; - return accumulator; -} - -- (BOOL)lookin_all:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return NO; - } - __block BOOL allPass = YES; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL boolValue = block(obj); - if (!boolValue) { - allPass = NO; - *stop = YES; - } - }]; - return allPass; -} - -- (BOOL)lookin_any:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return NO; - } - __block BOOL anyPass = NO; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL boolValue = block(obj); - if (boolValue) { - anyPass = YES; - *stop = YES; - } - }]; - return anyPass; -} - -- (NSArray *)lookin_arrayByRemovingObject:(id)obj { - if (!obj || ![self containsObject:obj]) { - return self; - } - NSMutableArray *mutableArray = self.mutableCopy; - [mutableArray removeObject:obj]; - return mutableArray.copy; -} - -- (NSArray *)lookin_nonredundantArray { - NSSet *set = [NSSet setWithArray:self]; - NSArray *newArray = [set allObjects]; - return newArray; -} - -- (id)lookin_safeObjectAtIndex:(NSInteger)idx { - if (idx == NSNotFound || idx < 0) { - return nil; - } - if (self.count <= idx) { - return nil; - } - return [self objectAtIndex:idx]; -} - -- (NSArray *)lookin_sortedArrayByStringLength { - NSArray *sortedArray = [self sortedArrayUsingComparator:^NSComparisonResult(NSString *obj1, NSString *obj2) { - if (obj1.length > obj2.length) { - return NSOrderedDescending; - } else if (obj1.length == obj2.length) { - return NSOrderedSame; - } else { - return NSOrderedAscending; - } - }]; - return sortedArray; -} - -@end - -@implementation NSMutableArray (Lookin) - -- (void)lookin_dequeueWithCount:(NSUInteger)count add:(id (^)(NSUInteger idx))addBlock notDequeued:(void (^)(NSUInteger idx, id obj))notDequeuedBlock doNext:(void (^)(NSUInteger idx, id obj))doBlock { - for (NSUInteger i = 0; i < count; i++) { - if ([self lookin_hasIndex:i]) { - id obj = [self objectAtIndex:i]; - if (doBlock) { - doBlock(i, obj); - } - } else { - if (addBlock) { - id newObj = addBlock(i); - if (newObj) { - [self addObject:newObj]; - if (doBlock) { - doBlock(i, newObj); - } - } else { - NSAssert(NO, @""); - } - } else { - NSAssert(NO, @""); - } - } - } - - if (notDequeuedBlock) { - if (self.count > count) { - for (NSUInteger i = count; i < self.count; i++) { - id obj = [self objectAtIndex:i]; - notDequeuedBlock(i, obj); - } - } - } -} - -- (void)lookin_removeObjectsPassingTest:(BOOL (^)(NSUInteger idx, id obj))block { - if (!block) { - return; - } - NSMutableIndexSet *indexSet = [NSMutableIndexSet indexSet]; - [self enumerateObjectsUsingBlock:^(id _Nonnull currentObj, NSUInteger idx, BOOL * _Nonnull stop) { - BOOL boolValue = block(idx, currentObj); - if (boolValue) { - [indexSet addIndex:idx]; - } - }]; - [self removeObjectsAtIndexes:indexSet]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSObject+Lookin.h b/Sources/LookinServer/Shared/Category/NSObject+Lookin.h deleted file mode 100644 index c8839cf1..00000000 --- a/Sources/LookinServer/Shared/Category/NSObject+Lookin.h +++ /dev/null @@ -1,108 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSObject+Lookin.h -// Lookin -// -// Created by Li Kai on 2018/12/22. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import -#import "LookinCodingValueType.h" - -@interface NSObject (Lookin) - -#pragma mark - Data Bind - -/** - 给对象绑定上另一个对象以供后续取出使用,如果 object 传入 nil 则会清除该 key 之前绑定的对象 - - @attention 被绑定的对象会被 strong 强引用 - @note 内部是使用 objc_setAssociatedObject / objc_getAssociatedObject 来实现 - - @code - - (UITableViewCell *)cellForIndexPath:(NSIndexPath *)indexPath { - // 1)在这里给 button 绑定上 indexPath 对象 - [cell lookin_bindObject:indexPath forKey:@"indexPath"]; - } - - - (void)didTapButton:(UIButton *)button { - // 2)在这里取出被点击的 button 的 indexPath 对象 - NSIndexPath *indexPathTapped = [button lookin_getBindObjectForKey:@"indexPath"]; - } - @endcode - */ -- (void)lookin_bindObject:(id)object forKey:(NSString *)key; - -/** - 给对象绑定上另一个对象以供后续取出使用,但相比于 lookin_bindObject:forKey:,该方法不会 strong 强引用传入的 object - */ -- (void)lookin_bindObjectWeakly:(id)object forKey:(NSString *)key; - -/** - 取出之前使用 bind 方法绑定的对象 - */ -- (id)lookin_getBindObjectForKey:(NSString *)key; - -/** - 给对象绑定上一个 double 值以供后续取出使用 - */ -- (void)lookin_bindDouble:(double)doubleValue forKey:(NSString *)key; - -/** - 取出之前用 lookin_bindDouble:forKey: 绑定的值 - */ -- (double)lookin_getBindDoubleForKey:(NSString *)key; - -/** - 给对象绑定上一个 BOOL 值以供后续取出使用 - */ -- (void)lookin_bindBOOL:(BOOL)boolValue forKey:(NSString *)key; - -/** - 取出之前用 lookin_bindBOOL:forKey: 绑定的值 - */ -- (BOOL)lookin_getBindBOOLForKey:(NSString *)key; - -/** - 给对象绑定上一个 long 值以供后续取出使用 - */ -- (void)lookin_bindLong:(long)longValue forKey:(NSString *)key; - -/** - 取出之前用 lookin_bindLong:forKey: 绑定的值 - */ -- (long)lookin_getBindLongForKey:(NSString *)key; - -/** - 给对象绑定上一个 CGPoint 值以供后续取出使用 - */ -- (void)lookin_bindPoint:(CGPoint)pointValue forKey:(NSString *)key; - -/** - 取出之前用 lookin_bindPoint:forKey: 绑定的值 - */ -- (CGPoint)lookin_getBindPointForKey:(NSString *)key; - -/** - 移除之前使用 bind 方法绑定的对象 - */ -- (void)lookin_clearBindForKey:(NSString *)key; - -@end - -@interface NSObject (Lookin_Coding) - -/// 会把 NSImage/UIImage 转换为 NSData,把 NSColor/UIColor 转换回 NSNumber 数组(rgba) -- (id)lookin_encodedObjectWithType:(LookinCodingValueType)type; -/// 会把 NSData 转换回 NSImage/UIImage,把 NSNumber 数组(rgba) 转换为 NSColor/UIColor -- (id)lookin_decodedObjectWithType:(LookinCodingValueType)type; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSObject+Lookin.m b/Sources/LookinServer/Shared/Category/NSObject+Lookin.m deleted file mode 100644 index e39a0e3a..00000000 --- a/Sources/LookinServer/Shared/Category/NSObject+Lookin.m +++ /dev/null @@ -1,238 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSObject+Lookin.m -// Lookin -// -// Created by Li Kai on 2018/12/22. -// https://lookin.work -// - -#import "NSObject+Lookin.h" -#import -#import "TargetConditionals.h" -#import "LookinWeakContainer.h" - -@implementation NSObject (Lookin) - -#pragma mark - Data Bind - -static char kAssociatedObjectKey_LookinAllBindObjects; -- (NSMutableDictionary *)lookin_allBindObjects { - NSMutableDictionary *dict = objc_getAssociatedObject(self, &kAssociatedObjectKey_LookinAllBindObjects); - if (!dict) { - dict = [NSMutableDictionary dictionary]; - objc_setAssociatedObject(self, &kAssociatedObjectKey_LookinAllBindObjects, dict, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - } - return dict; -} - -- (void)lookin_bindObject:(id)object forKey:(NSString *)key { - if (!key.length) { - NSAssert(NO, @""); - return; - } - @synchronized (self) { - if (object) { - [[self lookin_allBindObjects] setObject:object forKey:key]; - } else { - [[self lookin_allBindObjects] removeObjectForKey:key]; - } - } -} - -- (id)lookin_getBindObjectForKey:(NSString *)key { - if (!key.length) { - NSAssert(NO, @""); - return nil; - } - @synchronized (self) { - id storedObj = [[self lookin_allBindObjects] objectForKey:key]; - if ([storedObj isKindOfClass:[LookinWeakContainer class]]) { - storedObj = [(LookinWeakContainer *)storedObj object]; - } - return storedObj; - } -} - -- (void)lookin_bindObjectWeakly:(id)object forKey:(NSString *)key { - if (!key.length) { - NSAssert(NO, @""); - return; - } - if (object) { - LookinWeakContainer *container = [[LookinWeakContainer alloc] init]; - container.object = object; - [self lookin_bindObject:container forKey:key]; - } else { - [self lookin_bindObject:nil forKey:key]; - } -} - -- (void)lookin_bindDouble:(double)doubleValue forKey:(NSString *)key { - [self lookin_bindObject:@(doubleValue) forKey:key]; -} - -- (double)lookin_getBindDoubleForKey:(NSString *)key { - id object = [self lookin_getBindObjectForKey:key]; - if ([object isKindOfClass:[NSNumber class]]) { - double doubleValue = [(NSNumber *)object doubleValue]; - return doubleValue; - - } else { - return 0.0; - } -} - -- (void)lookin_bindBOOL:(BOOL)boolValue forKey:(NSString *)key { - [self lookin_bindObject:@(boolValue) forKey:key]; -} - -- (BOOL)lookin_getBindBOOLForKey:(NSString *)key { - id object = [self lookin_getBindObjectForKey:key]; - if ([object isKindOfClass:[NSNumber class]]) { - BOOL boolValue = [(NSNumber *)object boolValue]; - return boolValue; - - } else { - return NO; - } -} - -- (void)lookin_bindLong:(long)longValue forKey:(NSString *)key { - [self lookin_bindObject:@(longValue) forKey:key]; -} - -- (long)lookin_getBindLongForKey:(NSString *)key { - id object = [self lookin_getBindObjectForKey:key]; - if ([object isKindOfClass:[NSNumber class]]) { - long longValue = [(NSNumber *)object longValue]; - return longValue; - - } else { - return 0; - } -} - -- (void)lookin_bindPoint:(CGPoint)pointValue forKey:(NSString *)key { -#if TARGET_OS_IPHONE - [self lookin_bindObject:[NSValue valueWithCGPoint:pointValue] forKey:key]; -#elif TARGET_OS_OSX - NSPoint nsPoint = NSMakePoint(pointValue.x, pointValue.y); - [self lookin_bindObject:[NSValue valueWithPoint:nsPoint] forKey:key]; -#endif -} - -- (CGPoint)lookin_getBindPointForKey:(NSString *)key { - id object = [self lookin_getBindObjectForKey:key]; - if ([object isKindOfClass:[NSValue class]]) { -#if TARGET_OS_IPHONE - CGPoint pointValue = [(NSValue *)object CGPointValue]; -#elif TARGET_OS_OSX - NSPoint nsPointValue = [(NSValue *)object pointValue]; - CGPoint pointValue = CGPointMake(nsPointValue.x, nsPointValue.y); -#endif - return pointValue; - } else { - return CGPointZero; - } -} - -- (void)lookin_clearBindForKey:(NSString *)key { - [self lookin_bindObject:nil forKey:key]; -} - -@end - -@implementation NSObject (Lookin_Coding) - -- (id)lookin_encodedObjectWithType:(LookinCodingValueType)type { - if (type == LookinCodingValueTypeColor) { - if ([self isKindOfClass:[LookinColor class]]) { - CGFloat r, g, b, a; -#if TARGET_OS_IPHONE - CGFloat white; - if ([(UIColor *)self getRed:&r green:&g blue:&b alpha:&a]) { - // valid - } else if ([(UIColor *)self getWhite:&white alpha:&a]) { - r = white; - g = white; - b = white; - } else { - NSAssert(NO, @""); - r = 0; - g = 0; - b = 0; - a = 0; - } -#elif TARGET_OS_OSX - NSColor *color = [((NSColor *)self) colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; - [color getRed:&r green:&g blue:&b alpha:&a]; -#endif - NSArray *rgba = @[@(r), @(g), @(b), @(a)]; - return rgba; - - } else { - NSAssert(NO, @""); - return nil; - } - - } else if (type == LookinCodingValueTypeImage) { -#if TARGET_OS_IPHONE - if ([self isKindOfClass:[UIImage class]]) { - UIImage *image = (UIImage *)self; - return UIImagePNGRepresentation(image); - - } else { - NSAssert(NO, @""); - return nil; - } -#elif TARGET_OS_OSX - if ([self isKindOfClass:[NSImage class]]) { - NSImage *image = (NSImage *)self; - return [image TIFFRepresentation]; - - } else { - NSAssert(NO, @""); - return nil; - } -#endif - - } else { - return self; - } -} - -- (id)lookin_decodedObjectWithType:(LookinCodingValueType)type { - if (type == LookinCodingValueTypeColor) { - if ([self isKindOfClass:[NSArray class]]) { - NSArray *rgba = (NSArray *)self; - CGFloat r = [rgba[0] doubleValue]; - CGFloat g = [rgba[1] doubleValue]; - CGFloat b = [rgba[2] doubleValue]; - CGFloat a = [rgba[3] doubleValue]; - LookinColor *color = [LookinColor colorWithRed:r green:g blue:b alpha:a]; - return color; - - } else { - NSAssert(NO, @""); - return nil; - } - - } else if (type == LookinCodingValueTypeImage) { - if ([self isKindOfClass:[NSData class]]) { - LookinImage *image = [[LookinImage alloc] initWithData:(NSData *)self]; - return image; - } else { - NSAssert(NO, @""); - return nil; - } - - } else { - return self; - } -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSSet+Lookin.h b/Sources/LookinServer/Shared/Category/NSSet+Lookin.h deleted file mode 100644 index 757042ca..00000000 --- a/Sources/LookinServer/Shared/Category/NSSet+Lookin.h +++ /dev/null @@ -1,39 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSSet+Lookin.h -// Lookin -// -// Created by Li Kai on 2019/1/13. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import "TargetConditionals.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_OSX -#import -#endif - -@interface NSSet<__covariant ValueType> (Lookin) - -- (NSSet *)lookin_map:(id (^)(ValueType obj))block; - -- (ValueType)lookin_firstFiltered:(BOOL (^)(ValueType obj))block; - -- (NSSet *)lookin_filter:(BOOL (^)(ValueType obj))block; - - -/** - 是否有任何一个元素满足某条件 - @note 元素将被依次传入 block 里,如果任何一个 block 返回 YES,则该方法返回 YES。如果所有 block 均返回 NO,则该方法返回 NO。 - */ -- (BOOL)lookin_any:(BOOL (^)(ValueType obj))block; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSSet+Lookin.m b/Sources/LookinServer/Shared/Category/NSSet+Lookin.m deleted file mode 100644 index 1ec9edf2..00000000 --- a/Sources/LookinServer/Shared/Category/NSSet+Lookin.m +++ /dev/null @@ -1,81 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSSet+Lookin.m -// Lookin -// -// Created by Li Kai on 2019/1/13. -// https://lookin.work -// - - - -#import "NSSet+Lookin.h" - -@implementation NSSet (Lookin) - -- (NSSet *)lookin_map:(id (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - NSMutableSet *newSet = [NSMutableSet setWithCapacity:self.count]; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { - id newObj = block(obj); - if (newObj) { - [newSet addObject:newObj]; - } - }]; - return [newSet copy]; -} - -- (id)lookin_firstFiltered:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - __block id targetObj = nil; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { - if (block(obj)) { - targetObj = obj; - *stop = YES; - } - }]; - return targetObj; -} - -- (NSSet *)lookin_filter:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return nil; - } - - NSMutableSet *mSet = [NSMutableSet set]; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { - if (block(obj)) { - [mSet addObject:obj]; - } - }]; - return [mSet copy]; -} - -- (BOOL)lookin_any:(BOOL (^)(id obj))block { - if (!block) { - NSAssert(NO, @""); - return NO; - } - __block BOOL boolValue = NO; - [self enumerateObjectsUsingBlock:^(id _Nonnull obj, BOOL * _Nonnull stop) { - if (block(obj)) { - boolValue = YES; - *stop = YES; - } - }]; - return boolValue; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSString+Lookin.h b/Sources/LookinServer/Shared/Category/NSString+Lookin.h deleted file mode 100644 index 15f11717..00000000 --- a/Sources/LookinServer/Shared/Category/NSString+Lookin.h +++ /dev/null @@ -1,42 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSString+Lookin.h -// Lookin -// -// Created by Li Kai on 2019/5/11. -// https://lookin.work -// - -#import "LookinDefines.h" - - - -#import - -@interface NSString (Lookin) - -/** - 把 CGFloat 转成字符串,最多保留 3 位小数,转换后末尾的 0 会被删除 - 如:1.2341 => @"1.234", 2.1002 => @"2.1", 3.000 => @"3" - */ -+ (NSString *)lookin_stringFromDouble:(double)doubleValue decimal:(NSUInteger)decimal; - -+ (NSString *)lookin_stringFromRect:(CGRect)rect; - -+ (NSString *)lookin_stringFromInset:(LookinInsets)insets; - -+ (NSString *)lookin_stringFromSize:(CGSize)size; - -+ (NSString *)lookin_stringFromPoint:(CGPoint)point; - -+ (NSString *)lookin_rgbaStringFromColor:(LookinColor *)color; - -- (NSString *)lookin_safeInitWithUTF8String:(const char *)string; - -/// 把 1.2.3 这种 String 版本号转换成数字,可用于大小比较,如 110205 代表 11.2.5 版本 -- (NSInteger)lookin_numbericOSVersion; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSString+Lookin.m b/Sources/LookinServer/Shared/Category/NSString+Lookin.m deleted file mode 100644 index 3e297cc2..00000000 --- a/Sources/LookinServer/Shared/Category/NSString+Lookin.m +++ /dev/null @@ -1,117 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSString+Lookin.m -// Lookin -// -// Created by Li Kai on 2019/5/11. -// https://lookin.work -// - - - -#import "NSString+Lookin.h" - -@implementation NSString (Lookin) - -+ (NSString *)lookin_stringFromDouble:(double)doubleValue decimal:(NSUInteger)decimal { - NSString *formatString = [NSString stringWithFormat:@"%%.%@f", @(decimal)]; - NSString *string = [NSString stringWithFormat:formatString, doubleValue]; - for (int i = 0; i < decimal; i++) { - if ([[string substringFromIndex:string.length - 1] isEqualToString:@"0"]) { - string = [string substringToIndex:string.length - 1]; - } - } - if ([[string substringFromIndex:string.length - 1] isEqualToString:@"."]) { - string = [string substringToIndex:string.length - 1]; - } - return string; -} - -+ (NSString *)lookin_stringFromInset:(LookinInsets)insets { - return [NSString stringWithFormat:@"{%@, %@, %@, %@}", - [NSString lookin_stringFromDouble:insets.top decimal:2], - [NSString lookin_stringFromDouble:insets.left decimal:2], - [NSString lookin_stringFromDouble:insets.bottom decimal:2], - [NSString lookin_stringFromDouble:insets.right decimal:2]]; -} - -+ (NSString *)lookin_stringFromSize:(CGSize)size { - return [NSString stringWithFormat:@"{%@, %@}", - [NSString lookin_stringFromDouble:size.width decimal:2], - [NSString lookin_stringFromDouble:size.height decimal:2]]; -} - - -+ (NSString *)lookin_stringFromPoint:(CGPoint)point { - return [NSString stringWithFormat:@"{%@, %@}", - [NSString lookin_stringFromDouble:point.x decimal:2], - [NSString lookin_stringFromDouble:point.y decimal:2]]; -} - -+ (NSString *)lookin_stringFromRect:(CGRect)rect { - return [NSString stringWithFormat:@"{%@, %@, %@, %@}", - [NSString lookin_stringFromDouble:rect.origin.x decimal:2], - [NSString lookin_stringFromDouble:rect.origin.y decimal:2], - [NSString lookin_stringFromDouble:rect.size.width decimal:2], - [NSString lookin_stringFromDouble:rect.size.height decimal:2]]; -} - -+ (NSString *)lookin_rgbaStringFromColor:(LookinColor *)color { - if (!color) { - return @"nil"; - } - -#if TARGET_OS_IPHONE - UIColor *rgbColor = color; -#elif TARGET_OS_OSX - NSColor *rgbColor = [color colorUsingColorSpace:NSColorSpace.sRGBColorSpace]; -#endif - - CGFloat r, g, b, a; - [rgbColor getRed:&r green:&g blue:&b alpha:&a]; - - NSString *colorDesc; - if (a >= 1) { - colorDesc = [NSString stringWithFormat:@"(%.0f, %.0f, %.0f)", r * 255, g * 255, b * 255]; - } else { - colorDesc = [NSString stringWithFormat:@"(%.0f, %.0f, %.0f, %@)", r * 255, g * 255, b * 255, [NSString lookin_stringFromDouble:a decimal:2]]; - - } - - return colorDesc; -} - -- (NSString *)lookin_safeInitWithUTF8String:(const char *)string { - if (NULL != string) { - return [self initWithUTF8String:string]; - } - return nil; -} - -- (NSInteger)lookin_numbericOSVersion { - if (self.length == 0) { - NSAssert(NO, @""); - return 0; - } - NSArray *versionArr = [self componentsSeparatedByString:@"."]; - if (versionArr.count != 3) { - NSAssert(NO, @""); - return 0; - } - - NSInteger numbericOSVersion = 0; - NSInteger pos = 0; - - while ([versionArr count] > pos && pos < 3) { - numbericOSVersion += ([[versionArr objectAtIndex:pos] integerValue] * pow(10, (4 - pos * 2))); - pos++; - } - - return numbericOSVersion; -} - - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSValue+Lookin.h b/Sources/LookinServer/Shared/Category/NSValue+Lookin.h deleted file mode 100644 index ab863042..00000000 --- a/Sources/LookinServer/Shared/Category/NSValue+Lookin.h +++ /dev/null @@ -1,50 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSValue+Lookin.h -// LookinServer -// -// Created by JH on 2024/11/5. -// - -#import -#import -#import "LookinDefines.h" - -NS_ASSUME_NONNULL_BEGIN - -#if TARGET_OS_OSX -NSString *NSStringFromInsets(NSEdgeInsets insets); -NSString *NSStringFromCGAffineTransform(CGAffineTransform transform); -NSString *NSStringFromCGVector(CGVector vector); -NSString *NSStringFromCGRect(CGRect rect); -NSString *NSStringFromCGPoint(CGPoint point); -NSString *NSStringFromCGSize(CGSize size); -NSString *NSStringFromDirectionalEdgeInsets(NSDirectionalEdgeInsets insets); -#else -NSString *NSStringFromInsets(UIEdgeInsets insets); -#endif - -@interface NSValue (Lookin) -#if TARGET_OS_OSX -+ (NSValue *)valueWithCGVector:(CGVector)vector; -+ (NSValue *)valueWithCGRect:(CGRect)rect; -+ (NSValue *)valueWithCGPoint:(CGPoint)point; -+ (NSValue *)valueWithCGSize:(CGSize)size; -+ (NSValue *)valueWithCGAffineTransform:(CGAffineTransform)transform; -- (CGAffineTransform)CGAffineTransformValue; -- (CGVector)CGVectorValue; -- (CGRect)CGRectValue; -- (CGPoint)CGPointValue; -- (CGSize)CGSizeValue; -+ (NSValue *)valueWithInsets:(NSEdgeInsets)insets; -- (NSEdgeInsets)InsetsValue; -#else -+ (NSValue *)valueWithInsets:(UIEdgeInsets)insets; -- (UIEdgeInsets)InsetsValue; -#endif -@end - -NS_ASSUME_NONNULL_END - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Category/NSValue+Lookin.m b/Sources/LookinServer/Shared/Category/NSValue+Lookin.m deleted file mode 100644 index 396ff85c..00000000 --- a/Sources/LookinServer/Shared/Category/NSValue+Lookin.m +++ /dev/null @@ -1,111 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// NSValue+Lookin.m -// LookinServer -// -// Created by JH on 2024/11/5. -// - -#import "NSValue+Lookin.h" - -#if TARGET_OS_OSX -NSString *NSStringFromInsets(NSEdgeInsets insets) { - return [NSString stringWithFormat:@"{%.*g, %.*g, %.*g, %.*g}", 17, insets.top, 17, insets.left, 17, insets.bottom, 17, insets.right]; -} - -NSString *NSStringFromCGAffineTransform(CGAffineTransform transform) { - return [NSString stringWithFormat:@"[%.*g, %.*g, %.*g, %.*g, %.*g, %.*g]", 17, transform.a, 17, transform.b, 17, transform.c, 17, transform.d, 17, transform.tx, 17, transform.ty]; -} - -NSString *NSStringFromCGVector(CGVector vector) { - return [NSString stringWithFormat:@"{%.*g, %.*g}", 17, vector.dx, 17, vector.dy]; -} - -NSString *NSStringFromCGRect(CGRect rect) { - return NSStringFromRect(rect); -} - -NSString *NSStringFromCGPoint(CGPoint point) { - return NSStringFromPoint(point); -} - -NSString *NSStringFromCGSize(CGSize size) { - return NSStringFromSize(size); -} - -NSString *NSStringFromDirectionalEdgeInsets(NSDirectionalEdgeInsets insets) { - return [NSString stringWithFormat:@"{%.*g, %.*g, %.*g, %.*g}", 17, insets.top, 17, insets.leading, 17, insets.bottom, 17, insets.trailing]; -} -#else -NSString *NSStringFromInsets(UIEdgeInsets insets) { - return [NSString stringWithFormat:@"{%.*g, %.*g, %.*g, %.*g}", 17, insets.top, 17, insets.left, 17, insets.bottom, 17, insets.right]; -} -#endif - -@implementation NSValue (Lookin) -#if TARGET_OS_OSX -+ (NSValue *)valueWithCGVector:(CGVector)vector { - return [self valueWithBytes:&vector objCType:@encode(CGVector)]; -} - -+ (NSValue *)valueWithCGRect:(CGRect)rect { - return [self valueWithRect:rect]; -} - -+ (NSValue *)valueWithCGSize:(CGSize)size { - return [self valueWithSize:size]; -} - -+ (NSValue *)valueWithCGPoint:(CGPoint)point { - return [self valueWithPoint:point]; -} - -+ (NSValue *)valueWithCGAffineTransform:(CGAffineTransform)transform { - return [self valueWithBytes:&transform objCType:@encode(CGAffineTransform)]; -} - -- (CGAffineTransform)CGAffineTransformValue { - CGAffineTransform transform; - [self getValue:&transform]; - return transform; -} - -- (CGVector)CGVectorValue { - CGVector vector; - [self getValue:&vector]; - return vector; -} - -- (CGRect)CGRectValue { - return [self rectValue]; -} - -- (CGPoint)CGPointValue { - return [self pointValue]; -} - -- (CGSize)CGSizeValue { - return [self sizeValue]; -} - -+ (NSValue *)valueWithInsets:(NSEdgeInsets)insets { - return [self valueWithEdgeInsets:insets]; -} - -- (NSEdgeInsets)InsetsValue { - return [self edgeInsetsValue]; -} -#else -+ (NSValue *)valueWithInsets:(UIEdgeInsets)insets { - return [self valueWithUIEdgeInsets:insets]; -} - -- (UIEdgeInsets)InsetsValue { - return self.UIEdgeInsetsValue; -} -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAppInfo.h b/Sources/LookinServer/Shared/LookinAppInfo.h deleted file mode 100644 index 1082d12d..00000000 --- a/Sources/LookinServer/Shared/LookinAppInfo.h +++ /dev/null @@ -1,72 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAppInfo.h -// qmuidemo -// -// Created by Li Kai on 2018/11/3. -// Copyright © 2018 QMUI Team. All rights reserved. -// - - - -#import "LookinDefines.h" - -typedef NS_ENUM(NSInteger, LookinAppInfoDevice) { - LookinAppInfoDeviceSimulator, // 模拟器 - LookinAppInfoDeviceIPad, // iPad 真机 - LookinAppInfoDeviceOthers // 应该视为 iPhone 真机 -}; - -@interface LookinAppInfo : NSObject - -/// 每次启动 app 时都会随机生成一个 appInfoIdentifier 直到 app 被 kill 掉 -@property(nonatomic, assign) NSUInteger appInfoIdentifier; -/// mac 端应该先读取该属性,如果为 YES 则表示应该使用之前保存的旧 appInfo 对象即可 -@property(nonatomic, assign) BOOL shouldUseCache; -/// LookinServer 的版本 -@property(nonatomic, assign) int serverVersion; -/// 类似 "1.1.9",只在 1.2.3 以及之后的 LookinServer 版本里有值 -@property(nonatomic, assign) NSString *serverReadableVersion; -/// 如果 iOS 侧使用了 SPM 或引入了 Swift Subspec,则该属性为 1 -/// 如果 iOS 侧没使用,则该属性为 -1 -/// 如果不知道,则该属性为 0 -@property(nonatomic, assign) int swiftEnabledInLookinServer; -/// app 的当前截图 -@property(nonatomic, strong) LookinImage *screenshot; -/// 可能为 nil,比如新建的 iOS 空项目 -@property(nonatomic, strong) LookinImage *appIcon; -/// @"微信读书" -@property(nonatomic, copy) NSString *appName; -/// hughkli.lookin -@property(nonatomic, copy) NSString *appBundleIdentifier; -/// @"iPhone X" -@property(nonatomic, copy) NSString *deviceDescription; -/// @"12.1" -@property(nonatomic, copy) NSString *osDescription; -/// 返回 os 的主版本号,比如 iOS 12.1 的设备将返回 12,iOS 13.2.1 的设备将返回 13 -@property(nonatomic, assign) NSUInteger osMainVersion; -/// 设备类型 -@property(nonatomic, assign) LookinAppInfoDevice deviceType; -/// 屏幕的宽度 -@property(nonatomic, assign) double screenWidth; -/// 屏幕的高度 -@property(nonatomic, assign) double screenHeight; -/// 是几倍的屏幕 -@property(nonatomic, assign) double screenScale; - -- (BOOL)isEqualToAppInfo:(LookinAppInfo *)info; - -#if TARGET_OS_IPHONE - -+ (LookinAppInfo *)currentInfoWithScreenshot:(BOOL)hasScreenshot icon:(BOOL)hasIcon localIdentifiers:(NSArray *)localIdentifiers; - -#else - -@property(nonatomic, assign) NSTimeInterval cachedTimestamp; - -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAppInfo.m b/Sources/LookinServer/Shared/LookinAppInfo.m deleted file mode 100644 index 6d72dcd6..00000000 --- a/Sources/LookinServer/Shared/LookinAppInfo.m +++ /dev/null @@ -1,277 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAppInfo.m -// qmuidemo -// -// Created by Li Kai on 2018/11/3. -// Copyright © 2018 QMUI Team. All rights reserved. -// - - - -#import "LookinAppInfo.h" -#import "LKS_MultiplatformAdapter.h" - -static NSString * const CodingKey_AppIcon = @"1"; -static NSString * const CodingKey_Screenshot = @"2"; -static NSString * const CodingKey_DeviceDescription = @"3"; -static NSString * const CodingKey_OsDescription = @"4"; -static NSString * const CodingKey_AppName = @"5"; -static NSString * const CodingKey_ScreenWidth = @"6"; -static NSString * const CodingKey_ScreenHeight = @"7"; -static NSString * const CodingKey_DeviceType = @"8"; - -@implementation LookinAppInfo - -- (id)copyWithZone:(NSZone *)zone { - LookinAppInfo *newAppInfo = [[LookinAppInfo allocWithZone:zone] init]; - newAppInfo.appIcon = self.appIcon; - newAppInfo.appName = self.appName; - newAppInfo.deviceDescription = self.deviceDescription; - newAppInfo.osDescription = self.osDescription; - newAppInfo.osMainVersion = self.osMainVersion; - newAppInfo.deviceType = self.deviceType; - newAppInfo.screenWidth = self.screenWidth; - newAppInfo.screenHeight = self.screenHeight; - newAppInfo.screenScale = self.screenScale; - newAppInfo.appInfoIdentifier = self.appInfoIdentifier; - return newAppInfo; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - - self.serverVersion = [aDecoder decodeIntForKey:@"serverVersion"]; - self.serverReadableVersion = [aDecoder decodeObjectForKey:@"serverReadableVersion"]; - self.swiftEnabledInLookinServer = [aDecoder decodeIntForKey:@"swiftEnabledInLookinServer"]; - NSData *screenshotData = [aDecoder decodeObjectForKey:CodingKey_Screenshot]; - self.screenshot = [[LookinImage alloc] initWithData:screenshotData]; - - NSData *appIconData = [aDecoder decodeObjectForKey:CodingKey_AppIcon]; - self.appIcon = [[LookinImage alloc] initWithData:appIconData]; - - self.appName = [aDecoder decodeObjectForKey:CodingKey_AppName]; - self.appBundleIdentifier = [aDecoder decodeObjectForKey:@"appBundleIdentifier"]; - self.deviceDescription = [aDecoder decodeObjectForKey:CodingKey_DeviceDescription]; - self.osDescription = [aDecoder decodeObjectForKey:CodingKey_OsDescription]; - self.osMainVersion = [aDecoder decodeIntegerForKey:@"osMainVersion"]; - self.deviceType = [aDecoder decodeIntegerForKey:CodingKey_DeviceType]; - self.screenWidth = [aDecoder decodeDoubleForKey:CodingKey_ScreenWidth]; - self.screenHeight = [aDecoder decodeDoubleForKey:CodingKey_ScreenHeight]; - self.screenScale = [aDecoder decodeDoubleForKey:@"screenScale"]; - self.appInfoIdentifier = [aDecoder decodeIntegerForKey:@"appInfoIdentifier"]; - self.shouldUseCache = [aDecoder decodeBoolForKey:@"shouldUseCache"]; - } - return self; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInt:self.serverVersion forKey:@"serverVersion"]; - [aCoder encodeObject:self.serverReadableVersion forKey:@"serverReadableVersion"]; - [aCoder encodeInt:self.swiftEnabledInLookinServer forKey:@"swiftEnabledInLookinServer"]; - -#if TARGET_OS_IPHONE - NSData *screenshotData = UIImagePNGRepresentation(self.screenshot); - [aCoder encodeObject:screenshotData forKey:CodingKey_Screenshot]; - - NSData *appIconData = UIImagePNGRepresentation(self.appIcon); - [aCoder encodeObject:appIconData forKey:CodingKey_AppIcon]; -#elif TARGET_OS_OSX - NSData *screenshotData = [self.screenshot TIFFRepresentation]; - [aCoder encodeObject:screenshotData forKey:CodingKey_Screenshot]; - - NSData *appIconData = [self.appIcon TIFFRepresentation]; - [aCoder encodeObject:appIconData forKey:CodingKey_AppIcon]; -#endif - - [aCoder encodeObject:self.appName forKey:CodingKey_AppName]; - [aCoder encodeObject:self.appBundleIdentifier forKey:@"appBundleIdentifier"]; - [aCoder encodeObject:self.deviceDescription forKey:CodingKey_DeviceDescription]; - [aCoder encodeObject:self.osDescription forKey:CodingKey_OsDescription]; - [aCoder encodeInteger:self.osMainVersion forKey:@"osMainVersion"]; - [aCoder encodeInteger:self.deviceType forKey:CodingKey_DeviceType]; - [aCoder encodeDouble:self.screenWidth forKey:CodingKey_ScreenWidth]; - [aCoder encodeDouble:self.screenHeight forKey:CodingKey_ScreenHeight]; - [aCoder encodeDouble:self.screenScale forKey:@"screenScale"]; - [aCoder encodeInteger:self.appInfoIdentifier forKey:@"appInfoIdentifier"]; - [aCoder encodeBool:self.shouldUseCache forKey:@"shouldUseCache"]; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinAppInfo class]]) { - return NO; - } - if ([self isEqualToAppInfo:object]) { - return YES; - } - return NO; -} - -- (NSUInteger)hash { - return self.appName.hash ^ self.deviceDescription.hash ^ self.osDescription.hash ^ self.deviceType; -} - -- (BOOL)isEqualToAppInfo:(LookinAppInfo *)info { - if (!info) { - return NO; - } - if ([self.appName isEqualToString:info.appName] && [self.deviceDescription isEqualToString:info.deviceDescription] && [self.osDescription isEqualToString:info.osDescription] && self.deviceType == info.deviceType) { - return YES; - } - return NO; -} - -+ (LookinAppInfo *)currentInfoWithScreenshot:(BOOL)hasScreenshot icon:(BOOL)hasIcon localIdentifiers:(NSArray *)localIdentifiers { - NSInteger selfIdentifier = [self getAppInfoIdentifier]; - if ([localIdentifiers containsObject:@(selfIdentifier)]) { - LookinAppInfo *info = [LookinAppInfo new]; - info.appInfoIdentifier = selfIdentifier; - info.shouldUseCache = YES; - return info; - } - - LookinAppInfo *info = [[LookinAppInfo alloc] init]; - info.serverReadableVersion = LOOKIN_SERVER_READABLE_VERSION; -#ifdef LOOKIN_SERVER_SWIFT_ENABLED - info.swiftEnabledInLookinServer = 1; -#else - info.swiftEnabledInLookinServer = -1; -#endif - info.appInfoIdentifier = selfIdentifier; - info.appName = [self appName]; -#if TARGET_OS_IPHONE - info.deviceDescription = [UIDevice currentDevice].name; -#elif TARGET_OS_OSX - info.deviceDescription = [LKS_MultiplatformAdapter deviceDescription]; -#endif - info.appBundleIdentifier = [[NSBundle mainBundle] bundleIdentifier]; - if ([self isSimulator]) { - info.deviceType = LookinAppInfoDeviceSimulator; - } else if ([LKS_MultiplatformAdapter isiPad]) { - info.deviceType = LookinAppInfoDeviceIPad; -#if TARGET_OS_OSX - } else { - info.deviceType = LookinAppInfoDeviceMac; -#else - } else { - info.deviceType = LookinAppInfoDeviceOthers; -#endif - } - -#if TARGET_OS_IPHONE - info.osDescription = [UIDevice currentDevice].systemVersion; - NSString *mainVersionStr = [[[UIDevice currentDevice] systemVersion] componentsSeparatedByString:@"."].firstObject; - info.osMainVersion = [mainVersionStr integerValue]; -#elif TARGET_OS_OSX - info.osDescription = [LKS_MultiplatformAdapter operatingSystemDescription]; - info.osMainVersion = [LKS_MultiplatformAdapter operatingSystemMainVersion]; -#endif - - CGSize screenSize = [LKS_MultiplatformAdapter mainScreenBounds].size; - info.screenWidth = screenSize.width; - info.screenHeight = screenSize.height; - info.screenScale = [LKS_MultiplatformAdapter mainScreenScale]; - - if (hasScreenshot) { - info.screenshot = [self screenshotImage]; - } - if (hasIcon) { - info.appIcon = [self appIcon]; - } - - return info; -} - -+ (NSString *)appName { - NSDictionary *info = [[NSBundle mainBundle] infoDictionary]; - NSString *displayName = [info objectForKey:@"CFBundleDisplayName"]; - NSString *name = [info objectForKey:@"CFBundleName"]; - return displayName.length ? displayName : name; -} - -+ (LookinImage *)appIcon { -#if TARGET_OS_TV - return nil; -#elif TARGET_OS_IPHONE - NSString *imageName; - id CFBundleIcons = [[[NSBundle mainBundle] infoDictionary] objectForKey:@"CFBundleIcons"]; - if ([CFBundleIcons respondsToSelector:@selector(objectForKey:)]) { - id CFBundlePrimaryIcon = [CFBundleIcons objectForKey:@"CFBundlePrimaryIcon"]; - if ([CFBundlePrimaryIcon respondsToSelector:@selector(objectForKey:)]) { - imageName = [[CFBundlePrimaryIcon objectForKey:@"CFBundleIconFiles"] lastObject]; - } else if ([CFBundlePrimaryIcon isKindOfClass:NSString.class]) { - imageName = CFBundlePrimaryIcon; - } - } - if (!imageName.length) { - // 正常情况下拿到的 name 可能比如 "AppIcon60x60"。但某些情况可能为 nil,此时直接 return 否则 [UIImage imageNamed:nil] 可能导致 console 报 "CUICatalog: Invalid asset name supplied: '(null)'" 的错误信息 - return nil; - } - return [UIImage imageNamed:imageName]; -#elif TARGET_OS_OSX - return [[NSApplication sharedApplication] applicationIconImage]; -#endif -} - -+ (LookinImage *)screenshotImage { - LookinWindow *window = [LKS_MultiplatformAdapter keyWindow]; -#if TARGET_OS_OSX - // macOS 上当应用失去焦点或处于后台时 keyWindow 为 nil。 - // 回退到第一个可见窗口(优先 mainWindow,其次任意已排序的窗口)。 - if (!window) { - window = [NSApplication sharedApplication].mainWindow; - } - if (!window) { - window = [NSApplication sharedApplication].windows.firstObject; - } -#endif - if (!window) { - return nil; - } -#if TARGET_OS_IPHONE - CGSize size = window.bounds.size; - if (size.width <= 0 || size.height <= 0) { - return nil; - } - UIGraphicsBeginImageContextWithOptions(size, YES, 0.4); - [window drawViewHierarchyInRect:window.bounds afterScreenUpdates:YES]; - UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); - UIGraphicsEndImageContext(); -#elif TARGET_OS_OSX - CGImageRef cgImage = CGWindowListCreateImage(CGRectZero, kCGWindowListOptionIncludingWindow, (int)window.windowNumber, kCGWindowImageBoundsIgnoreFraming); - if (!cgImage) { - return nil; - } - NSImage *image = [[NSImage alloc] initWithCGImage:cgImage size:window.frame.size]; - CGImageRelease(cgImage); -#endif - return image; -} - -+ (BOOL)isSimulator { - if (TARGET_OS_SIMULATOR) { - return YES; - } - return NO; -} - -+ (NSInteger)getAppInfoIdentifier { - static dispatch_once_t onceToken; - static NSInteger identifier = 0; - dispatch_once(&onceToken,^{ - identifier = [[NSDate date] timeIntervalSince1970]; - }); - return identifier; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttrIdentifiers.h b/Sources/LookinServer/Shared/LookinAttrIdentifiers.h deleted file mode 100644 index c1b4d246..00000000 --- a/Sources/LookinServer/Shared/LookinAttrIdentifiers.h +++ /dev/null @@ -1,635 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttrIdentifiers.h -// Lookin -// -// Created by Li Kai on 2019/9/18. -// https://lookin.work -// - - - -#import - -#pragma mark - Group - -typedef NSString * LookinAttrGroupIdentifier; - -extern LookinAttrGroupIdentifier const LookinAttrGroup_None; -extern LookinAttrGroupIdentifier const LookinAttrGroup_Class; -extern LookinAttrGroupIdentifier const LookinAttrGroup_Relation; -extern LookinAttrGroupIdentifier const LookinAttrGroup_Layout; -extern LookinAttrGroupIdentifier const LookinAttrGroup_AutoLayout; -extern LookinAttrGroupIdentifier const LookinAttrGroup_ViewLayer; - -extern LookinAttrGroupIdentifier const LookinAttrGroup_UIImageView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UILabel; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UIControl; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UIButton; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UIScrollView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UITableView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UITextView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UITextField; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UIVisualEffectView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_UIStackView; - -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSImageView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSControl; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSButton; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSScrollView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSTableView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSTextView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSTextField; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSVisualEffectView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSStackView; -extern LookinAttrGroupIdentifier const LookinAttrGroup_NSWindow; - -// UITraitCollection -extern LookinAttrGroupIdentifier const LookinAttrGroup_UITraitCollection; - -extern LookinAttrGroupIdentifier const LookinAttrGroup_UserCustom; - -#pragma mark - Section - -typedef NSString * LookinAttrSectionIdentifier; - -extern LookinAttrSectionIdentifier const LookinAttrSec_None; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UserCustom; - -extern LookinAttrSectionIdentifier const LookinAttrSec_Class_Class; - -extern LookinAttrSectionIdentifier const LookinAttrSec_Relation_Relation; - -extern LookinAttrSectionIdentifier const LookinAttrSec_Layout_Frame; -extern LookinAttrSectionIdentifier const LookinAttrSec_Layout_Bounds; -extern LookinAttrSectionIdentifier const LookinAttrSec_Layout_SafeArea; -extern LookinAttrSectionIdentifier const LookinAttrSec_Layout_Position; -extern LookinAttrSectionIdentifier const LookinAttrSec_Layout_AnchorPoint; - -extern LookinAttrSectionIdentifier const LookinAttrSec_AutoLayout_Hugging; -extern LookinAttrSectionIdentifier const LookinAttrSec_AutoLayout_Resistance; -extern LookinAttrSectionIdentifier const LookinAttrSec_AutoLayout_Constraints; -extern LookinAttrSectionIdentifier const LookinAttrSec_AutoLayout_IntrinsicSize; - -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Visibility; -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_InterationAndMasks; -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Corner; -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_BgColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Border; -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Shadow; -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Tag; - -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_ContentMode; -extern LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_TintColor; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UIImageView_Name; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIImageView_Open; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UILabel_Text; -extern LookinAttrSectionIdentifier const LookinAttrSec_UILabel_Font; -extern LookinAttrSectionIdentifier const LookinAttrSec_UILabel_NumberOfLines; -extern LookinAttrSectionIdentifier const LookinAttrSec_UILabel_TextColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_UILabel_BreakMode; -extern LookinAttrSectionIdentifier const LookinAttrSec_UILabel_Alignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_UILabel_CanAdjustFont; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UIControl_EnabledSelected; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIControl_VerAlignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIControl_HorAlignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIControl_QMUIOutsideEdge; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UIButton_ContentInsets; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIButton_TitleInsets; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIButton_ImageInsets; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ContentInset; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_AdjustedInset; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_IndicatorInset; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_Offset; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ContentSize; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_Behavior; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ShowsIndicator; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_Bounce; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ScrollPaging; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ContentTouches; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_Zoom; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_QMUIInitialInset; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UITableView_Style; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITableView_SectionsNumber; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITableView_RowsNumber; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITableView_SeparatorStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITableView_SeparatorColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITableView_SeparatorInset; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextView_Basic; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextView_Text; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextView_Font; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextView_TextColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextView_Alignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextView_ContainerInset; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Text; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Placeholder; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Font; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextField_TextColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Alignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Clears; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextField_CanAdjustFont; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITextField_ClearButtonMode; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UIVisualEffectView_Style; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIVisualEffectView_QMUIForegroundColor; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UIStackView_Axis; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIStackView_Distribution; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIStackView_Alignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIStackView_Spacing; - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_Name; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_Open; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_Scaling; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_Behavior; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_ContentTintColor; - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSControl_State; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSControl_ControlSize; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSControl_Font; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSControl_Alignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSControl_Misc; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSControl_Value; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSControl_StringValue; - - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSButton_ButtonType; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSButton_Title; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSButton_BezelStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSButton_Bordered; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSButton_Transparent; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSButton_BezelColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSButton_ContentTintColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSButton_Misc; - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_ContentOffset; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_ContentSize; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_ContentInset; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_BorderType; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_Scroller; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_Ruler; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_LineScroll; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_PageScroll; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_ScrollElasiticity; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_Misc; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_Magnification; - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_RowHeight; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AutomaticRowHeights; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_IntercellSpacing; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_Style; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_ColumnAutoresizingStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_GridStyleMask; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_SelectionHighlightStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_GridColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_RowSizeStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_NumberOfRows; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_NumberOfColumns; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsColumnReordering; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsColumnResizing; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsMultipleSelection; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsEmptySelection; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsColumnSelection; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsTypeSelect; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_Autosave; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_FloatsGroupRows; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_RowActionsVisible; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_UsesStaticContents; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_UserInterfaceLayoutDirection; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag; - - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Font; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Basic; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_String; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_TextColor; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Alignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_ContainerInset; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_BaseWritingDirection; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Size; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Resizable; - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Bordered; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Bezeled; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_BezelStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Editable; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Selectable; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_DrawsBackground; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_PreferredMaxLayoutWidth; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_MaximumNumberOfLines; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_AllowsDefaultTighteningForTruncation; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_LineBreakStrategy; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Placeholder; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_TextColor; - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_Material; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_BlendingMode; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_State; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_Emphasized; - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_Orientation; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_EdgeInsets; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_DetachesHiddenViews; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_Distribution; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_Alignment; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_Spacing; - -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Title; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Subtitle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_State; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Style; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_CollectionBehavior; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Appearance; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_TitleVisibility; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_ToolbarStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_TitlebarSeparatorStyle; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Behavior; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_AnimationBehavior; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Level; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_TabbingMode; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Size; -extern LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Info; - - -#pragma mark - Attr - -typedef NSString * LookinAttrIdentifier; - -extern LookinAttrIdentifier const LookinAttr_None; - -/// 用户自定义的 -extern LookinAttrIdentifier const LookinAttr_UserCustom; - -extern LookinAttrIdentifier const LookinAttr_Class_Class_Class; - -extern LookinAttrIdentifier const LookinAttr_Relation_Relation_Relation; - -extern LookinAttrIdentifier const LookinAttr_Layout_Frame_Frame; -extern LookinAttrIdentifier const LookinAttr_Layout_Bounds_Bounds; -extern LookinAttrIdentifier const LookinAttr_Layout_SafeArea_SafeArea; -extern LookinAttrIdentifier const LookinAttr_Layout_Position_Position; -extern LookinAttrIdentifier const LookinAttr_Layout_AnchorPoint_AnchorPoint; - -extern LookinAttrIdentifier const LookinAttr_AutoLayout_Hugging_Hor; -extern LookinAttrIdentifier const LookinAttr_AutoLayout_Hugging_Ver; -extern LookinAttrIdentifier const LookinAttr_AutoLayout_Resistance_Hor; -extern LookinAttrIdentifier const LookinAttr_AutoLayout_Resistance_Ver; -extern LookinAttrIdentifier const LookinAttr_AutoLayout_Constraints_Constraints; -extern LookinAttrIdentifier const LookinAttr_AutoLayout_IntrinsicSize_Size; - -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Visibility_Hidden; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Visibility_Opacity; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_InterationAndMasks_Interaction; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_InterationAndMasks_MasksToBounds; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Corner_Radius; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_BgColor_BgColor; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Border_Color; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Border_Width; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_Color; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_Opacity; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_Radius; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_OffsetW; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_OffsetH; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_ContentMode_Mode; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_TintColor_Color; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_TintColor_Mode; -extern LookinAttrIdentifier const LookinAttr_ViewLayer_Tag_Tag; - -extern LookinAttrIdentifier const LookinAttr_UIImageView_Name_Name; -extern LookinAttrIdentifier const LookinAttr_UIImageView_Open_Open; - -extern LookinAttrIdentifier const LookinAttr_UILabel_Text_Text; -extern LookinAttrIdentifier const LookinAttr_UILabel_Font_Name; -extern LookinAttrIdentifier const LookinAttr_UILabel_Font_Size; -extern LookinAttrIdentifier const LookinAttr_UILabel_NumberOfLines_NumberOfLines; -extern LookinAttrIdentifier const LookinAttr_UILabel_TextColor_Color; -extern LookinAttrIdentifier const LookinAttr_UILabel_Alignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_UILabel_BreakMode_Mode; -extern LookinAttrIdentifier const LookinAttr_UILabel_CanAdjustFont_CanAdjustFont; - -extern LookinAttrIdentifier const LookinAttr_UIControl_EnabledSelected_Enabled; -extern LookinAttrIdentifier const LookinAttr_UIControl_EnabledSelected_Selected; -extern LookinAttrIdentifier const LookinAttr_UIControl_VerAlignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_UIControl_HorAlignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_UIControl_QMUIOutsideEdge_Edge; - -extern LookinAttrIdentifier const LookinAttr_UIButton_ContentInsets_Insets; -extern LookinAttrIdentifier const LookinAttr_UIButton_TitleInsets_Insets; -extern LookinAttrIdentifier const LookinAttr_UIButton_ImageInsets_Insets; - -extern LookinAttrIdentifier const LookinAttr_UIScrollView_Offset_Offset; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_ContentSize_Size; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_ContentInset_Inset; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_AdjustedInset_Inset; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_Behavior_Behavior; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_IndicatorInset_Inset; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_ScrollPaging_ScrollEnabled; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_ScrollPaging_PagingEnabled; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_Bounce_Ver; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_Bounce_Hor; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_ShowsIndicator_Hor; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_ShowsIndicator_Ver; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_ContentTouches_Delay; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_ContentTouches_CanCancel; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_Zoom_MinScale; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_Zoom_MaxScale; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_Zoom_Scale; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_Zoom_Bounce; -extern LookinAttrIdentifier const LookinAttr_UIScrollView_QMUIInitialInset_Inset; - -extern LookinAttrIdentifier const LookinAttr_UITableView_Style_Style; -extern LookinAttrIdentifier const LookinAttr_UITableView_SectionsNumber_Number; -extern LookinAttrIdentifier const LookinAttr_UITableView_RowsNumber_Number; -extern LookinAttrIdentifier const LookinAttr_UITableView_SeparatorInset_Inset; -extern LookinAttrIdentifier const LookinAttr_UITableView_SeparatorColor_Color; -extern LookinAttrIdentifier const LookinAttr_UITableView_SeparatorStyle_Style; - -extern LookinAttrIdentifier const LookinAttr_UITextView_Font_Name; -extern LookinAttrIdentifier const LookinAttr_UITextView_Font_Size; -extern LookinAttrIdentifier const LookinAttr_UITextView_Basic_Editable; -extern LookinAttrIdentifier const LookinAttr_UITextView_Basic_Selectable; -extern LookinAttrIdentifier const LookinAttr_UITextView_Text_Text; -extern LookinAttrIdentifier const LookinAttr_UITextView_TextColor_Color; -extern LookinAttrIdentifier const LookinAttr_UITextView_Alignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_UITextView_ContainerInset_Inset; - -extern LookinAttrIdentifier const LookinAttr_UITextField_Text_Text; -extern LookinAttrIdentifier const LookinAttr_UITextField_Placeholder_Placeholder; -extern LookinAttrIdentifier const LookinAttr_UITextField_Font_Name; -extern LookinAttrIdentifier const LookinAttr_UITextField_Font_Size; -extern LookinAttrIdentifier const LookinAttr_UITextField_TextColor_Color; -extern LookinAttrIdentifier const LookinAttr_UITextField_Alignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_UITextField_Clears_ClearsOnBeginEditing; -extern LookinAttrIdentifier const LookinAttr_UITextField_Clears_ClearsOnInsertion; -extern LookinAttrIdentifier const LookinAttr_UITextField_CanAdjustFont_CanAdjustFont; -extern LookinAttrIdentifier const LookinAttr_UITextField_CanAdjustFont_MinSize; -extern LookinAttrIdentifier const LookinAttr_UITextField_ClearButtonMode_Mode; - -extern LookinAttrIdentifier const LookinAttr_UIVisualEffectView_Style_Style; -extern LookinAttrIdentifier const LookinAttr_UIVisualEffectView_QMUIForegroundColor_Color; - -extern LookinAttrIdentifier const LookinAttr_UIStackView_Axis_Axis; -extern LookinAttrIdentifier const LookinAttr_UIStackView_Distribution_Distribution; -extern LookinAttrIdentifier const LookinAttr_UIStackView_Alignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_UIStackView_Spacing_Spacing; - -extern LookinAttrIdentifier const LookinAttr_NSImageView_Name_Name; -extern LookinAttrIdentifier const LookinAttr_NSImageView_Open_Open; -extern LookinAttrIdentifier const LookinAttr_NSImageView_Scaling_ImageScaling; -extern LookinAttrIdentifier const LookinAttr_NSImageView_Scaling_ImageAlignment; -extern LookinAttrIdentifier const LookinAttr_NSImageView_Scaling_ImageFrameStyle; -extern LookinAttrIdentifier const LookinAttr_NSImageView_Behavior_Animates; -extern LookinAttrIdentifier const LookinAttr_NSImageView_Behavior_Editable; -extern LookinAttrIdentifier const LookinAttr_NSImageView_ContentTintColor_ContentTintColor; - - -extern LookinAttrIdentifier const LookinAttr_NSControl_State_Enabled; -extern LookinAttrIdentifier const LookinAttr_NSControl_State_Highlighted; -extern LookinAttrIdentifier const LookinAttr_NSControl_State_Continuous; -extern LookinAttrIdentifier const LookinAttr_NSControl_ControlSize_Size; -extern LookinAttrIdentifier const LookinAttr_NSControl_Font_Name; -extern LookinAttrIdentifier const LookinAttr_NSControl_Font_Size; -extern LookinAttrIdentifier const LookinAttr_NSControl_Alignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_NSControl_Misc_WritingDirection; -extern LookinAttrIdentifier const LookinAttr_NSControl_Misc_IgnoresMultiClick; -extern LookinAttrIdentifier const LookinAttr_NSControl_Misc_UsesSingleLineMode; -extern LookinAttrIdentifier const LookinAttr_NSControl_Misc_AllowsExpansionToolTips; -extern LookinAttrIdentifier const LookinAttr_NSControl_Value_StringValue; -extern LookinAttrIdentifier const LookinAttr_NSControl_Value_IntValue; -extern LookinAttrIdentifier const LookinAttr_NSControl_Value_IntegerValue; -extern LookinAttrIdentifier const LookinAttr_NSControl_Value_FloatValue; -extern LookinAttrIdentifier const LookinAttr_NSControl_Value_DoubleValue; -extern LookinAttrIdentifier const LookinAttr_NSButton_ButtonType_ButtonType; -extern LookinAttrIdentifier const LookinAttr_NSButton_Title_Title; -extern LookinAttrIdentifier const LookinAttr_NSButton_Title_AlernateTitle; -extern LookinAttrIdentifier const LookinAttr_NSButton_BezelStyle_BezelStyle; -extern LookinAttrIdentifier const LookinAttr_NSButton_Bordered_Bordered; -extern LookinAttrIdentifier const LookinAttr_NSButton_Transparent_Transparent; -extern LookinAttrIdentifier const LookinAttr_NSButton_BezelColor_BezelColor; -extern LookinAttrIdentifier const LookinAttr_NSButton_ContentTintColor_ContentTintColor; -extern LookinAttrIdentifier const LookinAttr_NSButton_Misc_ShowsBorderOnlyWhileMouseInside; -extern LookinAttrIdentifier const LookinAttr_NSButton_Misc_MaxAcceleratorLevel; -extern LookinAttrIdentifier const LookinAttr_NSButton_Misc_SpringLoaded; -extern LookinAttrIdentifier const LookinAttr_NSButton_Misc_HasDestructiveAction; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_ContentOffset_Offset; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_ContentSize_Size; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_ContentInset_ContentInset; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_BorderType_BorderType; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_Horizontal; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_Vertical; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_AutohidesScrollers; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_ScrollerStyle; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_ScrollerKnobStyle; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_ScrollerInsets; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Ruler_Horizontal; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Ruler_Vertical; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Ruler_Visible; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_LineScroll_Horizontal; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_LineScroll_Vertical; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_LineScroll_LineScroll; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_PageScroll_Horizontal; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_PageScroll_Vertical; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_PageScroll_PageScroll; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_ScrollElasiticity_Horizontal; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_ScrollElasiticity_Vertical; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Misc_ScrollsDynamically; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Misc_UsesPredominantAxisScrolling; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Magnification_AllowsMagnification; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Magnification_Magnification; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Magnification_Max; -extern LookinAttrIdentifier const LookinAttr_NSScrollView_Magnification_Min; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AllowsColumnReordering_AllowsColumnReordering; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AllowsColumnResizing_AllowsColumnResizing; -extern LookinAttrIdentifier const LookinAttr_NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle; -extern LookinAttrIdentifier const LookinAttr_NSTableView_GridStyleMask_GridStyleMask; -extern LookinAttrIdentifier const LookinAttr_NSTableView_IntercellSpacing_IntercellSpacing; -extern LookinAttrIdentifier const LookinAttr_NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors; -extern LookinAttrIdentifier const LookinAttr_NSTableView_GridColor_GridColor; -extern LookinAttrIdentifier const LookinAttr_NSTableView_RowSizeStyle_RowSizeStyle; -extern LookinAttrIdentifier const LookinAttr_NSTableView_RowHeight_RowHeight; -extern LookinAttrIdentifier const LookinAttr_NSTableView_NumberOfRows_NumberOfRows; -extern LookinAttrIdentifier const LookinAttr_NSTableView_NumberOfColumns_NumberOfColumns; -extern LookinAttrIdentifier const LookinAttr_NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AllowsMultipleSelection_AllowsMultipleSelection; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AllowsEmptySelection_AllowsEmptySelection; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AllowsColumnSelection_AllowsColumnSelection; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AllowsTypeSelect_AllowsTypeSelect; -extern LookinAttrIdentifier const LookinAttr_NSTableView_SelectionHighlightStyle_SelectionHighlightStyle; -extern LookinAttrIdentifier const LookinAttr_NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AutomaticRowHeights_AutomaticRowHeights; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AutosaveName_AutosaveName; -extern LookinAttrIdentifier const LookinAttr_NSTableView_AutosaveTableColumns_AutosaveTableColumns; -extern LookinAttrIdentifier const LookinAttr_NSTableView_FloatsGroupRows_FloatsGroupRows; -extern LookinAttrIdentifier const LookinAttr_NSTableView_RowActionsVisible_RowActionsVisible; -extern LookinAttrIdentifier const LookinAttr_NSTableView_UsesStaticContents_UsesStaticContents; -extern LookinAttrIdentifier const LookinAttr_NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection; -extern LookinAttrIdentifier const LookinAttr_NSTableView_Style_Style; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Font_Name; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Font_Size; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Basic_Editable; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Basic_Selectable; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Basic_RichText; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Basic_FieldEditor; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Basic_ImportsGraphics; -extern LookinAttrIdentifier const LookinAttr_NSTextView_String_String; -extern LookinAttrIdentifier const LookinAttr_NSTextView_TextColor_Color; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Alignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_NSTextView_ContainerInset_Inset; -extern LookinAttrIdentifier const LookinAttr_NSTextView_BaseWritingDirection_BaseWritingDirection; -extern LookinAttrIdentifier const LookinAttr_NSTextView_MaxSize_MaxSize; -extern LookinAttrIdentifier const LookinAttr_NSTextView_MinSize_MinSize; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Resizable_Horizontal; -extern LookinAttrIdentifier const LookinAttr_NSTextView_Resizable_Vertical; -extern LookinAttrIdentifier const LookinAttr_NSTextField_Bordered_Bordered; -extern LookinAttrIdentifier const LookinAttr_NSTextField_Bezeled_Bezeled; -extern LookinAttrIdentifier const LookinAttr_NSTextField_Editable_Editable; -extern LookinAttrIdentifier const LookinAttr_NSTextField_Selectable_Selectable; -extern LookinAttrIdentifier const LookinAttr_NSTextField_DrawsBackground_DrawsBackground; -extern LookinAttrIdentifier const LookinAttr_NSTextField_BezelStyle_BezelStyle; -extern LookinAttrIdentifier const LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth; -extern LookinAttrIdentifier const LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines; -extern LookinAttrIdentifier const LookinAttr_NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation; -extern LookinAttrIdentifier const LookinAttr_NSTextField_LineBreakStrategy_LineBreakStrategy; -extern LookinAttrIdentifier const LookinAttr_NSTextField_Placeholder_Placeholder; -extern LookinAttrIdentifier const LookinAttr_NSTextField_TextColor_Color; -extern LookinAttrIdentifier const LookinAttr_NSTextField_BackgroundColor_Color; -extern LookinAttrIdentifier const LookinAttr_NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes; -extern LookinAttrIdentifier const LookinAttr_NSTextField_ImportsGraphics_ImportsGraphics; -extern LookinAttrIdentifier const LookinAttr_NSVisualEffectView_Material_Material; -extern LookinAttrIdentifier const LookinAttr_NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle; -extern LookinAttrIdentifier const LookinAttr_NSVisualEffectView_BlendingMode_BlendingMode; -extern LookinAttrIdentifier const LookinAttr_NSVisualEffectView_State_State; -extern LookinAttrIdentifier const LookinAttr_NSVisualEffectView_Emphasized_Emphasized; -extern LookinAttrIdentifier const LookinAttr_NSStackView_Orientation_Orientation; -extern LookinAttrIdentifier const LookinAttr_NSStackView_EdgeInsets_EdgeInsets; -extern LookinAttrIdentifier const LookinAttr_NSStackView_DetachesHiddenViews_DetachesHiddenViews; -extern LookinAttrIdentifier const LookinAttr_NSStackView_Distribution_Distribution; -extern LookinAttrIdentifier const LookinAttr_NSStackView_Alignment_Alignment; -extern LookinAttrIdentifier const LookinAttr_NSStackView_Spacing_Spacing; - -extern LookinAttrIdentifier const LookinAttr_NSWindow_Title_Title; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Title_Subtitle; -extern LookinAttrIdentifier const LookinAttr_NSWindow_State_KeyWindow; -extern LookinAttrIdentifier const LookinAttr_NSWindow_State_MainWindow; -extern LookinAttrIdentifier const LookinAttr_NSWindow_State_Visible; -extern LookinAttrIdentifier const LookinAttr_NSWindow_State_CanBecomeKeyWindow; -extern LookinAttrIdentifier const LookinAttr_NSWindow_State_CanBecomeMainWindow; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_Titled; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_Closable; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_Miniaturizable; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_Resizable; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_UnifiedTitleAndToolbar; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_FullScreen; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_FullSizeContentView; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_UtilityWindow; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_DocModalWindow; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_NonactivatingPanel; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Style_HUDWindow; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_CanJoinAllSpaces; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_MoveToActiveSpace; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_ParticipatesInCycle; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_IgnoresCycle; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenPrimary; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenAuxiliary; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenNone; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenAllowsTiling; -extern LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenDisallowsTiling; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_TitlebarAppearsTransparent; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_TitleVisibility; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_ToolbarStyle; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_TitlebarSeparatorStyle; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_BackgroundColor; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_AlphaValue; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_Opaque; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_HasShadow; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_Movable; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_MovableByWindowBackground; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_AnimationBehavior; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_Level; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_HidesOnDeactivate; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_TabbingMode; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Size_MinSize; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Size_MaxSize; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Info_WindowNumber; -extern LookinAttrIdentifier const LookinAttr_NSWindow_Info_BackingScaleFactor; - -// UIWindowScene -extern LookinAttrGroupIdentifier const LookinAttrGroup_UIWindowScene; - -extern LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_State; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Title; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Orientation; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Windows; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Screen; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_StatusBar; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Traits; -extern LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Session; - -// UITraitCollection -extern LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Appearance; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_SizeClass; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Display; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Device; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Layout; -extern LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Content; - -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_State_ActivationState; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Title_Title; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Orientation_InterfaceOrientation; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Windows_WindowCount; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Windows_KeyWindowClassName; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Screen_ScreenBounds; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Screen_ScreenScale; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_StatusBar_StatusBarHidden; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_StatusBar_StatusBarStyle; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_StatusBar_StatusBarFrame; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_UserInterfaceStyle; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_HorizontalSizeClass; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_VerticalSizeClass; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_UserInterfaceLevel; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_ActiveAppearance; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_AccessibilityContrast; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_LegibilityWeight; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_DisplayScale; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_DisplayGamut; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_UserInterfaceIdiom; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_LayoutDirection; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_PreferredContentSizeCategory; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_SceneCaptureState; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_ImageDynamicRange; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_TypesettingLanguage; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Session_PersistentIdentifier; -extern LookinAttrIdentifier const LookinAttr_UIWindowScene_Session_SessionRole; - -// UITraitCollection -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_UserInterfaceStyle; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_UserInterfaceLevel; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_ActiveAppearance; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_AccessibilityContrast; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_LegibilityWeight; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_SizeClass_HorizontalSizeClass; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_SizeClass_VerticalSizeClass; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Display_DisplayScale; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Display_DisplayGamut; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Display_ImageDynamicRange; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Device_UserInterfaceIdiom; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Device_ForceTouchCapability; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Layout_LayoutDirection; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Content_PreferredContentSizeCategory; -extern LookinAttrIdentifier const LookinAttr_UITraitCollection_Content_TypesettingLanguage; - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttrIdentifiers.m b/Sources/LookinServer/Shared/LookinAttrIdentifiers.m deleted file mode 100644 index 9e58649a..00000000 --- a/Sources/LookinServer/Shared/LookinAttrIdentifiers.m +++ /dev/null @@ -1,620 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttrIdentifiers.m -// Lookin -// -// Created by Li Kai on 2019/9/18. -// https://lookin.work -// - - - -#import "LookinAttrIdentifiers.h" - -// value 不能重复(AppDelegate 里的 runTests 有相关 test) -// 如果要去掉某一项可以考虑注释掉而非直接删除,以防止新项和旧项的 value 相同而引发 preference 错乱(这些 value 会被存储到 userDefaults 里) - -#pragma mark - Group - -LookinAttrGroupIdentifier const LookinAttrGroup_None = @"n"; -LookinAttrGroupIdentifier const LookinAttrGroup_Class = @"c"; -LookinAttrGroupIdentifier const LookinAttrGroup_Relation = @"r"; -LookinAttrGroupIdentifier const LookinAttrGroup_Layout = @"l"; -LookinAttrGroupIdentifier const LookinAttrGroup_AutoLayout = @"a"; -LookinAttrGroupIdentifier const LookinAttrGroup_ViewLayer = @"vl"; -LookinAttrGroupIdentifier const LookinAttrGroup_UIImageView = @"i"; -LookinAttrGroupIdentifier const LookinAttrGroup_UILabel = @"la"; -LookinAttrGroupIdentifier const LookinAttrGroup_UIControl = @"co"; -LookinAttrGroupIdentifier const LookinAttrGroup_UIButton = @"b"; -LookinAttrGroupIdentifier const LookinAttrGroup_UIScrollView = @"s"; -LookinAttrGroupIdentifier const LookinAttrGroup_UITableView = @"ta"; -LookinAttrGroupIdentifier const LookinAttrGroup_UITextView = @"te"; -LookinAttrGroupIdentifier const LookinAttrGroup_UITextField = @"tf"; -LookinAttrGroupIdentifier const LookinAttrGroup_UIVisualEffectView = @"ve"; -LookinAttrGroupIdentifier const LookinAttrGroup_UIStackView = @"UIStackView"; - - - -LookinAttrGroupIdentifier const LookinAttrGroup_NSImageView = @"NSImageView"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSControl = @"NSControl"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSButton = @"NSButton"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSScrollView = @"NSScrollView"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSTableView = @"NSTableView"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSTextView = @"NSTextView"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSTextField = @"NSTextField"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSVisualEffectView = @"NSVisualEffectView"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSStackView = @"NSStackView"; -LookinAttrGroupIdentifier const LookinAttrGroup_NSWindow = @"NSWindow"; - - -LookinAttrGroupIdentifier const LookinAttrGroup_UserCustom = @"guc"; // 用户自定义 - -#pragma mark - Section - -LookinAttrSectionIdentifier const LookinAttrSec_None = @"n"; - -LookinAttrSectionIdentifier const LookinAttrSec_UserCustom = @"sec_ctm"; - -LookinAttrSectionIdentifier const LookinAttrSec_Class_Class = @"cl_c"; - -LookinAttrSectionIdentifier const LookinAttrSec_Relation_Relation = @"r_r"; - -LookinAttrSectionIdentifier const LookinAttrSec_Layout_Frame = @"l_f"; -LookinAttrSectionIdentifier const LookinAttrSec_Layout_Bounds = @"l_b"; -LookinAttrSectionIdentifier const LookinAttrSec_Layout_SafeArea = @"l_s"; -LookinAttrSectionIdentifier const LookinAttrSec_Layout_Position = @"l_p"; -LookinAttrSectionIdentifier const LookinAttrSec_Layout_AnchorPoint = @"l_a"; - -LookinAttrSectionIdentifier const LookinAttrSec_AutoLayout_Hugging = @"a_h"; -LookinAttrSectionIdentifier const LookinAttrSec_AutoLayout_Resistance = @"a_r"; -LookinAttrSectionIdentifier const LookinAttrSec_AutoLayout_Constraints = @"a_c"; -LookinAttrSectionIdentifier const LookinAttrSec_AutoLayout_IntrinsicSize = @"a_i"; - -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Visibility = @"v_v"; -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_InterationAndMasks = @"v_i"; -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Corner = @"v_c"; -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_BgColor = @"v_b"; -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Border = @"v_bo"; -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Shadow = @"v_s"; -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_ContentMode = @"v_co"; -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_TintColor = @"v_t"; -LookinAttrSectionIdentifier const LookinAttrSec_ViewLayer_Tag = @"v_ta"; - -LookinAttrSectionIdentifier const LookinAttrSec_UIImageView_Name = @"i_n"; -LookinAttrSectionIdentifier const LookinAttrSec_UIImageView_Open = @"i_o"; - -LookinAttrSectionIdentifier const LookinAttrSec_UILabel_Text = @"lb_t"; -LookinAttrSectionIdentifier const LookinAttrSec_UILabel_Font = @"lb_f"; -LookinAttrSectionIdentifier const LookinAttrSec_UILabel_NumberOfLines = @"lb_n"; -LookinAttrSectionIdentifier const LookinAttrSec_UILabel_TextColor = @"lb_tc"; -LookinAttrSectionIdentifier const LookinAttrSec_UILabel_BreakMode = @"lb_b"; -LookinAttrSectionIdentifier const LookinAttrSec_UILabel_Alignment = @"lb_a"; -LookinAttrSectionIdentifier const LookinAttrSec_UILabel_CanAdjustFont = @"lb_c"; - -LookinAttrSectionIdentifier const LookinAttrSec_UIControl_EnabledSelected = @"c_e"; -LookinAttrSectionIdentifier const LookinAttrSec_UIControl_VerAlignment = @"c_v"; -LookinAttrSectionIdentifier const LookinAttrSec_UIControl_HorAlignment = @"c_h"; -LookinAttrSectionIdentifier const LookinAttrSec_UIControl_QMUIOutsideEdge = @"c_o"; - -LookinAttrSectionIdentifier const LookinAttrSec_UIButton_ContentInsets = @"b_c"; -LookinAttrSectionIdentifier const LookinAttrSec_UIButton_TitleInsets = @"b_t"; -LookinAttrSectionIdentifier const LookinAttrSec_UIButton_ImageInsets = @"b_i"; - -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ContentInset = @"s_c"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_AdjustedInset = @"s_a"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_IndicatorInset = @"s_i"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_Offset = @"s_o"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ContentSize = @"s_cs"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_Behavior = @"s_b"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ShowsIndicator = @"s_si"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_Bounce = @"s_bo"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ScrollPaging = @"s_s"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_ContentTouches = @"s_ct"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_Zoom = @"s_z"; -LookinAttrSectionIdentifier const LookinAttrSec_UIScrollView_QMUIInitialInset = @"s_ii"; - -LookinAttrSectionIdentifier const LookinAttrSec_UITableView_Style = @"t_s"; -LookinAttrSectionIdentifier const LookinAttrSec_UITableView_SectionsNumber = @"t_sn"; -LookinAttrSectionIdentifier const LookinAttrSec_UITableView_RowsNumber = @"t_r"; -LookinAttrSectionIdentifier const LookinAttrSec_UITableView_SeparatorStyle = @"t_ss"; -LookinAttrSectionIdentifier const LookinAttrSec_UITableView_SeparatorColor = @"t_sc"; -LookinAttrSectionIdentifier const LookinAttrSec_UITableView_SeparatorInset = @"t_si"; - -LookinAttrSectionIdentifier const LookinAttrSec_UITextView_Basic = @"tv_b"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextView_Text = @"tv_t"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextView_Font = @"tv_f"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextView_TextColor = @"tv_tc"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextView_Alignment = @"tv_a"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextView_ContainerInset = @"tv_c"; - -LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Text = @"tf_t"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Placeholder = @"tf_p"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Font = @"tf_f"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextField_TextColor = @"tf_tc"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Alignment = @"tf_a"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextField_Clears = @"tf_c"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextField_CanAdjustFont = @"tf_ca"; -LookinAttrSectionIdentifier const LookinAttrSec_UITextField_ClearButtonMode = @"tf_cb"; - -LookinAttrSectionIdentifier const LookinAttrSec_UIVisualEffectView_Style = @"ve_s"; -LookinAttrSectionIdentifier const LookinAttrSec_UIVisualEffectView_QMUIForegroundColor = @"ve_f"; - -LookinAttrSectionIdentifier const LookinAttrSec_UIStackView_Axis = @"usv_axis"; -LookinAttrSectionIdentifier const LookinAttrSec_UIStackView_Distribution = @"usv_dis"; -LookinAttrSectionIdentifier const LookinAttrSec_UIStackView_Alignment = @"usv_align"; -LookinAttrSectionIdentifier const LookinAttrSec_UIStackView_Spacing = @"usv_spa"; - -LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_Name = @"NSImageView_Name"; -LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_Open = @"NSImageView_Open"; -LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_Scaling = @"NSImageView_Scaling"; -LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_Behavior = @"NSImageView_Behavior"; -LookinAttrSectionIdentifier const LookinAttrSec_NSImageView_ContentTintColor = @"NSImageView_ContentTintColor"; -LookinAttrSectionIdentifier const LookinAttrSec_NSControl_State = @"NSControl_State"; -LookinAttrSectionIdentifier const LookinAttrSec_NSControl_ControlSize = @"NSControl_ControlSize"; -LookinAttrSectionIdentifier const LookinAttrSec_NSControl_Font = @"NSControl_Font"; -LookinAttrSectionIdentifier const LookinAttrSec_NSControl_Alignment = @"NSControl_Alignment"; -LookinAttrSectionIdentifier const LookinAttrSec_NSControl_Misc = @"NSControl_Misc"; -LookinAttrSectionIdentifier const LookinAttrSec_NSControl_Value = @"NSControl_Value"; -LookinAttrSectionIdentifier const LookinAttrSec_NSControl_StringValue = @"NSControl_StringValue"; -LookinAttrSectionIdentifier const LookinAttrSec_NSButton_ButtonType = @"NSButton_ButtonType"; -LookinAttrSectionIdentifier const LookinAttrSec_NSButton_Title = @"NSButton_Title"; -LookinAttrSectionIdentifier const LookinAttrSec_NSButton_BezelStyle = @"NSButton_BezelStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSButton_Bordered = @"NSButton_Bordered"; -LookinAttrSectionIdentifier const LookinAttrSec_NSButton_Transparent = @"NSButton_Transparent"; -LookinAttrSectionIdentifier const LookinAttrSec_NSButton_BezelColor = @"NSButton_BezelColor"; -LookinAttrSectionIdentifier const LookinAttrSec_NSButton_ContentTintColor = @"NSButton_ContentTintColor"; -LookinAttrSectionIdentifier const LookinAttrSec_NSButton_Misc = @"NSButton_Misc"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_ContentOffset = @"NSScrollView_ContentOffset"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_ContentSize = @"NSScrollView_ContentSize"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_ContentInset = @"NSScrollView_ContentInset"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_BorderType = @"NSScrollView_BorderType"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_Scroller = @"NSScrollView_Scroller"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_Ruler = @"NSScrollView_Ruler"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_LineScroll = @"NSScrollView_LineScroll"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_PageScroll = @"NSScrollView_PageScroll"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_ScrollElasiticity = @"NSScrollView_ScrollElasiticity"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_Misc = @"NSScrollView_Misc"; -LookinAttrSectionIdentifier const LookinAttrSec_NSScrollView_Magnification = @"NSScrollView_Magnification"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_RowHeight = @"NSTableView_RowHeight"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AutomaticRowHeights = @"NSTableView_AutomaticRowHeights"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_IntercellSpacing = @"NSTableView_IntercellSpacing"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_Style = @"NSTableView_Style"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_ColumnAutoresizingStyle = @"NSTableView_ColumnAutoresizingStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_GridStyleMask = @"NSTableView_GridStyleMask"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_SelectionHighlightStyle = @"NSTableView_SelectionHighlightStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_GridColor = @"NSTableView_GridColor"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_RowSizeStyle = @"NSTableView_RowSizeStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_NumberOfRows = @"NSTableView_NumberOfRows"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_NumberOfColumns = @"NSTableView_NumberOfColumns"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors = @"NSTableView_UseAlternatingRowBackgroundColors"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsColumnReordering = @"NSTableView_AllowsColumnReordering"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsColumnResizing = @"NSTableView_AllowsColumnResizing"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsMultipleSelection = @"NSTableView_AllowsMultipleSelection"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsEmptySelection = @"NSTableView_AllowsEmptySelection"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsColumnSelection = @"NSTableView_AllowsColumnSelection"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_AllowsTypeSelect = @"NSTableView_AllowsTypeSelect"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle = @"NSTableView_DraggingDestinationFeedbackStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_Autosave = @"NSTableView_Autosave"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_FloatsGroupRows = @"NSTableView_FloatsGroupRows"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_RowActionsVisible = @"NSTableView_RowActionsVisible"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_UsesStaticContents = @"NSTableView_UsesStaticContents"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_UserInterfaceLayoutDirection = @"NSTableView_UserInterfaceLayoutDirection"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag = @"NSTableView_VerticalMotionCanBeginDrag"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Font = @"NSTextView_Font"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Basic = @"NSTextView_Basic"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_String = @"NSTextView_String"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_TextColor = @"NSTextView_TextColor"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Alignment = @"NSTextView_Alignment"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_ContainerInset = @"NSTextView_ContainerInset"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_BaseWritingDirection = @"NSTextView_BaseWritingDirection"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Size = @"NSTextView_Size"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextView_Resizable = @"NSTextView_Resizable"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Bordered = @"NSTextField_Bordered"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Bezeled = @"NSTextField_Bezeled"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_BezelStyle = @"NSTextField_BezelStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Editable = @"NSTextField_Editable"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Selectable = @"NSTextField_Selectable"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_DrawsBackground = @"NSTextField_DrawsBackground"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_PreferredMaxLayoutWidth = @"NSTextField_PreferredMaxLayoutWidth"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_MaximumNumberOfLines = @"NSTextField_MaximumNumberOfLines"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_AllowsDefaultTighteningForTruncation = @"NSTextField_AllowsDefaultTighteningForTruncation"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_LineBreakStrategy = @"NSTextField_LineBreakStrategy"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_Placeholder = @"NSTextField_Placeholder"; -LookinAttrSectionIdentifier const LookinAttrSec_NSTextField_TextColor = @"NSTextField_TextColor"; -LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_Material = @"NSVisualEffectView_Material"; -LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle = @"NSVisualEffectView_InteriorBackgroundStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_BlendingMode = @"NSVisualEffectView_BlendingMode"; -LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_State = @"NSVisualEffectView_State"; -LookinAttrSectionIdentifier const LookinAttrSec_NSVisualEffectView_Emphasized = @"NSVisualEffectView_Emphasized"; -LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_Orientation = @"NSStackView_Orientation"; -LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_EdgeInsets = @"NSStackView_EdgeInsets"; -LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_DetachesHiddenViews = @"NSStackView_DetachesHiddenViews"; -LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_Distribution = @"NSStackView_Distribution"; -LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_Alignment = @"NSStackView_Alignment"; -LookinAttrSectionIdentifier const LookinAttrSec_NSStackView_Spacing = @"NSStackView_Spacing"; - -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Title = @"NSWindow_Title"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Subtitle = @"NSWindow_Subtitle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_State = @"NSWindow_State"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Style = @"NSWindow_Style"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_CollectionBehavior = @"NSWindow_CollectionBehavior"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Appearance = @"NSWindow_Appearance"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_TitleVisibility = @"NSWindow_TitleVisibility"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_ToolbarStyle = @"NSWindow_ToolbarStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_TitlebarSeparatorStyle = @"NSWindow_TitlebarSeparatorStyle"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Behavior = @"NSWindow_Behavior"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_AnimationBehavior = @"NSWindow_AnimationBehavior"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Level = @"NSWindow_Level"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_TabbingMode = @"NSWindow_TabbingMode"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Size = @"NSWindow_Size"; -LookinAttrSectionIdentifier const LookinAttrSec_NSWindow_Info = @"NSWindow_Info"; - -#pragma mark - Attr - -LookinAttrIdentifier const LookinAttr_None = @"n"; -LookinAttrIdentifier const LookinAttr_UserCustom = @"ctm"; - -LookinAttrIdentifier const LookinAttr_Class_Class_Class = @"c_c_c"; - - -LookinAttrIdentifier const LookinAttr_Relation_Relation_Relation = @"r_r_r"; - -LookinAttrIdentifier const LookinAttr_Layout_Frame_Frame = @"l_f_f"; -LookinAttrIdentifier const LookinAttr_Layout_Bounds_Bounds = @"l_b_b"; -LookinAttrIdentifier const LookinAttr_Layout_SafeArea_SafeArea = @"l_s_s"; -LookinAttrIdentifier const LookinAttr_Layout_Position_Position = @"l_p_p"; -LookinAttrIdentifier const LookinAttr_Layout_AnchorPoint_AnchorPoint = @"l_a_a"; - -LookinAttrIdentifier const LookinAttr_AutoLayout_Hugging_Hor = @"al_h_h"; -LookinAttrIdentifier const LookinAttr_AutoLayout_Hugging_Ver = @"al_h_v"; -LookinAttrIdentifier const LookinAttr_AutoLayout_Resistance_Hor = @"al_r_h"; -LookinAttrIdentifier const LookinAttr_AutoLayout_Resistance_Ver = @"al_r_v"; -LookinAttrIdentifier const LookinAttr_AutoLayout_Constraints_Constraints = @"al_c_c"; -LookinAttrIdentifier const LookinAttr_AutoLayout_IntrinsicSize_Size = @"cl_i_s"; - -LookinAttrIdentifier const LookinAttr_ViewLayer_Visibility_Hidden = @"vl_v_h"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Visibility_Opacity = @"vl_v_o"; -LookinAttrIdentifier const LookinAttr_ViewLayer_InterationAndMasks_Interaction = @"vl_i_i"; -LookinAttrIdentifier const LookinAttr_ViewLayer_InterationAndMasks_MasksToBounds = @"vl_i_m"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Corner_Radius = @"vl_c_r"; -LookinAttrIdentifier const LookinAttr_ViewLayer_BgColor_BgColor = @"vl_b_b"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Border_Color = @"vl_b_c"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Border_Width = @"vl_b_w"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_Color = @"vl_s_c"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_Opacity = @"vl_s_o"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_Radius = @"vl_s_r"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_OffsetW = @"vl_s_ow"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Shadow_OffsetH = @"vl_s_oh"; -LookinAttrIdentifier const LookinAttr_ViewLayer_ContentMode_Mode = @"vl_c_m"; -LookinAttrIdentifier const LookinAttr_ViewLayer_TintColor_Color = @"vl_t_c"; -LookinAttrIdentifier const LookinAttr_ViewLayer_TintColor_Mode = @"vl_t_m"; -LookinAttrIdentifier const LookinAttr_ViewLayer_Tag_Tag = @"vl_t_t"; - -LookinAttrIdentifier const LookinAttr_UIImageView_Name_Name = @"iv_n_n"; -LookinAttrIdentifier const LookinAttr_UIImageView_Open_Open = @"iv_o_o"; - -LookinAttrIdentifier const LookinAttr_UILabel_Text_Text = @"lb_t_t"; -LookinAttrIdentifier const LookinAttr_UILabel_Font_Name = @"lb_f_n"; -LookinAttrIdentifier const LookinAttr_UILabel_Font_Size = @"lb_f_s"; -LookinAttrIdentifier const LookinAttr_UILabel_NumberOfLines_NumberOfLines = @"lb_n_n"; -LookinAttrIdentifier const LookinAttr_UILabel_TextColor_Color = @"lb_t_c"; -LookinAttrIdentifier const LookinAttr_UILabel_Alignment_Alignment = @"lb_a_a"; -LookinAttrIdentifier const LookinAttr_UILabel_BreakMode_Mode = @"lb_b_m"; -LookinAttrIdentifier const LookinAttr_UILabel_CanAdjustFont_CanAdjustFont = @"lb_c_c"; - -LookinAttrIdentifier const LookinAttr_UIControl_EnabledSelected_Enabled = @"ct_e_e"; -LookinAttrIdentifier const LookinAttr_UIControl_EnabledSelected_Selected = @"ct_e_s"; -LookinAttrIdentifier const LookinAttr_UIControl_VerAlignment_Alignment = @"ct_v_a"; -LookinAttrIdentifier const LookinAttr_UIControl_HorAlignment_Alignment = @"ct_h_a"; -LookinAttrIdentifier const LookinAttr_UIControl_QMUIOutsideEdge_Edge = @"ct_o_e"; - -LookinAttrIdentifier const LookinAttr_UIButton_ContentInsets_Insets = @"bt_c_i"; -LookinAttrIdentifier const LookinAttr_UIButton_TitleInsets_Insets = @"bt_t_i"; -LookinAttrIdentifier const LookinAttr_UIButton_ImageInsets_Insets = @"bt_i_i"; - -LookinAttrIdentifier const LookinAttr_UIScrollView_Offset_Offset = @"sv_o_o"; -LookinAttrIdentifier const LookinAttr_UIScrollView_ContentSize_Size = @"sv_c_s"; -LookinAttrIdentifier const LookinAttr_UIScrollView_ContentInset_Inset = @"sv_c_i"; -LookinAttrIdentifier const LookinAttr_UIScrollView_AdjustedInset_Inset = @"sv_a_i"; -LookinAttrIdentifier const LookinAttr_UIScrollView_Behavior_Behavior = @"sv_b_b"; -LookinAttrIdentifier const LookinAttr_UIScrollView_IndicatorInset_Inset = @"sv_i_i"; -LookinAttrIdentifier const LookinAttr_UIScrollView_ScrollPaging_ScrollEnabled = @"sv_s_s"; -LookinAttrIdentifier const LookinAttr_UIScrollView_ScrollPaging_PagingEnabled = @"sv_s_p"; -LookinAttrIdentifier const LookinAttr_UIScrollView_Bounce_Ver = @"sv_b_v"; -LookinAttrIdentifier const LookinAttr_UIScrollView_Bounce_Hor = @"sv_b_h"; -LookinAttrIdentifier const LookinAttr_UIScrollView_ShowsIndicator_Hor = @"sv_h_h"; -LookinAttrIdentifier const LookinAttr_UIScrollView_ShowsIndicator_Ver = @"sv_s_v"; -LookinAttrIdentifier const LookinAttr_UIScrollView_ContentTouches_Delay = @"sv_c_d"; -LookinAttrIdentifier const LookinAttr_UIScrollView_ContentTouches_CanCancel = @"sv_c_c"; -LookinAttrIdentifier const LookinAttr_UIScrollView_Zoom_MinScale = @"sv_z_mi"; -LookinAttrIdentifier const LookinAttr_UIScrollView_Zoom_MaxScale = @"sv_z_ma"; -LookinAttrIdentifier const LookinAttr_UIScrollView_Zoom_Scale = @"sv_z_s"; -LookinAttrIdentifier const LookinAttr_UIScrollView_Zoom_Bounce = @"sv_z_b"; -LookinAttrIdentifier const LookinAttr_UIScrollView_QMUIInitialInset_Inset = @"sv_qi_i"; - -LookinAttrIdentifier const LookinAttr_UITableView_Style_Style = @"tv_s_s"; -LookinAttrIdentifier const LookinAttr_UITableView_SectionsNumber_Number = @"tv_s_n"; -LookinAttrIdentifier const LookinAttr_UITableView_RowsNumber_Number = @"tv_r_n"; -LookinAttrIdentifier const LookinAttr_UITableView_SeparatorInset_Inset = @"tv_s_i"; -LookinAttrIdentifier const LookinAttr_UITableView_SeparatorColor_Color = @"tv_s_c"; -LookinAttrIdentifier const LookinAttr_UITableView_SeparatorStyle_Style = @"tv_ss_s"; - -LookinAttrIdentifier const LookinAttr_UITextView_Font_Name = @"te_f_n"; -LookinAttrIdentifier const LookinAttr_UITextView_Font_Size = @"te_f_s"; -LookinAttrIdentifier const LookinAttr_UITextView_Basic_Editable = @"te_b_e"; -LookinAttrIdentifier const LookinAttr_UITextView_Basic_Selectable = @"te_b_s"; -LookinAttrIdentifier const LookinAttr_UITextView_Text_Text = @"te_t_t"; -LookinAttrIdentifier const LookinAttr_UITextView_TextColor_Color = @"te_t_c"; -LookinAttrIdentifier const LookinAttr_UITextView_Alignment_Alignment = @"te_a_a"; -LookinAttrIdentifier const LookinAttr_UITextView_ContainerInset_Inset = @"te_c_i"; - -LookinAttrIdentifier const LookinAttr_UITextField_Text_Text = @"tf_t_t"; -LookinAttrIdentifier const LookinAttr_UITextField_Placeholder_Placeholder = @"tf_p_p"; -LookinAttrIdentifier const LookinAttr_UITextField_Font_Name = @"tf_f_n"; -LookinAttrIdentifier const LookinAttr_UITextField_Font_Size = @"tf_f_s"; -LookinAttrIdentifier const LookinAttr_UITextField_TextColor_Color = @"tf_t_c"; -LookinAttrIdentifier const LookinAttr_UITextField_Alignment_Alignment = @"tf_a_a"; -LookinAttrIdentifier const LookinAttr_UITextField_Clears_ClearsOnBeginEditing = @"tf_c_c"; -LookinAttrIdentifier const LookinAttr_UITextField_Clears_ClearsOnInsertion = @"tf_c_co"; -LookinAttrIdentifier const LookinAttr_UITextField_CanAdjustFont_CanAdjustFont = @"tf_c_ca"; -LookinAttrIdentifier const LookinAttr_UITextField_CanAdjustFont_MinSize = @"tf_c_m"; -LookinAttrIdentifier const LookinAttr_UITextField_ClearButtonMode_Mode = @"tf_cb_m"; - -LookinAttrIdentifier const LookinAttr_UIVisualEffectView_Style_Style = @"ve_s_s"; -LookinAttrIdentifier const LookinAttr_UIVisualEffectView_QMUIForegroundColor_Color = @"ve_f_c"; - -LookinAttrIdentifier const LookinAttr_UIStackView_Axis_Axis = @"usv_axis_axis"; -LookinAttrIdentifier const LookinAttr_UIStackView_Distribution_Distribution = @"usv_dis_dis"; -LookinAttrIdentifier const LookinAttr_UIStackView_Alignment_Alignment = @"usv_ali_ali"; -LookinAttrIdentifier const LookinAttr_UIStackView_Spacing_Spacing = @"usv_spa_spa"; - -LookinAttrIdentifier const LookinAttr_NSImageView_Name_Name = @"NSImageView_Name_Name"; -LookinAttrIdentifier const LookinAttr_NSImageView_Open_Open = @"NSImageView_Open_Open"; -LookinAttrIdentifier const LookinAttr_NSImageView_Scaling_ImageScaling = @"NSImageView_Scaling_ImageScaling"; -LookinAttrIdentifier const LookinAttr_NSImageView_Scaling_ImageAlignment = @"NSImageView_Scaling_ImageAlignment"; -LookinAttrIdentifier const LookinAttr_NSImageView_Scaling_ImageFrameStyle = @"NSImageView_Scaling_ImageFrameStyle"; -LookinAttrIdentifier const LookinAttr_NSImageView_Behavior_Animates = @"NSImageView_Behavior_Animates"; -LookinAttrIdentifier const LookinAttr_NSImageView_Behavior_Editable = @"NSImageView_Behavior_Editable"; -LookinAttrIdentifier const LookinAttr_NSImageView_ContentTintColor_ContentTintColor = @"NSImageView_ContentTintColor_ContentTintColor"; -LookinAttrIdentifier const LookinAttr_NSControl_State_Enabled = @"NSControl_State_Enabled"; -LookinAttrIdentifier const LookinAttr_NSControl_State_Highlighted = @"NSControl_State_Highlighted"; -LookinAttrIdentifier const LookinAttr_NSControl_State_Continuous = @"NSControl_State_Continuous"; -LookinAttrIdentifier const LookinAttr_NSControl_ControlSize_Size = @"NSControl_ControlSize_Size"; -LookinAttrIdentifier const LookinAttr_NSControl_Font_Name = @"NSControl_Font_Name"; -LookinAttrIdentifier const LookinAttr_NSControl_Font_Size = @"NSControl_Font_Size"; -LookinAttrIdentifier const LookinAttr_NSControl_Alignment_Alignment = @"NSControl_Alignment_Alignment"; -LookinAttrIdentifier const LookinAttr_NSControl_Misc_WritingDirection = @"NSControl_Misc_WritingDirection"; -LookinAttrIdentifier const LookinAttr_NSControl_Misc_IgnoresMultiClick = @"NSControl_Misc_IgnoresMultiClick"; -LookinAttrIdentifier const LookinAttr_NSControl_Misc_UsesSingleLineMode = @"NSControl_Misc_UsesSingleLineMode"; -LookinAttrIdentifier const LookinAttr_NSControl_Misc_AllowsExpansionToolTips = @"NSControl_Misc_AllowsExpansionToolTips"; -LookinAttrIdentifier const LookinAttr_NSControl_Value_StringValue = @"NSControl_Value_StringValue"; -LookinAttrIdentifier const LookinAttr_NSControl_Value_IntValue = @"NSControl_Value_IntValue"; -LookinAttrIdentifier const LookinAttr_NSControl_Value_IntegerValue = @"NSControl_Value_IntegerValue"; -LookinAttrIdentifier const LookinAttr_NSControl_Value_FloatValue = @"NSControl_Value_FloatValue"; -LookinAttrIdentifier const LookinAttr_NSControl_Value_DoubleValue = @"NSControl_Value_DoubleValue"; -LookinAttrIdentifier const LookinAttr_NSButton_ButtonType_ButtonType = @"NSButton_ButtonType_ButtonType"; -LookinAttrIdentifier const LookinAttr_NSButton_Title_Title = @"NSButton_Title_Title"; -LookinAttrIdentifier const LookinAttr_NSButton_Title_AlernateTitle = @"NSButton_Title_AlernateTitle"; -LookinAttrIdentifier const LookinAttr_NSButton_BezelStyle_BezelStyle = @"NSButton_BezelStyle_BezelStyle"; -LookinAttrIdentifier const LookinAttr_NSButton_Bordered_Bordered = @"NSButton_Bordered_Bordered"; -LookinAttrIdentifier const LookinAttr_NSButton_Transparent_Transparent = @"NSButton_Transparent_Transparent"; -LookinAttrIdentifier const LookinAttr_NSButton_BezelColor_BezelColor = @"NSButton_BezelColor_BezelColor"; -LookinAttrIdentifier const LookinAttr_NSButton_ContentTintColor_ContentTintColor = @"NSButton_ContentTintColor_ContentTintColor"; -LookinAttrIdentifier const LookinAttr_NSButton_Misc_ShowsBorderOnlyWhileMouseInside = @"NSButton_Misc_ShowsBorderOnlyWhileMouseInside"; -LookinAttrIdentifier const LookinAttr_NSButton_Misc_MaxAcceleratorLevel = @"NSButton_Misc_MaxAcceleratorLevel"; -LookinAttrIdentifier const LookinAttr_NSButton_Misc_SpringLoaded = @"NSButton_Misc_SpringLoaded"; -LookinAttrIdentifier const LookinAttr_NSButton_Misc_HasDestructiveAction = @"NSButton_Misc_HasDestructiveAction"; -LookinAttrIdentifier const LookinAttr_NSScrollView_ContentOffset_Offset = @"NSScrollView_ContentOffset_Offset"; -LookinAttrIdentifier const LookinAttr_NSScrollView_ContentSize_Size = @"NSScrollView_ContentSize_Size"; -LookinAttrIdentifier const LookinAttr_NSScrollView_ContentInset_ContentInset = @"NSScrollView_ContentInset_ContentInset"; -LookinAttrIdentifier const LookinAttr_NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets = @"NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets"; -LookinAttrIdentifier const LookinAttr_NSScrollView_BorderType_BorderType = @"NSScrollView_BorderType_BorderType"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_Horizontal = @"NSScrollView_Scroller_Horizontal"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_Vertical = @"NSScrollView_Scroller_Vertical"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_AutohidesScrollers = @"NSScrollView_Scroller_AutohidesScrollers"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_ScrollerStyle = @"NSScrollView_Scroller_ScrollerStyle"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_ScrollerKnobStyle = @"NSScrollView_Scroller_ScrollerKnobStyle"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Scroller_ScrollerInsets = @"NSScrollView_Scroller_ScrollerInsets"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Ruler_Horizontal = @"NSScrollView_Ruler_Horizontal"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Ruler_Vertical = @"NSScrollView_Ruler_Vertical"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Ruler_Visible = @"NSScrollView_Ruler_Visible"; -LookinAttrIdentifier const LookinAttr_NSScrollView_LineScroll_Horizontal = @"NSScrollView_LineScroll_Horizontal"; -LookinAttrIdentifier const LookinAttr_NSScrollView_LineScroll_Vertical = @"NSScrollView_LineScroll_Vertical"; -LookinAttrIdentifier const LookinAttr_NSScrollView_LineScroll_LineScroll = @"NSScrollView_LineScroll_LineScroll"; -LookinAttrIdentifier const LookinAttr_NSScrollView_PageScroll_Horizontal = @"NSScrollView_PageScroll_Horizontal"; -LookinAttrIdentifier const LookinAttr_NSScrollView_PageScroll_Vertical = @"NSScrollView_PageScroll_Vertical"; -LookinAttrIdentifier const LookinAttr_NSScrollView_PageScroll_PageScroll = @"NSScrollView_PageScroll_PageScroll"; -LookinAttrIdentifier const LookinAttr_NSScrollView_ScrollElasiticity_Horizontal = @"NSScrollView_ScrollElasiticity_Horizontal"; -LookinAttrIdentifier const LookinAttr_NSScrollView_ScrollElasiticity_Vertical = @"NSScrollView_ScrollElasiticity_Vertical"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Misc_ScrollsDynamically = @"NSScrollView_Misc_ScrollsDynamically"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Misc_UsesPredominantAxisScrolling = @"NSScrollView_Misc_UsesPredominantAxisScrolling"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Magnification_AllowsMagnification = @"NSScrollView_Magnification_AllowsMagnification"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Magnification_Magnification = @"NSScrollView_Magnification_Magnification"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Magnification_Max = @"NSScrollView_Magnification_Max"; -LookinAttrIdentifier const LookinAttr_NSScrollView_Magnification_Min = @"NSScrollView_Magnification_Min"; -LookinAttrIdentifier const LookinAttr_NSTableView_AllowsColumnReordering_AllowsColumnReordering = @"NSTableView_AllowsColumnReordering_AllowsColumnReordering"; -LookinAttrIdentifier const LookinAttr_NSTableView_AllowsColumnResizing_AllowsColumnResizing = @"NSTableView_AllowsColumnResizing_AllowsColumnResizing"; -LookinAttrIdentifier const LookinAttr_NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle = @"NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle"; -LookinAttrIdentifier const LookinAttr_NSTableView_GridStyleMask_GridStyleMask = @"NSTableView_GridStyleMask_GridStyleMask"; -LookinAttrIdentifier const LookinAttr_NSTableView_IntercellSpacing_IntercellSpacing = @"NSTableView_IntercellSpacing_IntercellSpacing"; -LookinAttrIdentifier const LookinAttr_NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors = @"NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors"; -LookinAttrIdentifier const LookinAttr_NSTableView_GridColor_GridColor = @"NSTableView_GridColor_GridColor"; -LookinAttrIdentifier const LookinAttr_NSTableView_RowSizeStyle_RowSizeStyle = @"NSTableView_RowSizeStyle_RowSizeStyle"; -LookinAttrIdentifier const LookinAttr_NSTableView_RowHeight_RowHeight = @"NSTableView_RowHeight_RowHeight"; -LookinAttrIdentifier const LookinAttr_NSTableView_NumberOfRows_NumberOfRows = @"NSTableView_NumberOfRows_NumberOfRows"; -LookinAttrIdentifier const LookinAttr_NSTableView_NumberOfColumns_NumberOfColumns = @"NSTableView_NumberOfColumns_NumberOfColumns"; -LookinAttrIdentifier const LookinAttr_NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag = @"NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag"; -LookinAttrIdentifier const LookinAttr_NSTableView_AllowsMultipleSelection_AllowsMultipleSelection = @"NSTableView_AllowsMultipleSelection_AllowsMultipleSelection"; -LookinAttrIdentifier const LookinAttr_NSTableView_AllowsEmptySelection_AllowsEmptySelection = @"NSTableView_AllowsEmptySelection_AllowsEmptySelection"; -LookinAttrIdentifier const LookinAttr_NSTableView_AllowsColumnSelection_AllowsColumnSelection = @"NSTableView_AllowsColumnSelection_AllowsColumnSelection"; -LookinAttrIdentifier const LookinAttr_NSTableView_AllowsTypeSelect_AllowsTypeSelect = @"NSTableView_AllowsTypeSelect_AllowsTypeSelect"; -LookinAttrIdentifier const LookinAttr_NSTableView_SelectionHighlightStyle_SelectionHighlightStyle = @"NSTableView_SelectionHighlightStyle_SelectionHighlightStyle"; -LookinAttrIdentifier const LookinAttr_NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle = @"NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle"; -LookinAttrIdentifier const LookinAttr_NSTableView_AutomaticRowHeights_AutomaticRowHeights = @"NSTableView_AutomaticRowHeights_AutomaticRowHeights"; -LookinAttrIdentifier const LookinAttr_NSTableView_AutosaveName_AutosaveName = @"NSTableView_AutosaveName_AutosaveName"; -LookinAttrIdentifier const LookinAttr_NSTableView_AutosaveTableColumns_AutosaveTableColumns = @"NSTableView_AutosaveTableColumns_AutosaveTableColumns"; -LookinAttrIdentifier const LookinAttr_NSTableView_FloatsGroupRows_FloatsGroupRows = @"NSTableView_FloatsGroupRows_FloatsGroupRows"; -LookinAttrIdentifier const LookinAttr_NSTableView_RowActionsVisible_RowActionsVisible = @"NSTableView_RowActionsVisible_RowActionsVisible"; -LookinAttrIdentifier const LookinAttr_NSTableView_UsesStaticContents_UsesStaticContents = @"NSTableView_UsesStaticContents_UsesStaticContents"; -LookinAttrIdentifier const LookinAttr_NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection = @"NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection"; -LookinAttrIdentifier const LookinAttr_NSTableView_Style_Style = @"NSTableView_Style_Style"; -LookinAttrIdentifier const LookinAttr_NSTextView_Font_Name = @"NSTextView_Font_Name"; -LookinAttrIdentifier const LookinAttr_NSTextView_Font_Size = @"NSTextView_Font_Size"; -LookinAttrIdentifier const LookinAttr_NSTextView_Basic_Editable = @"NSTextView_Basic_Editable"; -LookinAttrIdentifier const LookinAttr_NSTextView_Basic_Selectable = @"NSTextView_Basic_Selectable"; -LookinAttrIdentifier const LookinAttr_NSTextView_Basic_RichText = @"NSTextView_Basic_RichText"; -LookinAttrIdentifier const LookinAttr_NSTextView_Basic_FieldEditor = @"NSTextView_Basic_FieldEditor"; -LookinAttrIdentifier const LookinAttr_NSTextView_Basic_ImportsGraphics = @"NSTextView_Basic_ImportsGraphics"; -LookinAttrIdentifier const LookinAttr_NSTextView_String_String = @"NSTextView_String_String"; -LookinAttrIdentifier const LookinAttr_NSTextView_TextColor_Color = @"NSTextView_TextColor_Color"; -LookinAttrIdentifier const LookinAttr_NSTextView_Alignment_Alignment = @"NSTextView_Alignment_Alignment"; -LookinAttrIdentifier const LookinAttr_NSTextView_ContainerInset_Inset = @"NSTextView_ContainerInset_Inset"; -LookinAttrIdentifier const LookinAttr_NSTextView_BaseWritingDirection_BaseWritingDirection = @"NSTextView_BaseWritingDirection_BaseWritingDirection"; -LookinAttrIdentifier const LookinAttr_NSTextView_MaxSize_MaxSize = @"NSTextView_MaxSize_MaxSize"; -LookinAttrIdentifier const LookinAttr_NSTextView_MinSize_MinSize = @"NSTextView_MinSize_MinSize"; -LookinAttrIdentifier const LookinAttr_NSTextView_Resizable_Horizontal = @"NSTextView_Resizable_Horizontal"; -LookinAttrIdentifier const LookinAttr_NSTextView_Resizable_Vertical = @"NSTextView_Resizable_Vertical"; -LookinAttrIdentifier const LookinAttr_NSTextField_Bordered_Bordered = @"NSTextField_Bordered_Bordered"; -LookinAttrIdentifier const LookinAttr_NSTextField_Bezeled_Bezeled = @"NSTextField_Bezeled_Bezeled"; -LookinAttrIdentifier const LookinAttr_NSTextField_Editable_Editable = @"NSTextField_Editable_Editable"; -LookinAttrIdentifier const LookinAttr_NSTextField_Selectable_Selectable = @"NSTextField_Selectable_Selectable"; -LookinAttrIdentifier const LookinAttr_NSTextField_DrawsBackground_DrawsBackground = @"NSTextField_DrawsBackground_DrawsBackground"; -LookinAttrIdentifier const LookinAttr_NSTextField_BezelStyle_BezelStyle = @"NSTextField_BezelStyle_BezelStyle"; -LookinAttrIdentifier const LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth = @"NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth"; -LookinAttrIdentifier const LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines = @"NSTextField_MaximumNumberOfLines_MaximumNumberOfLines"; -LookinAttrIdentifier const LookinAttr_NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation = @"NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation"; -LookinAttrIdentifier const LookinAttr_NSTextField_LineBreakStrategy_LineBreakStrategy = @"NSTextField_LineBreakStrategy_LineBreakStrategy"; -LookinAttrIdentifier const LookinAttr_NSTextField_Placeholder_Placeholder = @"NSTextField_Placeholder_Placeholder"; -LookinAttrIdentifier const LookinAttr_NSTextField_TextColor_Color = @"NSTextField_TextColor_Color"; -LookinAttrIdentifier const LookinAttr_NSTextField_BackgroundColor_Color = @"NSTextField_BackgroundColor_Color"; -LookinAttrIdentifier const LookinAttr_NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes = @"NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes"; -LookinAttrIdentifier const LookinAttr_NSTextField_ImportsGraphics_ImportsGraphics = @"NSTextField_ImportsGraphics_ImportsGraphics"; -LookinAttrIdentifier const LookinAttr_NSVisualEffectView_Material_Material = @"NSVisualEffectView_Material_Material"; -LookinAttrIdentifier const LookinAttr_NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle = @"NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle"; -LookinAttrIdentifier const LookinAttr_NSVisualEffectView_BlendingMode_BlendingMode = @"NSVisualEffectView_BlendingMode_BlendingMode"; -LookinAttrIdentifier const LookinAttr_NSVisualEffectView_State_State = @"NSVisualEffectView_State_State"; -LookinAttrIdentifier const LookinAttr_NSVisualEffectView_Emphasized_Emphasized = @"NSVisualEffectView_Emphasized_Emphasized"; -LookinAttrIdentifier const LookinAttr_NSStackView_Orientation_Orientation = @"NSStackView_Orientation_Orientation"; -LookinAttrIdentifier const LookinAttr_NSStackView_EdgeInsets_EdgeInsets = @"NSStackView_EdgeInsets_EdgeInsets"; -LookinAttrIdentifier const LookinAttr_NSStackView_DetachesHiddenViews_DetachesHiddenViews = @"NSStackView_DetachesHiddenViews_DetachesHiddenViews"; -LookinAttrIdentifier const LookinAttr_NSStackView_Distribution_Distribution = @"NSStackView_Distribution_Distribution"; -LookinAttrIdentifier const LookinAttr_NSStackView_Alignment_Alignment = @"NSStackView_Alignment_Alignment"; -LookinAttrIdentifier const LookinAttr_NSStackView_Spacing_Spacing = @"NSStackView_Spacing_Spacing"; - -LookinAttrIdentifier const LookinAttr_NSWindow_Title_Title = @"NSWindow_Title_Title"; -LookinAttrIdentifier const LookinAttr_NSWindow_Title_Subtitle = @"NSWindow_Title_Subtitle"; -LookinAttrIdentifier const LookinAttr_NSWindow_State_KeyWindow = @"NSWindow_State_KeyWindow"; -LookinAttrIdentifier const LookinAttr_NSWindow_State_MainWindow = @"NSWindow_State_MainWindow"; -LookinAttrIdentifier const LookinAttr_NSWindow_State_Visible = @"NSWindow_State_Visible"; -LookinAttrIdentifier const LookinAttr_NSWindow_State_CanBecomeKeyWindow = @"NSWindow_State_CanBecomeKeyWindow"; -LookinAttrIdentifier const LookinAttr_NSWindow_State_CanBecomeMainWindow = @"NSWindow_State_CanBecomeMainWindow"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_Titled = @"NSWindow_Style_Titled"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_Closable = @"NSWindow_Style_Closable"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_Miniaturizable = @"NSWindow_Style_Miniaturizable"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_Resizable = @"NSWindow_Style_Resizable"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_UnifiedTitleAndToolbar = @"NSWindow_Style_UnifiedTitleAndToolbar"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_FullScreen = @"NSWindow_Style_FullScreen"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_FullSizeContentView = @"NSWindow_Style_FullSizeContentView"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_UtilityWindow = @"NSWindow_Style_UtilityWindow"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_DocModalWindow = @"NSWindow_Style_DocModalWindow"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_NonactivatingPanel = @"NSWindow_Style_NonactivatingPanel"; -LookinAttrIdentifier const LookinAttr_NSWindow_Style_HUDWindow = @"NSWindow_Style_HUDWindow"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_CanJoinAllSpaces = @"NSWindow_CollectionBehavior_CanJoinAllSpaces"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_MoveToActiveSpace = @"NSWindow_CollectionBehavior_MoveToActiveSpace"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_ParticipatesInCycle = @"NSWindow_CollectionBehavior_ParticipatesInCycle"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_IgnoresCycle = @"NSWindow_CollectionBehavior_IgnoresCycle"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenPrimary = @"NSWindow_CollectionBehavior_FullScreenPrimary"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenAuxiliary = @"NSWindow_CollectionBehavior_FullScreenAuxiliary"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenNone = @"NSWindow_CollectionBehavior_FullScreenNone"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenAllowsTiling = @"NSWindow_CollectionBehavior_FullScreenAllowsTiling"; -LookinAttrIdentifier const LookinAttr_NSWindow_CollectionBehavior_FullScreenDisallowsTiling = @"NSWindow_CollectionBehavior_FullScreenDisallowsTiling"; -LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_TitlebarAppearsTransparent = @"NSWindow_Appearance_TitlebarAppearsTransparent"; -LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_TitleVisibility = @"NSWindow_Appearance_TitleVisibility"; -LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_ToolbarStyle = @"NSWindow_Appearance_ToolbarStyle"; -LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_TitlebarSeparatorStyle = @"NSWindow_Appearance_TitlebarSeparatorStyle"; -LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_BackgroundColor = @"NSWindow_Appearance_BackgroundColor"; -LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_AlphaValue = @"NSWindow_Appearance_AlphaValue"; -LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_Opaque = @"NSWindow_Appearance_Opaque"; -LookinAttrIdentifier const LookinAttr_NSWindow_Appearance_HasShadow = @"NSWindow_Appearance_HasShadow"; -LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_Movable = @"NSWindow_Behavior_Movable"; -LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_MovableByWindowBackground = @"NSWindow_Behavior_MovableByWindowBackground"; -LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_AnimationBehavior = @"NSWindow_Behavior_AnimationBehavior"; -LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_Level = @"NSWindow_Behavior_Level"; -LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_HidesOnDeactivate = @"NSWindow_Behavior_HidesOnDeactivate"; -LookinAttrIdentifier const LookinAttr_NSWindow_Behavior_TabbingMode = @"NSWindow_Behavior_TabbingMode"; -LookinAttrIdentifier const LookinAttr_NSWindow_Size_MinSize = @"NSWindow_Size_MinSize"; -LookinAttrIdentifier const LookinAttr_NSWindow_Size_MaxSize = @"NSWindow_Size_MaxSize"; -LookinAttrIdentifier const LookinAttr_NSWindow_Info_WindowNumber = @"NSWindow_Info_WindowNumber"; -LookinAttrIdentifier const LookinAttr_NSWindow_Info_BackingScaleFactor = @"NSWindow_Info_BackingScaleFactor"; - -// UIWindowScene -LookinAttrGroupIdentifier const LookinAttrGroup_UIWindowScene = @"UIWindowScene"; - -LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_State = @"UIWindowScene_State"; -LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Title = @"UIWindowScene_Title"; -LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Orientation = @"UIWindowScene_Orientation"; -LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Windows = @"UIWindowScene_Windows"; -LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Screen = @"UIWindowScene_Screen"; -LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_StatusBar = @"UIWindowScene_StatusBar"; -LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Traits = @"UIWindowScene_Traits"; -LookinAttrSectionIdentifier const LookinAttrSec_UIWindowScene_Session = @"UIWindowScene_Session"; - -LookinAttrIdentifier const LookinAttr_UIWindowScene_State_ActivationState = @"UIWindowScene_State_ActivationState"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Title_Title = @"UIWindowScene_Title_Title"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Orientation_InterfaceOrientation = @"UIWindowScene_Orientation_InterfaceOrientation"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Windows_WindowCount = @"UIWindowScene_Windows_WindowCount"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Windows_KeyWindowClassName = @"UIWindowScene_Windows_KeyWindowClassName"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Screen_ScreenBounds = @"UIWindowScene_Screen_ScreenBounds"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Screen_ScreenScale = @"UIWindowScene_Screen_ScreenScale"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_StatusBar_StatusBarHidden = @"UIWindowScene_StatusBar_StatusBarHidden"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_StatusBar_StatusBarStyle = @"UIWindowScene_StatusBar_StatusBarStyle"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_StatusBar_StatusBarFrame = @"UIWindowScene_StatusBar_StatusBarFrame"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_UserInterfaceStyle = @"UIWindowScene_Traits_UserInterfaceStyle"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_HorizontalSizeClass = @"UIWindowScene_Traits_HorizontalSizeClass"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_VerticalSizeClass = @"UIWindowScene_Traits_VerticalSizeClass"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Session_PersistentIdentifier = @"UIWindowScene_Session_PersistentIdentifier"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Session_SessionRole = @"UIWindowScene_Session_SessionRole"; - -// UIWindowScene additional traits -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_UserInterfaceLevel = @"UIWindowScene_Traits_UserInterfaceLevel"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_ActiveAppearance = @"UIWindowScene_Traits_ActiveAppearance"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_AccessibilityContrast = @"UIWindowScene_Traits_AccessibilityContrast"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_LegibilityWeight = @"UIWindowScene_Traits_LegibilityWeight"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_DisplayScale = @"UIWindowScene_Traits_DisplayScale"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_DisplayGamut = @"UIWindowScene_Traits_DisplayGamut"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_UserInterfaceIdiom = @"UIWindowScene_Traits_UserInterfaceIdiom"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_LayoutDirection = @"UIWindowScene_Traits_LayoutDirection"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_PreferredContentSizeCategory = @"UIWindowScene_Traits_PreferredContentSizeCategory"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_SceneCaptureState = @"UIWindowScene_Traits_SceneCaptureState"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_ImageDynamicRange = @"UIWindowScene_Traits_ImageDynamicRange"; -LookinAttrIdentifier const LookinAttr_UIWindowScene_Traits_TypesettingLanguage = @"UIWindowScene_Traits_TypesettingLanguage"; - -// UITraitCollection -LookinAttrGroupIdentifier const LookinAttrGroup_UITraitCollection = @"UITraitCollection"; - -LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Appearance = @"UITraitCollection_Appearance"; -LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_SizeClass = @"UITraitCollection_SizeClass"; -LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Display = @"UITraitCollection_Display"; -LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Device = @"UITraitCollection_Device"; -LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Layout = @"UITraitCollection_Layout"; -LookinAttrSectionIdentifier const LookinAttrSec_UITraitCollection_Content = @"UITraitCollection_Content"; - -LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_UserInterfaceStyle = @"UITraitCollection_Appearance_UserInterfaceStyle"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_UserInterfaceLevel = @"UITraitCollection_Appearance_UserInterfaceLevel"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_ActiveAppearance = @"UITraitCollection_Appearance_ActiveAppearance"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_AccessibilityContrast = @"UITraitCollection_Appearance_AccessibilityContrast"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Appearance_LegibilityWeight = @"UITraitCollection_Appearance_LegibilityWeight"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_SizeClass_HorizontalSizeClass = @"UITraitCollection_SizeClass_HorizontalSizeClass"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_SizeClass_VerticalSizeClass = @"UITraitCollection_SizeClass_VerticalSizeClass"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Display_DisplayScale = @"UITraitCollection_Display_DisplayScale"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Display_DisplayGamut = @"UITraitCollection_Display_DisplayGamut"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Display_ImageDynamicRange = @"UITraitCollection_Display_ImageDynamicRange"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Device_UserInterfaceIdiom = @"UITraitCollection_Device_UserInterfaceIdiom"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Device_ForceTouchCapability = @"UITraitCollection_Device_ForceTouchCapability"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Layout_LayoutDirection = @"UITraitCollection_Layout_LayoutDirection"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Content_PreferredContentSizeCategory = @"UITraitCollection_Content_PreferredContentSizeCategory"; -LookinAttrIdentifier const LookinAttr_UITraitCollection_Content_TypesettingLanguage = @"UITraitCollection_Content_TypesettingLanguage"; - - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttrType.h b/Sources/LookinServer/Shared/LookinAttrType.h deleted file mode 100644 index feba9ce9..00000000 --- a/Sources/LookinServer/Shared/LookinAttrType.h +++ /dev/null @@ -1,50 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttrIdentifiers.h -// Lookin -// -// Created by Li Kai on 2018/12/1. -// https://lookin.work -// - -/// 注意:新属性只能加到末尾,否则新旧版本搭配时可能有兼容问题 -typedef NS_ENUM(NSInteger, LookinAttrType) { - LookinAttrTypeNone, - LookinAttrTypeVoid, - LookinAttrTypeChar, - LookinAttrTypeInt, - LookinAttrTypeShort, - LookinAttrTypeLong, - LookinAttrTypeLongLong, - LookinAttrTypeUnsignedChar, - LookinAttrTypeUnsignedInt, - LookinAttrTypeUnsignedShort, - LookinAttrTypeUnsignedLong, - LookinAttrTypeUnsignedLongLong, - LookinAttrTypeFloat, - LookinAttrTypeDouble, - LookinAttrTypeBOOL, - LookinAttrTypeSel, - LookinAttrTypeClass, - LookinAttrTypeCGPoint, - LookinAttrTypeCGVector, - LookinAttrTypeCGSize, - LookinAttrTypeCGRect, - LookinAttrTypeCGAffineTransform, - LookinAttrTypeUIEdgeInsets, - LookinAttrTypeUIOffset, - LookinAttrTypeNSString, - LookinAttrTypeEnumInt, - LookinAttrTypeEnumLong, - /// value 实际为 RGBA 数组,即 @[NSNumber, NSNumber, NSNumber, NSNumber],NSNumber 范围是 0 ~ 1 - LookinAttrTypeUIColor, - /// 业务需要根据具体的 AttrIdentifier 来解析 - LookinAttrTypeCustomObj, - - LookinAttrTypeEnumString, - LookinAttrTypeShadow, - LookinAttrTypeJson -}; - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttribute.h b/Sources/LookinServer/Shared/LookinAttribute.h deleted file mode 100644 index d86f2987..00000000 --- a/Sources/LookinServer/Shared/LookinAttribute.h +++ /dev/null @@ -1,48 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttribute.h -// qmuidemo -// -// Created by Li Kai on 2018/11/17. -// Copyright © 2018 QMUI Team. All rights reserved. -// - -#import "LookinAttrIdentifiers.h" -#import "LookinCodingValueType.h" -#import "LookinAttrType.h" - -@class LookinDisplayItem; - -@interface LookinAttribute : NSObject - -@property(nonatomic, copy) LookinAttrIdentifier identifier; - -/// 只有 Custom Attr 才有该属性 -@property(nonatomic, copy) NSString *displayTitle; - -/// 标识 value 的具体类型(如 double / NSString /...) -@property(nonatomic, assign) LookinAttrType attrType; - -/// 具体的值,需配合 attrType 属性来解析它 -/// 对于 String、Color 等 attyType,该属性可能为 nil -@property(nonatomic, strong) id value; - -/// 额外信息,大部分情况下它是 nil -/// 当 attyType 为 LookinAttrTypeEnumString 时,extraValue 是一个 [String] 且保存了 allEnumCases -@property(nonatomic, strong) id extraValue; - -/// 仅 Custom Attr 可能有该属性 -/// 对于有 retainedSetter 的 Custom Attr,它的 setter 会以 customSetterID 作为 key 被保存到 LKS_CustomAttrSetterManager 里,后续可以通过这个 uniqueID 重新把 setter 从 LKS_CustomAttrSetterManager 里取出来并调用 -@property(nonatomic, copy) NSString *customSetterID; - -#pragma mark - 以下属性不会参与 encode/decode - -/// 标识该 LookinAttribute 对象隶属于哪一个 LookinDisplayItem -@property(nonatomic, weak) LookinDisplayItem *targetDisplayItem; - -- (BOOL)isUserCustom; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttribute.m b/Sources/LookinServer/Shared/LookinAttribute.m deleted file mode 100644 index c0625d70..00000000 --- a/Sources/LookinServer/Shared/LookinAttribute.m +++ /dev/null @@ -1,64 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttribute.m -// qmuidemo -// -// Created by Li Kai on 2018/11/17. -// Copyright © 2018 QMUI Team. All rights reserved. -// - - - -#import "LookinAttribute.h" -#import "LookinDisplayItem.h" - -@implementation LookinAttribute - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinAttribute *newAttr = [[LookinAttribute allocWithZone:zone] init]; - newAttr.identifier = self.identifier; - newAttr.displayTitle = self.displayTitle; - newAttr.value = self.value; - newAttr.attrType = self.attrType; - newAttr.extraValue = self.extraValue; - newAttr.customSetterID = self.customSetterID; - return newAttr; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.displayTitle forKey:@"displayTitle"]; - [aCoder encodeObject:self.identifier forKey:@"identifier"]; - [aCoder encodeInteger:self.attrType forKey:@"attrType"]; - [aCoder encodeObject:self.value forKey:@"value"]; - [aCoder encodeObject:self.extraValue forKey:@"extraValue"]; - [aCoder encodeObject:self.customSetterID forKey:@"customSetterID"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.displayTitle = [aDecoder decodeObjectForKey:@"displayTitle"]; - self.identifier = [aDecoder decodeObjectForKey:@"identifier"]; - self.attrType = [aDecoder decodeIntegerForKey:@"attrType"]; - self.value = [aDecoder decodeObjectForKey:@"value"]; - self.extraValue = [aDecoder decodeObjectForKey:@"extraValue"]; - self.customSetterID = [aDecoder decodeObjectForKey:@"customSetterID"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (BOOL)isUserCustom { - return [self.identifier isEqualToString:LookinAttr_UserCustom]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttributeModification.h b/Sources/LookinServer/Shared/LookinAttributeModification.h deleted file mode 100644 index c9357e0b..00000000 --- a/Sources/LookinServer/Shared/LookinAttributeModification.h +++ /dev/null @@ -1,31 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributeModification.h -// Lookin -// -// Created by Li Kai on 2018/11/20. -// https://lookin.work -// - - - -#import -#import "LookinAttrType.h" - -@interface LookinAttributeModification : NSObject - -@property(nonatomic, assign) unsigned long targetOid; - -@property(nonatomic, assign) SEL setterSelector; -@property(nonatomic, assign) SEL getterSelector; - -@property(nonatomic, assign) LookinAttrType attrType; -@property(nonatomic, strong) id value; - -/// 1.0.4 开始加入这个参数 -@property(nonatomic, copy) NSString *clientReadableVersion; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttributeModification.m b/Sources/LookinServer/Shared/LookinAttributeModification.m deleted file mode 100644 index 39f16c9a..00000000 --- a/Sources/LookinServer/Shared/LookinAttributeModification.m +++ /dev/null @@ -1,40 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributeModification.m -// Lookin -// -// Created by Li Kai on 2018/11/20. -// https://lookin.work -// - -#import "LookinAttributeModification.h" - -@implementation LookinAttributeModification - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:@(self.targetOid) forKey:@"targetOid"]; - [aCoder encodeObject:NSStringFromSelector(self.setterSelector) forKey:@"setterSelector"]; - [aCoder encodeInteger:self.attrType forKey:@"attrType"]; - [aCoder encodeObject:self.value forKey:@"value"]; - [aCoder encodeObject:self.clientReadableVersion forKey:@"clientReadableVersion"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.targetOid = [[aDecoder decodeObjectForKey:@"targetOid"] unsignedLongValue]; - self.setterSelector = NSSelectorFromString([aDecoder decodeObjectForKey:@"setterSelector"]); - self.attrType = [aDecoder decodeIntegerForKey:@"attrType"]; - self.value = [aDecoder decodeObjectForKey:@"value"]; - self.clientReadableVersion = [aDecoder decodeObjectForKey:@"clientReadableVersion"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttributesGroup.h b/Sources/LookinServer/Shared/LookinAttributesGroup.h deleted file mode 100644 index e8ee7ed2..00000000 --- a/Sources/LookinServer/Shared/LookinAttributesGroup.h +++ /dev/null @@ -1,41 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributesGroup.h -// Lookin -// -// Created by Li Kai on 2018/11/19. -// https://lookin.work -// - - - -#import -#import "LookinAttrIdentifiers.h" - -@class LookinAttributesSection; - -/** - In Lookin, a LookinAttributesGroup instance will be rendered as a property card. - - When isUserCustom is false: two LookinAttributesGroup instances will be regard as equal when they has the same LookinAttrGroupIdentifier. - When isUserCustom is true: two LookinAttributesGroup instances will be regard as equal when they has the same title. - 当 isUserCustom 为 false 时:若两个 attrGroup 有相同的 LookinAttrGroupIdentifier,则 isEqual: 返回 YES - */ -@interface LookinAttributesGroup : NSObject - -/// 只有在 identifier 为 custom 时,才存在该值 -@property(nonatomic, copy) NSString *userCustomTitle; - -@property(nonatomic, copy) LookinAttrGroupIdentifier identifier; - -@property(nonatomic, copy) NSArray *attrSections; - -/// 如果是 custom 则返回 userCustomTitle,如果不是 custom 则返回 identifier -- (NSString *)uniqueKey; - -- (BOOL)isUserCustom; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttributesGroup.m b/Sources/LookinServer/Shared/LookinAttributesGroup.m deleted file mode 100644 index 49a4825e..00000000 --- a/Sources/LookinServer/Shared/LookinAttributesGroup.m +++ /dev/null @@ -1,92 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributesGroup.m -// Lookin -// -// Created by Li Kai on 2018/11/19. -// https://lookin.work -// - - - -#import "LookinAttributesGroup.h" -#import "LookinAttribute.h" -#import "LookinAttributesSection.h" -#import "LookinDashboardBlueprint.h" -#import "NSArray+Lookin.h" - -@implementation LookinAttributesGroup - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinAttributesGroup *newGroup = [[LookinAttributesGroup allocWithZone:zone] init]; - newGroup.userCustomTitle = self.userCustomTitle; - newGroup.identifier = self.identifier; - newGroup.attrSections = [self.attrSections lookin_map:^id(NSUInteger idx, LookinAttributesSection *value) { - return value.copy; - }]; - return newGroup; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.userCustomTitle forKey:@"userCustomTitle"]; - [aCoder encodeObject:self.identifier forKey:@"identifier"]; - [aCoder encodeObject:self.attrSections forKey:@"attrSections"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.userCustomTitle = [aDecoder decodeObjectForKey:@"userCustomTitle"]; - self.identifier = [aDecoder decodeObjectForKey:@"identifier"]; - self.attrSections = [aDecoder decodeObjectForKey:@"attrSections"]; - } - return self; -} - -- (NSUInteger)hash { - return self.uniqueKey.hash; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinAttributesGroup class]]) { - return NO; - } - LookinAttributesGroup *targetObject = object; - - if (![self.identifier isEqualToString:targetObject.identifier]) { - return false; - } - if ([self.identifier isEqualToString:LookinAttrGroup_UserCustom]) { - BOOL ret = [self.userCustomTitle isEqualToString:targetObject.userCustomTitle]; - return ret; - } else { - return true; - } -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (NSString *)uniqueKey { - if ([self.identifier isEqualToString:LookinAttrGroup_UserCustom]) { - return self.userCustomTitle; - } else { - return self.identifier; - } -} - -- (BOOL)isUserCustom { - return [self.identifier isEqualToString:LookinAttrSec_UserCustom]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttributesSection.h b/Sources/LookinServer/Shared/LookinAttributesSection.h deleted file mode 100644 index 32ddec8d..00000000 --- a/Sources/LookinServer/Shared/LookinAttributesSection.h +++ /dev/null @@ -1,35 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributesSection.h -// Lookin -// -// Created by Li Kai on 2019/3/2. -// https://lookin.work -// - - - -#import -#import "LookinAttrIdentifiers.h" - -@class LookinAttribute; - -typedef NS_ENUM (NSInteger, LookinAttributesSectionStyle) { - LookinAttributesSectionStyleDefault, // 每个 attr 独占一行 - LookinAttributesSectionStyle0, // frame 等卡片使用,前 4 个 attr 每行两个,之后每个 attr 在同一排,每个宽度为 1/4 - LookinAttributesSectionStyle1, // 第一个 attr 在第一排靠左,第二个 attr 在第一排靠右,之后的 attr 每个独占一行 - LookinAttributesSectionStyle2 // 第一排独占一行,剩下的在同一行且均分宽度 -}; - -@interface LookinAttributesSection : NSObject - -@property(nonatomic, copy) LookinAttrSectionIdentifier identifier; - -@property(nonatomic, copy) NSArray *attributes; - -- (BOOL)isUserCustom; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAttributesSection.m b/Sources/LookinServer/Shared/LookinAttributesSection.m deleted file mode 100644 index 7617c094..00000000 --- a/Sources/LookinServer/Shared/LookinAttributesSection.m +++ /dev/null @@ -1,56 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAttributesSection.m -// Lookin -// -// Created by Li Kai on 2019/3/2. -// https://lookin.work -// - - - -#import "LookinAttributesSection.h" -#import "LookinAttribute.h" - -#import "NSArray+Lookin.h" - -@implementation LookinAttributesSection - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinAttributesSection *newSection = [[LookinAttributesSection allocWithZone:zone] init]; - newSection.identifier = self.identifier; - newSection.attributes = [self.attributes lookin_map:^id(NSUInteger idx, LookinAttribute *value) { - return value.copy; - }]; - return newSection; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.identifier forKey:@"identifier"]; - [aCoder encodeObject:self.attributes forKey:@"attributes"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.identifier = [aDecoder decodeObjectForKey:@"identifier"]; - self.attributes = [aDecoder decodeObjectForKey:@"attributes"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (BOOL)isUserCustom { - return [self.identifier isEqualToString:LookinAttrSec_UserCustom]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAutoLayoutConstraint.h b/Sources/LookinServer/Shared/LookinAutoLayoutConstraint.h deleted file mode 100644 index f30a713f..00000000 --- a/Sources/LookinServer/Shared/LookinAutoLayoutConstraint.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAutoLayoutConstraint.h -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - - - -#import "LookinDefines.h" - -@class LookinObject; - -typedef NS_ENUM(NSInteger, LookinConstraintItemType) { - LookinConstraintItemTypeUnknown, - LookinConstraintItemTypeNil, - LookinConstraintItemTypeView, - LookinConstraintItemTypeSelf, - LookinConstraintItemTypeSuper, - LookinConstraintItemTypeLayoutGuide -}; - -@interface LookinAutoLayoutConstraint : NSObject - -#if TARGET_OS_IPHONE || TARGET_OS_MAC -+ (instancetype)instanceFromNSConstraint:(NSLayoutConstraint *)constraint isEffective:(BOOL)isEffective firstItemType:(LookinConstraintItemType)firstItemType secondItemType:(LookinConstraintItemType)secondItemType; -#endif - -@property(nonatomic, assign) BOOL effective; -@property(nonatomic, assign) BOOL active; -@property(nonatomic, assign) BOOL shouldBeArchived; -@property(nonatomic, strong) LookinObject *firstItem; -@property(nonatomic, assign) LookinConstraintItemType firstItemType; -/// iOS 里的 NSLayoutAttribute,注意 iOS 和 macOS 虽然都有 NSLayoutAttribute 但是 value 非常不同,因此这里使用 NSInteger 避免混淆 -@property(nonatomic, assign) NSInteger firstAttribute; -@property(nonatomic, assign) NSLayoutRelation relation; -@property(nonatomic, strong) LookinObject *secondItem; -@property(nonatomic, assign) LookinConstraintItemType secondItemType; -/// iOS 里的 NSLayoutAttribute,注意 iOS 和 macOS 虽然都有 NSLayoutAttribute 但是 value 非常不同,因此这里使用 NSInteger 避免混淆 -@property(nonatomic, assign) NSInteger secondAttribute; -@property(nonatomic, assign) CGFloat multiplier; -@property(nonatomic, assign) CGFloat constant; -@property(nonatomic, assign) CGFloat priority; -@property(nonatomic, copy) NSString *identifier; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinAutoLayoutConstraint.m b/Sources/LookinServer/Shared/LookinAutoLayoutConstraint.m deleted file mode 100644 index 53d9ec80..00000000 --- a/Sources/LookinServer/Shared/LookinAutoLayoutConstraint.m +++ /dev/null @@ -1,113 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinAutoLayoutConstraint.m -// Lookin -// -// Created by Li Kai on 2019/9/28. -// https://lookin.work -// - - - -#import "LookinAutoLayoutConstraint.h" -#import "LookinObject.h" - -@implementation LookinAutoLayoutConstraint - -#if TARGET_OS_IPHONE || TARGET_OS_MAC - -+ (instancetype)instanceFromNSConstraint:(NSLayoutConstraint *)constraint isEffective:(BOOL)isEffective firstItemType:(LookinConstraintItemType)firstItemType secondItemType:(LookinConstraintItemType)secondItemType { - LookinAutoLayoutConstraint *instance = [LookinAutoLayoutConstraint new]; - instance.effective = isEffective; - instance.active = constraint.active; - instance.shouldBeArchived = constraint.shouldBeArchived; - instance.firstItem = [LookinObject instanceWithObject:constraint.firstItem]; - instance.firstItemType = firstItemType; - instance.firstAttribute = constraint.firstAttribute; - instance.relation = constraint.relation; - instance.secondItem = [LookinObject instanceWithObject:constraint.secondItem]; - instance.secondItemType = secondItemType; - instance.secondAttribute = constraint.secondAttribute; - instance.multiplier = constraint.multiplier; - instance.constant = constraint.constant; - instance.priority = constraint.priority; - instance.identifier = constraint.identifier; - - return instance; -} - -- (void)setFirstAttribute:(NSInteger)firstAttribute { - _firstAttribute = firstAttribute; -#if TARGET_OS_IPHONE - [self _assertUnknownAttribute:firstAttribute]; -#endif -} - -- (void)setSecondAttribute:(NSInteger)secondAttribute { - _secondAttribute = secondAttribute; -#if TARGET_OS_IPHONE - [self _assertUnknownAttribute:secondAttribute]; -#endif -} - -#if TARGET_OS_IPHONE -- (void)_assertUnknownAttribute:(NSInteger)attribute { - // 以下几个 assert 用来帮助发现那些系统私有的定义,正式发布时应该去掉这几个 assert - if (attribute > 20 && attribute < 32) { - NSAssert(NO, nil); - } - if (attribute > 37) { - NSAssert(NO, nil); - } -} -#endif - -#endif - -#pragma mark - - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeBool:self.effective forKey:@"effective"]; - [aCoder encodeBool:self.active forKey:@"active"]; - [aCoder encodeBool:self.shouldBeArchived forKey:@"shouldBeArchived"]; - [aCoder encodeObject:self.firstItem forKey:@"firstItem"]; - [aCoder encodeInteger:self.firstItemType forKey:@"firstItemType"]; - [aCoder encodeInteger:self.firstAttribute forKey:@"firstAttribute"]; - [aCoder encodeInteger:self.relation forKey:@"relation"]; - [aCoder encodeObject:self.secondItem forKey:@"secondItem"]; - [aCoder encodeInteger:self.secondItemType forKey:@"secondItemType"]; - [aCoder encodeInteger:self.secondAttribute forKey:@"secondAttribute"]; - [aCoder encodeDouble:self.multiplier forKey:@"multiplier"]; - [aCoder encodeDouble:self.constant forKey:@"constant"]; - [aCoder encodeDouble:self.priority forKey:@"priority"]; - [aCoder encodeObject:self.identifier forKey:@"identifier"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.effective = [aDecoder decodeBoolForKey:@"effective"]; - self.active = [aDecoder decodeBoolForKey:@"active"]; - self.shouldBeArchived = [aDecoder decodeBoolForKey:@"shouldBeArchived"]; - self.firstItem = [aDecoder decodeObjectForKey:@"firstItem"]; - self.firstItemType = [aDecoder decodeIntegerForKey:@"firstItemType"]; - self.firstAttribute = [aDecoder decodeIntegerForKey:@"firstAttribute"]; - self.relation = [aDecoder decodeIntegerForKey:@"relation"]; - self.secondItem = [aDecoder decodeObjectForKey:@"secondItem"]; - self.secondItemType = [aDecoder decodeIntegerForKey:@"secondItemType"]; - self.secondAttribute = [aDecoder decodeIntegerForKey:@"secondAttribute"]; - self.multiplier = [aDecoder decodeDoubleForKey:@"multiplier"]; - self.constant = [aDecoder decodeDoubleForKey:@"constant"]; - self.priority = [aDecoder decodeDoubleForKey:@"priority"]; - self.identifier = [aDecoder decodeObjectForKey:@"identifier"]; - } - return self; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinCodingValueType.h b/Sources/LookinServer/Shared/LookinCodingValueType.h deleted file mode 100644 index 8349d68f..00000000 --- a/Sources/LookinServer/Shared/LookinCodingValueType.h +++ /dev/null @@ -1,30 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinCodingValueType.h -// Lookin -// -// Created by Li Kai on 2019/2/13. -// https://lookin.work -// - -typedef NS_ENUM(NSInteger, LookinCodingValueType) { - LookinCodingValueTypeUnknown, - LookinCodingValueTypeChar, - LookinCodingValueTypeDouble, - LookinCodingValueTypeFloat, - LookinCodingValueTypeLongLong, -// LookinCodingValueTypePoint, -// LookinCodingValueTypeString, -// LookinCodingValueTypeStringArray, -// LookinCodingValueTypeEdgeInsets, -// LookinCodingValueTypeRect, - LookinCodingValueTypeBOOL, -// LookinCodingValueTypeSize, - LookinCodingValueTypeColor, - LookinCodingValueTypeEnum, -// LookinCodingValueTypeRange, - LookinCodingValueTypeImage -}; - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinConnectionAttachment.h b/Sources/LookinServer/Shared/LookinConnectionAttachment.h deleted file mode 100644 index 54a4b900..00000000 --- a/Sources/LookinServer/Shared/LookinConnectionAttachment.h +++ /dev/null @@ -1,24 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinConnectionAttachment.h -// Lookin -// -// Created by Li Kai on 2019/2/15. -// https://lookin.work -// - - - -#import -#import "LookinCodingValueType.h" - -@interface LookinConnectionAttachment : NSObject - -@property(nonatomic, assign) LookinCodingValueType dataType; - -@property(nonatomic, strong) id data; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinConnectionAttachment.m b/Sources/LookinServer/Shared/LookinConnectionAttachment.m deleted file mode 100644 index 99eda1ea..00000000 --- a/Sources/LookinServer/Shared/LookinConnectionAttachment.m +++ /dev/null @@ -1,52 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinConnectionAttachment.m -// Lookin -// -// Created by Li Kai on 2019/2/15. -// https://lookin.work -// - - - -#import "LookinConnectionAttachment.h" -#import "LookinDefines.h" -#import "NSObject+Lookin.h" - -static NSString * const Key_Data = @"0"; -static NSString * const Key_DataType = @"1"; - -@interface LookinConnectionAttachment () - -@end - -@implementation LookinConnectionAttachment - -- (instancetype)init { - if (self = [super init]) { - } - return self; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - - [aCoder encodeObject:[self.data lookin_encodedObjectWithType:self.dataType] forKey:Key_Data]; - [aCoder encodeInteger:self.dataType forKey:Key_DataType]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.dataType = [aDecoder decodeIntegerForKey:Key_DataType]; - self.data = [[aDecoder decodeObjectForKey:Key_Data] lookin_decodedObjectWithType:self.dataType]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinConnectionResponseAttachment.h b/Sources/LookinServer/Shared/LookinConnectionResponseAttachment.h deleted file mode 100644 index 46bb0e97..00000000 --- a/Sources/LookinServer/Shared/LookinConnectionResponseAttachment.h +++ /dev/null @@ -1,36 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinConnectionResponse.h -// Lookin -// -// Created by Li Kai on 2019/1/15. -// https://lookin.work -// - - - -#import -#import "LookinConnectionAttachment.h" - -@interface LookinConnectionResponseAttachment : LookinConnectionAttachment - -+ (instancetype)attachmentWithError:(NSError *)error; - -@property(nonatomic, assign) int lookinServerVersion; - -@property(nonatomic, strong) NSError *error; - -/// 如果为 YES,则表示 app 正处于后台模式,默认为 NO -@property(nonatomic, assign) BOOL appIsInBackground; - -/** - dataTotalCount 为 0 时表示仅有这一个 response,默认为 0 - dataTotalCount 大于 0 时表示可能有多个 response,当所有 response 的 currentDataCount 的总和大于 dataTotalCount 即表示所有 response 已接收完毕 - */ -@property(nonatomic, assign) NSUInteger dataTotalCount; -@property(nonatomic, assign) NSUInteger currentDataCount; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinConnectionResponseAttachment.m b/Sources/LookinServer/Shared/LookinConnectionResponseAttachment.m deleted file mode 100644 index 71f62fed..00000000 --- a/Sources/LookinServer/Shared/LookinConnectionResponseAttachment.m +++ /dev/null @@ -1,62 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinConnectionResponse.m -// Lookin -// -// Created by Li Kai on 2019/1/15. -// https://lookin.work -// - - - -#import "LookinConnectionResponseAttachment.h" -#import "LookinDefines.h" - -@interface LookinConnectionResponseAttachment () - -@end - -@implementation LookinConnectionResponseAttachment - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [super encodeWithCoder:aCoder]; - [aCoder encodeInt:self.lookinServerVersion forKey:@"lookinServerVersion"]; - [aCoder encodeObject:self.error forKey:@"error"]; - [aCoder encodeObject:@(self.dataTotalCount) forKey:@"dataTotalCount"]; - [aCoder encodeObject:@(self.currentDataCount) forKey:@"currentDataCount"]; - [aCoder encodeBool:self.appIsInBackground forKey:@"appIsInBackground"]; -} - -- (instancetype)init { - if (self = [super init]) { - self.lookinServerVersion = LOOKIN_SERVER_VERSION; - self.dataTotalCount = 0; - } - return self; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super initWithCoder:aDecoder]) { - self.lookinServerVersion = [aDecoder decodeIntForKey:@"lookinServerVersion"]; - self.error = [aDecoder decodeObjectForKey:@"error"]; - self.dataTotalCount = [[aDecoder decodeObjectForKey:@"dataTotalCount"] unsignedIntegerValue]; - self.currentDataCount = [[aDecoder decodeObjectForKey:@"currentDataCount"] unsignedIntegerValue]; - self.appIsInBackground = [aDecoder decodeBoolForKey:@"appIsInBackground"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -+ (instancetype)attachmentWithError:(NSError *)error { - LookinConnectionResponseAttachment *attachment = [LookinConnectionResponseAttachment new]; - attachment.error = error; - return attachment; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinCustomAttrModification.h b/Sources/LookinServer/Shared/LookinCustomAttrModification.h deleted file mode 100644 index 1be7926a..00000000 --- a/Sources/LookinServer/Shared/LookinCustomAttrModification.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LookinCustomAttrModification.h -// LookinShared -// -// Created by likaimacbookhome on 2023/11/4. -// - -#import -#import "LookinAttrType.h" - -@interface LookinCustomAttrModification : NSObject - -@property(nonatomic, assign) LookinAttrType attrType; -@property(nonatomic, copy) NSString *customSetterID; -@property(nonatomic, strong) id value; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinCustomAttrModification.m b/Sources/LookinServer/Shared/LookinCustomAttrModification.m deleted file mode 100644 index 6d490165..00000000 --- a/Sources/LookinServer/Shared/LookinCustomAttrModification.m +++ /dev/null @@ -1,34 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LookinCustomAttrModification.m -// LookinShared -// -// Created by likaimacbookhome on 2023/11/4. -// - -#import "LookinCustomAttrModification.h" - -@implementation LookinCustomAttrModification - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInteger:self.attrType forKey:@"attrType"]; - [aCoder encodeObject:self.value forKey:@"value"]; - [aCoder encodeObject:self.customSetterID forKey:@"customSetterID"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.attrType = [aDecoder decodeIntegerForKey:@"attrType"]; - self.value = [aDecoder decodeObjectForKey:@"value"]; - self.customSetterID = [aDecoder decodeObjectForKey:@"customSetterID"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinCustomDisplayItemInfo.h b/Sources/LookinServer/Shared/LookinCustomDisplayItemInfo.h deleted file mode 100644 index a74d6ed3..00000000 --- a/Sources/LookinServer/Shared/LookinCustomDisplayItemInfo.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER -// -// LookinCustomDisplayItemInfo.h -// LookinServer -// -// Created by likai.123 on 2023/11/1. -// - -#import - -@interface LookinCustomDisplayItemInfo : NSObject - -/// 该属性可能有值(CGRect)也可能是 nil(nil 时则表示无图像) -@property(nonatomic, strong) NSValue *frameInWindow; -@property(nonatomic, copy) NSString *title; -@property(nonatomic, copy) NSString *subtitle; -@property(nonatomic, copy) NSString *danceuiSource; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinCustomDisplayItemInfo.m b/Sources/LookinServer/Shared/LookinCustomDisplayItemInfo.m deleted file mode 100644 index 26adc5e9..00000000 --- a/Sources/LookinServer/Shared/LookinCustomDisplayItemInfo.m +++ /dev/null @@ -1,59 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinCustomDisplayItemInfo.m -// LookinServer -// -// Created by likai.123 on 2023/11/1. -// - -#import "LookinCustomDisplayItemInfo.h" -#if TARGET_OS_IPHONE -#import -#endif - -@implementation LookinCustomDisplayItemInfo - -- (id)copyWithZone:(NSZone *)zone { - LookinCustomDisplayItemInfo *newInstance = [[LookinCustomDisplayItemInfo allocWithZone:zone] init]; - - if (self.frameInWindow) { -#if TARGET_OS_IPHONE - CGRect rect = [self.frameInWindow CGRectValue]; - newInstance.frameInWindow = [NSValue valueWithCGRect:rect]; -#elif TARGET_OS_OSX - CGRect rect = [self.frameInWindow rectValue]; - newInstance.frameInWindow = [NSValue valueWithRect:rect]; -#endif - } - newInstance.title = self.title; - newInstance.subtitle = self.subtitle; - newInstance.danceuiSource = self.danceuiSource; - - return newInstance; -} - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.frameInWindow forKey:@"frameInWindow"]; - [aCoder encodeObject:self.title forKey:@"title"]; - [aCoder encodeObject:self.subtitle forKey:@"subtitle"]; - [aCoder encodeObject:self.danceuiSource forKey:@"danceuiSource"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.frameInWindow = [aDecoder decodeObjectForKey:@"frameInWindow"]; - self.title = [aDecoder decodeObjectForKey:@"title"]; - self.subtitle = [aDecoder decodeObjectForKey:@"subtitle"]; - self.danceuiSource = [aDecoder decodeObjectForKey:@"danceuiSource"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinDashboardBlueprint.h b/Sources/LookinServer/Shared/LookinDashboardBlueprint.h deleted file mode 100644 index b4b9cac4..00000000 --- a/Sources/LookinServer/Shared/LookinDashboardBlueprint.h +++ /dev/null @@ -1,79 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDashboardBlueprint.h -// Lookin -// -// Created by Li Kai on 2019/6/5. -// https://lookin.work -// - - - -#import -#import "LookinAttrIdentifiers.h" -#import "LookinAttrType.h" - -/** - 该对象定义了: - - 每一个 Attr 的信息 - - 哪些 GroupID, SectionID, AttrID 是合法的 - - 这些 ID 的父子顺序,比如 LookinAttrGroup_Frame 包含哪些 Section - - 这些 ID 展示顺序(比如哪个 Group 在前、哪个 Group 在后) - */ -@interface LookinDashboardBlueprint : NSObject - -+ (NSArray *)groupIDs; - -+ (NSArray *)sectionIDsForGroupID:(LookinAttrGroupIdentifier)groupID; - -+ (NSArray *)attrIDsForSectionID:(LookinAttrSectionIdentifier)sectionID; - -/// 返回包含目标 attr 的 groupID 和 sectionID -+ (void)getHostGroupID:(inout LookinAttrGroupIdentifier *)groupID sectionID:(inout LookinAttrSectionIdentifier *)sectionID fromAttrID:(LookinAttrIdentifier)attrID; - -/// 返回某个 group 的标题 -+ (NSString *)groupTitleWithGroupID:(LookinAttrGroupIdentifier)groupID; - -/// 返回某个 section 的标题,nil 则表示不显示标题 -+ (NSString *)sectionTitleWithSectionID:(LookinAttrSectionIdentifier)secID; - -/// 当某个 LookinAttribute 确定是 NSObject 类型时,该方法返回它具体是什么对象,比如 UIColor 等 -+ (LookinAttrType)objectAttrTypeWithAttrID:(LookinAttrIdentifier)attrID; - -/// 返回某个 LookinAttribute 代表的属性是哪一个类拥有的,比如 LookinAttrSec_UILabel_TextColor 是 UILabel 才有的 -+ (NSString *)classNameWithAttrID:(LookinAttrIdentifier)attrID; - -+ (BOOL)isWindowPropertyWithAttrID:(LookinAttrIdentifier)attrID; - -/// 一个 attr 要么属于 UIView 要么属于 CALayer,如果它属于 UIView 那么该方法返回 YES -+ (BOOL)isUIViewPropertyWithAttrID:(LookinAttrIdentifier)attrID; - -/// 如果某个 attribute 是 enum,则这里会返回相应的 enum 的名称(如 @"NSTextAlignment"),进而可通过这个名称查询可用的枚举值列表 -+ (NSString *)enumListNameWithAttrID:(LookinAttrIdentifier)attrID; - -/// 如果返回 YES,则说明用户在 Lookin 里修改了该 Attribute 的值后,应该重新拉取和更新相关图层的位置、截图等信息 -+ (BOOL)needPatchAfterModificationWithAttrID:(LookinAttrIdentifier)attrID; - -/// 完整的名字 -+ (NSString *)fullTitleWithAttrID:(LookinAttrIdentifier)attrID; - -/// 在某些 textField 和 checkbox 里会显示这里返回的 title -+ (NSString *)briefTitleWithAttrID:(LookinAttrIdentifier)attrID; - -/// 获取 getter 方法 -+ (SEL)getterWithAttrID:(LookinAttrIdentifier)attrID; - -/// 获取 setter 方法 -+ (SEL)setterWithAttrID:(LookinAttrIdentifier)attrID; - -/// 获取 “hideIfNil” 的值。如果为 YES,则当读取 getter 获取的 value 为 nil 时,Lookin 不会传输该 attr -/// 如果为 NO,则即使 value 为 nil 也会传输(比如 label 的 text 属性,即使它是 nil 我们也要显示,所以它的 hideIfNil 应该为 NO) -+ (BOOL)hideIfNilWithAttrID:(LookinAttrIdentifier)attrID; - -/// 该属性需要的最低的 iOS 版本,比如 safeAreaInsets 从 iOS 11.0 开始出现,则该方法返回 11,如果返回 0 则表示不限制 iOS 版本(注意 Lookin 项目仅支持 iOS 8.0+) -+ (NSInteger)minAvailableOSVersionWithAttrID:(LookinAttrIdentifier)attrID; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinDashboardBlueprint.m b/Sources/LookinServer/Shared/LookinDashboardBlueprint.m deleted file mode 100644 index a8fe959e..00000000 --- a/Sources/LookinServer/Shared/LookinDashboardBlueprint.m +++ /dev/null @@ -1,3414 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDashboardBlueprint.m -// Lookin -// -// Created by Li Kai on 2019/6/5. -// https://lookin.work -// - - - -#import "LookinDashboardBlueprint.h" - -@implementation LookinDashboardBlueprint - -+ (NSArray *)groupIDs { - static NSArray *array; - static dispatch_once_t onceToken; - dispatch_once(&onceToken,^{ - array = @[ - LookinAttrGroup_Class, - LookinAttrGroup_Relation, - LookinAttrGroup_Layout, - LookinAttrGroup_AutoLayout, - LookinAttrGroup_ViewLayer, -#if TARGET_OS_IPHONE - LookinAttrGroup_UIStackView, - LookinAttrGroup_UIVisualEffectView, - LookinAttrGroup_UIImageView, - LookinAttrGroup_UILabel, - LookinAttrGroup_UIControl, - LookinAttrGroup_UIButton, - LookinAttrGroup_UIScrollView, - LookinAttrGroup_UITableView, - LookinAttrGroup_UITextView, - LookinAttrGroup_UITextField, - LookinAttrGroup_UIWindowScene, - LookinAttrGroup_UITraitCollection, -#endif -#if TARGET_OS_OSX - LookinAttrGroup_NSImageView, - LookinAttrGroup_NSControl, - LookinAttrGroup_NSButton, - LookinAttrGroup_NSScrollView, - LookinAttrGroup_NSTableView, - LookinAttrGroup_NSTextView, - LookinAttrGroup_NSTextField, - LookinAttrGroup_NSVisualEffectView, - LookinAttrGroup_NSStackView, - LookinAttrGroup_NSWindow, -#endif - ]; - }); - return array; -} - -+ (NSArray *)sectionIDsForGroupID:(LookinAttrGroupIdentifier)groupID { - static NSDictionary *> *dict; - static dispatch_once_t onceToken; - dispatch_once(&onceToken,^{ - dict = @{ - LookinAttrGroup_Class: @[LookinAttrSec_Class_Class], - - LookinAttrGroup_Relation: @[LookinAttrSec_Relation_Relation], - - LookinAttrGroup_Layout: @[LookinAttrSec_Layout_Frame, - LookinAttrSec_Layout_Bounds, - LookinAttrSec_Layout_SafeArea, - LookinAttrSec_Layout_Position, - LookinAttrSec_Layout_AnchorPoint], - - LookinAttrGroup_AutoLayout: @[LookinAttrSec_AutoLayout_Constraints, - LookinAttrSec_AutoLayout_IntrinsicSize, - LookinAttrSec_AutoLayout_Hugging, - LookinAttrSec_AutoLayout_Resistance], - - LookinAttrGroup_ViewLayer: @[ - LookinAttrSec_ViewLayer_Visibility, - LookinAttrSec_ViewLayer_InterationAndMasks, - LookinAttrSec_ViewLayer_BgColor, - LookinAttrSec_ViewLayer_Border, - LookinAttrSec_ViewLayer_Corner, - LookinAttrSec_ViewLayer_Shadow, - LookinAttrSec_ViewLayer_Tag, -#if TARGET_OS_IPHONE - LookinAttrSec_ViewLayer_ContentMode, - LookinAttrSec_ViewLayer_TintColor, -#endif - ], - -#if TARGET_OS_IPHONE - LookinAttrGroup_UIStackView: @[ - LookinAttrSec_UIStackView_Axis, - LookinAttrSec_UIStackView_Distribution, - LookinAttrSec_UIStackView_Alignment, - LookinAttrSec_UIStackView_Spacing - ], - - LookinAttrGroup_UIVisualEffectView: @[ - LookinAttrSec_UIVisualEffectView_Style, - LookinAttrSec_UIVisualEffectView_QMUIForegroundColor - ], - - LookinAttrGroup_UIImageView: @[LookinAttrSec_UIImageView_Name, - LookinAttrSec_UIImageView_Open], - - LookinAttrGroup_UILabel: @[ - LookinAttrSec_UILabel_Text, - LookinAttrSec_UILabel_Font, - LookinAttrSec_UILabel_NumberOfLines, - LookinAttrSec_UILabel_TextColor, - LookinAttrSec_UILabel_BreakMode, - LookinAttrSec_UILabel_Alignment, - LookinAttrSec_UILabel_CanAdjustFont], - - LookinAttrGroup_UIControl: @[LookinAttrSec_UIControl_EnabledSelected, - LookinAttrSec_UIControl_QMUIOutsideEdge, - LookinAttrSec_UIControl_VerAlignment, - LookinAttrSec_UIControl_HorAlignment], - - LookinAttrGroup_UIButton: @[LookinAttrSec_UIButton_ContentInsets, - LookinAttrSec_UIButton_TitleInsets, - LookinAttrSec_UIButton_ImageInsets], - - LookinAttrGroup_UIScrollView: @[LookinAttrSec_UIScrollView_ContentInset, - LookinAttrSec_UIScrollView_AdjustedInset, - LookinAttrSec_UIScrollView_QMUIInitialInset, - LookinAttrSec_UIScrollView_IndicatorInset, - LookinAttrSec_UIScrollView_Offset, - LookinAttrSec_UIScrollView_ContentSize, - LookinAttrSec_UIScrollView_Behavior, - LookinAttrSec_UIScrollView_ShowsIndicator, - LookinAttrSec_UIScrollView_Bounce, - LookinAttrSec_UIScrollView_ScrollPaging, - LookinAttrSec_UIScrollView_ContentTouches, - LookinAttrSec_UIScrollView_Zoom], - - LookinAttrGroup_UITableView: @[LookinAttrSec_UITableView_Style, - LookinAttrSec_UITableView_SectionsNumber, - LookinAttrSec_UITableView_RowsNumber, - LookinAttrSec_UITableView_SeparatorStyle, - LookinAttrSec_UITableView_SeparatorColor, - LookinAttrSec_UITableView_SeparatorInset], - - LookinAttrGroup_UITextView: @[LookinAttrSec_UITextView_Basic, - LookinAttrSec_UITextView_Text, - LookinAttrSec_UITextView_Font, - LookinAttrSec_UITextView_TextColor, - LookinAttrSec_UITextView_Alignment, - LookinAttrSec_UITextView_ContainerInset], - - LookinAttrGroup_UITextField: @[LookinAttrSec_UITextField_Text, - LookinAttrSec_UITextField_Placeholder, - LookinAttrSec_UITextField_Font, - LookinAttrSec_UITextField_TextColor, - LookinAttrSec_UITextField_Alignment, - LookinAttrSec_UITextField_Clears, - LookinAttrSec_UITextField_CanAdjustFont, - LookinAttrSec_UITextField_ClearButtonMode], - LookinAttrGroup_UIWindowScene: @[ - LookinAttrSec_UIWindowScene_State, - LookinAttrSec_UIWindowScene_Title, - LookinAttrSec_UIWindowScene_Orientation, - LookinAttrSec_UIWindowScene_Windows, - LookinAttrSec_UIWindowScene_Screen, - LookinAttrSec_UIWindowScene_StatusBar, - LookinAttrSec_UIWindowScene_Traits, - LookinAttrSec_UIWindowScene_Session, - ], - LookinAttrGroup_UITraitCollection: @[ - LookinAttrSec_UITraitCollection_Appearance, - LookinAttrSec_UITraitCollection_SizeClass, - LookinAttrSec_UITraitCollection_Display, - LookinAttrSec_UITraitCollection_Device, - LookinAttrSec_UITraitCollection_Layout, - LookinAttrSec_UITraitCollection_Content, - ], -#endif -#if TARGET_OS_OSX - LookinAttrGroup_NSImageView: @[ - LookinAttrSec_NSImageView_Name, - LookinAttrSec_NSImageView_Open, - LookinAttrSec_NSImageView_Scaling, - LookinAttrSec_NSImageView_Behavior, - LookinAttrSec_NSImageView_ContentTintColor, - ], - - LookinAttrGroup_NSControl: @[ - LookinAttrSec_NSControl_State, - LookinAttrSec_NSControl_ControlSize, - LookinAttrSec_NSControl_Font, - LookinAttrSec_NSControl_Alignment, - LookinAttrSec_NSControl_Misc, - LookinAttrSec_NSControl_StringValue, - LookinAttrSec_NSControl_Value, - ], - - LookinAttrGroup_NSButton: @[ - LookinAttrSec_NSButton_ButtonType, - LookinAttrSec_NSButton_BezelStyle, - LookinAttrSec_NSButton_Title, - LookinAttrSec_NSButton_Bordered, - LookinAttrSec_NSButton_BezelColor, - LookinAttrSec_NSButton_Misc, - ], - - LookinAttrGroup_NSScrollView: @[ - LookinAttrSec_NSScrollView_ContentOffset, - LookinAttrSec_NSScrollView_ContentSize, - LookinAttrSec_NSScrollView_ContentInset, - LookinAttrSec_NSScrollView_BorderType, - LookinAttrSec_NSScrollView_Scroller, - LookinAttrSec_NSScrollView_Ruler, - LookinAttrSec_NSScrollView_LineScroll, - LookinAttrSec_NSScrollView_PageScroll, - LookinAttrSec_NSScrollView_ScrollElasiticity, - LookinAttrSec_NSScrollView_Misc, - LookinAttrSec_NSScrollView_Magnification, - ], - - LookinAttrGroup_NSTableView: @[ - LookinAttrSec_NSTableView_RowHeight, - LookinAttrSec_NSTableView_AutomaticRowHeights, - LookinAttrSec_NSTableView_IntercellSpacing, - LookinAttrSec_NSTableView_Style, - LookinAttrSec_NSTableView_ColumnAutoresizingStyle, - LookinAttrSec_NSTableView_GridStyleMask, - LookinAttrSec_NSTableView_SelectionHighlightStyle, - LookinAttrSec_NSTableView_GridColor, - LookinAttrSec_NSTableView_RowSizeStyle, - LookinAttrSec_NSTableView_NumberOfRows, - LookinAttrSec_NSTableView_NumberOfColumns, - LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors, - LookinAttrSec_NSTableView_AllowsColumnReordering, - LookinAttrSec_NSTableView_AllowsColumnResizing, - LookinAttrSec_NSTableView_AllowsMultipleSelection, - LookinAttrSec_NSTableView_AllowsEmptySelection, - LookinAttrSec_NSTableView_AllowsColumnSelection, - LookinAttrSec_NSTableView_AllowsTypeSelect, - LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle, - LookinAttrSec_NSTableView_Autosave, - LookinAttrSec_NSTableView_FloatsGroupRows, - LookinAttrSec_NSTableView_RowActionsVisible, - LookinAttrSec_NSTableView_UsesStaticContents, - LookinAttrSec_NSTableView_UserInterfaceLayoutDirection, - LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag, - ], - - LookinAttrGroup_NSTextView: @[ - LookinAttrSec_NSTextView_Font, - LookinAttrSec_NSTextView_Basic, - LookinAttrSec_NSTextView_String, - LookinAttrSec_NSTextView_TextColor, - LookinAttrSec_NSTextView_Alignment, - LookinAttrSec_NSTextView_ContainerInset, - LookinAttrSec_NSTextView_BaseWritingDirection, - LookinAttrSec_NSTextView_Size, - LookinAttrSec_NSTextView_Resizable, - ], - - LookinAttrGroup_NSTextField: @[ - LookinAttrSec_NSTextField_BezelStyle, - LookinAttrSec_NSTextField_LineBreakStrategy, - LookinAttrSec_NSTextField_Bordered, - LookinAttrSec_NSTextField_TextColor, - LookinAttrSec_NSTextField_Placeholder, - LookinAttrSec_NSTextField_PreferredMaxLayoutWidth, - ], - - - LookinAttrGroup_NSVisualEffectView: @[ - LookinAttrSec_NSVisualEffectView_Material, - LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle, - LookinAttrSec_NSVisualEffectView_BlendingMode, - LookinAttrSec_NSVisualEffectView_State, - LookinAttrSec_NSVisualEffectView_Emphasized, - ], - - LookinAttrGroup_NSStackView: @[ - LookinAttrSec_NSStackView_Orientation, - LookinAttrSec_NSStackView_EdgeInsets, - LookinAttrSec_NSStackView_DetachesHiddenViews, - LookinAttrSec_NSStackView_Distribution, - LookinAttrSec_NSStackView_Alignment, - LookinAttrSec_NSStackView_Spacing, - ], - - LookinAttrGroup_NSWindow: @[ - LookinAttrSec_NSWindow_Title, - LookinAttrSec_NSWindow_Subtitle, - LookinAttrSec_NSWindow_State, - LookinAttrSec_NSWindow_Style, - LookinAttrSec_NSWindow_CollectionBehavior, - LookinAttrSec_NSWindow_Appearance, - LookinAttrSec_NSWindow_TitleVisibility, - LookinAttrSec_NSWindow_ToolbarStyle, - LookinAttrSec_NSWindow_TitlebarSeparatorStyle, - LookinAttrSec_NSWindow_Behavior, - LookinAttrSec_NSWindow_AnimationBehavior, - LookinAttrSec_NSWindow_Level, - LookinAttrSec_NSWindow_TabbingMode, - LookinAttrSec_NSWindow_Size, - LookinAttrSec_NSWindow_Info, - ], -#endif - - }; - }); - return dict[groupID]; -} - -+ (NSArray *)attrIDsForSectionID:(LookinAttrSectionIdentifier)sectionID { - static NSDictionary *> *dict; - static dispatch_once_t onceToken; - dispatch_once(&onceToken,^{ - dict = @{ - LookinAttrSec_Class_Class: @[LookinAttr_Class_Class_Class], - - LookinAttrSec_Relation_Relation: @[LookinAttr_Relation_Relation_Relation], - - LookinAttrSec_Layout_Frame: @[LookinAttr_Layout_Frame_Frame], - LookinAttrSec_Layout_Bounds: @[LookinAttr_Layout_Bounds_Bounds], - LookinAttrSec_Layout_SafeArea: @[LookinAttr_Layout_SafeArea_SafeArea], - LookinAttrSec_Layout_Position: @[LookinAttr_Layout_Position_Position], - LookinAttrSec_Layout_AnchorPoint: @[LookinAttr_Layout_AnchorPoint_AnchorPoint], - - LookinAttrSec_AutoLayout_Hugging: @[LookinAttr_AutoLayout_Hugging_Hor, - LookinAttr_AutoLayout_Hugging_Ver], - LookinAttrSec_AutoLayout_Resistance: @[LookinAttr_AutoLayout_Resistance_Hor, - LookinAttr_AutoLayout_Resistance_Ver], - LookinAttrSec_AutoLayout_Constraints: @[LookinAttr_AutoLayout_Constraints_Constraints], - LookinAttrSec_AutoLayout_IntrinsicSize: @[LookinAttr_AutoLayout_IntrinsicSize_Size], - - LookinAttrSec_ViewLayer_Visibility: @[LookinAttr_ViewLayer_Visibility_Hidden, - LookinAttr_ViewLayer_Visibility_Opacity], - - LookinAttrSec_ViewLayer_InterationAndMasks:@[ -#if TARGET_OS_IPHONE - LookinAttr_ViewLayer_InterationAndMasks_Interaction, -#endif - LookinAttr_ViewLayer_InterationAndMasks_MasksToBounds], - - LookinAttrSec_ViewLayer_Corner: @[LookinAttr_ViewLayer_Corner_Radius], - - LookinAttrSec_ViewLayer_BgColor: @[LookinAttr_ViewLayer_BgColor_BgColor], - - LookinAttrSec_ViewLayer_Border: @[LookinAttr_ViewLayer_Border_Color, - LookinAttr_ViewLayer_Border_Width], - - LookinAttrSec_ViewLayer_Shadow: @[LookinAttr_ViewLayer_Shadow_Color, - LookinAttr_ViewLayer_Shadow_Opacity, - LookinAttr_ViewLayer_Shadow_Radius, - LookinAttr_ViewLayer_Shadow_OffsetW, - LookinAttr_ViewLayer_Shadow_OffsetH], -#if TARGET_OS_IPHONE - - LookinAttrSec_ViewLayer_ContentMode: @[LookinAttr_ViewLayer_ContentMode_Mode], - - LookinAttrSec_ViewLayer_TintColor: @[LookinAttr_ViewLayer_TintColor_Color, - LookinAttr_ViewLayer_TintColor_Mode], -#endif - - LookinAttrSec_ViewLayer_Tag: @[LookinAttr_ViewLayer_Tag_Tag], -#if TARGET_OS_IPHONE - - LookinAttrSec_UIStackView_Axis: @[LookinAttr_UIStackView_Axis_Axis], - - LookinAttrSec_UIStackView_Distribution: @[LookinAttr_UIStackView_Distribution_Distribution], - - LookinAttrSec_UIStackView_Alignment: @[LookinAttr_UIStackView_Alignment_Alignment], - - LookinAttrSec_UIStackView_Spacing: @[LookinAttr_UIStackView_Spacing_Spacing], - - LookinAttrSec_UIVisualEffectView_Style: @[LookinAttr_UIVisualEffectView_Style_Style], - - LookinAttrSec_UIVisualEffectView_QMUIForegroundColor: @[LookinAttr_UIVisualEffectView_QMUIForegroundColor_Color], - - LookinAttrSec_UIImageView_Name: @[LookinAttr_UIImageView_Name_Name], - - LookinAttrSec_UIImageView_Open: @[LookinAttr_UIImageView_Open_Open], - - LookinAttrSec_UILabel_Font: @[LookinAttr_UILabel_Font_Name, - LookinAttr_UILabel_Font_Size], - - LookinAttrSec_UILabel_NumberOfLines: @[LookinAttr_UILabel_NumberOfLines_NumberOfLines], - - LookinAttrSec_UILabel_Text: @[LookinAttr_UILabel_Text_Text], - - LookinAttrSec_UILabel_TextColor: @[LookinAttr_UILabel_TextColor_Color], - - LookinAttrSec_UILabel_BreakMode: @[LookinAttr_UILabel_BreakMode_Mode], - - LookinAttrSec_UILabel_Alignment: @[LookinAttr_UILabel_Alignment_Alignment], - - LookinAttrSec_UILabel_CanAdjustFont: @[LookinAttr_UILabel_CanAdjustFont_CanAdjustFont], - - LookinAttrSec_UIControl_EnabledSelected: @[LookinAttr_UIControl_EnabledSelected_Enabled, - LookinAttr_UIControl_EnabledSelected_Selected], - - LookinAttrSec_UIControl_QMUIOutsideEdge: @[LookinAttr_UIControl_QMUIOutsideEdge_Edge], - - LookinAttrSec_UIControl_VerAlignment: @[LookinAttr_UIControl_VerAlignment_Alignment], - - LookinAttrSec_UIControl_HorAlignment: @[LookinAttr_UIControl_HorAlignment_Alignment], - - LookinAttrSec_UIButton_ContentInsets: @[LookinAttr_UIButton_ContentInsets_Insets], - - LookinAttrSec_UIButton_TitleInsets: @[LookinAttr_UIButton_TitleInsets_Insets], - - LookinAttrSec_UIButton_ImageInsets: @[LookinAttr_UIButton_ImageInsets_Insets], - - LookinAttrSec_UIScrollView_ContentInset: @[LookinAttr_UIScrollView_ContentInset_Inset], - - LookinAttrSec_UIScrollView_AdjustedInset: @[LookinAttr_UIScrollView_AdjustedInset_Inset], - - LookinAttrSec_UIScrollView_QMUIInitialInset: @[LookinAttr_UIScrollView_QMUIInitialInset_Inset], - - LookinAttrSec_UIScrollView_IndicatorInset: @[LookinAttr_UIScrollView_IndicatorInset_Inset], - - LookinAttrSec_UIScrollView_Offset: @[LookinAttr_UIScrollView_Offset_Offset], - - LookinAttrSec_UIScrollView_ContentSize: @[LookinAttr_UIScrollView_ContentSize_Size], - - LookinAttrSec_UIScrollView_Behavior: @[LookinAttr_UIScrollView_Behavior_Behavior], - - LookinAttrSec_UIScrollView_ShowsIndicator: @[LookinAttr_UIScrollView_ShowsIndicator_Hor, - LookinAttr_UIScrollView_ShowsIndicator_Ver], - - LookinAttrSec_UIScrollView_Bounce: @[LookinAttr_UIScrollView_Bounce_Hor, - LookinAttr_UIScrollView_Bounce_Ver], - - LookinAttrSec_UIScrollView_ScrollPaging: @[LookinAttr_UIScrollView_ScrollPaging_ScrollEnabled, - LookinAttr_UIScrollView_ScrollPaging_PagingEnabled], - - LookinAttrSec_UIScrollView_ContentTouches: @[LookinAttr_UIScrollView_ContentTouches_Delay, - LookinAttr_UIScrollView_ContentTouches_CanCancel], - - LookinAttrSec_UIScrollView_Zoom: @[LookinAttr_UIScrollView_Zoom_Bounce, - LookinAttr_UIScrollView_Zoom_Scale, - LookinAttr_UIScrollView_Zoom_MinScale, - LookinAttr_UIScrollView_Zoom_MaxScale], - - LookinAttrSec_UITableView_Style: @[LookinAttr_UITableView_Style_Style], - - LookinAttrSec_UITableView_SectionsNumber: @[LookinAttr_UITableView_SectionsNumber_Number], - - LookinAttrSec_UITableView_RowsNumber: @[LookinAttr_UITableView_RowsNumber_Number], - - LookinAttrSec_UITableView_SeparatorInset: @[LookinAttr_UITableView_SeparatorInset_Inset], - - LookinAttrSec_UITableView_SeparatorColor: @[LookinAttr_UITableView_SeparatorColor_Color], - - LookinAttrSec_UITableView_SeparatorStyle: @[LookinAttr_UITableView_SeparatorStyle_Style], - - LookinAttrSec_UITextView_Basic: @[LookinAttr_UITextView_Basic_Editable, - LookinAttr_UITextView_Basic_Selectable], - - LookinAttrSec_UITextView_Text: @[LookinAttr_UITextView_Text_Text], - - LookinAttrSec_UITextView_Font: @[LookinAttr_UITextView_Font_Name, - LookinAttr_UITextView_Font_Size], - - LookinAttrSec_UITextView_TextColor: @[LookinAttr_UITextView_TextColor_Color], - - LookinAttrSec_UITextView_Alignment: @[LookinAttr_UITextView_Alignment_Alignment], - - LookinAttrSec_UITextView_ContainerInset: @[LookinAttr_UITextView_ContainerInset_Inset], - - LookinAttrSec_UITextField_Text: @[LookinAttr_UITextField_Text_Text], - - LookinAttrSec_UITextField_Placeholder: @[LookinAttr_UITextField_Placeholder_Placeholder], - - LookinAttrSec_UITextField_Font: @[LookinAttr_UITextField_Font_Name, - LookinAttr_UITextField_Font_Size], - - LookinAttrSec_UITextField_TextColor: @[LookinAttr_UITextField_TextColor_Color], - - LookinAttrSec_UITextField_Alignment: @[LookinAttr_UITextField_Alignment_Alignment], - - LookinAttrSec_UITextField_Clears: @[LookinAttr_UITextField_Clears_ClearsOnBeginEditing, - LookinAttr_UITextField_Clears_ClearsOnInsertion], - - LookinAttrSec_UITextField_CanAdjustFont: @[LookinAttr_UITextField_CanAdjustFont_CanAdjustFont, - LookinAttr_UITextField_CanAdjustFont_MinSize], - - LookinAttrSec_UITextField_ClearButtonMode: @[LookinAttr_UITextField_ClearButtonMode_Mode], - LookinAttrSec_UIWindowScene_State: @[ - LookinAttr_UIWindowScene_State_ActivationState, - ], - LookinAttrSec_UIWindowScene_Title: @[ - LookinAttr_UIWindowScene_Title_Title, - ], - LookinAttrSec_UIWindowScene_Orientation: @[ - LookinAttr_UIWindowScene_Orientation_InterfaceOrientation, - ], - LookinAttrSec_UIWindowScene_Windows: @[ - LookinAttr_UIWindowScene_Windows_WindowCount, - LookinAttr_UIWindowScene_Windows_KeyWindowClassName, - ], - LookinAttrSec_UIWindowScene_Screen: @[ - LookinAttr_UIWindowScene_Screen_ScreenBounds, - LookinAttr_UIWindowScene_Screen_ScreenScale, - ], - LookinAttrSec_UIWindowScene_StatusBar: @[ - LookinAttr_UIWindowScene_StatusBar_StatusBarHidden, - LookinAttr_UIWindowScene_StatusBar_StatusBarStyle, - LookinAttr_UIWindowScene_StatusBar_StatusBarFrame, - ], - LookinAttrSec_UIWindowScene_Traits: @[ - LookinAttr_UIWindowScene_Traits_UserInterfaceStyle, - LookinAttr_UIWindowScene_Traits_HorizontalSizeClass, - LookinAttr_UIWindowScene_Traits_VerticalSizeClass, - LookinAttr_UIWindowScene_Traits_UserInterfaceLevel, - LookinAttr_UIWindowScene_Traits_ActiveAppearance, - LookinAttr_UIWindowScene_Traits_AccessibilityContrast, - LookinAttr_UIWindowScene_Traits_LegibilityWeight, - LookinAttr_UIWindowScene_Traits_DisplayScale, - LookinAttr_UIWindowScene_Traits_DisplayGamut, - LookinAttr_UIWindowScene_Traits_UserInterfaceIdiom, - LookinAttr_UIWindowScene_Traits_LayoutDirection, - LookinAttr_UIWindowScene_Traits_PreferredContentSizeCategory, - LookinAttr_UIWindowScene_Traits_SceneCaptureState, - LookinAttr_UIWindowScene_Traits_ImageDynamicRange, - LookinAttr_UIWindowScene_Traits_TypesettingLanguage, - ], - LookinAttrSec_UIWindowScene_Session: @[ - LookinAttr_UIWindowScene_Session_PersistentIdentifier, - LookinAttr_UIWindowScene_Session_SessionRole, - ], - // UITraitCollection - LookinAttrSec_UITraitCollection_Appearance: @[ - LookinAttr_UITraitCollection_Appearance_UserInterfaceStyle, - LookinAttr_UITraitCollection_Appearance_UserInterfaceLevel, - LookinAttr_UITraitCollection_Appearance_ActiveAppearance, - LookinAttr_UITraitCollection_Appearance_AccessibilityContrast, - LookinAttr_UITraitCollection_Appearance_LegibilityWeight, - ], - LookinAttrSec_UITraitCollection_SizeClass: @[ - LookinAttr_UITraitCollection_SizeClass_HorizontalSizeClass, - LookinAttr_UITraitCollection_SizeClass_VerticalSizeClass, - ], - LookinAttrSec_UITraitCollection_Display: @[ - LookinAttr_UITraitCollection_Display_DisplayScale, - LookinAttr_UITraitCollection_Display_DisplayGamut, - LookinAttr_UITraitCollection_Display_ImageDynamicRange, - ], - LookinAttrSec_UITraitCollection_Device: @[ - LookinAttr_UITraitCollection_Device_UserInterfaceIdiom, - LookinAttr_UITraitCollection_Device_ForceTouchCapability, - ], - LookinAttrSec_UITraitCollection_Layout: @[ - LookinAttr_UITraitCollection_Layout_LayoutDirection, - ], - LookinAttrSec_UITraitCollection_Content: @[ - LookinAttr_UITraitCollection_Content_PreferredContentSizeCategory, - LookinAttr_UITraitCollection_Content_TypesettingLanguage, - ], -#endif -#if TARGET_OS_OSX - LookinAttrSec_NSImageView_Name:@[ - LookinAttr_NSImageView_Name_Name - ], - LookinAttrSec_NSImageView_Open:@[ - LookinAttr_NSImageView_Open_Open - ], - LookinAttrSec_NSImageView_Scaling: @[ - LookinAttr_NSImageView_Scaling_ImageScaling, - LookinAttr_NSImageView_Scaling_ImageAlignment, - LookinAttr_NSImageView_Scaling_ImageFrameStyle, - ], - LookinAttrSec_NSImageView_Behavior: @[ - LookinAttr_NSImageView_Behavior_Animates, - LookinAttr_NSImageView_Behavior_Editable, - ], - LookinAttrSec_NSImageView_ContentTintColor: @[ - LookinAttr_NSImageView_ContentTintColor_ContentTintColor, - ], - LookinAttrSec_NSControl_State: @[ - LookinAttr_NSControl_State_Enabled, - LookinAttr_NSControl_State_Highlighted, - LookinAttr_NSControl_State_Continuous, - ], - LookinAttrSec_NSControl_ControlSize: @[ - LookinAttr_NSControl_ControlSize_Size - ], - LookinAttrSec_NSControl_Font: @[ - LookinAttr_NSControl_Font_Name, - LookinAttr_NSControl_Font_Size - ], - LookinAttrSec_NSControl_Alignment: @[ - LookinAttr_NSControl_Alignment_Alignment - ], - LookinAttrSec_NSControl_Misc: @[ - LookinAttr_NSControl_Misc_WritingDirection, - LookinAttr_NSControl_Misc_IgnoresMultiClick, - LookinAttr_NSControl_Misc_UsesSingleLineMode, - LookinAttr_NSControl_Misc_AllowsExpansionToolTips, - ], - LookinAttrSec_NSControl_StringValue: @[ - LookinAttr_NSControl_Value_StringValue, - ], - LookinAttrSec_NSControl_Value: @[ - LookinAttr_NSControl_Value_IntValue, - LookinAttr_NSControl_Value_IntegerValue, - LookinAttr_NSControl_Value_FloatValue, - LookinAttr_NSControl_Value_DoubleValue, - ], - - LookinAttrSec_NSButton_ButtonType: @[ - LookinAttr_NSButton_ButtonType_ButtonType - ], - LookinAttrSec_NSButton_Title: @[ - LookinAttr_NSButton_Title_Title, - LookinAttr_NSButton_Title_AlernateTitle, - ], - LookinAttrSec_NSButton_BezelStyle: @[LookinAttr_NSButton_BezelStyle_BezelStyle], - LookinAttrSec_NSButton_Bordered: @[ - LookinAttr_NSButton_Bordered_Bordered, - LookinAttr_NSButton_Transparent_Transparent, - LookinAttr_NSButton_Misc_ShowsBorderOnlyWhileMouseInside, - LookinAttr_NSButton_Misc_SpringLoaded, - LookinAttr_NSButton_Misc_HasDestructiveAction, - ], - LookinAttrSec_NSButton_BezelColor: @[ - LookinAttr_NSButton_BezelColor_BezelColor, - LookinAttr_NSButton_ContentTintColor_ContentTintColor, - ], - LookinAttrSec_NSButton_Misc: @[ - LookinAttr_NSButton_Misc_MaxAcceleratorLevel, - ], - - - - LookinAttrSec_NSScrollView_ContentOffset: @[ - LookinAttr_NSScrollView_ContentOffset_Offset - ], - LookinAttrSec_NSScrollView_ContentSize: @[ - LookinAttr_NSScrollView_ContentSize_Size - ], - LookinAttrSec_NSScrollView_ContentInset: @[ - LookinAttr_NSScrollView_ContentInset_ContentInset, - LookinAttr_NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets - ], - LookinAttrSec_NSScrollView_BorderType: @[ - LookinAttr_NSScrollView_BorderType_BorderType - ], - LookinAttrSec_NSScrollView_Scroller: @[ - LookinAttr_NSScrollView_Scroller_Horizontal, - LookinAttr_NSScrollView_Scroller_Vertical, - LookinAttr_NSScrollView_Scroller_AutohidesScrollers, - LookinAttr_NSScrollView_Scroller_ScrollerStyle, - LookinAttr_NSScrollView_Scroller_ScrollerKnobStyle, - LookinAttr_NSScrollView_Scroller_ScrollerInsets, - ], - LookinAttrSec_NSScrollView_Ruler: @[ - LookinAttr_NSScrollView_Ruler_Horizontal, - LookinAttr_NSScrollView_Ruler_Vertical, - LookinAttr_NSScrollView_Ruler_Visible, - ], - LookinAttrSec_NSScrollView_LineScroll: @[ - LookinAttr_NSScrollView_LineScroll_Horizontal, - LookinAttr_NSScrollView_LineScroll_Vertical, - LookinAttr_NSScrollView_LineScroll_LineScroll, - ], - LookinAttrSec_NSScrollView_PageScroll: @[ - LookinAttr_NSScrollView_PageScroll_Horizontal, - LookinAttr_NSScrollView_PageScroll_Vertical, - LookinAttr_NSScrollView_PageScroll_PageScroll, - ], - LookinAttrSec_NSScrollView_ScrollElasiticity: @[ - LookinAttr_NSScrollView_ScrollElasiticity_Horizontal, - LookinAttr_NSScrollView_ScrollElasiticity_Vertical, - ], - LookinAttrSec_NSScrollView_Misc: @[ - LookinAttr_NSScrollView_Misc_ScrollsDynamically, - LookinAttr_NSScrollView_Misc_UsesPredominantAxisScrolling, - ], - LookinAttrSec_NSScrollView_Magnification: @[ - LookinAttr_NSScrollView_Magnification_AllowsMagnification, - LookinAttr_NSScrollView_Magnification_Magnification, - LookinAttr_NSScrollView_Magnification_Max, - LookinAttr_NSScrollView_Magnification_Min, - ], - - LookinAttrSec_NSTableView_RowHeight: @[ - LookinAttr_NSTableView_RowHeight_RowHeight, - ], - LookinAttrSec_NSTableView_AutomaticRowHeights: @[ - LookinAttr_NSTableView_AutomaticRowHeights_AutomaticRowHeights, - ], - LookinAttrSec_NSTableView_IntercellSpacing: @[ - LookinAttr_NSTableView_IntercellSpacing_IntercellSpacing - ], - LookinAttrSec_NSTableView_Style: @[ - LookinAttr_NSTableView_Style_Style - ], - LookinAttrSec_NSTableView_ColumnAutoresizingStyle: @[ - LookinAttr_NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle - ], - LookinAttrSec_NSTableView_GridStyleMask: @[ - LookinAttr_NSTableView_GridStyleMask_GridStyleMask - ], - LookinAttrSec_NSTableView_SelectionHighlightStyle: @[ - LookinAttr_NSTableView_SelectionHighlightStyle_SelectionHighlightStyle - ], - LookinAttrSec_NSTableView_GridColor: @[ - LookinAttr_NSTableView_GridColor_GridColor - ], - LookinAttrSec_NSTableView_RowSizeStyle: @[ - LookinAttr_NSTableView_RowSizeStyle_RowSizeStyle - ], - LookinAttrSec_NSTableView_NumberOfRows: @[ - LookinAttr_NSTableView_NumberOfRows_NumberOfRows - ], - LookinAttrSec_NSTableView_NumberOfColumns: @[ - LookinAttr_NSTableView_NumberOfColumns_NumberOfColumns - ], - LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors: @[ - LookinAttr_NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors - ], - LookinAttrSec_NSTableView_AllowsColumnReordering: @[ - LookinAttr_NSTableView_AllowsColumnReordering_AllowsColumnReordering - ], - LookinAttrSec_NSTableView_AllowsColumnResizing: @[ - LookinAttr_NSTableView_AllowsColumnResizing_AllowsColumnResizing - ], - LookinAttrSec_NSTableView_AllowsMultipleSelection: @[ - LookinAttr_NSTableView_AllowsMultipleSelection_AllowsMultipleSelection - ], - LookinAttrSec_NSTableView_AllowsEmptySelection: @[ - LookinAttr_NSTableView_AllowsEmptySelection_AllowsEmptySelection - ], - LookinAttrSec_NSTableView_AllowsColumnSelection: @[ - LookinAttr_NSTableView_AllowsColumnSelection_AllowsColumnSelection - ], - LookinAttrSec_NSTableView_AllowsTypeSelect: @[ - LookinAttr_NSTableView_AllowsTypeSelect_AllowsTypeSelect - ], - LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle: @[ - LookinAttr_NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle - ], - LookinAttrSec_NSTableView_Autosave: @[ - LookinAttr_NSTableView_AutosaveName_AutosaveName, - LookinAttr_NSTableView_AutosaveTableColumns_AutosaveTableColumns - ], - LookinAttrSec_NSTableView_FloatsGroupRows: @[ - LookinAttr_NSTableView_FloatsGroupRows_FloatsGroupRows - ], - LookinAttrSec_NSTableView_RowActionsVisible: @[ - LookinAttr_NSTableView_RowActionsVisible_RowActionsVisible - ], - LookinAttrSec_NSTableView_UsesStaticContents: @[ - LookinAttr_NSTableView_UsesStaticContents_UsesStaticContents - ], - LookinAttrSec_NSTableView_UserInterfaceLayoutDirection: @[ - LookinAttr_NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection - ], - LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag: @[ - LookinAttr_NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag - ], - - - - LookinAttrSec_NSTextView_Font: @[ - LookinAttr_NSTextView_Font_Name, - LookinAttr_NSTextView_Font_Size - ], - LookinAttrSec_NSTextView_Basic: @[ - LookinAttr_NSTextView_Basic_Editable, - LookinAttr_NSTextView_Basic_Selectable, - LookinAttr_NSTextView_Basic_RichText, - LookinAttr_NSTextView_Basic_FieldEditor, - LookinAttr_NSTextView_Basic_ImportsGraphics, - ], - LookinAttrSec_NSTextView_String: @[ - LookinAttr_NSTextView_String_String - ], - LookinAttrSec_NSTextView_TextColor: @[ - LookinAttr_NSTextView_TextColor_Color - ], - LookinAttrSec_NSTextView_Alignment: @[ - LookinAttr_NSTextView_Alignment_Alignment - ], - LookinAttrSec_NSTextView_ContainerInset: @[ - LookinAttr_NSTextView_ContainerInset_Inset - ], - LookinAttrSec_NSTextView_BaseWritingDirection: @[ - LookinAttr_NSTextView_BaseWritingDirection_BaseWritingDirection - ], - LookinAttrSec_NSTextView_Size: @[ - LookinAttr_NSTextView_MaxSize_MaxSize, - LookinAttr_NSTextView_MinSize_MinSize, - ], - LookinAttrSec_NSTextView_Resizable: @[ - LookinAttr_NSTextView_Resizable_Horizontal, - LookinAttr_NSTextView_Resizable_Vertical, - ], - - LookinAttrSec_NSTextField_BezelStyle: @[ - LookinAttr_NSTextField_BezelStyle_BezelStyle - ], - LookinAttrSec_NSTextField_Bordered: @[ - LookinAttr_NSTextField_Bordered_Bordered, - LookinAttr_NSTextField_Bezeled_Bezeled, - LookinAttr_NSTextField_Editable_Editable, - LookinAttr_NSTextField_Selectable_Selectable, - LookinAttr_NSTextField_DrawsBackground_DrawsBackground, - LookinAttr_NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation, - LookinAttr_NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes, - LookinAttr_NSTextField_ImportsGraphics_ImportsGraphics, - ], - LookinAttrSec_NSTextField_TextColor: @[ - LookinAttr_NSTextField_TextColor_Color, - LookinAttr_NSTextField_BackgroundColor_Color, - ], - LookinAttrSec_NSTextField_Placeholder: @[ - LookinAttr_NSTextField_Placeholder_Placeholder - ], - LookinAttrSec_NSTextField_LineBreakStrategy: @[ - LookinAttr_NSTextField_LineBreakStrategy_LineBreakStrategy, - ], - LookinAttrSec_NSTextField_PreferredMaxLayoutWidth: @[ - LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth, - LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines, - ], - - - - - LookinAttrSec_NSVisualEffectView_Material: @[ - LookinAttr_NSVisualEffectView_Material_Material - ], - LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle: @[ - LookinAttr_NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle - ], - LookinAttrSec_NSVisualEffectView_BlendingMode: @[ - LookinAttr_NSVisualEffectView_BlendingMode_BlendingMode - ], - LookinAttrSec_NSVisualEffectView_State: @[ - LookinAttr_NSVisualEffectView_State_State - ], - LookinAttrSec_NSVisualEffectView_Emphasized: @[ - LookinAttr_NSVisualEffectView_Emphasized_Emphasized - ], - - - - LookinAttrSec_NSStackView_Orientation:@[ - LookinAttr_NSStackView_Orientation_Orientation - ], - LookinAttrSec_NSStackView_EdgeInsets:@[ - LookinAttr_NSStackView_EdgeInsets_EdgeInsets - ], - LookinAttrSec_NSStackView_DetachesHiddenViews:@[ - LookinAttr_NSStackView_DetachesHiddenViews_DetachesHiddenViews - ], - LookinAttrSec_NSStackView_Distribution:@[ - LookinAttr_NSStackView_Distribution_Distribution - ], - LookinAttrSec_NSStackView_Alignment:@[ - LookinAttr_NSStackView_Alignment_Alignment - ], - LookinAttrSec_NSStackView_Spacing:@[ - LookinAttr_NSStackView_Spacing_Spacing - ], - - LookinAttrSec_NSWindow_Title: @[ - LookinAttr_NSWindow_Title_Title, - ], - LookinAttrSec_NSWindow_Subtitle: @[ - LookinAttr_NSWindow_Title_Subtitle, - ], - LookinAttrSec_NSWindow_State: @[ - LookinAttr_NSWindow_State_KeyWindow, - LookinAttr_NSWindow_State_MainWindow, - LookinAttr_NSWindow_State_Visible, - LookinAttr_NSWindow_State_CanBecomeKeyWindow, - LookinAttr_NSWindow_State_CanBecomeMainWindow, - ], - LookinAttrSec_NSWindow_Style: @[ - LookinAttr_NSWindow_Style_Titled, - LookinAttr_NSWindow_Style_Closable, - LookinAttr_NSWindow_Style_Miniaturizable, - LookinAttr_NSWindow_Style_Resizable, - LookinAttr_NSWindow_Style_UnifiedTitleAndToolbar, - LookinAttr_NSWindow_Style_FullScreen, - LookinAttr_NSWindow_Style_FullSizeContentView, - LookinAttr_NSWindow_Style_UtilityWindow, - LookinAttr_NSWindow_Style_DocModalWindow, - LookinAttr_NSWindow_Style_NonactivatingPanel, - LookinAttr_NSWindow_Style_HUDWindow, - ], - LookinAttrSec_NSWindow_CollectionBehavior: @[ - LookinAttr_NSWindow_CollectionBehavior_CanJoinAllSpaces, - LookinAttr_NSWindow_CollectionBehavior_MoveToActiveSpace, - LookinAttr_NSWindow_CollectionBehavior_ParticipatesInCycle, - LookinAttr_NSWindow_CollectionBehavior_IgnoresCycle, - LookinAttr_NSWindow_CollectionBehavior_FullScreenPrimary, - LookinAttr_NSWindow_CollectionBehavior_FullScreenAuxiliary, - LookinAttr_NSWindow_CollectionBehavior_FullScreenNone, - LookinAttr_NSWindow_CollectionBehavior_FullScreenAllowsTiling, - LookinAttr_NSWindow_CollectionBehavior_FullScreenDisallowsTiling, - ], - LookinAttrSec_NSWindow_Appearance: @[ - LookinAttr_NSWindow_Appearance_TitlebarAppearsTransparent, - LookinAttr_NSWindow_Appearance_BackgroundColor, - LookinAttr_NSWindow_Appearance_AlphaValue, - LookinAttr_NSWindow_Appearance_Opaque, - LookinAttr_NSWindow_Appearance_HasShadow, - ], - LookinAttrSec_NSWindow_TitleVisibility: @[ - LookinAttr_NSWindow_Appearance_TitleVisibility, - ], - LookinAttrSec_NSWindow_ToolbarStyle: @[ - LookinAttr_NSWindow_Appearance_ToolbarStyle, - ], - LookinAttrSec_NSWindow_TitlebarSeparatorStyle: @[ - LookinAttr_NSWindow_Appearance_TitlebarSeparatorStyle, - ], - LookinAttrSec_NSWindow_Behavior: @[ - LookinAttr_NSWindow_Behavior_Movable, - LookinAttr_NSWindow_Behavior_MovableByWindowBackground, - LookinAttr_NSWindow_Behavior_HidesOnDeactivate, - ], - LookinAttrSec_NSWindow_AnimationBehavior: @[ - LookinAttr_NSWindow_Behavior_AnimationBehavior, - ], - LookinAttrSec_NSWindow_Level: @[ - LookinAttr_NSWindow_Behavior_Level, - ], - LookinAttrSec_NSWindow_TabbingMode: @[ - LookinAttr_NSWindow_Behavior_TabbingMode, - ], - LookinAttrSec_NSWindow_Size: @[ - LookinAttr_NSWindow_Size_MinSize, - LookinAttr_NSWindow_Size_MaxSize, - ], - LookinAttrSec_NSWindow_Info: @[ - LookinAttr_NSWindow_Info_WindowNumber, - LookinAttr_NSWindow_Info_BackingScaleFactor, - ], - -#endif - }; - }); - return dict[sectionID]; -} - -+ (void)getHostGroupID:(inout LookinAttrGroupIdentifier *)groupID_inout sectionID:(inout LookinAttrSectionIdentifier *)sectionID_inout fromAttrID:(LookinAttrIdentifier)targetAttrID { - __block LookinAttrGroupIdentifier targetGroupID = nil; - __block LookinAttrSectionIdentifier targetSecID = nil; - [[self groupIDs] enumerateObjectsUsingBlock:^(LookinAttrGroupIdentifier _Nonnull groupID, NSUInteger idx, BOOL * _Nonnull stop0) { - [[self sectionIDsForGroupID:groupID] enumerateObjectsUsingBlock:^(LookinAttrSectionIdentifier _Nonnull secID, NSUInteger idx, BOOL * _Nonnull stop1) { - [[self attrIDsForSectionID:secID] enumerateObjectsUsingBlock:^(LookinAttrIdentifier _Nonnull attrID, NSUInteger idx, BOOL * _Nonnull stop2) { - if ([attrID isEqualToString:targetAttrID]) { - targetGroupID = groupID; - targetSecID = secID; - *stop0 = YES; - *stop1 = YES; - *stop2 = YES; - } - }]; - }]; - }]; - - if (groupID_inout && targetGroupID) { - *groupID_inout = targetGroupID; - } - if (sectionID_inout && targetSecID) { - *sectionID_inout = targetSecID; - } -} - -+ (NSString *)groupTitleWithGroupID:(LookinAttrGroupIdentifier)groupID { - static dispatch_once_t onceToken; - static NSDictionary *rawInfo = nil; - dispatch_once(&onceToken,^{ - rawInfo = @{ - LookinAttrGroup_Class: @"Class", - LookinAttrGroup_Relation: @"Relation", - LookinAttrGroup_Layout: @"Layout", - LookinAttrGroup_AutoLayout: @"AutoLayout", -#if TARGET_OS_IPHONE - LookinAttrGroup_ViewLayer: @"CALayer / UIView", - LookinAttrGroup_UIImageView: @"UIImageView", - LookinAttrGroup_UILabel: @"UILabel", - LookinAttrGroup_UIControl: @"UIControl", - LookinAttrGroup_UIButton: @"UIButton", - LookinAttrGroup_UIScrollView: @"UIScrollView", - LookinAttrGroup_UITableView: @"UITableView", - LookinAttrGroup_UITextView: @"UITextView", - LookinAttrGroup_UITextField: @"UITextField", - LookinAttrGroup_UIVisualEffectView: @"UIVisualEffectView", - LookinAttrGroup_UIStackView: @"UIStackView", - LookinAttrGroup_UIWindowScene: @"UIWindowScene", - LookinAttrGroup_UITraitCollection: @"UITraitCollection", -#else - LookinAttrGroup_ViewLayer: @"CALayer / NSView", - LookinAttrGroup_NSImageView: @"NSImageView", - LookinAttrGroup_NSControl: @"NSControl", - LookinAttrGroup_NSButton: @"NSButton", - LookinAttrGroup_NSScrollView: @"NSScrollView", - LookinAttrGroup_NSTableView: @"NSTableView", - LookinAttrGroup_NSTextView: @"NSTextView", - LookinAttrGroup_NSTextField: @"NSTextField", - LookinAttrGroup_NSVisualEffectView: @"NSVisualEffectView", - LookinAttrGroup_NSStackView: @"NSStackView", - LookinAttrGroup_NSWindow: @"NSWindow", -#endif - }; - }); - NSString *title = rawInfo[groupID]; - NSAssert(title.length, @""); - return title; -} - -+ (NSString *)sectionTitleWithSectionID:(LookinAttrSectionIdentifier)secID { - static dispatch_once_t onceToken; - static NSDictionary *rawInfo = nil; - dispatch_once(&onceToken,^{ - rawInfo = @{ - LookinAttrSec_Layout_Frame: @"Frame", - LookinAttrSec_Layout_Bounds: @"Bounds", - LookinAttrSec_Layout_SafeArea: @"SafeArea", - LookinAttrSec_Layout_Position: @"Position", - LookinAttrSec_Layout_AnchorPoint: @"AnchorPoint", - LookinAttrSec_AutoLayout_Hugging: @"HuggingPriority", - LookinAttrSec_AutoLayout_Resistance: @"ResistancePriority", - LookinAttrSec_AutoLayout_IntrinsicSize: @"IntrinsicSize", - LookinAttrSec_ViewLayer_Corner: @"CornerRadius", - LookinAttrSec_ViewLayer_BgColor: @"BackgroundColor", - LookinAttrSec_ViewLayer_Border: @"Border", - LookinAttrSec_ViewLayer_Shadow: @"Shadow", - LookinAttrSec_ViewLayer_Tag: @"Tag", - LookinAttrSec_ViewLayer_ContentMode: @"ContentMode", - LookinAttrSec_ViewLayer_TintColor: @"TintColor", - LookinAttrSec_UIStackView_Axis: @"Axis", - LookinAttrSec_UIStackView_Distribution: @"Distribution", - LookinAttrSec_UIStackView_Alignment: @"Alignment", - LookinAttrSec_UIVisualEffectView_Style: @"Style", - LookinAttrSec_UIVisualEffectView_QMUIForegroundColor: @"ForegroundColor", - LookinAttrSec_UIImageView_Name: @"ImageName", - LookinAttrSec_UILabel_TextColor: @"TextColor", - LookinAttrSec_UITextView_TextColor: @"TextColor", - LookinAttrSec_UITextField_TextColor: @"TextColor", - LookinAttrSec_UILabel_BreakMode: @"LineBreakMode", - LookinAttrSec_UILabel_NumberOfLines: @"NumberOfLines", - LookinAttrSec_UILabel_Text: @"Text", - LookinAttrSec_UITextView_Text: @"Text", - LookinAttrSec_UITextField_Text: @"Text", - LookinAttrSec_UITextField_Placeholder: @"Placeholder", - LookinAttrSec_UILabel_Alignment: @"TextAlignment", - LookinAttrSec_UITextView_Alignment: @"TextAlignment", - LookinAttrSec_UITextField_Alignment: @"TextAlignment", - LookinAttrSec_UIControl_HorAlignment: @"HorizontalAlignment", - LookinAttrSec_UIControl_VerAlignment: @"VerticalAlignment", - LookinAttrSec_UIControl_QMUIOutsideEdge: @"QMUI_outsideEdge", - LookinAttrSec_UIButton_ContentInsets: @"ContentInsets", - LookinAttrSec_UIButton_TitleInsets: @"TitleInsets", - LookinAttrSec_UIButton_ImageInsets: @"ImageInsets", - LookinAttrSec_UIScrollView_QMUIInitialInset: @"QMUI_initialContentInset", - LookinAttrSec_UIScrollView_ContentInset: @"ContentInset", - LookinAttrSec_UIScrollView_AdjustedInset: @"AdjustedContentInset", - LookinAttrSec_UIScrollView_IndicatorInset: @"ScrollIndicatorInsets", - LookinAttrSec_UIScrollView_Offset: @"ContentOffset", - LookinAttrSec_UIScrollView_ContentSize: @"ContentSize", - LookinAttrSec_UIScrollView_Behavior: @"InsetAdjustmentBehavior", - LookinAttrSec_UIScrollView_ShowsIndicator: @"ShowsScrollIndicator", - LookinAttrSec_UIScrollView_Bounce: @"AlwaysBounce", - LookinAttrSec_UIScrollView_Zoom: @"Zoom", - LookinAttrSec_UITableView_Style: @"Style", - LookinAttrSec_UITableView_SectionsNumber: @"NumberOfSections", - LookinAttrSec_UITableView_RowsNumber: @"NumberOfRows", - LookinAttrSec_UITableView_SeparatorColor: @"SeparatorColor", - LookinAttrSec_UITableView_SeparatorInset: @"SeparatorInset", - LookinAttrSec_UITableView_SeparatorStyle: @"SeparatorStyle", - LookinAttrSec_UILabel_Font: @"Font", - LookinAttrSec_UITextField_Font: @"Font", - LookinAttrSec_UITextView_Font: @"Font", - LookinAttrSec_UITextView_ContainerInset: @"ContainerInset", - LookinAttrSec_UITextField_ClearButtonMode: @"ClearButtonMode", - LookinAttrSec_NSImageView_Name: @"ImageName", - LookinAttrSec_NSImageView_Open: @"Open", - LookinAttrSec_NSImageView_Scaling: @"Scaling", - LookinAttrSec_NSImageView_Behavior: @"Behavior", - LookinAttrSec_NSImageView_ContentTintColor: @"ContentTintColor", - LookinAttrSec_NSControl_State: @"State", - LookinAttrSec_NSControl_ControlSize: @"ControlSize", - LookinAttrSec_NSControl_Font: @"Font", - LookinAttrSec_NSControl_Alignment: @"Alignment", - LookinAttrSec_NSControl_Misc: @"Misc", - LookinAttrSec_NSControl_StringValue: @"StringValue", - LookinAttrSec_NSControl_Value: @"Value", - LookinAttrSec_NSButton_ButtonType: @"ButtonType", - LookinAttrSec_NSButton_Title: @"Title", - LookinAttrSec_NSButton_BezelStyle: @"BezelStyle", - LookinAttrSec_NSButton_BezelColor: @"Colors", - LookinAttrSec_NSButton_Misc: @"Misc", - LookinAttrSec_NSScrollView_ContentOffset: @"ContentOffset", - LookinAttrSec_NSScrollView_ContentSize: @"ContentSize", - LookinAttrSec_NSScrollView_ContentInset: @"ContentInset", - LookinAttrSec_NSScrollView_BorderType: @"BorderType", - LookinAttrSec_NSScrollView_Scroller: @"Scroller", - LookinAttrSec_NSScrollView_Ruler: @"Ruler", - LookinAttrSec_NSScrollView_LineScroll: @"LineScroll", - LookinAttrSec_NSScrollView_PageScroll: @"PageScroll", - LookinAttrSec_NSScrollView_ScrollElasiticity: @"ScrollElasiticity", - LookinAttrSec_NSScrollView_Misc: @"Misc", - LookinAttrSec_NSScrollView_Magnification: @"Magnification", - LookinAttrSec_NSTableView_RowHeight: @"RowHeight", - LookinAttrSec_NSTableView_AutomaticRowHeights: @"AutomaticRowHeights", - LookinAttrSec_NSTableView_IntercellSpacing: @"IntercellSpacing", - LookinAttrSec_NSTableView_Style: @"Style", - LookinAttrSec_NSTableView_ColumnAutoresizingStyle: @"ColumnAutoresizingStyle", - LookinAttrSec_NSTableView_GridStyleMask: @"GridStyleMask", - LookinAttrSec_NSTableView_SelectionHighlightStyle: @"SelectionHighlightStyle", - LookinAttrSec_NSTableView_GridColor: @"GridColor", - LookinAttrSec_NSTableView_RowSizeStyle: @"RowSizeStyle", - LookinAttrSec_NSTableView_NumberOfRows: @"NumberOfRows", - LookinAttrSec_NSTableView_NumberOfColumns: @"NumberOfColumns", - LookinAttrSec_NSTableView_UseAlternatingRowBackgroundColors: @"UseAlternatingRowBackgroundColors", - LookinAttrSec_NSTableView_AllowsColumnReordering: @"AllowsColumnReordering", - LookinAttrSec_NSTableView_AllowsColumnResizing: @"AllowsColumnResizing", - LookinAttrSec_NSTableView_AllowsMultipleSelection: @"AllowsMultipleSelection", - LookinAttrSec_NSTableView_AllowsEmptySelection: @"AllowsEmptySelection", - LookinAttrSec_NSTableView_AllowsColumnSelection: @"AllowsColumnSelection", - LookinAttrSec_NSTableView_AllowsTypeSelect: @"AllowsTypeSelect", - LookinAttrSec_NSTableView_DraggingDestinationFeedbackStyle: @"DraggingDestinationFeedbackStyle", - LookinAttrSec_NSTableView_Autosave: @"Autosave", - LookinAttrSec_NSTableView_FloatsGroupRows: @"FloatsGroupRows", - LookinAttrSec_NSTableView_RowActionsVisible: @"RowActionsVisible", - LookinAttrSec_NSTableView_UsesStaticContents: @"UsesStaticContents", - LookinAttrSec_NSTableView_UserInterfaceLayoutDirection: @"UserInterfaceLayoutDirection", - LookinAttrSec_NSTableView_VerticalMotionCanBeginDrag: @"VerticalMotionCanBeginDrag", - LookinAttrSec_NSTextView_Font: @"Font", - LookinAttrSec_NSTextView_Basic: @"Basic", - LookinAttrSec_NSTextView_String: @"String", - LookinAttrSec_NSTextView_TextColor: @"TextColor", - LookinAttrSec_NSTextView_Alignment: @"Alignment", - LookinAttrSec_NSTextView_ContainerInset: @"ContainerInset", - LookinAttrSec_NSTextView_BaseWritingDirection: @"BaseWritingDirection", - LookinAttrSec_NSTextView_Size: @"Size", - LookinAttrSec_NSTextView_Resizable: @"Resizable", - LookinAttrSec_NSTextField_BezelStyle: @"BezelStyle", - LookinAttrSec_NSTextField_LineBreakStrategy: @"LineBreakStrategy", - LookinAttrSec_NSTextField_TextColor: @"Colors", - LookinAttrSec_NSTextField_Placeholder: @"Placeholder", - LookinAttrSec_NSTextField_PreferredMaxLayoutWidth: @"Layout", - LookinAttrSec_NSVisualEffectView_Material: @"Material", - LookinAttrSec_NSVisualEffectView_InteriorBackgroundStyle: @"InteriorBackgroundStyle", - LookinAttrSec_NSVisualEffectView_BlendingMode: @"BlendingMode", - LookinAttrSec_NSVisualEffectView_State: @"State", - LookinAttrSec_NSVisualEffectView_Emphasized: @"Emphasized", - LookinAttrSec_NSStackView_Orientation: @"Orientation", - LookinAttrSec_NSStackView_EdgeInsets: @"EdgeInsets", - LookinAttrSec_NSStackView_DetachesHiddenViews: @"DetachesHiddenViews", - LookinAttrSec_NSStackView_Distribution: @"Distribution", - LookinAttrSec_NSStackView_Alignment: @"Alignment", - LookinAttrSec_NSStackView_Spacing: @"Spacing", - LookinAttrSec_NSWindow_Title: @"Title", - LookinAttrSec_NSWindow_Subtitle: @"Subtitle", - LookinAttrSec_NSWindow_State: @"State", - LookinAttrSec_NSWindow_Style: @"StyleMask", - LookinAttrSec_NSWindow_CollectionBehavior: @"CollectionBehavior", - LookinAttrSec_NSWindow_Appearance: @"Appearance", - LookinAttrSec_NSWindow_TitleVisibility: @"TitleVisibility", - LookinAttrSec_NSWindow_ToolbarStyle: @"ToolbarStyle", - LookinAttrSec_NSWindow_TitlebarSeparatorStyle: @"TitlebarSeparatorStyle", - LookinAttrSec_NSWindow_Behavior: @"Behavior", - LookinAttrSec_NSWindow_AnimationBehavior: @"AnimationBehavior", - LookinAttrSec_NSWindow_Level: @"Level", - LookinAttrSec_NSWindow_TabbingMode: @"TabbingMode", - LookinAttrSec_NSWindow_Size: @"Size", - LookinAttrSec_NSWindow_Info: @"Info", - // UIWindowScene - LookinAttrSec_UIWindowScene_State: @"State", - LookinAttrSec_UIWindowScene_Title: @"Title", - LookinAttrSec_UIWindowScene_Orientation: @"Orientation", - LookinAttrSec_UIWindowScene_Windows: @"Windows", - LookinAttrSec_UIWindowScene_Screen: @"Screen", - LookinAttrSec_UIWindowScene_StatusBar: @"StatusBar", - LookinAttrSec_UIWindowScene_Traits: @"Traits", - LookinAttrSec_UIWindowScene_Session: @"Session", - // UITraitCollection - LookinAttrSec_UITraitCollection_Appearance: @"Appearance", - LookinAttrSec_UITraitCollection_SizeClass: @"SizeClass", - LookinAttrSec_UITraitCollection_Display: @"Display", - LookinAttrSec_UITraitCollection_Device: @"Device", - LookinAttrSec_UITraitCollection_Layout: @"Layout", - LookinAttrSec_UITraitCollection_Content: @"Content", - }; - }); - return rawInfo[secID]; -} - -/** - className: 必填项,标识该属性是哪一个类拥有的 - - fullTitle: 完整的名字,将作为搜索的 keywords,也会展示在搜索结果中,如果为 nil 则不会被搜索到 - - briefTitle:简略的名字,仅 checkbox 和那种自带标题的 input 才需要这个属性,如果需要该属性但该属性又为空,则会读取 fullTitle - - setterString:用户试图修改属性值时会用到,若该字段为空字符串(即 @“”)则该属性不可修改,若该字段为 nil 则会在 fullTitle 的基础上自动生成(自动改首字母大小写、加前缀后缀,比如 alpha 会被转换为 setAlpha:) - - getterString:必填项,业务中读取属性值时会用到。如果该字段为 nil ,则会在 fullTitle 的基础上自动生成(自动把 fullTitle 的第一个字母改成小写,比如 Alpha 会被转换为 alpha)。如果该字段为空字符串(比如 image_open_open)则属性值会被固定为 nil,attrType 会被指为 LookinAttrTypeCustomObj - - typeIfObj:当某个 LookinAttribute 确定是 NSObject 类型时,该方法返回它具体是什么对象,比如 UIColor、NSString - - enumList:如果某个 attribute 是 enum,则这里标识了相应的 enum 的名称(如 "NSTextAlignment"),业务可通过这个名称进而查询可用的枚举值列表 - - patch:如果为 YES,则用户修改了该 Attribute 的值后,Lookin 会重新拉取和更新相关图层的位置、截图等信息,如果为 nil 则默认是 NO - - hideIfNil:如果为 YES,则当获取的 value 为 nil 时,Lookin 不会传输该 attr。如果为 NO,则即使 value 为 nil 也会传输(比如 label 的 text 属性,即使它是 nil 我们也要显示,所以它的 hideIfNil 应该为 NO)。如果该字段为 nil 则默认是 NO - - osVersion: 该属性需要的最低的 iOS 版本,比如 safeAreaInsets 从 iOS 11.0 开始出现,则该属性应该为 @11,如果为 nil 则表示不限制 iOS 版本 - - */ -+ (NSDictionary *)_infoForAttrID:(LookinAttrIdentifier)attrID { - static NSDictionary *> *dict; - static dispatch_once_t onceToken; - dispatch_once(&onceToken,^{ - dict = @{ - LookinAttr_Class_Class_Class: @{ - @"className": @"CALayer", - @"getterString": @"lks_relatedClassChainList", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj) - }, - - LookinAttr_Relation_Relation_Relation: @{ - @"className": @"CALayer", - @"getterString": @"lks_selfRelation", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - - LookinAttr_Layout_Frame_Frame: @{ - @"className": @"CALayer", - @"fullTitle": @"Frame", - @"patch": @(YES) - }, - LookinAttr_Layout_Bounds_Bounds: @{ - @"className": @"CALayer", - @"fullTitle": @"Bounds", - @"patch": @(YES) - }, - LookinAttr_Layout_SafeArea_SafeArea: @{ - @"className": @"UIView", - @"fullTitle": @"SafeAreaInsets", - @"setterString": @"", - @"osVersion": @(11) - }, - LookinAttr_Layout_SafeArea_SafeArea: @{ - @"className": @"NSView", - @"fullTitle": @"SafeAreaInsets", - @"setterString": @"", - @"osVersion": @(11) - }, - LookinAttr_Layout_Position_Position: @{ - @"className": @"CALayer", - @"fullTitle": @"Position", - @"patch": @(YES) - }, - LookinAttr_Layout_AnchorPoint_AnchorPoint: @{ - @"className": @"CALayer", - @"fullTitle": @"AnchorPoint", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Hugging_Hor: @{ - @"className": @"UIView", - @"fullTitle": @"ContentHuggingPriority(Horizontal)", - @"getterString": @"lks_horizontalContentHuggingPriority", - @"setterString": @"setLks_horizontalContentHuggingPriority:", - @"briefTitle": @"H", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Hugging_Ver: @{ - @"className": @"UIView", - @"fullTitle": @"ContentHuggingPriority(Vertical)", - @"getterString": @"lks_verticalContentHuggingPriority", - @"setterString": @"setLks_verticalContentHuggingPriority:", - @"briefTitle": @"V", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Resistance_Hor: @{ - @"className": @"UIView", - @"fullTitle": @"ContentCompressionResistancePriority(Horizontal)", - @"getterString": @"lks_horizontalContentCompressionResistancePriority", - @"setterString": @"setLks_horizontalContentCompressionResistancePriority:", - @"briefTitle": @"H", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Resistance_Ver: @{ - @"className": @"UIView", - @"fullTitle": @"ContentCompressionResistancePriority(Vertical)", - @"getterString": @"lks_verticalContentCompressionResistancePriority", - @"setterString": @"setLks_verticalContentCompressionResistancePriority:", - @"briefTitle": @"V", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Constraints_Constraints: @{ - @"className": @"UIView", - @"getterString": @"lks_constraints", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - LookinAttr_AutoLayout_IntrinsicSize_Size: @{ - @"className": @"UIView", - @"fullTitle": @"IntrinsicContentSize", - @"setterString": @"" - }, - LookinAttr_AutoLayout_Hugging_Hor: @{ - @"className": @"NSView", - @"fullTitle": @"ContentHuggingPriority(Horizontal)", - @"getterString": @"lks_horizontalContentHuggingPriority", - @"setterString": @"setLks_horizontalContentHuggingPriority:", - @"briefTitle": @"H", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Hugging_Ver: @{ - @"className": @"NSView", - @"fullTitle": @"ContentHuggingPriority(Vertical)", - @"getterString": @"lks_verticalContentHuggingPriority", - @"setterString": @"setLks_verticalContentHuggingPriority:", - @"briefTitle": @"V", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Resistance_Hor: @{ - @"className": @"NSView", - @"fullTitle": @"ContentCompressionResistancePriority(Horizontal)", - @"getterString": @"lks_horizontalContentCompressionResistancePriority", - @"setterString": @"setLks_horizontalContentCompressionResistancePriority:", - @"briefTitle": @"H", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Resistance_Ver: @{ - @"className": @"NSView", - @"fullTitle": @"ContentCompressionResistancePriority(Vertical)", - @"getterString": @"lks_verticalContentCompressionResistancePriority", - @"setterString": @"setLks_verticalContentCompressionResistancePriority:", - @"briefTitle": @"V", - @"patch": @(YES) - }, - LookinAttr_AutoLayout_Constraints_Constraints: @{ - @"className": @"NSView", - @"getterString": @"lks_constraints", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - LookinAttr_AutoLayout_IntrinsicSize_Size: @{ - @"className": @"NSView", - @"fullTitle": @"IntrinsicContentSize", - @"setterString": @"" - }, - - LookinAttr_ViewLayer_InterationAndMasks_Interaction: @{ - @"className": @"UIView", - @"fullTitle": @"UserInteractionEnabled", - @"getterString": @"isUserInteractionEnabled", - @"patch": @(NO) - }, - - LookinAttr_ViewLayer_ContentMode_Mode: @{ - @"className": @"UIView", - @"fullTitle": @"ContentMode", - @"enumList": @"UIViewContentMode", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_TintColor_Color: @{ - @"className": @"UIView", - @"fullTitle": @"TintColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_ViewLayer_TintColor_Mode: @{ - @"className": @"UIView", - @"fullTitle": @"TintAdjustmentMode", - @"enumList": @"UIViewTintAdjustmentMode", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Tag_Tag: @{ - @"className": @"UIView", - @"fullTitle": @"Tag", - @"briefTitle": @"", - @"patch": @(NO) - }, - - LookinAttr_ViewLayer_Tag_Tag: @{ - @"className": @"NSView", - @"fullTitle": @"Tag", - @"briefTitle": @"", - @"patch": @(NO) - }, - LookinAttr_ViewLayer_Visibility_Hidden: @{ - @"className": @"CALayer", - @"fullTitle": @"Hidden", - @"getterString": @"isHidden", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Visibility_Opacity: @{ - @"className": @"CALayer", - @"fullTitle": @"Opacity / Alpha", - @"setterString": @"setOpacity:", - @"getterString": @"opacity", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_InterationAndMasks_MasksToBounds: @{ - @"className": @"CALayer", - @"fullTitle": @"MasksToBounds / ClipsToBounds", - @"briefTitle": @"MasksToBounds", - @"setterString": @"setMasksToBounds:", - @"getterString": @"masksToBounds", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Corner_Radius: @{ - @"className": @"CALayer", - @"fullTitle": @"CornerRadius", - @"briefTitle": @"", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_BgColor_BgColor: @{ - @"className": @"CALayer", - @"fullTitle": @"BackgroundColor", - @"setterString": @"setLks_backgroundColor:", - @"getterString": @"lks_backgroundColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Border_Color: @{ - @"className": @"CALayer", - @"fullTitle": @"BorderColor", - @"setterString": @"setLks_borderColor:", - @"getterString": @"lks_borderColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Border_Width: @{ - @"className": @"CALayer", - @"fullTitle": @"BorderWidth", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_Color: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowColor", - @"setterString": @"setLks_shadowColor:", - @"getterString": @"lks_shadowColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_Opacity: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowOpacity", - @"briefTitle": @"Opacity", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_Radius: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowRadius", - @"briefTitle": @"Radius", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_OffsetW: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowOffsetWidth", - @"briefTitle": @"OffsetW", - @"setterString": @"setLks_shadowOffsetWidth:", - @"getterString": @"lks_shadowOffsetWidth", - @"patch": @(YES) - }, - LookinAttr_ViewLayer_Shadow_OffsetH: @{ - @"className": @"CALayer", - @"fullTitle": @"ShadowOffsetHeight", - @"briefTitle": @"OffsetH", - @"setterString": @"setLks_shadowOffsetHeight:", - @"getterString": @"lks_shadowOffsetHeight", - @"patch": @(YES) - }, - - LookinAttr_UIStackView_Axis_Axis: @{ - @"className": @"UIStackView", - @"fullTitle": @"Axis", - @"enumList": @"UILayoutConstraintAxis", - @"patch": @(YES) - }, - - LookinAttr_UIStackView_Distribution_Distribution: @{ - @"className": @"UIStackView", - @"fullTitle": @"Distribution", - @"enumList": @"UIStackViewDistribution", - @"patch": @(YES) - }, - - LookinAttr_UIStackView_Alignment_Alignment: @{ - @"className": @"UIStackView", - @"fullTitle": @"Alignment", - @"enumList": @"UIStackViewAlignment", - @"patch": @(YES) - }, - - LookinAttr_UIStackView_Spacing_Spacing: @{ - @"className": @"UIStackView", - @"fullTitle": @"Spacing", - @"patch": @(YES) - }, - - LookinAttr_UIVisualEffectView_Style_Style: @{ - @"className": @"UIVisualEffectView", - @"setterString": @"setLks_blurEffectStyleNumber:", - @"getterString": @"lks_blurEffectStyleNumber", - @"enumList": @"UIBlurEffectStyle", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"patch": @(YES), - @"hideIfNil": @(YES) - }, - - LookinAttr_UIVisualEffectView_QMUIForegroundColor_Color: @{ - @"className": @"QMUIVisualEffectView", - @"fullTitle": @"ForegroundColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES), - }, - - LookinAttr_UIImageView_Name_Name: @{ - @"className": @"UIImageView", - @"fullTitle": @"ImageName", - @"setterString": @"", - @"getterString": @"lks_imageSourceName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_UIImageView_Open_Open: @{ - @"className": @"UIImageView", - @"setterString": @"", - @"getterString": @"lks_imageViewOidIfHasImage", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - - LookinAttr_UILabel_Text_Text: @{ - @"className": @"UILabel", - @"fullTitle": @"Text", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_UILabel_NumberOfLines_NumberOfLines: @{ - @"className": @"UILabel", - @"fullTitle": @"NumberOfLines", - @"briefTitle": @"", - @"patch": @(YES) - }, - LookinAttr_UILabel_Font_Size: @{ - @"className": @"UILabel", - @"fullTitle": @"FontSize", - @"briefTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_UILabel_Font_Name: @{ - @"className": @"UILabel", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UILabel_TextColor_Color: @{ - @"className": @"UILabel", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_UILabel_Alignment_Alignment: @{ - @"className": @"UILabel", - @"fullTitle": @"TextAlignment", - @"enumList": @"NSTextAlignment", - @"patch": @(YES) - }, - LookinAttr_UILabel_BreakMode_Mode: @{ - @"className": @"UILabel", - @"fullTitle": @"LineBreakMode", - @"enumList": @"NSLineBreakMode", - @"patch": @(YES) - }, - LookinAttr_UILabel_CanAdjustFont_CanAdjustFont: @{ - @"className": @"UILabel", - @"fullTitle": @"AdjustsFontSizeToFitWidth", - @"patch": @(YES) - }, - - LookinAttr_UIControl_EnabledSelected_Enabled: @{ - @"className": @"UIControl", - @"fullTitle": @"Enabled", - @"getterString": @"isEnabled", - @"patch": @(NO) - }, - LookinAttr_UIControl_EnabledSelected_Selected: @{ - @"className": @"UIControl", - @"fullTitle": @"Selected", - @"getterString": @"isSelected", - @"patch": @(YES) - }, - LookinAttr_UIControl_VerAlignment_Alignment: @{ - @"className": @"UIControl", - @"fullTitle": @"ContentVerticalAlignment", - @"enumList": @"UIControlContentVerticalAlignment", - @"patch": @(YES) - }, - LookinAttr_UIControl_HorAlignment_Alignment: @{ - @"className": @"UIControl", - @"fullTitle": @"ContentHorizontalAlignment", - @"enumList": @"UIControlContentHorizontalAlignment", - @"patch": @(YES) - }, - LookinAttr_UIControl_QMUIOutsideEdge_Edge: @{ - @"className": @"UIControl", - @"fullTitle": @"qmui_outsideEdge" - }, - - LookinAttr_UIButton_ContentInsets_Insets: @{ - @"className": @"UIButton", - @"fullTitle": @"ContentEdgeInsets", - @"patch": @(YES) - }, - LookinAttr_UIButton_TitleInsets_Insets: @{ - @"className": @"UIButton", - @"fullTitle": @"TitleEdgeInsets", - @"patch": @(YES) - }, - LookinAttr_UIButton_ImageInsets_Insets: @{ - @"className": @"UIButton", - @"fullTitle": @"ImageEdgeInsets", - @"patch": @(YES) - }, - - LookinAttr_UIScrollView_Offset_Offset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ContentOffset", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_ContentSize_Size: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ContentSize", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_ContentInset_Inset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ContentInset", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_QMUIInitialInset_Inset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"qmui_initialContentInset", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_AdjustedInset_Inset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"AdjustedContentInset", - @"setterString": @"", - @"osVersion": @(11) - }, - LookinAttr_UIScrollView_Behavior_Behavior: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ContentInsetAdjustmentBehavior", - @"enumList": @"UIScrollViewContentInsetAdjustmentBehavior", - @"patch": @(YES), - @"osVersion": @(11) - }, - LookinAttr_UIScrollView_IndicatorInset_Inset: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ScrollIndicatorInsets", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ScrollPaging_ScrollEnabled: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ScrollEnabled", - @"getterString": @"isScrollEnabled", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ScrollPaging_PagingEnabled: @{ - @"className": @"UIScrollView", - @"fullTitle": @"PagingEnabled", - @"getterString": @"isPagingEnabled", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Bounce_Ver: @{ - @"className": @"UIScrollView", - @"fullTitle": @"AlwaysBounceVertical", - @"briefTitle": @"Vertical", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Bounce_Hor: @{ - @"className": @"UIScrollView", - @"fullTitle": @"AlwaysBounceHorizontal", - @"briefTitle": @"Horizontal", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ShowsIndicator_Hor: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ShowsHorizontalScrollIndicator", - @"briefTitle": @"Horizontal", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ShowsIndicator_Ver: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ShowsVerticalScrollIndicator", - @"briefTitle": @"Vertical", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ContentTouches_Delay: @{ - @"className": @"UIScrollView", - @"fullTitle": @"DelaysContentTouches", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_ContentTouches_CanCancel: @{ - @"className": @"UIScrollView", - @"fullTitle": @"CanCancelContentTouches", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Zoom_MinScale: @{ - @"className": @"UIScrollView", - @"fullTitle": @"MinimumZoomScale", - @"briefTitle": @"MinScale", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Zoom_MaxScale: @{ - @"className": @"UIScrollView", - @"fullTitle": @"MaximumZoomScale", - @"briefTitle": @"MaxScale", - @"patch": @(NO) - }, - LookinAttr_UIScrollView_Zoom_Scale: @{ - @"className": @"UIScrollView", - @"fullTitle": @"ZoomScale", - @"briefTitle": @"Scale", - @"patch": @(YES) - }, - LookinAttr_UIScrollView_Zoom_Bounce: @{ - @"className": @"UIScrollView", - @"fullTitle": @"BouncesZoom", - @"patch": @(NO) - }, - - LookinAttr_UITableView_Style_Style: @{ - @"className": @"UITableView", - @"fullTitle": @"Style", - @"setterString": @"", - @"enumList": @"UITableViewStyle", - @"patch": @(YES) - }, - LookinAttr_UITableView_SectionsNumber_Number: @{ - @"className": @"UITableView", - @"fullTitle": @"NumberOfSections", - @"setterString": @"", - @"patch": @(YES) - }, - LookinAttr_UITableView_RowsNumber_Number: @{ - @"className": @"UITableView", - @"setterString": @"", - @"getterString": @"lks_numberOfRows", - @"typeIfObj": @(LookinAttrTypeCustomObj) - }, - LookinAttr_UITableView_SeparatorInset_Inset: @{ - @"className": @"UITableView", - @"fullTitle": @"SeparatorInset", - @"patch": @(NO) - }, - LookinAttr_UITableView_SeparatorColor_Color: @{ - @"className": @"UITableView", - @"fullTitle": @"SeparatorColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_UITableView_SeparatorStyle_Style: @{ - @"className": @"UITableView", - @"fullTitle": @"SeparatorStyle", - @"enumList": @"UITableViewCellSeparatorStyle", - @"patch": @(YES) - }, - - LookinAttr_UITextView_Text_Text: @{ - @"className": @"UITextView", - @"fullTitle": @"Text", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_UITextView_Font_Name: @{ - @"className": @"UITextView", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UITextView_Font_Size: @{ - @"className": @"UITextView", - @"fullTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_UITextView_Basic_Editable: @{ - @"className": @"UITextView", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"patch": @(NO) - }, - LookinAttr_UITextView_Basic_Selectable: @{ - @"className": @"UITextView", - @"fullTitle": @"Selectable", - @"getterString": @"isSelectable", - @"patch": @(NO) - }, - LookinAttr_UITextView_TextColor_Color: @{ - @"className": @"UITextView", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_UITextView_Alignment_Alignment: @{ - @"className": @"UITextView", - @"fullTitle": @"TextAlignment", - @"enumList": @"NSTextAlignment", - @"patch": @(YES) - }, - LookinAttr_UITextView_ContainerInset_Inset: @{ - @"className": @"UITextView", - @"fullTitle": @"TextContainerInset", - @"patch": @(YES) - }, - - LookinAttr_UITextField_Font_Name: @{ - @"className": @"UITextField", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UITextField_Font_Size: @{ - @"className": @"UITextField", - @"fullTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_UITextField_TextColor_Color: @{ - @"className": @"UITextField", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_UITextField_Alignment_Alignment: @{ - @"className": @"UITextField", - @"fullTitle": @"TextAlignment", - @"enumList": @"NSTextAlignment", - @"patch": @(YES) - }, - LookinAttr_UITextField_Text_Text: @{ - @"className": @"UITextField", - @"fullTitle": @"Text", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_UITextField_Placeholder_Placeholder: @{ - @"className": @"UITextField", - @"fullTitle": @"Placeholder", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_UITextField_Clears_ClearsOnBeginEditing: @{ - @"className": @"UITextField", - @"fullTitle": @"ClearsOnBeginEditing", - @"patch": @(NO) - }, - LookinAttr_UITextField_Clears_ClearsOnInsertion: @{ - @"className": @"UITextField", - @"fullTitle": @"ClearsOnInsertion", - @"patch": @(NO) - }, - LookinAttr_UITextField_CanAdjustFont_CanAdjustFont: @{ - @"className": @"UITextField", - @"fullTitle": @"AdjustsFontSizeToFitWidth", - @"patch": @(YES) - }, - LookinAttr_UITextField_CanAdjustFont_MinSize: @{ - @"className": @"UITextField", - @"fullTitle": @"MinimumFontSize", - @"patch": @(YES) - }, - LookinAttr_UITextField_ClearButtonMode_Mode: @{ - @"className": @"UITextField", - @"fullTitle": @"ClearButtonMode", - @"enumList": @"UITextFieldViewMode", - @"patch": @(NO) - }, - LookinAttr_NSImageView_Name_Name: @{ - @"className": @"NSImageView", - @"fullTitle": @"ImageName", - @"setterString": @"", - @"getterString": @"lks_imageSourceName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES) - }, - LookinAttr_NSImageView_Open_Open: @{ - @"className": @"NSImageView", - @"setterString": @"", - @"getterString": @"lks_imageViewOidIfHasImage", - @"typeIfObj": @(LookinAttrTypeCustomObj), - @"hideIfNil": @(YES) - }, - LookinAttr_NSImageView_Scaling_ImageScaling: @{ - @"className": @"NSImageView", - @"fullTitle": @"ImageScaling", - @"enumList": @"NSImageScaling", - @"patch": @(YES) - }, - LookinAttr_NSImageView_Scaling_ImageAlignment: @{ - @"className": @"NSImageView", - @"fullTitle": @"ImageAlignment", - @"enumList": @"NSImageAlignment", - @"patch": @(YES) - }, - LookinAttr_NSImageView_Scaling_ImageFrameStyle: @{ - @"className": @"NSImageView", - @"fullTitle": @"ImageFrameStyle", - @"enumList": @"NSImageFrameStyle", - @"patch": @(YES) - }, - LookinAttr_NSImageView_Behavior_Animates: @{ - @"className": @"NSImageView", - @"fullTitle": @"Animates", - @"patch": @(YES) - }, - LookinAttr_NSImageView_Behavior_Editable: @{ - @"className": @"NSImageView", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"patch": @(YES) - }, - LookinAttr_NSImageView_ContentTintColor_ContentTintColor: @{ - @"className": @"NSImageView", - @"fullTitle": @"ContentTintColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSControl_State_Enabled: @{ - @"className": @"NSControl", - @"fullTitle": @"Enabled", - @"getterString": @"isEnabled", - @"patch": @(YES) - }, - LookinAttr_NSControl_State_Highlighted: @{ - @"className": @"NSControl", - @"fullTitle": @"Highlighted", - @"getterString": @"isHighlighted", - @"patch": @(YES) - }, - LookinAttr_NSControl_State_Continuous: @{ - @"className": @"NSControl", - @"fullTitle": @"Continuous", - @"getterString": @"isContinuous", - @"patch": @(NO) - }, - LookinAttr_NSControl_ControlSize_Size: @{ - @"className": @"NSControl", - @"fullTitle": @"ControlSize", - @"enumList": @"NSControlSize", - @"patch": @(YES) - }, - LookinAttr_NSControl_Font_Name: @{ - @"className": @"NSControl", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_NSControl_Font_Size: @{ - @"className": @"NSControl", - @"fullTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_NSControl_Alignment_Alignment: @{ - @"className": @"NSControl", - @"fullTitle": @"Alignment", - @"enumList": @"NSTextAlignment_AppKit", - @"patch": @(YES) - }, - LookinAttr_NSControl_Misc_WritingDirection: @{ - @"className": @"NSControl", - @"fullTitle": @"BaseWritingDirection", - @"enumList": @"NSWritingDirection", - @"patch": @(NO) - }, - LookinAttr_NSControl_Misc_IgnoresMultiClick: @{ - @"className": @"NSControl", - @"fullTitle": @"IgnoresMultiClick", - @"patch": @(NO) - }, - LookinAttr_NSControl_Misc_UsesSingleLineMode: @{ - @"className": @"NSControl", - @"fullTitle": @"UsesSingleLineMode", - @"patch": @(NO) - }, - LookinAttr_NSControl_Misc_AllowsExpansionToolTips: @{ - @"className": @"NSControl", - @"fullTitle": @"AllowsExpansionToolTips", - @"patch": @(NO) - }, - LookinAttr_NSControl_Value_StringValue: @{ - @"className": @"NSControl", - @"fullTitle": @"StringValue", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSControl_Value_IntValue: @{ - @"className": @"NSControl", - @"fullTitle": @"IntValue", - @"patch": @(YES) - }, - LookinAttr_NSControl_Value_IntegerValue: @{ - @"className": @"NSControl", - @"fullTitle": @"IntegerValue", - @"patch": @(YES) - }, - LookinAttr_NSControl_Value_FloatValue: @{ - @"className": @"NSControl", - @"fullTitle": @"FloatValue", - @"patch": @(YES) - }, - LookinAttr_NSControl_Value_DoubleValue: @{ - @"className": @"NSControl", - @"fullTitle": @"DoubleValue", - @"patch": @(YES) - }, - LookinAttr_NSButton_ButtonType_ButtonType: @{ - @"className": @"NSButton", - @"fullTitle": @"ButtonType", - @"getterString": @"lks_buttonType", - @"enumList": @"NSButtonType", - @"patch": @(YES) - }, - LookinAttr_NSButton_Title_Title: @{ - @"className": @"NSButton", - @"fullTitle": @"Title", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSButton_Title_AlernateTitle: @{ - @"className": @"NSButton", - @"fullTitle": @"AlternateTitle", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSButton_BezelStyle_BezelStyle: @{ - @"className": @"NSButton", - @"fullTitle": @"BezelStyle", - @"enumList": @"NSBezelStyle", - @"patch": @(YES) - }, - LookinAttr_NSButton_Bordered_Bordered: @{ - @"className": @"NSButton", - @"fullTitle": @"Bordered", - @"getterString": @"isBordered", - @"patch": @(YES) - }, - LookinAttr_NSButton_Transparent_Transparent: @{ - @"className": @"NSButton", - @"fullTitle": @"Transparent", - @"getterString": @"isTransparent", - @"patch": @(YES) - }, - LookinAttr_NSButton_BezelColor_BezelColor: @{ - @"className": @"NSButton", - @"fullTitle": @"BezelColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSButton_ContentTintColor_ContentTintColor: @{ - @"className": @"NSButton", - @"fullTitle": @"ContentTintColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSButton_Misc_ShowsBorderOnlyWhileMouseInside: @{ - @"className": @"NSButton", - @"fullTitle": @"ShowsBorderOnlyWhileMouseInside", - @"patch": @(YES) - }, - LookinAttr_NSButton_Misc_MaxAcceleratorLevel: @{ - @"className": @"NSButton", - @"fullTitle": @"MaxAcceleratorLevel", - @"patch": @(YES) - }, - LookinAttr_NSButton_Misc_SpringLoaded: @{ - @"className": @"NSButton", - @"fullTitle": @"SpringLoaded", - @"patch": @(YES) - }, - LookinAttr_NSButton_Misc_HasDestructiveAction: @{ - @"className": @"NSButton", - @"fullTitle": @"HasDestructiveAction", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ContentOffset_Offset: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ContentOffset", - @"setterString": @"lks_setContentOffset:", - @"getterString": @"lks_contentOffset", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ContentSize_Size: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ContentSize", - @"setterString": @"lks_setContentSize:", - @"getterString": @"lks_contentSize", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ContentInset_ContentInset: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ContentInset", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ContentInset_AutomaticallyAdjustsContentInsets: @{ - @"className": @"NSScrollView", - @"fullTitle": @"AutomaticallyAdjustsContentInsets", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_BorderType_BorderType: @{ - @"className": @"NSScrollView", - @"fullTitle": @"BorderType", - @"enumList": @"NSBorderType", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HasHorizontalScroller", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HasVerticalScroller", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_AutohidesScrollers: @{ - @"className": @"NSScrollView", - @"fullTitle": @"AutohidesScrollers", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_ScrollerStyle: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ScrollerStyle", - @"enumList": @"NSScrollerStyle", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_ScrollerKnobStyle: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ScrollerKnobStyle", - @"enumList": @"NSScrollerKnobStyle", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Scroller_ScrollerInsets: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ScrollerInsets", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Ruler_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HasHorizontalRuler", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Ruler_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HasVerticalRuler", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Ruler_Visible: @{ - @"className": @"NSScrollView", - @"fullTitle": @"RulersVisible", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_LineScroll_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HorizontalLineScroll", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_LineScroll_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"VerticalLineScroll", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_LineScroll_LineScroll: @{ - @"className": @"NSScrollView", - @"fullTitle": @"LineScroll", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_PageScroll_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HorizontalPageScroll", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_PageScroll_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"VerticalPageScroll", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_PageScroll_PageScroll: @{ - @"className": @"NSScrollView", - @"fullTitle": @"PageScroll", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ScrollElasiticity_Horizontal: @{ - @"className": @"NSScrollView", - @"fullTitle": @"HorizontalScrollElasticity", - @"enumList": @"NSScrollElasticity", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_ScrollElasiticity_Vertical: @{ - @"className": @"NSScrollView", - @"fullTitle": @"VerticalScrollElasticity", - @"enumList": @"NSScrollElasticity", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Misc_ScrollsDynamically: @{ - @"className": @"NSScrollView", - @"fullTitle": @"ScrollsDynamically", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Misc_UsesPredominantAxisScrolling: @{ - @"className": @"NSScrollView", - @"fullTitle": @"UsesPredominantAxisScrolling", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Magnification_AllowsMagnification: @{ - @"className": @"NSScrollView", - @"fullTitle": @"AllowsMagnification", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Magnification_Magnification: @{ - @"className": @"NSScrollView", - @"fullTitle": @"Magnification", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Magnification_Max: @{ - @"className": @"NSScrollView", - @"fullTitle": @"MaximunMagnification", - @"patch": @(YES) - }, - LookinAttr_NSScrollView_Magnification_Min: @{ - @"className": @"NSScrollView", - @"fullTitle": @"MinimumMagnification", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsColumnReordering_AllowsColumnReordering: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsColumnReordering", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsColumnResizing_AllowsColumnResizing: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsColumnResizing", - @"patch": @(YES) - }, - LookinAttr_NSTableView_ColumnAutoresizingStyle_ColumnAutoresizingStyle: @{ - @"className": @"NSTableView", - @"fullTitle": @"ColumnAutoresizingStyle", - @"enumList": @"NSTableViewColumnAutoresizingStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_GridStyleMask_GridStyleMask: @{ - @"className": @"NSTableView", - @"fullTitle": @"GridStyleMask", - @"enumList": @"NSTableViewGridLineStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_IntercellSpacing_IntercellSpacing: @{ - @"className": @"NSTableView", - @"fullTitle": @"IntercellSpacing", - @"patch": @(YES) - }, - LookinAttr_NSTableView_UseAlternatingRowBackgroundColors_UseAlternatingRowBackgroundColors: @{ - @"className": @"NSTableView", - @"fullTitle": @"UsesAlternatingRowBackgroundColors", - @"patch": @(YES) - }, - LookinAttr_NSTableView_GridColor_GridColor: @{ - @"className": @"NSTableView", - @"fullTitle": @"GridColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSTableView_RowSizeStyle_RowSizeStyle: @{ - @"className": @"NSTableView", - @"fullTitle": @"RowSizeStyle", - @"enumList": @"NSTableViewRowSizeStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_RowHeight_RowHeight: @{ - @"className": @"NSTableView", - @"fullTitle": @"RowHeight", - @"patch": @(YES) - }, - LookinAttr_NSTableView_NumberOfRows_NumberOfRows: @{ - @"className": @"NSTableView", - @"fullTitle": @"NumberOfRows", - @"setterString": @"", - @"patch": @(YES) - }, - LookinAttr_NSTableView_NumberOfColumns_NumberOfColumns: @{ - @"className": @"NSTableView", - @"fullTitle": @"NumberOfColumns", - @"setterString": @"", - @"patch": @(YES) - }, - LookinAttr_NSTableView_VerticalMotionCanBeginDrag_VerticalMotionCanBeginDrag: @{ - @"className": @"NSTableView", - @"fullTitle": @"VerticalMotionCanBeginDrag", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsMultipleSelection_AllowsMultipleSelection: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsMultipleSelection", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsEmptySelection_AllowsEmptySelection: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsEmptySelection", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsColumnSelection_AllowsColumnSelection: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsColumnSelection", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AllowsTypeSelect_AllowsTypeSelect: @{ - @"className": @"NSTableView", - @"fullTitle": @"AllowsTypeSelect", - @"patch": @(YES) - }, - LookinAttr_NSTableView_SelectionHighlightStyle_SelectionHighlightStyle: @{ - @"className": @"NSTableView", - @"fullTitle": @"SelectionHighlightStyle", - @"enumList": @"NSTableViewSelectionHighlightStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_DraggingDestinationFeedbackStyle_DraggingDestinationFeedbackStyle: @{ - @"className": @"NSTableView", - @"fullTitle": @"DraggingDestinationFeedbackStyle", - @"enumList": @"NSTableViewDraggingDestinationFeedbackStyle", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AutomaticRowHeights_AutomaticRowHeights: @{ - @"className": @"NSTableView", - @"fullTitle": @"AutomaticRowHeights", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AutosaveName_AutosaveName: @{ - @"className": @"NSTableView", - @"fullTitle": @"AutosaveName", - @"patch": @(YES) - }, - LookinAttr_NSTableView_AutosaveTableColumns_AutosaveTableColumns: @{ - @"className": @"NSTableView", - @"fullTitle": @"AutosaveTableColumns", - @"patch": @(YES) - }, - LookinAttr_NSTableView_FloatsGroupRows_FloatsGroupRows: @{ - @"className": @"NSTableView", - @"fullTitle": @"FloatsGroupRows", - @"patch": @(YES) - }, - LookinAttr_NSTableView_RowActionsVisible_RowActionsVisible: @{ - @"className": @"NSTableView", - @"fullTitle": @"RowActionsVisible", - @"patch": @(YES) - }, - LookinAttr_NSTableView_UsesStaticContents_UsesStaticContents: @{ - @"className": @"NSTableView", - @"fullTitle": @"UsesStaticContents", - @"patch": @(YES) - }, - LookinAttr_NSTableView_UserInterfaceLayoutDirection_UserInterfaceLayoutDirection: @{ - @"className": @"NSTableView", - @"fullTitle": @"UserInterfaceLayoutDirection", - @"enumList": @"NSUserInterfaceLayoutDirection", - @"patch": @(YES) - }, - LookinAttr_NSTableView_Style_Style: @{ - @"className": @"NSTableView", - @"fullTitle": @"Style", - @"enumList": @"NSTableViewStyle", - @"patch": @(YES) - }, - LookinAttr_NSTextView_Font_Name: @{ - @"className": @"NSTextView", - @"fullTitle": @"FontName", - @"setterString": @"", - @"getterString": @"lks_fontName", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_NSTextView_Font_Size: @{ - @"className": @"NSTextView", - @"fullTitle": @"FontSize", - @"setterString": @"setLks_fontSize:", - @"getterString": @"lks_fontSize", - @"patch": @(YES) - }, - LookinAttr_NSTextView_Basic_Editable: @{ - @"className": @"NSTextView", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Basic_Selectable: @{ - @"className": @"NSTextView", - @"fullTitle": @"Selectable", - @"getterString": @"isSelectable", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Basic_RichText: @{ - @"className": @"NSTextView", - @"fullTitle": @"RichText", - @"getterString": @"isRichText", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Basic_FieldEditor: @{ - @"className": @"NSTextView", - @"fullTitle": @"FieldEditor", - @"getterString": @"isFieldEditor", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Basic_ImportsGraphics: @{ - @"className": @"NSTextView", - @"fullTitle": @"ImportsGraphics", - @"patch": @(NO) - }, - LookinAttr_NSTextView_String_String: @{ - @"className": @"NSTextView", - @"fullTitle": @"String", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSTextView_TextColor_Color: @{ - @"className": @"NSTextView", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSTextView_Alignment_Alignment: @{ - @"className": @"NSTextView", - @"fullTitle": @"Alignment", - @"enumList": @"NSTextAlignment_AppKit", - @"patch": @(YES) - }, - LookinAttr_NSTextView_ContainerInset_Inset: @{ - @"className": @"NSTextView", - @"fullTitle": @"TextContainerInset", - @"patch": @(YES) - }, - LookinAttr_NSTextView_BaseWritingDirection_BaseWritingDirection: @{ - @"className": @"NSTextView", - @"fullTitle": @"BaseWritingDirection", - @"enumList": @"NSWritingDirection", - @"patch": @(NO) - }, - LookinAttr_NSTextView_MaxSize_MaxSize: @{ - @"className": @"NSTextView", - @"fullTitle": @"MaxSize", - @"patch": @(YES) - }, - LookinAttr_NSTextView_MinSize_MinSize: @{ - @"className": @"NSTextView", - @"fullTitle": @"MinSize", - @"patch": @(YES) - }, - LookinAttr_NSTextView_Resizable_Horizontal: @{ - @"className": @"NSTextView", - @"fullTitle": @"HorizontallyResizable", - @"patch": @(NO) - }, - LookinAttr_NSTextView_Resizable_Vertical: @{ - @"className": @"NSTextView", - @"fullTitle": @"VerticallyResizable", - @"patch": @(NO) - }, - LookinAttr_NSTextField_Bordered_Bordered: @{ - @"className": @"NSTextField", - @"fullTitle": @"Bordered", - @"getterString": @"isBordered", - @"patch": @(NO) - }, - LookinAttr_NSTextField_Bezeled_Bezeled: @{ - @"className": @"NSTextField", - @"fullTitle": @"Bezeled", - @"getterString": @"isBezeled", - @"patch": @(NO) - }, - LookinAttr_NSTextField_Editable_Editable: @{ - @"className": @"NSTextField", - @"fullTitle": @"Editable", - @"getterString": @"isEditable", - @"patch": @(NO) - }, - LookinAttr_NSTextField_Selectable_Selectable: @{ - @"className": @"NSTextField", - @"fullTitle": @"Selectable", - @"getterString": @"isSelectable", - @"patch": @(NO) - }, - LookinAttr_NSTextField_DrawsBackground_DrawsBackground: @{ - @"className": @"NSTextField", - @"fullTitle": @"DrawsBackground", - @"patch": @(YES) - }, - LookinAttr_NSTextField_BezelStyle_BezelStyle: @{ - @"className": @"NSTextField", - @"fullTitle": @"BezelStyle", - @"enumList": @"NSTextFieldBezelStyle", - @"patch": @(YES) - }, - LookinAttr_NSTextField_PreferredMaxLayoutWidth_PreferredMaxLayoutWidth: @{ - @"className": @"NSTextField", - @"fullTitle": @"PreferredMaxLayoutWidth", - @"patch": @(YES) - }, - LookinAttr_NSTextField_MaximumNumberOfLines_MaximumNumberOfLines: @{ - @"className": @"NSTextField", - @"fullTitle": @"MaximumNumberOfLines", - @"patch": @(YES) - }, - LookinAttr_NSTextField_AllowsDefaultTighteningForTruncation_AllowsDefaultTighteningForTruncation: @{ - @"className": @"NSTextField", - @"fullTitle": @"AllowsDefaultTighteningForTruncation", - @"patch": @(YES) - }, - LookinAttr_NSTextField_LineBreakStrategy_LineBreakStrategy: @{ - @"className": @"NSTextField", - @"fullTitle": @"LineBreakStrategy", - @"enumList": @"NSLineBreakStrategy", - @"patch": @(YES) - }, - LookinAttr_NSTextField_Placeholder_Placeholder: @{ - @"className": @"NSTextField", - @"fullTitle": @"PlaceholderString", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(YES) - }, - LookinAttr_NSTextField_TextColor_Color: @{ - @"className": @"NSTextField", - @"fullTitle": @"TextColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSTextField_BackgroundColor_Color: @{ - @"className": @"NSTextField", - @"fullTitle": @"BackgroundColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSTextField_AllowsEditingTextAttributes_AllowsEditingTextAttributes: @{ - @"className": @"NSTextField", - @"fullTitle": @"AllowsEditingTextAttributes", - @"patch": @(YES) - }, - LookinAttr_NSTextField_ImportsGraphics_ImportsGraphics: @{ - @"className": @"NSTextField", - @"fullTitle": @"ImportsGraphics", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_Material_Material: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"Material", - @"enumList": @"NSVisualEffectMaterial", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_InteriorBackgroundStyle_InteriorBackgroundStyle: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"InteriorBackgroundStyle", - @"enumList": @"NSBackgroundStyle", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_BlendingMode_BlendingMode: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"BlendingMode", - @"enumList": @"NSVisualEffectBlendingMode", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_State_State: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"State", - @"enumList": @"NSVisualEffectState", - @"patch": @(YES) - }, - LookinAttr_NSVisualEffectView_Emphasized_Emphasized: @{ - @"className": @"NSVisualEffectView", - @"fullTitle": @"Emphasized", - @"getterString": @"isEmphasized", - @"patch": @(YES) - }, - LookinAttr_NSStackView_Orientation_Orientation: @{ - @"className": @"NSStackView", - @"fullTitle": @"Orientation", - @"enumList": @"NSUserInterfaceLayoutOrientation", - @"patch": @(YES) - }, - LookinAttr_NSStackView_EdgeInsets_EdgeInsets: @{ - @"className": @"NSStackView", - @"fullTitle": @"EdgeInsets", - @"patch": @(YES) - }, - LookinAttr_NSStackView_DetachesHiddenViews_DetachesHiddenViews: @{ - @"className": @"NSStackView", - @"fullTitle": @"DetachesHiddenViews", - @"patch": @(YES) - }, - LookinAttr_NSStackView_Distribution_Distribution: @{ - @"className": @"NSStackView", - @"fullTitle": @"Distribution", - @"enumList": @"NSStackViewDistribution", - @"patch": @(YES) - }, - LookinAttr_NSStackView_Alignment_Alignment: @{ - @"className": @"NSStackView", - @"fullTitle": @"Alignment", - @"enumList": @"NSLayoutAttribute", - @"patch": @(YES) - }, - LookinAttr_NSStackView_Spacing_Spacing: @{ - @"className": @"NSStackView", - @"fullTitle": @"Spacing", - @"patch": @(YES) - }, - - // MARK: - NSWindow - LookinAttr_NSWindow_Title_Title: @{ - @"className": @"NSWindow", - @"fullTitle": @"Title", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_NSWindow_Title_Subtitle: @{ - @"className": @"NSWindow", - @"fullTitle": @"Subtitle", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO), - @"osVersion": @(11) - }, - LookinAttr_NSWindow_State_KeyWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"KeyWindow", - @"getterString": @"isKeyWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_State_MainWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"MainWindow", - @"getterString": @"isMainWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_State_Visible: @{ - @"className": @"NSWindow", - @"fullTitle": @"Visible", - @"getterString": @"isVisible", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_State_CanBecomeKeyWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"CanBecomeKeyWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_State_CanBecomeMainWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"CanBecomeMainWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_Titled: @{ - @"className": @"NSWindow", - @"fullTitle": @"Titled", - @"getterString": @"lks_styleMaskTitled", - @"setterString": @"setLks_styleMaskTitled:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_Closable: @{ - @"className": @"NSWindow", - @"fullTitle": @"Closable", - @"getterString": @"lks_styleMaskClosable", - @"setterString": @"setLks_styleMaskClosable:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_Miniaturizable: @{ - @"className": @"NSWindow", - @"fullTitle": @"Miniaturizable", - @"getterString": @"lks_styleMaskMiniaturizable", - @"setterString": @"setLks_styleMaskMiniaturizable:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_Resizable: @{ - @"className": @"NSWindow", - @"fullTitle": @"Resizable", - @"getterString": @"lks_styleMaskResizable", - @"setterString": @"setLks_styleMaskResizable:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_UnifiedTitleAndToolbar: @{ - @"className": @"NSWindow", - @"fullTitle": @"UnifiedTitleAndToolbar", - @"getterString": @"lks_styleMaskUnifiedTitleAndToolbar", - @"setterString": @"setLks_styleMaskUnifiedTitleAndToolbar:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_FullScreen: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreen", - @"getterString": @"lks_styleMaskFullScreen", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_FullSizeContentView: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullSizeContentView", - @"getterString": @"lks_styleMaskFullSizeContentView", - @"setterString": @"setLks_styleMaskFullSizeContentView:", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Style_UtilityWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"UtilityWindow", - @"getterString": @"lks_styleMaskUtilityWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_DocModalWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"DocModalWindow", - @"getterString": @"lks_styleMaskDocModalWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_NonactivatingPanel: @{ - @"className": @"NSWindow", - @"fullTitle": @"NonactivatingPanel", - @"getterString": @"lks_styleMaskNonactivatingPanel", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Style_HUDWindow: @{ - @"className": @"NSWindow", - @"fullTitle": @"HUDWindow", - @"getterString": @"lks_styleMaskHUDWindow", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_CanJoinAllSpaces: @{ - @"className": @"NSWindow", - @"fullTitle": @"CanJoinAllSpaces", - @"getterString": @"lks_collectionBehaviorCanJoinAllSpaces", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_MoveToActiveSpace: @{ - @"className": @"NSWindow", - @"fullTitle": @"MoveToActiveSpace", - @"getterString": @"lks_collectionBehaviorMoveToActiveSpace", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_ParticipatesInCycle: @{ - @"className": @"NSWindow", - @"fullTitle": @"ParticipatesInCycle", - @"getterString": @"lks_collectionBehaviorParticipatesInCycle", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_IgnoresCycle: @{ - @"className": @"NSWindow", - @"fullTitle": @"IgnoresCycle", - @"getterString": @"lks_collectionBehaviorIgnoresCycle", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenPrimary: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenPrimary", - @"getterString": @"lks_collectionBehaviorFullScreenPrimary", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenAuxiliary: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenAuxiliary", - @"getterString": @"lks_collectionBehaviorFullScreenAuxiliary", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenNone: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenNone", - @"getterString": @"lks_collectionBehaviorFullScreenNone", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenAllowsTiling: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenAllowsTiling", - @"getterString": @"lks_collectionBehaviorFullScreenAllowsTiling", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_CollectionBehavior_FullScreenDisallowsTiling: @{ - @"className": @"NSWindow", - @"fullTitle": @"FullScreenDisallowsTiling", - @"getterString": @"lks_collectionBehaviorFullScreenDisallowsTiling", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Appearance_TitlebarAppearsTransparent: @{ - @"className": @"NSWindow", - @"fullTitle": @"TitlebarAppearsTransparent", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Appearance_TitleVisibility: @{ - @"className": @"NSWindow", - @"fullTitle": @"TitleVisibility", - @"enumList": @"NSWindowTitleVisibility", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Appearance_ToolbarStyle: @{ - @"className": @"NSWindow", - @"fullTitle": @"ToolbarStyle", - @"enumList": @"NSWindowToolbarStyle", - @"patch": @(YES), - @"osVersion": @(11) - }, - LookinAttr_NSWindow_Appearance_TitlebarSeparatorStyle: @{ - @"className": @"NSWindow", - @"fullTitle": @"TitlebarSeparatorStyle", - @"enumList": @"NSTitlebarSeparatorStyle", - @"patch": @(YES), - @"osVersion": @(11) - }, - LookinAttr_NSWindow_Appearance_BackgroundColor: @{ - @"className": @"NSWindow", - @"fullTitle": @"BackgroundColor", - @"typeIfObj": @(LookinAttrTypeUIColor), - @"patch": @(YES) - }, - LookinAttr_NSWindow_Appearance_AlphaValue: @{ - @"className": @"NSWindow", - @"fullTitle": @"AlphaValue", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Appearance_Opaque: @{ - @"className": @"NSWindow", - @"fullTitle": @"Opaque", - @"getterString": @"isOpaque", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Appearance_HasShadow: @{ - @"className": @"NSWindow", - @"fullTitle": @"HasShadow", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Behavior_Movable: @{ - @"className": @"NSWindow", - @"fullTitle": @"Movable", - @"getterString": @"isMovable", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Behavior_MovableByWindowBackground: @{ - @"className": @"NSWindow", - @"fullTitle": @"MovableByWindowBackground", - @"getterString": @"isMovableByWindowBackground", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Behavior_AnimationBehavior: @{ - @"className": @"NSWindow", - @"fullTitle": @"AnimationBehavior", - @"enumList": @"NSWindowAnimationBehavior", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Behavior_Level: @{ - @"className": @"NSWindow", - @"fullTitle": @"Level", - @"enumList": @"NSWindowLevel", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Behavior_HidesOnDeactivate: @{ - @"className": @"NSWindow", - @"fullTitle": @"HidesOnDeactivate", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Behavior_TabbingMode: @{ - @"className": @"NSWindow", - @"fullTitle": @"TabbingMode", - @"enumList": @"NSWindowTabbingMode", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Size_MinSize: @{ - @"className": @"NSWindow", - @"fullTitle": @"MinSize", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Size_MaxSize: @{ - @"className": @"NSWindow", - @"fullTitle": @"MaxSize", - @"patch": @(YES) - }, - LookinAttr_NSWindow_Info_WindowNumber: @{ - @"className": @"NSWindow", - @"fullTitle": @"WindowNumber", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_NSWindow_Info_BackingScaleFactor: @{ - @"className": @"NSWindow", - @"fullTitle": @"BackingScaleFactor", - @"setterString": @"", - @"patch": @(NO) - }, - - // MARK: - UIWindowScene - LookinAttr_UIWindowScene_State_ActivationState: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ActivationState", - @"getterString": @"activationState", - @"setterString": @"", - @"enumList": @"UISceneActivationState", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Title_Title: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"Title", - @"patch": @(YES), - @"typeIfObj": @(LookinAttrTypeNSString) - }, - LookinAttr_UIWindowScene_Orientation_InterfaceOrientation: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"InterfaceOrientation", - @"setterString": @"", - @"enumList": @"UIInterfaceOrientation", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Windows_WindowCount: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"WindowCount", - @"getterString": @"lks_windowCount", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Windows_KeyWindowClassName: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"KeyWindowClassName", - @"getterString": @"lks_keyWindowClassName", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Screen_ScreenBounds: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ScreenBounds", - @"getterString": @"lks_screenBounds", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Screen_ScreenScale: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ScreenScale", - @"getterString": @"lks_screenScale", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_StatusBar_StatusBarHidden: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"StatusBarHidden", - @"getterString": @"lks_statusBarHidden", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_StatusBar_StatusBarStyle: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"StatusBarStyle", - @"getterString": @"lks_statusBarStyle", - @"setterString": @"", - @"enumList": @"UIStatusBarStyle", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_StatusBar_StatusBarFrame: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"StatusBarFrame", - @"getterString": @"lks_statusBarFrame", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Traits_UserInterfaceStyle: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"UserInterfaceStyle", - @"getterString": @"lks_userInterfaceStyle", - @"setterString": @"", - @"enumList": @"UIUserInterfaceStyle", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Traits_HorizontalSizeClass: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"HorizontalSizeClass", - @"getterString": @"lks_horizontalSizeClass", - @"setterString": @"", - @"enumList": @"UIUserInterfaceSizeClass", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Traits_VerticalSizeClass: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"VerticalSizeClass", - @"getterString": @"lks_verticalSizeClass", - @"setterString": @"", - @"enumList": @"UIUserInterfaceSizeClass", - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Traits_UserInterfaceLevel: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"UserInterfaceLevel", - @"getterString": @"lks_userInterfaceLevel", - @"setterString": @"", - @"enumList": @"UIUserInterfaceLevel", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_ActiveAppearance: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ActiveAppearance", - @"getterString": @"lks_activeAppearance", - @"setterString": @"", - @"enumList": @"UIUserInterfaceActiveAppearance", - @"patch": @(NO), - @"osVersion": @(14) - }, - LookinAttr_UIWindowScene_Traits_AccessibilityContrast: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"AccessibilityContrast", - @"getterString": @"lks_accessibilityContrast", - @"setterString": @"", - @"enumList": @"UIAccessibilityContrast", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_LegibilityWeight: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"LegibilityWeight", - @"getterString": @"lks_legibilityWeight", - @"setterString": @"", - @"enumList": @"UILegibilityWeight", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_DisplayScale: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"DisplayScale", - @"getterString": @"lks_traitDisplayScale", - @"setterString": @"", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_DisplayGamut: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"DisplayGamut", - @"getterString": @"lks_displayGamut", - @"setterString": @"", - @"enumList": @"UIDisplayGamut", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_UserInterfaceIdiom: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"UserInterfaceIdiom", - @"getterString": @"lks_userInterfaceIdiom", - @"setterString": @"", - @"enumList": @"UIUserInterfaceIdiom", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_LayoutDirection: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"LayoutDirection", - @"getterString": @"lks_layoutDirection", - @"setterString": @"", - @"enumList": @"UITraitEnvironmentLayoutDirection", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_PreferredContentSizeCategory: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"PreferredContentSizeCategory", - @"getterString": @"lks_preferredContentSizeCategory", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UIWindowScene_Traits_SceneCaptureState: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"SceneCaptureState", - @"getterString": @"lks_sceneCaptureState", - @"setterString": @"", - @"enumList": @"UISceneCaptureState", - @"patch": @(NO), - @"osVersion": @(17) - }, - LookinAttr_UIWindowScene_Traits_ImageDynamicRange: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"ImageDynamicRange", - @"getterString": @"lks_imageDynamicRange", - @"setterString": @"", - @"enumList": @"UIImageDynamicRange", - @"patch": @(NO), - @"osVersion": @(17) - }, - LookinAttr_UIWindowScene_Traits_TypesettingLanguage: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"TypesettingLanguage", - @"getterString": @"lks_typesettingLanguage", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(17) - }, - LookinAttr_UIWindowScene_Session_PersistentIdentifier: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"PersistentIdentifier", - @"getterString": @"lks_sessionPersistentIdentifier", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - LookinAttr_UIWindowScene_Session_SessionRole: @{ - @"className": @"UIWindowScene", - @"fullTitle": @"SessionRole", - @"getterString": @"lks_sessionRole", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO) - }, - // MARK: - UITraitCollection - LookinAttr_UITraitCollection_Appearance_UserInterfaceStyle: @{ - @"className": @"UIView", - @"fullTitle": @"UserInterfaceStyle", - @"getterString": @"lks_traitCollection_userInterfaceStyle", - @"setterString": @"", - @"enumList": @"UIUserInterfaceStyle", - @"patch": @(NO), - @"osVersion": @(12) - }, - LookinAttr_UITraitCollection_Appearance_UserInterfaceLevel: @{ - @"className": @"UIView", - @"fullTitle": @"UserInterfaceLevel", - @"getterString": @"lks_traitCollection_userInterfaceLevel", - @"setterString": @"", - @"enumList": @"UIUserInterfaceLevel", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UITraitCollection_Appearance_ActiveAppearance: @{ - @"className": @"UIView", - @"fullTitle": @"ActiveAppearance", - @"getterString": @"lks_traitCollection_activeAppearance", - @"setterString": @"", - @"enumList": @"UIUserInterfaceActiveAppearance", - @"patch": @(NO), - @"osVersion": @(14) - }, - LookinAttr_UITraitCollection_Appearance_AccessibilityContrast: @{ - @"className": @"UIView", - @"fullTitle": @"AccessibilityContrast", - @"getterString": @"lks_traitCollection_accessibilityContrast", - @"setterString": @"", - @"enumList": @"UIAccessibilityContrast", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UITraitCollection_Appearance_LegibilityWeight: @{ - @"className": @"UIView", - @"fullTitle": @"LegibilityWeight", - @"getterString": @"lks_traitCollection_legibilityWeight", - @"setterString": @"", - @"enumList": @"UILegibilityWeight", - @"patch": @(NO), - @"osVersion": @(13) - }, - LookinAttr_UITraitCollection_SizeClass_HorizontalSizeClass: @{ - @"className": @"UIView", - @"fullTitle": @"HorizontalSizeClass", - @"getterString": @"lks_traitCollection_horizontalSizeClass", - @"setterString": @"", - @"enumList": @"UIUserInterfaceSizeClass", - @"patch": @(NO) - }, - LookinAttr_UITraitCollection_SizeClass_VerticalSizeClass: @{ - @"className": @"UIView", - @"fullTitle": @"VerticalSizeClass", - @"getterString": @"lks_traitCollection_verticalSizeClass", - @"setterString": @"", - @"enumList": @"UIUserInterfaceSizeClass", - @"patch": @(NO) - }, - LookinAttr_UITraitCollection_Display_DisplayScale: @{ - @"className": @"UIView", - @"fullTitle": @"DisplayScale", - @"getterString": @"lks_traitCollection_displayScale", - @"setterString": @"", - @"patch": @(NO) - }, - LookinAttr_UITraitCollection_Display_DisplayGamut: @{ - @"className": @"UIView", - @"fullTitle": @"DisplayGamut", - @"getterString": @"lks_traitCollection_displayGamut", - @"setterString": @"", - @"enumList": @"UIDisplayGamut", - @"patch": @(NO), - @"osVersion": @(10) - }, - LookinAttr_UITraitCollection_Display_ImageDynamicRange: @{ - @"className": @"UIView", - @"fullTitle": @"ImageDynamicRange", - @"getterString": @"lks_traitCollection_imageDynamicRange", - @"setterString": @"", - @"enumList": @"UIImageDynamicRange", - @"patch": @(NO), - @"osVersion": @(17) - }, - LookinAttr_UITraitCollection_Device_UserInterfaceIdiom: @{ - @"className": @"UIView", - @"fullTitle": @"UserInterfaceIdiom", - @"getterString": @"lks_traitCollection_userInterfaceIdiom", - @"setterString": @"", - @"enumList": @"UIUserInterfaceIdiom", - @"patch": @(NO) - }, - LookinAttr_UITraitCollection_Device_ForceTouchCapability: @{ - @"className": @"UIView", - @"fullTitle": @"ForceTouchCapability", - @"getterString": @"lks_traitCollection_forceTouchCapability", - @"setterString": @"", - @"enumList": @"UIForceTouchCapability", - @"patch": @(NO), - @"osVersion": @(9) - }, - LookinAttr_UITraitCollection_Layout_LayoutDirection: @{ - @"className": @"UIView", - @"fullTitle": @"LayoutDirection", - @"getterString": @"lks_traitCollection_layoutDirection", - @"setterString": @"", - @"enumList": @"UITraitEnvironmentLayoutDirection", - @"patch": @(NO), - @"osVersion": @(10) - }, - LookinAttr_UITraitCollection_Content_PreferredContentSizeCategory: @{ - @"className": @"UIView", - @"fullTitle": @"PreferredContentSizeCategory", - @"getterString": @"lks_traitCollection_preferredContentSizeCategory", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"patch": @(NO), - @"osVersion": @(10) - }, - LookinAttr_UITraitCollection_Content_TypesettingLanguage: @{ - @"className": @"UIView", - @"fullTitle": @"TypesettingLanguage", - @"getterString": @"lks_traitCollection_typesettingLanguage", - @"setterString": @"", - @"typeIfObj": @(LookinAttrTypeNSString), - @"hideIfNil": @(YES), - @"patch": @(NO), - @"osVersion": @(17) - }, - }; - }); - - NSDictionary *targetInfo = dict[attrID]; - return targetInfo; -} - -+ (LookinAttrType)objectAttrTypeWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSNumber *typeIfObj = attrInfo[@"typeIfObj"]; - return [typeIfObj integerValue]; -} - -+ (NSString *)classNameWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *className = attrInfo[@"className"]; - - NSAssert(className.length > 0, @""); - - return className; -} - -+ (BOOL)isWindowPropertyWithAttrID:(LookinAttrIdentifier)attrID { - NSString *className = [self classNameWithAttrID:attrID]; - if ([className isEqualToString:@"UIWindowScene"]) { - return YES; - } - - if ([className isEqualToString:@"NSWindow"]) { - return YES; - } - return NO; -} - -+ (BOOL)isUIViewPropertyWithAttrID:(LookinAttrIdentifier)attrID { - NSString *className = [self classNameWithAttrID:attrID]; - - if ([className isEqualToString:@"CALayer"]) { - return NO; - } - - if ([className isEqualToString:@"UIWindowScene"]) { - return NO; - } - - if ([className isEqualToString:@"NSWindow"]) { - return NO; - } - - return YES; -} - -+ (NSString *)enumListNameWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *name = attrInfo[@"enumList"]; - return name; -} - -+ (BOOL)needPatchAfterModificationWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSNumber *needPatch = attrInfo[@"patch"]; - return [needPatch boolValue]; -} - -+ (NSString *)fullTitleWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *fullTitle = attrInfo[@"fullTitle"]; - return fullTitle; -} - -+ (NSString *)briefTitleWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *briefTitle = attrInfo[@"briefTitle"]; - if (!briefTitle) { - briefTitle = attrInfo[@"fullTitle"]; - } - return briefTitle; -} - -+ (SEL)getterWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *getterString = attrInfo[@"getterString"]; - if (getterString && getterString.length == 0) { - // 空字符串,比如 image_open_open - return nil; - } - if (!getterString) { - NSString *fullTitle = attrInfo[@"fullTitle"]; - NSAssert(fullTitle.length > 0, @""); - - getterString = [NSString stringWithFormat:@"%@%@", [fullTitle substringToIndex:1].lowercaseString, [fullTitle substringFromIndex:1]].copy; - } - return NSSelectorFromString(getterString); -} - -+ (SEL)setterWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSString *setterString = attrInfo[@"setterString"]; - if ([setterString isEqualToString:@""]) { - // 该属性不可在 Lookin 客户端中被修改 - return nil; - } - if (!setterString) { - NSString *fullTitle = attrInfo[@"fullTitle"]; - NSAssert(fullTitle.length > 0, @""); - - setterString = [NSString stringWithFormat:@"set%@%@:", [fullTitle substringToIndex:1].uppercaseString, [fullTitle substringFromIndex:1]]; - } - return NSSelectorFromString(setterString); -} - -+ (BOOL)hideIfNilWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSNumber *boolValue = attrInfo[@"hideIfNil"]; - return boolValue.boolValue; -} - -+ (NSInteger)minAvailableOSVersionWithAttrID:(LookinAttrIdentifier)attrID { - NSDictionary *attrInfo = [self _infoForAttrID:attrID]; - NSNumber *minVerNum = attrInfo[@"osVersion"]; - NSInteger minVer = [minVerNum integerValue]; - return minVer; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinDefines.h b/Sources/LookinServer/Shared/LookinDefines.h deleted file mode 100644 index 580090d6..00000000 --- a/Sources/LookinServer/Shared/LookinDefines.h +++ /dev/null @@ -1,187 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinMessageProtocol.h -// Lookin -// -// Created by Li Kai on 2018/8/6. -// https://lookin.work -// - -#import "TargetConditionals.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_OSX -#import -#endif - -#include - -#pragma mark - Version - -/// current connection protocol version of LookinServer -static const int LOOKIN_SERVER_VERSION = 8; - -/// current release version of LookinServer -static NSString * const LOOKIN_SERVER_READABLE_VERSION = @"1.3.0"; - -/// current connection protocol version of LookinClient -static const int LOOKIN_CLIENT_VERSION = 8; - -/// the minimum connection protocol version supported by current LookinClient -static const int LOOKIN_SUPPORTED_SERVER_MIN = 8; -/// the maximum connection protocol version supported by current LookinClient -static const int LOOKIN_SUPPORTED_SERVER_MAX = 8; - -#pragma mark - Connection - -/// LookinServer 在真机上会依次尝试监听 47175 ~ 47179 这几个端口 -static const int LookinUSBDeviceIPv4PortNumberStart = 47175; -static const int LookinUSBDeviceIPv4PortNumberEnd = 47179; - -/// LookinServer 在模拟器中会依次尝试监听 47164 ~ 47169 这几个端口 -static const int LookinSimulatorIPv4PortNumberStart = 47164; -static const int LookinSimulatorIPv4PortNumberEnd = 47169; - -/// LookinServer 在 macOS 原生应用中会依次尝试监听 47170 ~ 47174 这几个端口 -static const int LookinMacIPv4PortNumberStart = 47170; -static const int LookinMacIPv4PortNumberEnd = 47174; - -enum { - /// 确认两端是否可以响应通讯 - LookinRequestTypePing = 200, - /// 请求 App 的截图、设备型号等信息 - LookinRequestTypeApp = 201, - /// 请求 Hierarchy 信息 - LookinRequestTypeHierarchy = 202, - /// 请求 screenshots 和 attrGroups 信息 - LookinRequestTypeHierarchyDetails = 203, - /// 请求修改某个内置的 Attribute 的值 - LookinRequestTypeInbuiltAttrModification = 204, - /// 修改某个 attr 后,请求一系列最新的 Screenshots、属性值等信息 - LookinRequestTypeAttrModificationPatch = 205, - /// 执行某个方法 - LookinRequestTypeInvokeMethod = 206, - /** - @request: @{@"oid":} - @response: LookinObject * - */ - LookinRequestTypeFetchObject = 207, - - LookinRequestTypeFetchImageViewImage = 208, - - LookinRequestTypeModifyRecognizerEnable = 209, - - /// 请求 attribute group list - LookinRequestTypeAllAttrGroups = 210, - - /// 请求 iOS App 里某个 class 的所有 selector 名字列表(包括 superclass) - LookinRequestTypeAllSelectorNames = 213, - - /// 请求修改某个自定义 Attribute 的值 - LookinRequestTypeCustomAttrModification = 214, - - /// License 握手:Server → Host 发起 challenge(返回 nonce + server_instance_id) - LookinRequestTypeLicenseChallenge = 220, - /// License 握手:Host → Server 提交签名 + 中间证书供校验 - LookinRequestTypeLicenseVerify = 221, - - /// 从 LookinServer 1.2.7 & Lookin 1.0.7 开始,该属性被废弃、不再使用 - LookinPush_BringForwardScreenshotTask = 303, - - // 用户在 Lookin 客户端取消了之前 HierarchyDetails 的拉取 - LookinPush_CanceHierarchyDetails = 304, - - /// Server 在层级扫描期间检测到 SwiftUI hosting view - LookinPush_SwiftUISupportDetected = 305, -}; - -static NSString * const LookinParam_ViewLayerTag = @"tag"; - -static NSString * const LookinParam_SelectorName = @"sn"; -static NSString * const LookinParam_MethodType = @"mt"; -static NSString * const LookinParam_SelectorClassName = @"scn"; - -static NSString * const LookinStringFlag_VoidReturn = @"LOOKIN_TAG_RETURN_VALUE_VOID"; - -#pragma mark - Error - -static NSString * const LookinErrorDomain = @"LookinError"; - -enum { - LookinErrCode_Default = -400, - /// Lookin 内部业务逻辑错误 - LookinErrCode_Inner = -401, - /// PeerTalk 内部错误 - LookinErrCode_PeerTalk = -402, - /// 连接不存在或已断开 - LookinErrCode_NoConnect = -403, - /// ping 失败了,原因是 ping 请求超时 - LookinErrCode_PingFailForTimeout = -404, - /// 请求超时未返回 - LookinErrCode_Timeout = -405, - /// 有相同 Type 的新请求被发出,因此旧请求被丢弃 - LookinErrCode_Discard = -406, - /// ping 失败了,原因是 app 主动报告自身正处于后台模式 - LookinErrCode_PingFailForBackgroundState = -407, - - /// 没有找到对应的对象,可能已被释放 - LookinErrCode_ObjectNotFound = -500, - /// 不支持修改当前类型的 LookinCodingValueType - LookinErrCode_ModifyValueTypeInvalid = -501, - LookinErrCode_Exception = -502, - - /// License 校验失败,Host 未激活 LookInside 许可证 - LookinErrCode_LicenseRequired = -408, - - // LookinServer 版本过高,要升级 client - LookinErrCode_ServerVersionTooHigh = -600, - // LookinServer 版本过低,要升级 server - LookinErrCode_ServerVersionTooLow = -601, - - // 不支持的文件类型 - LookinErrCode_UnsupportedFileType = -700, -}; - -#define LookinErr_ObjNotFound [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_ObjectNotFound userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"Failed to get target object in iOS app", nil), NSLocalizedRecoverySuggestionErrorKey:NSLocalizedString(@"Perhaps the related object was deallocated. You can reload LookInside to get newest data.", nil)}] - -#define LookinErr_NoConnect [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_NoConnect userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"The operation failed due to disconnection with the iOS app.", nil)}] - -#define LookinErr_Inner [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_Inner userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"The operation failed due to an inner error.", nil)}] - -#define LookinErrorMake(errorTitle, errorDetail) [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_Default userInfo:@{NSLocalizedDescriptionKey:errorTitle, NSLocalizedRecoverySuggestionErrorKey:errorDetail}] - -#define LookinErrorText_Timeout NSLocalizedString(@"Perhaps your iOS app is paused with breakpoint in Xcode, blocked by other tasks in main thread, or moved to background state.", nil) - -#pragma mark - Colors - -#if TARGET_OS_IPHONE -#define LookinColor UIColor -#define LookinInsets UIEdgeInsets -#define LookinImage UIImage -#elif TARGET_OS_OSX -#define LookinColor NSColor -#define LookinInsets NSEdgeInsets -#define LookinImage NSImage -#endif - -#define LookinColorRGBAMake(r, g, b, a) [LookinColor colorWithRed:r/255.0 green:g/255.0 blue:b/255.0 alpha:a] -#define LookinColorMake(r, g, b) [LookinColor colorWithRed:r/255.0 green:g/255.0 blue:b/255.0 alpha:1] - -#pragma mark - Preview - -/// SCNNode 所允许的图片的最大的长和宽,单位是 px,这个值是 Scenekit 自身指定的 -/// Max pixel size of a SCNNode object. It is designated by SceneKit. -static const double LookinNodeImageMaxLengthInPx = 16384; - -typedef NS_OPTIONS(NSUInteger, LookinPreviewBitMask) { - LookinPreviewBitMask_None = 0, - - LookinPreviewBitMask_Selectable = 1 << 1, - LookinPreviewBitMask_Unselectable = 1 << 2, - - LookinPreviewBitMask_HasLight = 1 << 3, - LookinPreviewBitMask_NoLight = 1 << 4 -}; - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinDisplayItem.h b/Sources/LookinServer/Shared/LookinDisplayItem.h deleted file mode 100644 index 1aa7fb32..00000000 --- a/Sources/LookinServer/Shared/LookinDisplayItem.h +++ /dev/null @@ -1,191 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayItem.h -// qmuidemo -// -// Created by Li Kai on 2018/11/15. -// Copyright © 2018 QMUI Team. All rights reserved. -// - -#import "TargetConditionals.h" -#import "LookinObject.h" -#import "LookinDefines.h" -#import "LookinCustomDisplayItemInfo.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_OSX -#import -#endif - -@class LookinAttributesGroup, LookinIvarTrace, LookinPreviewItemLayer, LookinEventHandler, LKDisplayItemNode, LookinDisplayItem; - -typedef NS_ENUM(NSUInteger, LookinDisplayItemImageEncodeType) { - LookinDisplayItemImageEncodeTypeNone, // 不进行 encode - LookinDisplayItemImageEncodeTypeNSData, // 转换为 NSData - LookinDisplayItemImageEncodeTypeImage // 使用 NSImage / UIImage 自身的 encode 方法 -}; - -typedef NS_ENUM(NSUInteger, LookinDoNotFetchScreenshotReason) { - // 可以同步截图 - LookinFetchScreenshotPermitted, - // layer 尺寸过大 - LookinDoNotFetchScreenshotForTooLarge, - // 被 LookinServer 的用户配置拒绝 - LookinDoNotFetchScreenshotForUserConfig -}; - - -typedef NS_ENUM(NSUInteger, LookinDisplayItemProperty) { - // 当初次设置 delegate 对象时,会立即以该值触发一次 displayItem:propertyDidChange: - LookinDisplayItemProperty_None, - LookinDisplayItemProperty_FrameToRoot, - LookinDisplayItemProperty_DisplayingInHierarchy, - LookinDisplayItemProperty_InHiddenHierarchy, - LookinDisplayItemProperty_IsExpandable, - LookinDisplayItemProperty_IsExpanded, - LookinDisplayItemProperty_SoloScreenshot, - LookinDisplayItemProperty_GroupScreenshot, - LookinDisplayItemProperty_IsSelected, - LookinDisplayItemProperty_IsHovered, - LookinDisplayItemProperty_AvoidSyncScreenshot, - LookinDisplayItemProperty_InNoPreviewHierarchy, - LookinDisplayItemProperty_IsInSearch, - LookinDisplayItemProperty_HighlightedSearchString, -}; - -@protocol LookinDisplayItemDelegate - -- (void)displayItem:(LookinDisplayItem *)displayItem propertyDidChange:(LookinDisplayItemProperty)property; - -@end - -@interface LookinDisplayItem : NSObject - -/// 当 customInfo 不为 nil 时,意思是该 DisplayItem 为 UserCustom 配置的。此时,Encode 属性中仅 subitems 和 customAttrGroupList 属性有意义,其它几乎所有属性都无意义 -@property(nonatomic, strong) LookinCustomDisplayItemInfo *customInfo; - -@property(nonatomic, copy) NSArray *subitems; - -@property(nonatomic, assign) BOOL isHidden; - -@property(nonatomic, assign) float alpha; - -@property(nonatomic, assign) CGRect frame; - -@property(nonatomic, assign) CGRect bounds; - -/// 只在 macOS 上有效, iOS 总是为 NO -@property(nonatomic, assign, getter=isFlipped) BOOL flipped; - -/// 不存在 subitems 时,该属性的值为 nil -@property(nonatomic, strong) LookinImage *soloScreenshot; -/// 无论是否存在 subitems,该属性始终存在 -@property(nonatomic, strong) LookinImage *groupScreenshot; - -@property(nonatomic, strong) LookinObject *windowObject; -@property(nonatomic, strong) LookinObject *viewObject; -@property(nonatomic, strong) LookinObject *layerObject; -@property(nonatomic, strong) LookinObject *hostViewControllerObject; -@property(nonatomic, strong) LookinObject *hostWindowControllerObject; - -/// attrGroups 列表 -@property(nonatomic, copy) NSArray *attributesGroupList; -/// 通过 lookin_customDebugInfos 返回的属性列表 -@property(nonatomic, copy) NSArray *customAttrGroupList; -/// attributesGroupList + customAttrGroupList -- (NSArray *)queryAllAttrGroupList; - -@property(nonatomic, copy) NSArray *eventHandlers; - -// 如果当前 item 代表 UIWindow 且是 keyWindow,则该属性为 YES -@property(nonatomic, assign) BOOL representedAsKeyWindow; - - -/// view 或 layer 的 backgroundColor,利用该属性来提前渲染 node 的背景色,使得用户感觉加载的快一点 -/// 注意有一个缺点是,理论上应该像 screenshot 一样拆成 soloBackgroundColor 和 groupBackgroundColor,这里的 backgroundColor 实际上是 soloBackgroundColor,因此某些场景的显示会有瑕疵 -@property(nonatomic, strong) LookinColor *backgroundColor; - -/// 用户可以在 iOS 项目中添加 Lookin 自定义配置来显式地拒绝传输某些图层的图像,通常是屏蔽一些不重要的 View 以提升刷新速度。如果用户这么配置了,那么这个 shouldCaptureImage 就会被置为 NO -/// 默认为 YES -@property(nonatomic, assign) BOOL shouldCaptureImage; - -/// 用户通过重写 lookin_customDebugInfos 而自定义的该实例的名字 -/// 可能为 nil -@property(nonatomic, copy) NSString *customDisplayTitle; - -/// 为 DanceUI SDK 预留的内部字段,用于文件跳转 -/// 可能为 nil -@property(nonatomic, copy) NSString *danceuiSource; - -#pragma mark - No Encode/Decode - -@property(nonatomic, weak) id previewItemDelegate; -@property(nonatomic, weak) id rowViewDelegate; - -/// 父节点 -@property(nonatomic, weak) LookinDisplayItem *superItem; - -/// 如果存在 viewObject 则返回 viewObject,否则返回 layerObject -- (LookinObject *)displayingObject; - -/// 在 hierarchy 中的层级,比如顶层的 UIWindow.indentLevel 为 0,UIWindow 的 subitem 的 indentLevel 为 1 -- (NSInteger)indentLevel; - -/** - 该项是否被展开 - @note 假如自己没有被折叠,但是 superItem 被折叠了,则自己仍然不会被看到,但是 self.isExpanded 值仍然为 NO - @note 如果 item 没有 subitems(也就是 isExpandable 为 NO),则该值没有意义。换句话说,在获取该值之前,必须先判断一下 isExpandable - */ -@property(nonatomic, assign) BOOL isExpanded; - -/// 如果有 subitems,则该属性返回 YES,否则返回 NO -@property(nonatomic, assign, readonly) BOOL isExpandable; - -/** - 是否能在 hierarchy panel 上被看到,假如有任意一层的父级元素的 isExpanded 为 NO,则 displayingInHierarchy 为 NO。如果所有父级元素的 isExpanded 均为 YES,则 displayingInHierarchy 为 YES - */ -@property(nonatomic, assign, readonly) BOOL displayingInHierarchy; - -/** - 如果自身或任意一个上层元素的 isHidden 为 YES 或 alpha 为 0,则该属性返回 YES - */ -@property(nonatomic, assign, readonly) BOOL inHiddenHierarchy; - -@property(nonatomic, assign) LookinDisplayItemImageEncodeType screenshotEncodeType; - -/// 是否获取截图及原因。默认值为 LookinFetchScreenshotPermitted。 -@property(nonatomic, assign) LookinDoNotFetchScreenshotReason doNotFetchScreenshotReason; - -@property(nonatomic, weak) LookinPreviewItemLayer *previewLayer; - -@property(nonatomic, weak) LKDisplayItemNode *previewNode; - -/// 如果该值为 YES,则该 item 及所有子 item 均不会在 preview 中被显示出来,只能在 hierarchy 中选择。默认为 NO -@property(nonatomic, assign) BOOL noPreview; - -/// 如果自身或某个上级元素的 noPreview 值为 YES,则该方法返回 YES -/// 注意:当 userCustom 为 YES 时,该属性也可能返回 YES -@property(nonatomic, assign, readonly) BOOL inNoPreviewHierarchy; - -/// 当小于 0 时表示未被设置 -@property(nonatomic, assign) NSInteger previewZIndex; - -@property(nonatomic, assign) BOOL preferToBeCollapsed; - -- (void)notifySelectionChangeToDelegates; -- (void)notifyHoverChangeToDelegates; - -/// 根据 subItems 属性将 items 打平为一维数组 -+ (NSArray *)flatItemsFromHierarchicalItems:(NSArray *)items; - -@property(nonatomic, assign) BOOL hasDeterminedExpansion; - -/// 设置当前是否处于搜索状态 -@property(nonatomic, assign) BOOL isInSearch; -/// 因为搜索而应该被高亮的字符串 -@property(nonatomic, copy) NSString *highlightedSearchString; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinDisplayItem.m b/Sources/LookinServer/Shared/LookinDisplayItem.m deleted file mode 100644 index 4ff7bd38..00000000 --- a/Sources/LookinServer/Shared/LookinDisplayItem.m +++ /dev/null @@ -1,476 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayItem.m -// qmuidemo -// -// Created by Li Kai on 2018/11/15. -// Copyright © 2018 QMUI Team. All rights reserved. -// - -#import "LookinDisplayItem.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LookinAttribute.h" -#import "LookinEventHandler.h" -#import "LookinIvarTrace.h" -#import "Color+Lookin.h" -#import "NSArray+Lookin.h" -#import "NSObject+Lookin.h" -#import "LookinDashboardBlueprint.h" - -#if TARGET_OS_IPHONE -#import "../LookinServer/Server/Category/UIColor+LookinServer.h" -#import "../LookinServer/Server/Category/UIImage+LookinServer.h" -#elif TARGET_OS_OSX -#endif - -@interface LookinDisplayItem () - -@property(nonatomic, assign, readwrite) CGRect frameToRoot; -@property(nonatomic, assign, readwrite) BOOL inNoPreviewHierarchy; -@property(nonatomic, assign) NSInteger indentLevel; -@property(nonatomic, assign, readwrite) BOOL isExpandable; -@property(nonatomic, assign, readwrite) BOOL inHiddenHierarchy; -@property(nonatomic, assign, readwrite) BOOL displayingInHierarchy; - -@end - -@implementation LookinDisplayItem - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinDisplayItem *newDisplayItem = [[LookinDisplayItem allocWithZone:zone] init]; - newDisplayItem.subitems = [self.subitems lookin_map:^id(NSUInteger idx, LookinDisplayItem *value) { - return value.copy; - }]; - newDisplayItem.customInfo = self.customInfo.copy; - newDisplayItem.isHidden = self.isHidden; - newDisplayItem.alpha = self.alpha; - newDisplayItem.frame = self.frame; - newDisplayItem.bounds = self.bounds; -#if TARGET_OS_OSX - newDisplayItem.flipped = self.isFlipped; -#endif - newDisplayItem.soloScreenshot = self.soloScreenshot; - newDisplayItem.groupScreenshot = self.groupScreenshot; - newDisplayItem.viewObject = self.viewObject.copy; - newDisplayItem.layerObject = self.layerObject.copy; - newDisplayItem.windowObject = self.windowObject.copy; - newDisplayItem.hostViewControllerObject = self.hostViewControllerObject.copy; - newDisplayItem.hostWindowControllerObject = self.hostWindowControllerObject.copy; - newDisplayItem.attributesGroupList = [self.attributesGroupList lookin_map:^id(NSUInteger idx, LookinAttributesGroup *value) { - return value.copy; - }]; - newDisplayItem.customAttrGroupList = [self.customAttrGroupList lookin_map:^id(NSUInteger idx, LookinAttributesGroup *value) { - return value.copy; - }]; - newDisplayItem.eventHandlers = [self.eventHandlers lookin_map:^id(NSUInteger idx, LookinEventHandler *value) { - return value.copy; - }]; - newDisplayItem.shouldCaptureImage = self.shouldCaptureImage; - newDisplayItem.representedAsKeyWindow = self.representedAsKeyWindow; - newDisplayItem.customDisplayTitle = self.customDisplayTitle; - newDisplayItem.danceuiSource = self.danceuiSource; - [newDisplayItem _updateDisplayingInHierarchyProperty]; - return newDisplayItem; -} -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.customInfo forKey:@"customInfo"]; - [aCoder encodeObject:self.subitems forKey:@"subitems"]; - [aCoder encodeBool:self.isHidden forKey:@"hidden"]; - [aCoder encodeFloat:self.alpha forKey:@"alpha"]; -#if TARGET_OS_OSX - [aCoder encodeBool:self.isFlipped forKey:@"isFlipped"]; -#endif - [aCoder encodeObject:self.viewObject forKey:@"viewObject"]; - [aCoder encodeObject:self.layerObject forKey:@"layerObject"]; - [aCoder encodeObject:self.windowObject forKey:@"windowObject"]; - [aCoder encodeObject:self.hostViewControllerObject forKey:@"hostViewControllerObject"]; - [aCoder encodeObject:self.hostWindowControllerObject forKey:@"hostWindowControllerObject"]; - [aCoder encodeObject:self.attributesGroupList forKey:@"attributesGroupList"]; - [aCoder encodeObject:self.customAttrGroupList forKey:@"customAttrGroupList"]; - [aCoder encodeBool:self.representedAsKeyWindow forKey:@"representedAsKeyWindow"]; - [aCoder encodeObject:self.eventHandlers forKey:@"eventHandlers"]; - [aCoder encodeBool:self.shouldCaptureImage forKey:@"shouldCaptureImage"]; - if (self.screenshotEncodeType == LookinDisplayItemImageEncodeTypeNSData) { - [aCoder encodeObject:[self.soloScreenshot lookin_encodedObjectWithType:LookinCodingValueTypeImage] forKey:@"soloScreenshot"]; - [aCoder encodeObject:[self.groupScreenshot lookin_encodedObjectWithType:LookinCodingValueTypeImage] forKey:@"groupScreenshot"]; - } else if (self.screenshotEncodeType == LookinDisplayItemImageEncodeTypeImage) { - [aCoder encodeObject:self.soloScreenshot forKey:@"soloScreenshot"]; - [aCoder encodeObject:self.groupScreenshot forKey:@"groupScreenshot"]; - } - [aCoder encodeObject:self.customDisplayTitle forKey:@"customDisplayTitle"]; - [aCoder encodeObject:self.danceuiSource forKey:@"danceuiSource"]; -#if TARGET_OS_IPHONE - [aCoder encodeCGRect:self.frame forKey:@"frame"]; - [aCoder encodeCGRect:self.bounds forKey:@"bounds"]; - [aCoder encodeObject:self.backgroundColor.lks_rgbaComponents forKey:@"backgroundColor"]; - -#elif TARGET_OS_OSX - [aCoder encodeRect:self.frame forKey:@"frame"]; - [aCoder encodeRect:self.bounds forKey:@"bounds"]; - [aCoder encodeObject:self.backgroundColor.lookin_rgbaComponents forKey:@"backgroundColor"]; -#endif -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.customInfo = [aDecoder decodeObjectForKey:@"customInfo"]; - self.subitems = [aDecoder decodeObjectForKey:@"subitems"]; - self.isHidden = [aDecoder decodeBoolForKey:@"hidden"]; - self.alpha = [aDecoder decodeFloatForKey:@"alpha"]; -#if TARGET_OS_OSX - self.flipped = [aDecoder decodeBoolForKey:@"isFlipped"]; -#endif - self.windowObject = [aDecoder decodeObjectForKey:@"windowObject"]; - self.viewObject = [aDecoder decodeObjectForKey:@"viewObject"]; - self.layerObject = [aDecoder decodeObjectForKey:@"layerObject"]; - self.hostViewControllerObject = [aDecoder decodeObjectForKey:@"hostViewControllerObject"]; - self.hostWindowControllerObject = [aDecoder decodeObjectForKey:@"hostWindowControllerObject"]; - self.attributesGroupList = [aDecoder decodeObjectForKey:@"attributesGroupList"]; - self.customAttrGroupList = [aDecoder decodeObjectForKey:@"customAttrGroupList"]; - self.representedAsKeyWindow = [aDecoder decodeBoolForKey:@"representedAsKeyWindow"]; - - id soloScreenshotObj = [aDecoder decodeObjectForKey:@"soloScreenshot"]; - if (soloScreenshotObj) { - if ([soloScreenshotObj isKindOfClass:[NSData class]]) { - self.soloScreenshot = [soloScreenshotObj lookin_decodedObjectWithType:LookinCodingValueTypeImage]; - } else if ([soloScreenshotObj isKindOfClass:[LookinImage class]]) { - self.soloScreenshot = soloScreenshotObj; - } else { - NSAssert(NO, @""); - } - } - - id groupScreenshotObj = [aDecoder decodeObjectForKey:@"groupScreenshot"]; - if (groupScreenshotObj) { - if ([groupScreenshotObj isKindOfClass:[NSData class]]) { - self.groupScreenshot = [groupScreenshotObj lookin_decodedObjectWithType:LookinCodingValueTypeImage]; - } else if ([groupScreenshotObj isKindOfClass:[LookinImage class]]) { - self.groupScreenshot = groupScreenshotObj; - } else { - NSAssert(NO, @""); - } - } - - self.eventHandlers = [aDecoder decodeObjectForKey:@"eventHandlers"]; - /// 该属性从 LookinServer 1.1.3 开始添加 - self.shouldCaptureImage = [aDecoder containsValueForKey:@"shouldCaptureImage"] ? [aDecoder decodeBoolForKey:@"shouldCaptureImage"] : YES; - self.customDisplayTitle = [aDecoder decodeObjectForKey:@"customDisplayTitle"]; - self.danceuiSource = [aDecoder decodeObjectForKey:@"danceuiSource"]; -#if TARGET_OS_IPHONE - self.frame = [aDecoder decodeCGRectForKey:@"frame"]; - self.bounds = [aDecoder decodeCGRectForKey:@"bounds"]; - self.backgroundColor = [UIColor lks_colorFromRGBAComponents:[aDecoder decodeObjectForKey:@"backgroundColor"]]; -#elif TARGET_OS_OSX - self.frame = [aDecoder decodeRectForKey:@"frame"]; - self.bounds = [aDecoder decodeRectForKey:@"bounds"]; - self.backgroundColor = [NSColor lookin_colorFromRGBAComponents:[aDecoder decodeObjectForKey:@"backgroundColor"]]; - -#endif - [self _updateDisplayingInHierarchyProperty]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (instancetype)init { - if (self = [super init]) { - /// 在手机端,displayItem 被创建时会调用这个方法 - [self _updateDisplayingInHierarchyProperty]; - } - return self; -} - -- (LookinObject *)displayingObject { - return self.windowObject ? : self.viewObject ? : self.layerObject; -} - -- (void)setAttributesGroupList:(NSArray *)attributesGroupList { - _attributesGroupList = attributesGroupList; - - [_attributesGroupList enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop) { - [group.attrSections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop) { - [section.attributes enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop) { - attr.targetDisplayItem = self; - }]; - }]; - }]; -} - -- (void)setCustomAttrGroupList:(NSArray *)customAttrGroupList { - _customAttrGroupList = customAttrGroupList; - // 传进来的时候就已经排好序了 - [customAttrGroupList enumerateObjectsUsingBlock:^(LookinAttributesGroup * _Nonnull group, NSUInteger idx, BOOL * _Nonnull stop) { - [group.attrSections enumerateObjectsUsingBlock:^(LookinAttributesSection * _Nonnull section, NSUInteger idx, BOOL * _Nonnull stop) { - [section.attributes enumerateObjectsUsingBlock:^(LookinAttribute * _Nonnull attr, NSUInteger idx, BOOL * _Nonnull stop) { - attr.targetDisplayItem = self; - }]; - }]; - }]; -} - -- (void)setSubitems:(NSArray *)subitems { - [_subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - obj.superItem = nil; - }]; - - _subitems = subitems; - - self.isExpandable = (subitems.count > 0); - - [subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - NSAssert(!obj.superItem, @""); - obj.superItem = self; - - [obj _updateInHiddenHierarchyProperty]; - [obj _updateDisplayingInHierarchyProperty]; - }]; -} - -- (void)setIsExpandable:(BOOL)isExpandable { - if (_isExpandable == isExpandable) { - return; - } - _isExpandable = isExpandable; - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsExpandable]; -} - -- (void)setIsExpanded:(BOOL)isExpanded { - if (_isExpanded == isExpanded) { - return; - } - _isExpanded = isExpanded; - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj _updateDisplayingInHierarchyProperty]; - }]; - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsExpanded]; -} - -- (void)setSoloScreenshot:(LookinImage *)soloScreenshot { - if (_soloScreenshot == soloScreenshot) { - return; - } - _soloScreenshot = soloScreenshot; - [self _notifyDelegatesWith:LookinDisplayItemProperty_SoloScreenshot]; -} - -- (void)notifySelectionChangeToDelegates { - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsSelected]; -} - -- (void)notifyHoverChangeToDelegates { - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsHovered]; -} - -- (void)setDoNotFetchScreenshotReason:(LookinDoNotFetchScreenshotReason)doNotFetchScreenshotReason { - if (_doNotFetchScreenshotReason == doNotFetchScreenshotReason) { - return; - } - _doNotFetchScreenshotReason = doNotFetchScreenshotReason; - [self _notifyDelegatesWith:LookinDisplayItemProperty_AvoidSyncScreenshot]; -} - -- (void)setGroupScreenshot:(LookinImage *)groupScreenshot { - if (_groupScreenshot == groupScreenshot) { - return; - } - _groupScreenshot = groupScreenshot; - [self _notifyDelegatesWith:LookinDisplayItemProperty_GroupScreenshot]; -} - -- (void)setDisplayingInHierarchy:(BOOL)displayingInHierarchy { - if (_displayingInHierarchy == displayingInHierarchy) { - return; - } - _displayingInHierarchy = displayingInHierarchy; - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj _updateDisplayingInHierarchyProperty]; - }]; - - [self _notifyDelegatesWith:LookinDisplayItemProperty_DisplayingInHierarchy]; -} - -- (void)_updateDisplayingInHierarchyProperty { - if (self.superItem && (!self.superItem.displayingInHierarchy || !self.superItem.isExpanded)) { - self.displayingInHierarchy = NO; - } else { - self.displayingInHierarchy = YES; - } -} - -- (void)setIsHidden:(BOOL)isHidden { - _isHidden = isHidden; - [self _updateInHiddenHierarchyProperty]; -} - -- (void)setAlpha:(float)alpha { - _alpha = alpha; - [self _updateInHiddenHierarchyProperty]; -} - -- (void)setInHiddenHierarchy:(BOOL)inHiddenHierarchy { - if (_inHiddenHierarchy == inHiddenHierarchy) { - return; - } - _inHiddenHierarchy = inHiddenHierarchy; - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj _updateInHiddenHierarchyProperty]; - }]; - - [self _notifyDelegatesWith:LookinDisplayItemProperty_InHiddenHierarchy]; -} - -- (void)_updateInHiddenHierarchyProperty { - if (self.superItem.inHiddenHierarchy || self.isHidden || self.alpha <= 0) { - self.inHiddenHierarchy = YES; - } else { - self.inHiddenHierarchy = NO; - } -} - -+ (NSArray *)flatItemsFromHierarchicalItems:(NSArray *)items { - NSMutableArray *resultArray = [NSMutableArray array]; - - [items enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - if (obj.superItem) { - obj.indentLevel = obj.superItem.indentLevel + 1; - } - [resultArray addObject:obj]; - if (obj.subitems.count) { - [resultArray addObjectsFromArray:[self flatItemsFromHierarchicalItems:obj.subitems]]; - } - }]; - - return resultArray; -} - -- (NSString *)description { - if (self.viewObject) { - return self.viewObject.rawClassName; - } else if (self.layerObject) { - return self.layerObject.rawClassName; - } else if (self.windowObject) { - return self.windowObject.rawClassName; - } else { - return [super description]; - } -} - -- (void)setPreviewItemDelegate:(id)previewItemDelegate { - _previewItemDelegate = previewItemDelegate; - - if (![previewItemDelegate respondsToSelector:@selector(displayItem:propertyDidChange:)]) { - NSAssert(NO, @""); - _previewItemDelegate = nil; - return; - } - [self.previewItemDelegate displayItem:self propertyDidChange:LookinDisplayItemProperty_None]; -} - -- (void)setRowViewDelegate:(id)rowViewDelegate { - if (_rowViewDelegate == rowViewDelegate) { - return; - } - _rowViewDelegate = rowViewDelegate; - - if (![rowViewDelegate respondsToSelector:@selector(displayItem:propertyDidChange:)]) { - NSAssert(NO, @""); - _rowViewDelegate = nil; - return; - } - [self.rowViewDelegate displayItem:self propertyDidChange:LookinDisplayItemProperty_None]; -} - -- (void)setFrame:(CGRect)frame { - _frame = frame; - [self recursivelyNotifyFrameToRootMayChange]; -} - -- (void)recursivelyNotifyFrameToRootMayChange { - [self _notifyDelegatesWith:LookinDisplayItemProperty_FrameToRoot]; - - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj recursivelyNotifyFrameToRootMayChange]; - }]; -} - -- (void)setBounds:(CGRect)bounds { - _bounds = bounds; - [self recursivelyNotifyFrameToRootMayChange]; -} - -- (void)setInNoPreviewHierarchy:(BOOL)inNoPreviewHierarchy { - if (_inNoPreviewHierarchy == inNoPreviewHierarchy) { - return; - } - _inNoPreviewHierarchy = inNoPreviewHierarchy; - [self.subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { - [obj _updateInNoPreviewHierarchy]; - }]; - [self _notifyDelegatesWith:LookinDisplayItemProperty_InNoPreviewHierarchy]; -} - -- (void)setNoPreview:(BOOL)noPreview { - _noPreview = noPreview; - [self _updateInNoPreviewHierarchy]; -} - -- (void)_updateInNoPreviewHierarchy { - if (self.superItem.inNoPreviewHierarchy || self.noPreview) { - self.inNoPreviewHierarchy = YES; - } else { - self.inNoPreviewHierarchy = NO; - } -} - -- (void)_notifyDelegatesWith:(LookinDisplayItemProperty)property { - [self.previewItemDelegate displayItem:self propertyDidChange:property]; - [self.rowViewDelegate displayItem:self propertyDidChange:property]; -} - -- (void)setIsInSearch:(BOOL)isInSearch { - _isInSearch = isInSearch; - [self _notifyDelegatesWith:LookinDisplayItemProperty_IsInSearch]; -} - -- (void)setHighlightedSearchString:(NSString *)highlightedSearchString { - _highlightedSearchString = highlightedSearchString; - [self _notifyDelegatesWith:LookinDisplayItemProperty_HighlightedSearchString]; -} - -- (NSArray *)queryAllAttrGroupList { - NSMutableArray *array = [NSMutableArray array]; - if (self.attributesGroupList) { - [array addObjectsFromArray:self.attributesGroupList]; - } - if (self.customAttrGroupList) { - [array addObjectsFromArray:self.customAttrGroupList]; - } - return array; -} - -//- (void)dealloc -//{ -// NSLog(@"moss dealloc -%@", self); -//} - - -#if TARGET_OS_IPHONE -- (void)setFlipped:(BOOL)flipped {} - -- (BOOL)isFlipped { - return NO; -} -#endif - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinDisplayItemDetail.h b/Sources/LookinServer/Shared/LookinDisplayItemDetail.h deleted file mode 100644 index adb60c38..00000000 --- a/Sources/LookinServer/Shared/LookinDisplayItemDetail.h +++ /dev/null @@ -1,51 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayItemDetail.h -// Lookin -// -// Created by Li Kai on 2019/2/19. -// https://lookin.work -// - -#import "LookinDefines.h" - -@class LookinAttributesGroup; -@class LookinDisplayItem; - -@interface LookinDisplayItemDetail : NSObject - -@property(nonatomic, assign) unsigned long displayItemOid; - -@property(nonatomic, strong) LookinImage *groupScreenshot; - -@property(nonatomic, strong) LookinImage *soloScreenshot; - -@property(nonatomic, strong) NSValue *frameValue; - -@property(nonatomic, strong) NSValue *boundsValue; - -@property(nonatomic, strong) NSNumber *hiddenValue; - -@property(nonatomic, strong) NSNumber *alphaValue; - -@property(nonatomic, copy) NSString *customDisplayTitle; - -@property(nonatomic, copy) NSString *danceUISource; - -@property(nonatomic, copy) NSArray *attributesGroupList; -@property(nonatomic, copy) NSArray *customAttrGroupList; - -/// 注意 nil 和空数组的区别:nil 表示该属性无意义,空数组表示 subviews 为空 -/// Client 1.0.7 & Server 1.2.7 开始支持该属性 -/// 默认为 nil -@property(nonatomic, copy) NSArray *subitems; - -/// 当 Server 找不到 task 对应的图层时,会返回一个特殊的 LookinDisplayItemDetail 对象,这个对象会被设置 displayItemOid 和 failureCode,其中 failureCode 会被置为 -1 -/// Client 1.0.7 & Server 1.2.7 开始支持该属性 -/// 默认为 0 -@property(nonatomic, assign) NSInteger failureCode; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinDisplayItemDetail.m b/Sources/LookinServer/Shared/LookinDisplayItemDetail.m deleted file mode 100644 index 195c03c9..00000000 --- a/Sources/LookinServer/Shared/LookinDisplayItemDetail.m +++ /dev/null @@ -1,71 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayItemDetail.m -// Lookin -// -// Created by Li Kai on 2019/2/19. -// https://lookin.work -// - -#import "LookinDisplayItemDetail.h" -#import "Image+Lookin.h" - -#if TARGET_OS_IPHONE -#import "UIImage+LookinServer.h" -#endif - -@implementation LookinDisplayItemDetail - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:@(self.displayItemOid) forKey:@"displayItemOid"]; - [aCoder encodeObject:self.groupScreenshot.lookin_data forKey:@"groupScreenshot"]; - [aCoder encodeObject:self.soloScreenshot.lookin_data forKey:@"soloScreenshot"]; - [aCoder encodeObject:self.frameValue forKey:@"frameValue"]; - [aCoder encodeObject:self.boundsValue forKey:@"boundsValue"]; - [aCoder encodeObject:self.hiddenValue forKey:@"hiddenValue"]; - [aCoder encodeObject:self.alphaValue forKey:@"alphaValue"]; - [aCoder encodeObject:self.attributesGroupList forKey:@"attributesGroupList"]; - [aCoder encodeObject:self.customAttrGroupList forKey:@"customAttrGroupList"]; - [aCoder encodeObject:self.customDisplayTitle forKey:@"customDisplayTitle"]; - [aCoder encodeObject:self.danceUISource forKey:@"danceUISource"]; - [aCoder encodeInteger:self.failureCode forKey:@"failureCode"]; - if (self.subitems) { - [aCoder encodeObject:self.subitems forKey:@"subitems"]; - } -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.displayItemOid = [[aDecoder decodeObjectForKey:@"displayItemOid"] unsignedLongValue]; - self.groupScreenshot = [[LookinImage alloc] initWithData:[aDecoder decodeObjectForKey:@"groupScreenshot"]]; - self.soloScreenshot = [[LookinImage alloc] initWithData:[aDecoder decodeObjectForKey:@"soloScreenshot"]]; - self.frameValue = [aDecoder decodeObjectForKey:@"frameValue"]; - self.boundsValue = [aDecoder decodeObjectForKey:@"boundsValue"]; - self.hiddenValue = [aDecoder decodeObjectForKey:@"hiddenValue"]; - self.alphaValue = [aDecoder decodeObjectForKey:@"alphaValue"]; - self.attributesGroupList = [aDecoder decodeObjectForKey:@"attributesGroupList"]; - self.customAttrGroupList = [aDecoder decodeObjectForKey:@"customAttrGroupList"]; - self.customDisplayTitle = [aDecoder decodeObjectForKey:@"customDisplayTitle"]; - self.danceUISource = [aDecoder decodeObjectForKey:@"danceUISource"]; - - if ([aDecoder containsValueForKey:@"failureCode"]) { - self.failureCode = [aDecoder decodeIntegerForKey:@"failureCode"]; - } else { - self.failureCode = 0; - } - - if ([aDecoder containsValueForKey:@"subitems"]) { - self.subitems = [aDecoder decodeObjectForKey:@"subitems"]; - } - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinEventHandler.h b/Sources/LookinServer/Shared/LookinEventHandler.h deleted file mode 100644 index cf44b76c..00000000 --- a/Sources/LookinServer/Shared/LookinEventHandler.h +++ /dev/null @@ -1,43 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinEventHandler.h -// Lookin -// -// Created by Li Kai on 2019/8/7. -// https://lookin.work -// - - - -#import - -@class LookinObject, LookinIvarTrace, LookinStringTwoTuple; - -typedef NS_ENUM(NSInteger, LookinEventHandlerType) { - LookinEventHandlerTypeTargetAction, - LookinEventHandlerTypeGesture -}; - -@interface LookinEventHandler : NSObject - -@property(nonatomic, assign) LookinEventHandlerType handlerType; - -/// 比如 "UIControlEventTouchUpInside", "UITapGestureRecognizer" -@property(nonatomic, copy) NSString *eventName; -/// tuple.first => @"",tuple.second => @"handleTap" -@property(nonatomic, copy) NSArray *targetActions; - -/// 返回当前 recognizer 是继承自哪一个基本款 recognizer。 -/// 基本款 recognizer 指的是 TapRecognizer, PinchRecognizer 之类的常见 recognizer -/// 如果当前 recognizer 本身就是基本款 recognizer,则该属性为 nil -@property(nonatomic, copy) NSString *inheritedRecognizerName; -@property(nonatomic, assign) BOOL gestureRecognizerIsEnabled; -@property(nonatomic, copy) NSString *gestureRecognizerDelegator; -@property(nonatomic, copy) NSArray *recognizerIvarTraces; -/// recognizer 对象 -@property(nonatomic, assign) unsigned long long recognizerOid; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinEventHandler.m b/Sources/LookinServer/Shared/LookinEventHandler.m deleted file mode 100644 index 8a1576cb..00000000 --- a/Sources/LookinServer/Shared/LookinEventHandler.m +++ /dev/null @@ -1,71 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinEventHandler.m -// Lookin -// -// Created by Li Kai on 2019/8/7. -// https://lookin.work -// - - - -#import "LookinEventHandler.h" -#import "LookinObject.h" -#import "LookinTuple.h" - -#import "NSArray+Lookin.h" - -@implementation LookinEventHandler - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinEventHandler *newHandler = [[LookinEventHandler allocWithZone:zone] init]; - newHandler.handlerType = self.handlerType; - newHandler.eventName = self.eventName; - newHandler.targetActions = [self.targetActions lookin_map:^id(NSUInteger idx, LookinStringTwoTuple *value) { - return value.copy; - }]; - newHandler.gestureRecognizerIsEnabled = self.gestureRecognizerIsEnabled; - newHandler.gestureRecognizerDelegator = self.gestureRecognizerDelegator; - newHandler.inheritedRecognizerName = self.inheritedRecognizerName; - newHandler.recognizerIvarTraces = self.recognizerIvarTraces.copy; - newHandler.recognizerOid = self.recognizerOid; - return newHandler; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInteger:self.handlerType forKey:@"handlerType"]; - [aCoder encodeBool:self.gestureRecognizerIsEnabled forKey:@"gestureRecognizerIsEnabled"]; - [aCoder encodeObject:self.eventName forKey:@"eventName"]; - [aCoder encodeObject:self.gestureRecognizerDelegator forKey:@"gestureRecognizerDelegator"]; - [aCoder encodeObject:self.targetActions forKey:@"targetActions"]; - [aCoder encodeObject:self.inheritedRecognizerName forKey:@"inheritedRecognizerName"]; - [aCoder encodeObject:self.recognizerIvarTraces forKey:@"recognizerIvarTraces"]; - [aCoder encodeObject:@(self.recognizerOid) forKey:@"recognizerOid"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.handlerType = [aDecoder decodeIntegerForKey:@"handlerType"]; - self.gestureRecognizerIsEnabled = [aDecoder decodeBoolForKey:@"gestureRecognizerIsEnabled"]; - self.eventName = [aDecoder decodeObjectForKey:@"eventName"]; - self.gestureRecognizerDelegator = [aDecoder decodeObjectForKey:@"gestureRecognizerDelegator"]; - self.targetActions = [aDecoder decodeObjectForKey:@"targetActions"]; - self.inheritedRecognizerName = [aDecoder decodeObjectForKey:@"inheritedRecognizerName"]; - self.recognizerIvarTraces = [aDecoder decodeObjectForKey:@"recognizerIvarTraces"]; - self.recognizerOid = ((NSNumber *)[aDecoder decodeObjectForKey:@"recognizerOid"]).unsignedLongValue; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinHierarchyFile.h b/Sources/LookinServer/Shared/LookinHierarchyFile.h deleted file mode 100644 index afa02cd2..00000000 --- a/Sources/LookinServer/Shared/LookinHierarchyFile.h +++ /dev/null @@ -1,32 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinHierarchyFile.h -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - - - -#import - -@class LookinHierarchyInfo; - -@interface LookinHierarchyFile : NSObject - -/// 记录创建该文件的 LookinServer 的版本 -@property(nonatomic, assign) int serverVersion; - -@property(nonatomic, strong) LookinHierarchyInfo *hierarchyInfo; - -@property(nonatomic, copy) NSDictionary *soloScreenshots; -@property(nonatomic, copy) NSDictionary *groupScreenshots; - -/// 验证 file 的版本之类的是否和当前 Lookin 客户端匹配,如果没有问题则返回 nil,如果有问题则返回 error -+ (NSError *)verifyHierarchyFile:(LookinHierarchyFile *)file; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinHierarchyFile.m b/Sources/LookinServer/Shared/LookinHierarchyFile.m deleted file mode 100644 index 077890c0..00000000 --- a/Sources/LookinServer/Shared/LookinHierarchyFile.m +++ /dev/null @@ -1,64 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinHierarchyFile.m -// Lookin -// -// Created by Li Kai on 2019/5/12. -// https://lookin.work -// - - - -#import "LookinHierarchyFile.h" - -#import "NSArray+Lookin.h" - -@implementation LookinHierarchyFile - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeInt:self.serverVersion forKey:@"serverVersion"]; - [aCoder encodeObject:self.hierarchyInfo forKey:@"hierarchyInfo"]; - [aCoder encodeObject:self.soloScreenshots forKey:@"soloScreenshots"]; - [aCoder encodeObject:self.groupScreenshots forKey:@"groupScreenshots"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.serverVersion = [aDecoder decodeIntForKey:@"serverVersion"]; - self.hierarchyInfo = [aDecoder decodeObjectForKey:@"hierarchyInfo"]; - self.soloScreenshots = [aDecoder decodeObjectForKey:@"soloScreenshots"]; - self.groupScreenshots = [aDecoder decodeObjectForKey:@"groupScreenshots"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -+ (NSError *)verifyHierarchyFile:(LookinHierarchyFile *)hierarchyFile { - if (![hierarchyFile isKindOfClass:[LookinHierarchyFile class]]) { - return LookinErr_Inner; - } - - if (hierarchyFile.serverVersion < LOOKIN_SUPPORTED_SERVER_MIN) { - // 文件版本太旧 - // 如果不存在 serverVersion 这个字段,说明版本是 6 - int fileVersion = hierarchyFile.serverVersion ? : 6; - NSString *detail = [NSString stringWithFormat:NSLocalizedString(@"The document was created by a LookInside app with too old version. Current LookInside app version is %@, but the document version is %@.", nil), @(LOOKIN_CLIENT_VERSION), @(fileVersion)]; - return [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_ServerVersionTooLow userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"Failed to open the document.", nil), NSLocalizedRecoverySuggestionErrorKey:detail}]; - } - - if (hierarchyFile.serverVersion > LOOKIN_SUPPORTED_SERVER_MAX) { - // 文件版本太新 - NSString *detail = [NSString stringWithFormat:NSLocalizedString(@"Current LookInside app is too old to open this document. Current LookInside app version is %@, but the document version is %@.", nil), @(LOOKIN_CLIENT_VERSION), @(hierarchyFile.serverVersion)]; - return [NSError errorWithDomain:LookinErrorDomain code:LookinErrCode_ServerVersionTooHigh userInfo:@{NSLocalizedDescriptionKey:NSLocalizedString(@"Failed to open the document.", nil), NSLocalizedRecoverySuggestionErrorKey:detail}]; - } - - return nil; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinHierarchyInfo.h b/Sources/LookinServer/Shared/LookinHierarchyInfo.h deleted file mode 100644 index ef54ab23..00000000 --- a/Sources/LookinServer/Shared/LookinHierarchyInfo.h +++ /dev/null @@ -1,47 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayInfo.h -// WeRead -// -// Created by Li Kai on 2018/10/22. -// Copyright © 2018年 tencent. All rights reserved. -// - - - -#import "LookinDefines.h" -#import "TargetConditionals.h" -#if TARGET_OS_IPHONE -#import -#elif TARGET_OS_OSX -#import -#endif - -@class LookinDisplayItem, LookinAttributesGroup, LookinAppInfo; - -@interface LookinHierarchyInfo : NSObject - -#if TARGET_OS_IPHONE - -/// version 可能为 nil,此时说明 Client 版本号 < 1.0.4 -+ (instancetype)staticInfoWithLookinVersion:(NSString *)version; - -+ (instancetype)exportedInfo; - -#endif - -/// 这里其实就是顶端的那几个 UIWindow -@property(nonatomic, copy) NSArray *displayItems; - -@property(nonatomic, copy) NSDictionary *colorAlias; - -@property(nonatomic, copy) NSArray *collapsedClassList; - -@property(nonatomic, strong) LookinAppInfo *appInfo; - -@property(nonatomic, assign) int serverVersion; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinHierarchyInfo.m b/Sources/LookinServer/Shared/LookinHierarchyInfo.m deleted file mode 100644 index ac54f806..00000000 --- a/Sources/LookinServer/Shared/LookinHierarchyInfo.m +++ /dev/null @@ -1,100 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinDisplayInfo.m -// WeRead -// -// Created by Li Kai on 2018/10/22. -// Copyright © 2018年 tencent. All rights reserved. -// - -#import -#import "LookinHierarchyInfo.h" -#import "LookinAttributesGroup.h" -#import "LookinDisplayItem.h" -#import "LookinAppInfo.h" -#import "NSArray+Lookin.h" -#import "NSString+Lookin.h" - -#import "LKS_HierarchyDisplayItemsMaker.h" -#import "LKSConfigManager.h" -#import "LKS_CustomAttrSetterManager.h" - -@implementation LookinHierarchyInfo - -+ (instancetype)staticInfoWithLookinVersion:(NSString *)version { - BOOL readCustomInfo = NO; - // Client 1.0.4 开始支持 customInfo - if (version && [version lookin_numbericOSVersion] >= 10004) { - readCustomInfo = YES; - } - - [[LKS_CustomAttrSetterManager sharedInstance] removeAll]; - - LookinHierarchyInfo *info = [LookinHierarchyInfo new]; - info.serverVersion = LOOKIN_SERVER_VERSION; - info.displayItems = [LKS_HierarchyDisplayItemsMaker itemsWithScreenshots:NO attrList:NO lowImageQuality:NO readCustomInfo:readCustomInfo saveCustomSetter:YES]; - info.appInfo = [LookinAppInfo currentInfoWithScreenshot:NO icon:YES localIdentifiers:nil]; - info.collapsedClassList = [LKSConfigManager collapsedClassList]; - info.colorAlias = [LKSConfigManager colorAlias]; - return info; -} - -+ (instancetype)exportedInfo { - LookinHierarchyInfo *info = [LookinHierarchyInfo new]; - info.serverVersion = LOOKIN_SERVER_VERSION; - info.displayItems = [LKS_HierarchyDisplayItemsMaker itemsWithScreenshots:YES attrList:YES lowImageQuality:YES readCustomInfo:YES saveCustomSetter:NO]; - info.appInfo = [LookinAppInfo currentInfoWithScreenshot:NO icon:YES localIdentifiers:nil]; - info.collapsedClassList = [LKSConfigManager collapsedClassList]; - info.colorAlias = [LKSConfigManager colorAlias]; - return info; -} - -#pragma mark - - -static NSString * const LookinHierarchyInfoCodingKey_DisplayItems = @"1"; -static NSString * const LookinHierarchyInfoCodingKey_AppInfo = @"2"; -static NSString * const LookinHierarchyInfoCodingKey_ColorAlias = @"3"; -static NSString * const LookinHierarchyInfoCodingKey_CollapsedClassList = @"4"; - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.displayItems forKey:LookinHierarchyInfoCodingKey_DisplayItems]; - [aCoder encodeObject:self.colorAlias forKey:LookinHierarchyInfoCodingKey_ColorAlias]; - [aCoder encodeObject:self.collapsedClassList forKey:LookinHierarchyInfoCodingKey_CollapsedClassList]; - [aCoder encodeObject:self.appInfo forKey:LookinHierarchyInfoCodingKey_AppInfo]; - [aCoder encodeInt:self.serverVersion forKey:@"serverVersion"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.displayItems = [aDecoder decodeObjectForKey:LookinHierarchyInfoCodingKey_DisplayItems]; - self.colorAlias = [aDecoder decodeObjectForKey:LookinHierarchyInfoCodingKey_ColorAlias]; - self.collapsedClassList = [aDecoder decodeObjectForKey:LookinHierarchyInfoCodingKey_CollapsedClassList]; - self.appInfo = [aDecoder decodeObjectForKey:LookinHierarchyInfoCodingKey_AppInfo]; - self.serverVersion = [aDecoder decodeIntForKey:@"serverVersion"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinHierarchyInfo *newAppInfo = [[LookinHierarchyInfo allocWithZone:zone] init]; - newAppInfo.serverVersion = self.serverVersion; - newAppInfo.appInfo = self.appInfo.copy; - newAppInfo.collapsedClassList = self.collapsedClassList; - newAppInfo.colorAlias = self.colorAlias; - newAppInfo.displayItems = [self.displayItems lookin_map:^id(NSUInteger idx, LookinDisplayItem *oldItem) { - return oldItem.copy; - }]; - - return newAppInfo; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinObject.h b/Sources/LookinServer/Shared/LookinObject.h deleted file mode 100644 index 285749d4..00000000 --- a/Sources/LookinServer/Shared/LookinObject.h +++ /dev/null @@ -1,42 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinObject.h -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - - - -#import - -@class LookinObjectIvar, LookinIvarTrace; - -@interface LookinObject : NSObject - -#if TARGET_OS_IPHONE -+ (instancetype)instanceWithObject:(NSObject *)object; -#endif - -@property(nonatomic, assign) unsigned long oid; - -@property(nonatomic, copy) NSString *memoryAddress; - -/** - 比如有一个 UILabel 对象,则它的 classChainList 为 @[@"UILabel", @"UIView", @"UIResponder", @"NSObject"],而它的 ivarList 长度为 4,idx 从小到大分别是 UILabel 层级的 ivars, UIView 层级的 ivars..... - */ -@property(nonatomic, copy) NSArray *classChainList; - -@property(nonatomic, copy) NSString *specialTrace; - -@property(nonatomic, copy) NSArray *ivarTraces; - -/// 没有 demangle,会包含 Swift Module Name -/// 在 Lookin 的展示中,绝大多数情况下应该使用 lk_demangledSwiftName -- (NSString *)rawClassName; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinObject.m b/Sources/LookinServer/Shared/LookinObject.m deleted file mode 100644 index 89d8fedf..00000000 --- a/Sources/LookinServer/Shared/LookinObject.m +++ /dev/null @@ -1,82 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinObject.m -// Lookin -// -// Created by Li Kai on 2019/4/20. -// https://lookin.work -// - -#import "LookinObject.h" -#import "LookinIvarTrace.h" -#import "NSArray+Lookin.h" -#import "NSString+Lookin.h" - -#if TARGET_OS_IPHONE -#import "NSObject+LookinServer.h" -#endif - -@implementation LookinObject - -#if TARGET_OS_IPHONE -+ (instancetype)instanceWithObject:(NSObject *)object { - LookinObject *lookinObj = [LookinObject new]; - lookinObj.oid = [object lks_registerOid]; - - lookinObj.memoryAddress = [NSString stringWithFormat:@"%p", object]; - lookinObj.classChainList = [object lks_classChainList]; - - lookinObj.specialTrace = object.lks_specialTrace; - lookinObj.ivarTraces = object.lks_ivarTraces; - - return lookinObj; -} -#endif - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinObject *newObject = [[LookinObject allocWithZone:zone] init]; - newObject.oid = self.oid; - newObject.memoryAddress = self.memoryAddress; - newObject.classChainList = self.classChainList; - newObject.specialTrace = self.specialTrace; - newObject.ivarTraces = [self.ivarTraces lookin_map:^id(NSUInteger idx, LookinIvarTrace *value) { - return value.copy; - }]; - return newObject; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:@(self.oid) forKey:@"oid"]; - [aCoder encodeObject:self.memoryAddress forKey:@"memoryAddress"]; - [aCoder encodeObject:self.classChainList forKey:@"classChainList"]; - [aCoder encodeObject:self.specialTrace forKey:@"specialTrace"]; - [aCoder encodeObject:self.ivarTraces forKey:@"ivarTraces"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.oid = [(NSNumber *)[aDecoder decodeObjectForKey:@"oid"] unsignedLongValue]; - self.memoryAddress = [aDecoder decodeObjectForKey:@"memoryAddress"]; - self.classChainList = [aDecoder decodeObjectForKey:@"classChainList"]; - self.specialTrace = [aDecoder decodeObjectForKey:@"specialTrace"]; - self.ivarTraces = [aDecoder decodeObjectForKey:@"ivarTraces"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (NSString *)rawClassName { - return self.classChainList.firstObject; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinStaticAsyncUpdateTask.h b/Sources/LookinServer/Shared/LookinStaticAsyncUpdateTask.h deleted file mode 100644 index 85e1925c..00000000 --- a/Sources/LookinServer/Shared/LookinStaticAsyncUpdateTask.h +++ /dev/null @@ -1,67 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinStaticAsyncUpdateTask.h -// Lookin -// -// Created by Li Kai on 2019/6/21. -// https://lookin.work -// - - - -#import "LookinDefines.h" - -typedef NS_ENUM(NSInteger, LookinStaticAsyncUpdateTaskType) { - LookinStaticAsyncUpdateTaskTypeNoScreenshot, - LookinStaticAsyncUpdateTaskTypeSoloScreenshot, - LookinStaticAsyncUpdateTaskTypeGroupScreenshot -}; - -typedef NS_ENUM(NSInteger, LookinDetailUpdateTaskAttrRequest) { - /// 由 Server 端自己决定:同一批 task 里,server 端会保证同一个 layer 只会构造一次 attr - /// 在 Lookin turbo 模式下,由于同一个 layer 的 task 可能位于不同批的 task 里,因此这会导致冗余的 attr 构造行为、浪费一定时间 - LookinDetailUpdateTaskAttrRequest_Automatic, - /// 需要返回 attr - LookinDetailUpdateTaskAttrRequest_Need, - /// 不需要返回 attr - LookinDetailUpdateTaskAttrRequest_NotNeed -}; - -/// 业务重写了 isEqual -@interface LookinStaticAsyncUpdateTask : NSObject - -@property(nonatomic, assign) unsigned long oid; - -@property(nonatomic, assign) LookinStaticAsyncUpdateTaskType taskType; - -/// 是否需要返回 attr 数据,默认为 Automatic -/// Client 1.0.7 & Server 1.2.7 开始支持这个参数 -@property(nonatomic, assign) LookinDetailUpdateTaskAttrRequest attrRequest; - -/// 如果置为 YES,则 server 侧会返回这些基础信息:frameValue, boundsValue, hiddenValue, alphaValue -/// 默认为 NO -/// Client 1.0.7 & Server 1.2.7 开始支持这个参数 -@property(nonatomic, assign) BOOL needBasisVisualInfo; - -/// 如果置为 YES,则 server 侧会返回 subitems -/// 默认为 NO -/// Client 1.0.7 & Server 1.2.7 开始支持这个参数 -@property(nonatomic, assign) BOOL needSubitems; - -/// Client 1.0.4 开始加入这个参数 -@property(nonatomic, copy) NSString *clientReadableVersion; - -#pragma mark - Non Coding - -@property(nonatomic, assign) CGSize frameSize; - -@end - -@interface LookinStaticAsyncUpdateTasksPackage : NSObject - -@property(nonatomic, copy) NSArray *tasks; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinStaticAsyncUpdateTask.m b/Sources/LookinServer/Shared/LookinStaticAsyncUpdateTask.m deleted file mode 100644 index aef73a11..00000000 --- a/Sources/LookinServer/Shared/LookinStaticAsyncUpdateTask.m +++ /dev/null @@ -1,105 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinStaticAsyncUpdateTask.m -// Lookin -// -// Created by Li Kai on 2019/6/21. -// https://lookin.work -// - - - -#import "LookinStaticAsyncUpdateTask.h" - -@implementation LookinStaticAsyncUpdateTask - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:@(self.oid) forKey:@"oid"]; - [aCoder encodeInteger:self.taskType forKey:@"taskType"]; - [aCoder encodeObject:self.clientReadableVersion forKey:@"clientReadableVersion"]; - [aCoder encodeInteger:self.attrRequest forKey:@"attrRequest"]; - [aCoder encodeBool:self.needBasisVisualInfo forKey:@"needBasisVisualInfo"]; - [aCoder encodeBool:self.needSubitems forKey:@"needSubitems"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.oid = [[aDecoder decodeObjectForKey:@"oid"] unsignedLongValue]; - self.taskType = [aDecoder decodeIntegerForKey:@"taskType"]; - self.clientReadableVersion = [aDecoder decodeObjectForKey:@"clientReadableVersion"]; - if ([aDecoder containsValueForKey:@"attrRequest"]) { - NSInteger value = [aDecoder decodeIntegerForKey:@"attrRequest"]; - if (value >= LookinDetailUpdateTaskAttrRequest_Automatic && value <= LookinDetailUpdateTaskAttrRequest_NotNeed) { - self.attrRequest = value; - } else { - self.attrRequest = LookinDetailUpdateTaskAttrRequest_Automatic; - } - } else { - self.attrRequest = LookinDetailUpdateTaskAttrRequest_Automatic; - } - - if ([aDecoder containsValueForKey:@"needBasisVisualInfo"]) { - self.needBasisVisualInfo = [aDecoder decodeBoolForKey:@"needBasisVisualInfo"]; - } else { - self.needBasisVisualInfo = NO; - } - - if ([aDecoder containsValueForKey:@"needSubitems"]) { - self.needSubitems = [aDecoder decodeBoolForKey:@"needSubitems"]; - } else { - self.needSubitems = NO; - } - - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (NSUInteger)hash { - return self.oid ^ self.taskType ^ self.attrRequest ^ self.needBasisVisualInfo ^ self.needSubitems; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinStaticAsyncUpdateTask class]]) { - return NO; - } - LookinStaticAsyncUpdateTask *targetTask = object; - if (self.oid == targetTask.oid - && self.taskType == targetTask.taskType - && self.attrRequest == targetTask.attrRequest - && self.needBasisVisualInfo == targetTask.needBasisVisualInfo - && self.needSubitems == targetTask.needSubitems) { - return YES; - } - return NO; -} - -@end - -@implementation LookinStaticAsyncUpdateTasksPackage - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.tasks forKey:@"tasks"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.tasks = [aDecoder decodeObjectForKey:@"tasks"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinTuple.h b/Sources/LookinServer/Shared/LookinTuple.h deleted file mode 100644 index 296a4b80..00000000 --- a/Sources/LookinServer/Shared/LookinTuple.h +++ /dev/null @@ -1,31 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinTuples.h -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - - - -#import - -@interface LookinTwoTuple : NSObject - -@property(nonatomic, strong) NSObject *first; -@property(nonatomic, strong) NSObject *second; - -@end - -@interface LookinStringTwoTuple : NSObject - -+ (instancetype)tupleWithFirst:(NSString *)firstString second:(NSString *)secondString; - -@property(nonatomic, copy) NSString *first; -@property(nonatomic, copy) NSString *second; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinTuple.m b/Sources/LookinServer/Shared/LookinTuple.m deleted file mode 100644 index b84a960b..00000000 --- a/Sources/LookinServer/Shared/LookinTuple.m +++ /dev/null @@ -1,93 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinTuples.m -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - - - -#import "LookinTuple.h" - -@implementation LookinTwoTuple - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.first forKey:@"first"]; - [aCoder encodeObject:self.second forKey:@"second"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.first = [aDecoder decodeObjectForKey:@"first"]; - self.second = [aDecoder decodeObjectForKey:@"second"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (NSUInteger)hash { - return self.first.hash ^ self.second.hash; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinTwoTuple class]]) { - return NO; - } - LookinTwoTuple *comparedObj = object; - if ([self.first isEqual:comparedObj.first] && [self.second isEqual:comparedObj.second]) { - return YES; - } - return NO; -} - -@end - -@implementation LookinStringTwoTuple - -+ (instancetype)tupleWithFirst:(NSString *)firstString second:(NSString *)secondString { - LookinStringTwoTuple *tuple = [LookinStringTwoTuple new]; - tuple.first = firstString; - tuple.second = secondString; - return tuple; -} - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinStringTwoTuple *newTuple = [[LookinStringTwoTuple allocWithZone:zone] init]; - newTuple.first = self.first; - newTuple.second = self.second; - return newTuple; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.first forKey:@"first"]; - [aCoder encodeObject:self.second forKey:@"second"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.first = [aDecoder decodeObjectForKey:@"first"]; - self.second = [aDecoder decodeObjectForKey:@"second"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinWeakContainer.h b/Sources/LookinServer/Shared/LookinWeakContainer.h deleted file mode 100644 index af7b2ffe..00000000 --- a/Sources/LookinServer/Shared/LookinWeakContainer.h +++ /dev/null @@ -1,23 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinWeakContainer.h -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - - - -#import - -@interface LookinWeakContainer : NSObject - -+ (instancetype)containerWithObject:(id)object; - -@property (nonatomic, weak) id object; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/LookinWeakContainer.m b/Sources/LookinServer/Shared/LookinWeakContainer.m deleted file mode 100644 index ff9d2aa1..00000000 --- a/Sources/LookinServer/Shared/LookinWeakContainer.m +++ /dev/null @@ -1,43 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinWeakContainer.m -// Lookin -// -// Created by Li Kai on 2019/8/14. -// https://lookin.work -// - - - -#import "LookinWeakContainer.h" - -@implementation LookinWeakContainer - -+ (instancetype)containerWithObject:(id)object { - LookinWeakContainer *container = [LookinWeakContainer new]; - container.object = object; - return container; -} - -- (NSUInteger)hash { - return [self.object hash]; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinWeakContainer class]]) { - return NO; - } - LookinWeakContainer *comparedObj = object; - if ([self.object isEqual:comparedObj.object]) { - return YES; - } - return NO; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Peertalk/Lookin_PTChannel.h b/Sources/LookinServer/Shared/Peertalk/Lookin_PTChannel.h deleted file mode 100644 index 498a604e..00000000 --- a/Sources/LookinServer/Shared/Peertalk/Lookin_PTChannel.h +++ /dev/null @@ -1,136 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - - - -// -// Represents a communication channel between two endpoints talking the same -// Lookin_PTProtocol. -// -#import -#import -#import -#import - -#import "Lookin_PTProtocol.h" -#import "Lookin_PTUSBHub.h" - -@class Lookin_PTData, Lookin_PTAddress; -@protocol Lookin_PTChannelDelegate; - -@interface Lookin_PTChannel : NSObject - -// Delegate -@property (strong) id delegate; - -// Communication protocol. Must not be nil. -@property Lookin_PTProtocol *protocol; - -// YES if this channel is a listening server -@property (readonly) BOOL isListening; - -// YES if this channel is a connected peer -@property (readonly) BOOL isConnected; - -// Arbitrary attachment. Note that if you set this, the object will grow by -// 8 bytes (64 bits). -@property (strong) id userInfo; - -@property(nonatomic, assign) int uniqueID; -@property(nonatomic, assign) NSInteger targetPort; -- (NSString *)debugTag; - -// Create a new channel using the shared Lookin_PTProtocol for the current dispatch -// queue, with *delegate*. -+ (Lookin_PTChannel*)channelWithDelegate:(id)delegate; - - -// Initialize a new frame channel, configuring it to use the calling queue's -// protocol instance (as returned by [Lookin_PTProtocol sharedProtocolForQueue: -// dispatch_get_current_queue()]) -- (id)init; - -// Initialize a new frame channel with a specific protocol. -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol; - -// Initialize a new frame channel with a specific protocol and delegate. -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol delegate:(id)delegate; - - -// Connect to a TCP port on a device connected over USB -- (void)connectToPort:(int)port overUSBHub:(Lookin_PTUSBHub*)usbHub deviceID:(NSNumber*)deviceID callback:(void(^)(NSError *error))callback; - -// Connect to a TCP port at IPv4 address. Provided port must NOT be in network -// byte order. Provided in_addr_t must NOT be in network byte order. A value returned -// from inet_aton() will be in network byte order. You can use a value of inet_aton() -// as the address parameter here, but you must flip the byte order before passing the -// in_addr_t to this function. -- (void)connectToPort:(in_port_t)port IPv4Address:(in_addr_t)address callback:(void(^)(NSError *error, Lookin_PTAddress *address))callback; - -// Listen for connections on port and address, effectively starting a socket -// server. Provided port must NOT be in network byte order. Provided in_addr_t -// must NOT be in network byte order. -// For this to make sense, you should provide a onAccept block handler -// or a delegate implementing ioFrameChannel:didAcceptConnection:. -- (void)listenOnPort:(in_port_t)port IPv4Address:(in_addr_t)address callback:(void(^)(NSError *error))callback; - -// Send a frame with an optional payload and optional callback. -// If *callback* is not NULL, the block is invoked when either an error occured -// or when the frame (and payload, if any) has been completely sent. -- (void)sendFrameOfType:(uint32_t)frameType tag:(uint32_t)tag withPayload:(dispatch_data_t)payload callback:(void(^)(NSError *error))callback; - -// Lower-level method to assign a connected dispatch IO channel to this channel -- (BOOL)startReadingFromConnectedChannel:(dispatch_io_t)channel error:(__autoreleasing NSError**)error; - -// Close the channel, preventing further reading and writing. Any ongoing and -// queued reads and writes will be aborted. -- (void)close; - -// "graceful" close -- any ongoing and queued reads and writes will complete -// before the channel ends. -- (void)cancel; - -@end - - -// Wraps a mapped dispatch_data_t object. The memory pointed to by *data* is -// valid until *dispatchData* is deallocated (normally when the receiver is -// deallocated). -@interface Lookin_PTData : NSObject -@property (readonly) dispatch_data_t dispatchData; -@property (readonly) void *data; -@property (readonly) size_t length; -@end - - -// Represents a peer's address -@interface Lookin_PTAddress : NSObject -// For network addresses, this is the IP address in textual format -@property (readonly) NSString *name; -// For network addresses, this is the port number. Otherwise 0 (zero). -@property (readonly) NSInteger port; -@end - - -// Protocol for Lookin_PTChannel delegates -@protocol Lookin_PTChannelDelegate - -@required -// Invoked when a new frame has arrived on a channel. -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didReceiveFrameOfType:(uint32_t)type tag:(uint32_t)tag payload:(Lookin_PTData*)payload; - -@optional -// Invoked to accept an incoming frame on a channel. Reply NO ignore the -// incoming frame. If not implemented by the delegate, all frames are accepted. -- (BOOL)ioFrameChannel:(Lookin_PTChannel*)channel shouldAcceptFrameOfType:(uint32_t)type tag:(uint32_t)tag payloadSize:(uint32_t)payloadSize; - -// Invoked when the channel closed. If it closed because of an error, *error* is -// a non-nil NSError object. -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didEndWithError:(NSError*)error; - -// For listening channels, this method is invoked when a new connection has been -// accepted. -- (void)ioFrameChannel:(Lookin_PTChannel*)channel didAcceptConnection:(Lookin_PTChannel*)otherChannel fromAddress:(Lookin_PTAddress*)address; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Peertalk/Lookin_PTChannel.m b/Sources/LookinServer/Shared/Peertalk/Lookin_PTChannel.m deleted file mode 100644 index bb8529ed..00000000 --- a/Sources/LookinServer/Shared/Peertalk/Lookin_PTChannel.m +++ /dev/null @@ -1,675 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -#import "Lookin_PTChannel.h" -#import "Lookin_PTPrivate.h" -#include -#include -#include -#include -#include -#import - -// Read member of sockaddr_in without knowing the family -#define PT_SOCKADDR_ACCESS(ss, member4, member6) \ - (((ss)->ss_family == AF_INET) ? ( \ - ((const struct sockaddr_in *)(ss))->member4 \ - ) : ( \ - ((const struct sockaddr_in6 *)(ss))->member6 \ - )) - -// Connection state (storage: uint8_t) -#define kConnStateNone 0 -#define kConnStateConnecting 1 -#define kConnStateConnected 2 -#define kConnStateListening 3 - -// Delegate support optimization (storage: uint8_t) -#define kDelegateFlagImplements_ioFrameChannel_shouldAcceptFrameOfType_tag_payloadSize 1 -#define kDelegateFlagImplements_ioFrameChannel_didEndWithError 2 -#define kDelegateFlagImplements_ioFrameChannel_didAcceptConnection_fromAddress 4 - - -static int ChannelInstanceCount = 0; -static int ChannelUniqueID = 0; - -#pragma mark - -// Note: We are careful about the size of this struct as each connected peer -// implies one allocation of this struct. -@interface Lookin_PTChannel () { - dispatch_io_t dispatchObj_channel_; - dispatch_source_t dispatchObj_source_; - NSError *endError_; // 64 bit -@public // here be hacks - id delegate_; // 64 bit - uint8_t delegateFlags_; // 8 bit -@private - uint8_t connState_; // 8 bit - //char padding_[6]; // 48 bit -- only if allocation speed is important -} -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol delegate:(id)delegate; -- (BOOL)acceptIncomingConnection:(dispatch_fd_t)serverSocketFD; -@end -static const uint8_t kUserInfoKey; - -#pragma mark - -@interface Lookin_PTData () -- (id)initWithMappedDispatchData:(dispatch_data_t)mappedContiguousData data:(void*)data length:(size_t)length; -@end - -#pragma mark - -@interface Lookin_PTAddress () { - struct sockaddr_storage sockaddr_; -} -- (id)initWithSockaddr:(const struct sockaddr_storage*)addr; -@end - -#pragma mark - -@implementation Lookin_PTChannel - -@synthesize protocol = protocol_; - - -+ (Lookin_PTChannel*)channelWithDelegate:(id)delegate { - return [[Lookin_PTChannel alloc] initWithProtocol:[Lookin_PTProtocol sharedProtocolForQueue:dispatch_get_main_queue()] delegate:delegate]; -} - - -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol delegate:(id)delegate { - if (!(self = [super init])) return nil; - protocol_ = protocol; - self.delegate = delegate; - - [self didInit]; - - return self; -} - - -- (id)initWithProtocol:(Lookin_PTProtocol*)protocol { - if (!(self = [super init])) return nil; - protocol_ = protocol; - - [self didInit]; - - return self; -} - - -- (id)init { - [self didInit]; - - return [self initWithProtocol:[Lookin_PTProtocol sharedProtocolForQueue:dispatch_get_main_queue()]]; -} - -- (void)didInit { - ChannelUniqueID++; - ChannelInstanceCount++; - self.uniqueID = ChannelUniqueID; -// NSLog(@"LookinServer - Init channel(ID: %@). Total count: %@", @(self.uniqueID), @(ChannelInstanceCount)); -} - -- (void)dealloc { - ChannelInstanceCount--; -// NSLog(@"LookinServer - Dealloc channel%@. Still lives count: %@", self.debugTag, @(ChannelInstanceCount)); -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchObj_channel_) dispatch_release(dispatchObj_channel_); - else if (dispatchObj_source_) dispatch_release(dispatchObj_source_); -#endif -} - - -- (BOOL)isConnected { - return connState_ == kConnStateConnecting || connState_ == kConnStateConnected; -} - - -- (BOOL)isListening { - return connState_ == kConnStateListening; -} - - -- (id)userInfo { - return objc_getAssociatedObject(self, (void*)&kUserInfoKey); -} - -- (void)setUserInfo:(id)userInfo { - objc_setAssociatedObject(self, (const void*)&kUserInfoKey, userInfo, OBJC_ASSOCIATION_RETAIN); -} - - -- (void)setConnState:(char)connState { - connState_ = connState; -} - - -- (void)setDispatchChannel:(dispatch_io_t)channel { - assert(connState_ == kConnStateConnecting || connState_ == kConnStateConnected || connState_ == kConnStateNone); - dispatch_io_t prevChannel = dispatchObj_channel_; - if (prevChannel != channel) { - dispatchObj_channel_ = channel; -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchObj_channel_) dispatch_retain(dispatchObj_channel_); - if (prevChannel) dispatch_release(prevChannel); -#endif - if (!dispatchObj_channel_ && !dispatchObj_source_) { - connState_ = kConnStateNone; - } - } -} - - -- (void)setDispatchSource:(dispatch_source_t)source { - assert(connState_ == kConnStateListening || connState_ == kConnStateNone); - dispatch_source_t prevSource = dispatchObj_source_; - if (prevSource != source) { - dispatchObj_source_ = source; -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchObj_source_) dispatch_retain(dispatchObj_source_); - if (prevSource) dispatch_release(prevSource); -#endif - if (!dispatchObj_channel_ && !dispatchObj_source_) { - connState_ = kConnStateNone; - } - } -} - - -- (id)delegate { - return delegate_; -} - - -- (void)setDelegate:(id)delegate { - delegate_ = delegate; - delegateFlags_ = 0; - if (!delegate_) { - return; - } - - if ([delegate respondsToSelector:@selector(ioFrameChannel:shouldAcceptFrameOfType:tag:payloadSize:)]) { - delegateFlags_ |= kDelegateFlagImplements_ioFrameChannel_shouldAcceptFrameOfType_tag_payloadSize; - } - - if (delegate_ && [delegate respondsToSelector:@selector(ioFrameChannel:didEndWithError:)]) { - delegateFlags_ |= kDelegateFlagImplements_ioFrameChannel_didEndWithError; - } - - if (delegate_ && [delegate respondsToSelector:@selector(ioFrameChannel:didAcceptConnection:fromAddress:)]) { - delegateFlags_ |= kDelegateFlagImplements_ioFrameChannel_didAcceptConnection_fromAddress; - } -} - -- (NSString *)debugTag { - NSString *state = @""; - if (connState_ == kConnStateNone) { - state = @"None"; - } else if (connState_ == kConnStateConnecting) { - state = @"Connecting"; - } else if (connState_ == kConnStateConnected) { - state = @"Connected"; - } else if (connState_ == kConnStateListening) { - state = @"Listening"; - } else { - state = @"Undefined"; - } - return [NSString stringWithFormat:@"[%@-%@,%@]", @(self.uniqueID), @(self.targetPort), state]; -} - - -//- (void)setFileDescriptor:(dispatch_fd_t)fd { -// [self setDispatchChannel:dispatch_io_create(DISPATCH_IO_STREAM, fd, protocol_.queue, ^(int error) { -// close(fd); -// })]; -//} - - -#pragma mark - Connecting - - -- (void)connectToPort:(int)port overUSBHub:(Lookin_PTUSBHub*)usbHub deviceID:(NSNumber*)deviceID callback:(void(^)(NSError *error))callback { - assert(protocol_ != NULL); - if (connState_ != kConnStateNone) { - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:EPERM userInfo:nil]); - return; - } - connState_ = kConnStateConnecting; - [usbHub connectToDevice:deviceID port:port onStart:^(NSError *err, dispatch_io_t dispatchChannel) { - NSError *error = err; - if (!error) { - [self startReadingFromConnectedChannel:dispatchChannel error:&error]; - } else { - self->connState_ = kConnStateNone; - } - if (callback) callback(error); - } onEnd:^(NSError *error) { - if (self->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didEndWithError) { - [self->delegate_ ioFrameChannel:self didEndWithError:error]; - } - self->endError_ = nil; - }]; -} - - -- (void)connectToPort:(in_port_t)port IPv4Address:(in_addr_t)address callback:(void(^)(NSError *error, Lookin_PTAddress *address))callback { - assert(protocol_ != NULL); - if (connState_ != kConnStateNone) { - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:EPERM userInfo:nil], nil); - return; - } - connState_ = kConnStateConnecting; - - int error = 0; - - // Create socket - dispatch_fd_t fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd == -1) { - perror("socket(AF_INET, SOCK_STREAM, 0) failed"); - error = errno; - if (callback) callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil], nil); - return; - } - - // Connect socket - struct sockaddr_in addr; - bzero((char *)&addr, sizeof(addr)); - - addr.sin_len = sizeof(addr); - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - //addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - //addr.sin_addr.s_addr = htonl(INADDR_ANY); - addr.sin_addr.s_addr = htonl(address); - - // prevent SIGPIPE - int on = 1; - setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); - - // int socket, const struct sockaddr *address, socklen_t address_len - if (connect(fd, (const struct sockaddr *)&addr, addr.sin_len) == -1) { - //perror("connect"); - error = errno; - close(fd); - if (callback) callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], nil); - return; - } - - // get actual address - //if (getsockname(fd, (struct sockaddr*)&addr, (socklen_t*)&addr.sin_len) == -1) { - // error = errno; - // close(fd); - // if (callback) callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], nil); - // return; - //} - - dispatch_io_t dispatchChannel = dispatch_io_create(DISPATCH_IO_STREAM, fd, protocol_.queue, ^(int error) { - close(fd); - if (self->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didEndWithError) { - NSError *err = error == 0 ? self->endError_ : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil]; - [self->delegate_ ioFrameChannel:self didEndWithError:err]; - self->endError_ = nil; - } - }); - - if (!dispatchChannel) { - close(fd); - if (callback) callback([[NSError alloc] initWithDomain:@"PTError" code:0 userInfo:nil], nil); - return; - } - - // Success - NSError *err = nil; - Lookin_PTAddress *ptAddr = [[Lookin_PTAddress alloc] initWithSockaddr:(struct sockaddr_storage*)&addr]; - [self startReadingFromConnectedChannel:dispatchChannel error:&err]; - if (callback) callback(err, ptAddr); -} - - -#pragma mark - Listening and serving - - -- (void)listenOnPort:(in_port_t)port IPv4Address:(in_addr_t)address callback:(void(^)(NSError *error))callback { - assert(dispatchObj_source_ == nil); - - // Create socket - dispatch_fd_t fd = socket(AF_INET, SOCK_STREAM, 0); - if (fd == -1) { - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - // Connect socket - struct sockaddr_in addr; - bzero((char *)&addr, sizeof(addr)); - - addr.sin_family = AF_INET; - addr.sin_port = htons(port); - //addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); - //addr.sin_addr.s_addr = htonl(INADDR_ANY); - addr.sin_addr.s_addr = htonl(address); - - socklen_t socklen = sizeof(addr); - - int on = 1; - - if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) == -1) { - close(fd); - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - if (fcntl(fd, F_SETFL, O_NONBLOCK) == -1) { - close(fd); - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - if (bind(fd, (struct sockaddr*)&addr, socklen) != 0) { - close(fd); - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - if (listen(fd, 512) != 0) { - close(fd); - if (callback) callback([NSError errorWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]); - return; - } - - [self setDispatchSource:dispatch_source_create(DISPATCH_SOURCE_TYPE_READ, fd, 0, protocol_.queue)]; - - dispatch_source_set_event_handler(dispatchObj_source_, ^{ - unsigned long nconns = dispatch_source_get_data(self->dispatchObj_source_); - while ([self acceptIncomingConnection:fd] && --nconns); - }); - - dispatch_source_set_cancel_handler(dispatchObj_source_, ^{ - // Captures *self*, effectively holding a reference to *self* until cancelled. - self->dispatchObj_source_ = nil; - close(fd); - if (self->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didEndWithError) { - [self->delegate_ ioFrameChannel:self didEndWithError:self->endError_]; - self->endError_ = nil; - } - }); - - dispatch_resume(dispatchObj_source_); - //NSLog(@"%@ opened on fd #%d", self, fd); - - connState_ = kConnStateListening; - if (callback) callback(nil); -} - - -- (BOOL)acceptIncomingConnection:(dispatch_fd_t)serverSocketFD { - struct sockaddr_in addr; - socklen_t addrLen = sizeof(addr); - dispatch_fd_t clientSocketFD = accept(serverSocketFD, (struct sockaddr*)&addr, &addrLen); - - if (clientSocketFD == -1) { - perror("accept()"); - return NO; - } - - // prevent SIGPIPE - int on = 1; - setsockopt(clientSocketFD, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); - - if (fcntl(clientSocketFD, F_SETFL, O_NONBLOCK) == -1) { - perror("fcntl(.. O_NONBLOCK)"); - close(clientSocketFD); - return NO; - } - - if (delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didAcceptConnection_fromAddress) { - Lookin_PTChannel *peerChannel = [[Lookin_PTChannel alloc] initWithProtocol:protocol_ delegate:delegate_]; - __block Lookin_PTChannel *localChannelRef = self; - dispatch_io_t dispatchChannel = dispatch_io_create(DISPATCH_IO_STREAM, clientSocketFD, protocol_.queue, ^(int error) { - // Important note: This block captures *self*, thus a reference is held to - // *self* until the fd is truly closed. - localChannelRef = nil; - - close(clientSocketFD); - - if (peerChannel->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_didEndWithError) { - NSError *err = error == 0 ? peerChannel->endError_ : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil]; - [peerChannel->delegate_ ioFrameChannel:peerChannel didEndWithError:err]; - peerChannel->endError_ = nil; - } - }); - - [peerChannel setConnState:kConnStateConnected]; - [peerChannel setDispatchChannel:dispatchChannel]; - - assert(((struct sockaddr_storage*)&addr)->ss_len == addrLen); - Lookin_PTAddress *address = [[Lookin_PTAddress alloc] initWithSockaddr:(struct sockaddr_storage*)&addr]; - [delegate_ ioFrameChannel:self didAcceptConnection:peerChannel fromAddress:address]; - - NSError *err = nil; - if (![peerChannel startReadingFromConnectedChannel:dispatchChannel error:&err]) { -// NSLog(@"startReadingFromConnectedChannel failed in accept: %@", err); - } - } else { - close(clientSocketFD); - } - return YES; -} - - -#pragma mark - Closing the channel - - -- (void)close { -// NSLog(@"LookinServer - Will close chanel: %@", self.debugTag); - - if ((connState_ == kConnStateConnecting || connState_ == kConnStateConnected) && dispatchObj_channel_) { - dispatch_io_close(dispatchObj_channel_, DISPATCH_IO_STOP); - [self setDispatchChannel:NULL]; - } else if (connState_ == kConnStateListening && dispatchObj_source_) { - dispatch_source_cancel(dispatchObj_source_); - } -} - -/// 曾经连接上 Client,然后 Client 端关闭时,Peertalk 内部会对之前 connect 的 channel 调用该方法 -- (void)cancel { -// NSLog(@"LookinServer - Will cancel chanel: %@", self.debugTag); - - if ((connState_ == kConnStateConnecting || connState_ == kConnStateConnected) && dispatchObj_channel_) { - dispatch_io_close(dispatchObj_channel_, 0); - [self setDispatchChannel:NULL]; - } else if (connState_ == kConnStateListening && dispatchObj_source_) { - dispatch_source_cancel(dispatchObj_source_); - } -} - - -#pragma mark - Reading - - -- (BOOL)startReadingFromConnectedChannel:(dispatch_io_t)channel error:(__autoreleasing NSError**)error { - if (connState_ != kConnStateNone && connState_ != kConnStateConnecting && connState_ != kConnStateConnected) { - if (error) *error = [NSError errorWithDomain:NSPOSIXErrorDomain code:EPERM userInfo:nil]; - return NO; - } - - if (dispatchObj_channel_ != channel) { - [self close]; - [self setDispatchChannel:channel]; - } - - connState_ = kConnStateConnected; - - // helper - BOOL(^handleError)(NSError*,BOOL) = ^BOOL(NSError *error, BOOL isEOS) { - if (error) { - //NSLog(@"Error while communicating: %@", error); - self->endError_ = error; - [self close]; - return YES; - } else if (isEOS) { - [self cancel]; - return YES; - } - return NO; - }; - - [protocol_ readFramesOverChannel:channel onFrame:^(NSError *error, uint32_t type, uint32_t tag, uint32_t payloadSize, dispatch_block_t resumeReadingFrames) { - if (handleError(error, type == PTFrameTypeEndOfStream)) { - return; - } - - BOOL accepted = (channel == self->dispatchObj_channel_); - if (accepted && (self->delegateFlags_ & kDelegateFlagImplements_ioFrameChannel_shouldAcceptFrameOfType_tag_payloadSize)) { - accepted = [self->delegate_ ioFrameChannel:self shouldAcceptFrameOfType:type tag:tag payloadSize:payloadSize]; - } - - if (payloadSize == 0) { - if (accepted && self->delegate_) { - [self->delegate_ ioFrameChannel:self didReceiveFrameOfType:type tag:tag payload:nil]; - } else { - // simply ignore the frame - } - resumeReadingFrames(); - } else { - // has payload - if (!accepted) { - // Read and discard payload, ignoring frame - [self->protocol_ readAndDiscardDataOfSize:payloadSize overChannel:channel callback:^(NSError *error, BOOL endOfStream) { - if (!handleError(error, endOfStream)) { - resumeReadingFrames(); - } - }]; - } else { - [self->protocol_ readPayloadOfSize:payloadSize overChannel:channel callback:^(NSError *error, dispatch_data_t contiguousData, const uint8_t *buffer, size_t bufferSize) { - if (handleError(error, bufferSize == 0)) { - return; - } - - if (self->delegate_) { - Lookin_PTData *payload = [[Lookin_PTData alloc] initWithMappedDispatchData:contiguousData data:(void*)buffer length:bufferSize]; - [self->delegate_ ioFrameChannel:self didReceiveFrameOfType:type tag:tag payload:payload]; - } - - resumeReadingFrames(); - }]; - } - } - }]; - - return YES; -} - - -#pragma mark - Sending - -- (void)sendFrameOfType:(uint32_t)frameType tag:(uint32_t)tag withPayload:(dispatch_data_t)payload callback:(void(^)(NSError *error))callback { - if (connState_ == kConnStateConnecting || connState_ == kConnStateConnected) { - [protocol_ sendFrameOfType:frameType tag:tag withPayload:payload overChannel:dispatchObj_channel_ callback:callback]; - } else if (callback) { - callback([NSError errorWithDomain:NSPOSIXErrorDomain code:EPERM userInfo:nil]); - } -} - -#pragma mark - NSObject - -- (NSString*)description { - id userInfo = objc_getAssociatedObject(self, (void*)&kUserInfoKey); - return [NSString stringWithFormat:@"", self, ( connState_ == kConnStateConnecting ? @"connecting" - : connState_ == kConnStateConnected ? @"connected" - : connState_ == kConnStateListening ? @"listening" - : @"closed"), - userInfo ? " " : "", userInfo ? userInfo : @""]; -} - - -@end - - -#pragma mark - -@implementation Lookin_PTAddress - -- (id)initWithSockaddr:(const struct sockaddr_storage*)addr { - if (!(self = [super init])) return nil; - assert(addr); - memcpy((void*)&sockaddr_, (const void*)addr, addr->ss_len); - return self; -} - - -- (NSString*)name { - if (sockaddr_.ss_len) { - const void *sin_addr = NULL; - size_t bufsize = 0; - if (sockaddr_.ss_family == AF_INET6) { - bufsize = INET6_ADDRSTRLEN; - sin_addr = (const void *)&((const struct sockaddr_in6*)&sockaddr_)->sin6_addr; - } else { - bufsize = INET_ADDRSTRLEN; - sin_addr = (const void *)&((const struct sockaddr_in*)&sockaddr_)->sin_addr; - } - char *buf = CFAllocatorAllocate(kCFAllocatorDefault, bufsize+1, 0); - if (inet_ntop(sockaddr_.ss_family, sin_addr, buf, (unsigned int)bufsize-1) == NULL) { - CFAllocatorDeallocate(kCFAllocatorDefault, buf); - return nil; - } - return [[NSString alloc] initWithBytesNoCopy:(void*)buf length:strlen(buf) encoding:NSUTF8StringEncoding freeWhenDone:YES]; - } else { - return nil; - } -} - - -- (NSInteger)port { - if (sockaddr_.ss_len) { - return ntohs(PT_SOCKADDR_ACCESS(&sockaddr_, sin_port, sin6_port)); - } else { - return 0; - } -} - - -- (NSString*)description { - if (sockaddr_.ss_len) { - return [NSString stringWithFormat:@"%@:%u", self.name, (unsigned)self.port]; - } else { - return @"(?)"; - } -} - -@end - - -#pragma mark - -@implementation Lookin_PTData - -@synthesize dispatchData = dispatchData_; -@synthesize data = data_; -@synthesize length = length_; - -- (id)initWithMappedDispatchData:(dispatch_data_t)mappedContiguousData data:(void*)data length:(size_t)length { - if (!(self = [super init])) return nil; - dispatchData_ = mappedContiguousData; -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchData_) dispatch_retain(dispatchData_); -#endif - data_ = data; - length_ = length; - return self; -} - -- (void)dealloc { -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchData_) dispatch_release(dispatchData_); -#endif - data_ = NULL; - length_ = 0; -} - -#pragma mark - NSObject - -- (NSString*)description { - return [NSString stringWithFormat:@"", self, length_]; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Peertalk/Lookin_PTPrivate.h b/Sources/LookinServer/Shared/Peertalk/Lookin_PTPrivate.h deleted file mode 100644 index 6480de85..00000000 --- a/Sources/LookinServer/Shared/Peertalk/Lookin_PTPrivate.h +++ /dev/null @@ -1,20 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - - - -#if (defined(__IPHONE_OS_VERSION_MIN_REQUIRED) && (!defined(__IPHONE_6_0) || __IPHONE_OS_VERSION_MIN_REQUIRED < __IPHONE_6_0)) || \ - (defined(__MAC_OS_X_VERSION_MIN_REQUIRED) && (!defined(__MAC_10_8) || __MAC_OS_X_VERSION_MIN_REQUIRED < __MAC_10_8)) -#define PT_DISPATCH_RETAIN_RELEASE 1 -#else -#define PT_DISPATCH_RETAIN_RELEASE 0 -#endif - -#if PT_DISPATCH_RETAIN_RELEASE -#define PT_PRECISE_LIFETIME -#define PT_PRECISE_LIFETIME_UNUSED -#else -#define PT_PRECISE_LIFETIME __attribute__((objc_precise_lifetime)) -#define PT_PRECISE_LIFETIME_UNUSED __attribute__((objc_precise_lifetime, unused)) -#endif - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Peertalk/Lookin_PTProtocol.h b/Sources/LookinServer/Shared/Peertalk/Lookin_PTProtocol.h deleted file mode 100644 index b0207d37..00000000 --- a/Sources/LookinServer/Shared/Peertalk/Lookin_PTProtocol.h +++ /dev/null @@ -1,122 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - - - -// -// A universal frame-based communication protocol which can be used to exchange -// arbitrary structured data. -// -// In short: -// -// - Each transmission is comprised by one fixed-size frame. -// - Each frame contains a protocol version number. -// - Each frame contains an application frame type. -// - Each frame can contain an identifying tag. -// - Each frame can have application-specific data of up to UINT32_MAX size. -// - Transactions style messaging can be modeled on top using frame tags. -// - Lightweight API on top of libdispatch (aka GCD) -- close to the metal. -// -#include -#import - -// Special frame tag that signifies "no tag". Your implementation should never -// create a reply for a frame with this tag. -static const uint32_t PTFrameNoTag = 0; - -// Special frame type that signifies that the stream has ended. -static const uint32_t PTFrameTypeEndOfStream = 0; - -// NSError domain -FOUNDATION_EXPORT NSString * const Lookin_PTProtocolErrorDomain; - - -@interface Lookin_PTProtocol : NSObject - -// Queue on which to run data processing blocks. -@property dispatch_queue_t queue; - -// Get the shared protocol object for *queue* -+ (Lookin_PTProtocol*)sharedProtocolForQueue:(dispatch_queue_t)queue; - -// Initialize a new protocol object to use a specific queue. -- (id)initWithDispatchQueue:(dispatch_queue_t)queue; - -// Initialize a new protocol object to use the current calling queue. -- (id)init; - -#pragma mark Sending frames - -// Generate a new tag that is unique within this protocol object. -- (uint32_t)newTag; - -// Send a frame over *channel* with an optional payload and optional callback. -// If *callback* is not NULL, the block is invoked when either an error occured -// or when the frame (and payload, if any) has been completely sent. -- (void)sendFrameOfType:(uint32_t)frameType - tag:(uint32_t)tag - withPayload:(dispatch_data_t)payload - overChannel:(dispatch_io_t)channel - callback:(void(^)(NSError *error))callback; - -#pragma mark Receiving frames - -// Read frames over *channel* as they arrive. -// The onFrame handler is responsible for reading (or discarding) any payload -// and call *resumeReadingFrames* afterwards to resume reading frames. -// To stop reading frames, simply do not invoke *resumeReadingFrames*. -// When the stream ends, a frame of type PTFrameTypeEndOfStream is received. -- (void)readFramesOverChannel:(dispatch_io_t)channel - onFrame:(void(^)(NSError *error, - uint32_t type, - uint32_t tag, - uint32_t payloadSize, - dispatch_block_t resumeReadingFrames))onFrame; - -// Read a single frame over *channel*. A frame of type PTFrameTypeEndOfStream -// denotes the stream has ended. -- (void)readFrameOverChannel:(dispatch_io_t)channel - callback:(void(^)(NSError *error, - uint32_t frameType, - uint32_t frameTag, - uint32_t payloadSize))callback; - -#pragma mark Receiving frame payloads - -// Read a complete payload. It's the callers responsibility to make sure -// payloadSize is not too large since memory will be automatically allocated -// where only payloadSize is the limit. -// The returned dispatch_data_t object owns *buffer* and thus you need to call -// dispatch_retain on *contiguousData* if you plan to keep *buffer* around after -// returning from the callback. -- (void)readPayloadOfSize:(size_t)payloadSize - overChannel:(dispatch_io_t)channel - callback:(void(^)(NSError *error, - dispatch_data_t contiguousData, - const uint8_t *buffer, - size_t bufferSize))callback; - -// Discard data of *size* waiting on *channel*. *callback* can be NULL. -- (void)readAndDiscardDataOfSize:(size_t)size - overChannel:(dispatch_io_t)channel - callback:(void(^)(NSError *error, BOOL endOfStream))callback; - -@end - -@interface NSData (Lookin_PTProtocol) -// Creates a new dispatch_data_t object which references the receiver and uses -// the receivers bytes as its backing data. The returned dispatch_data_t object -// holds a reference to the recevier. It's the callers responsibility to call -// dispatch_release on the returned object when done. -- (dispatch_data_t)createReferencingDispatchData; -+ (NSData *)dataWithContentsOfDispatchData:(dispatch_data_t)data; -@end - -@interface NSDictionary (Lookin_PTProtocol) -// See description of -[NSData(Lookin_PTProtocol) createReferencingDispatchData] -- (dispatch_data_t)createReferencingDispatchData; - -// Decode *data* as a peroperty list-encoded dictionary. Returns nil on failure. -+ (NSDictionary*)dictionaryWithContentsOfDispatchData:(dispatch_data_t)data; -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Peertalk/Lookin_PTProtocol.m b/Sources/LookinServer/Shared/Peertalk/Lookin_PTProtocol.m deleted file mode 100644 index 72e85f8a..00000000 --- a/Sources/LookinServer/Shared/Peertalk/Lookin_PTProtocol.m +++ /dev/null @@ -1,428 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -#import "Lookin_PTProtocol.h" -#import "Lookin_PTPrivate.h" -#import - -static const uint32_t PTProtocolVersion1 = 1; - -NSString * const Lookin_PTProtocolErrorDomain = @"PTProtocolError"; - -// This is what we send as the header for each frame. -typedef struct _PTFrame { - // The version of the frame and protocol. - uint32_t version; - - // Type of frame - uint32_t type; - - // Unless zero, a tag is retained in frames that are responses to previous - // frames. Applications can use this to build transactions or request-response - // logic. - uint32_t tag; - - // If payloadSize is larger than zero, *payloadSize* number of bytes are - // following, constituting application-specific data. - uint32_t payloadSize; - -} PTFrame; - - -@interface Lookin_PTProtocol () { - uint32_t nextFrameTag_; - @public - dispatch_queue_t queue_; -} -- (dispatch_data_t)createDispatchDataWithFrameOfType:(uint32_t)type frameTag:(uint32_t)frameTag payload:(dispatch_data_t)payload; -@end - - -static void _release_queue_local_protocol(void *objcobj) { - if (objcobj) { - Lookin_PTProtocol *protocol = (__bridge_transfer id)objcobj; - protocol->queue_ = NULL; - } -} - - -@interface Lookin_RQueueLocalIOFrameProtocol : Lookin_PTProtocol -@end -@implementation Lookin_RQueueLocalIOFrameProtocol -- (void)setQueue:(dispatch_queue_t)queue { -} -@end - - -@implementation Lookin_PTProtocol - - -+ (Lookin_PTProtocol*)sharedProtocolForQueue:(dispatch_queue_t)queue { - static const char currentQueueFrameProtocolKey; - //dispatch_queue_t queue = dispatch_get_current_queue(); - Lookin_PTProtocol *currentQueueFrameProtocol = (__bridge Lookin_PTProtocol*)dispatch_queue_get_specific(queue, ¤tQueueFrameProtocolKey); - if (!currentQueueFrameProtocol) { - currentQueueFrameProtocol = [[Lookin_RQueueLocalIOFrameProtocol alloc] initWithDispatchQueue:NULL]; - currentQueueFrameProtocol->queue_ = queue; // reference, no retain, since we would create cyclic references - dispatch_queue_set_specific(queue, ¤tQueueFrameProtocolKey, (__bridge_retained void*)currentQueueFrameProtocol, &_release_queue_local_protocol); - return (__bridge Lookin_PTProtocol*)dispatch_queue_get_specific(queue, ¤tQueueFrameProtocolKey); // to avoid race conds - } else { - return currentQueueFrameProtocol; - } -} - - -- (id)initWithDispatchQueue:(dispatch_queue_t)queue { - if (!(self = [super init])) return nil; - queue_ = queue; -#if PT_DISPATCH_RETAIN_RELEASE - if (queue_) dispatch_retain(queue_); -#endif - return self; -} - -- (id)init { - return [self initWithDispatchQueue:dispatch_get_main_queue()]; -} - -- (void)dealloc { - if (queue_) { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(queue_); -#endif - } -} - -- (dispatch_queue_t)queue { - return queue_; -} - -- (void)setQueue:(dispatch_queue_t)queue { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_queue_t prev_queue = queue_; - queue_ = queue; - if (queue_) dispatch_retain(queue_); - if (prev_queue) dispatch_release(prev_queue); -#else - queue_ = queue; -#endif -} - - -- (uint32_t)newTag { - return ++nextFrameTag_; -} - - -#pragma mark - -#pragma mark Creating frames - - -- (dispatch_data_t)createDispatchDataWithFrameOfType:(uint32_t)type frameTag:(uint32_t)frameTag payload:(dispatch_data_t)payload { - PTFrame *frame = CFAllocatorAllocate(kCFAllocatorDefault, sizeof(PTFrame), 0); - frame->version = htonl(PTProtocolVersion1); - frame->type = htonl(type); - frame->tag = htonl(frameTag); - - if (payload) { - size_t payloadSize = dispatch_data_get_size(payload); - assert(payloadSize <= UINT32_MAX); - frame->payloadSize = htonl((uint32_t)payloadSize); - } else { - frame->payloadSize = 0; - } - - dispatch_data_t frameData = dispatch_data_create((const void*)frame, sizeof(PTFrame), queue_, ^{ - CFAllocatorDeallocate(kCFAllocatorDefault, (void*)frame); - }); - - if (payload && frame->payloadSize != 0) { - // chain frame + payload - dispatch_data_t data = dispatch_data_create_concat(frameData, payload); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(frameData); -#endif - frameData = data; - } - - return frameData; -} - - -#pragma mark - -#pragma mark Sending frames - - -- (void)sendFrameOfType:(uint32_t)frameType tag:(uint32_t)tag withPayload:(dispatch_data_t)payload overChannel:(dispatch_io_t)channel callback:(void(^)(NSError*))callback { - dispatch_data_t frame = [self createDispatchDataWithFrameOfType:frameType frameTag:tag payload:payload]; - dispatch_io_write(channel, 0, frame, queue_, ^(bool done, dispatch_data_t data, int _errno) { - if (done && callback) { - callback(_errno == 0 ? nil : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:_errno userInfo:nil]); - } - }); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(frame); -#endif -} - - -#pragma mark - -#pragma mark Receiving frames - - -- (void)readFrameOverChannel:(dispatch_io_t)channel callback:(void(^)(NSError *error, uint32_t frameType, uint32_t frameTag, uint32_t payloadSize))callback { - __block dispatch_data_t allData = NULL; - - dispatch_io_read(channel, 0, sizeof(PTFrame), queue_, ^(bool done, dispatch_data_t data, int error) { - //NSLog(@"dispatch_io_read: done=%d data=%p error=%d", done, data, error); - size_t dataSize = data ? dispatch_data_get_size(data) : 0; - - if (dataSize) { - if (!allData) { - allData = data; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_retain(allData); -#endif - } else { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_data_t allDataPrev = allData; - allData = dispatch_data_create_concat(allData, data); - dispatch_release(allDataPrev); -#else - allData = dispatch_data_create_concat(allData, data); -#endif - } - } - - if (done) { - if (error != 0) { - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], 0, 0, 0); - return; - } - - if (dataSize == 0) { - callback(nil, PTFrameTypeEndOfStream, 0, 0); - return; - } - - if (!allData || dispatch_data_get_size(allData) < sizeof(PTFrame)) { -#if PT_DISPATCH_RETAIN_RELEASE - if (allData) dispatch_release(allData); -#endif - callback([[NSError alloc] initWithDomain:Lookin_PTProtocolErrorDomain code:0 userInfo:nil], 0, 0, 0); - return; - } - - PTFrame *frame = NULL; - size_t size = 0; - - PT_PRECISE_LIFETIME dispatch_data_t contiguousData = dispatch_data_create_map(allData, (const void **)&frame, &size); // precise lifetime guarantees bytes in frame will stay valid till the end of scope -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(allData); -#endif - if (!contiguousData) { - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:ENOMEM userInfo:nil], 0, 0, 0); - return; - } - - frame->version = ntohl(frame->version); - if (frame->version != PTProtocolVersion1) { - callback([[NSError alloc] initWithDomain:Lookin_PTProtocolErrorDomain code:0 userInfo:nil], 0, 0, 0); - } else { - frame->type = ntohl(frame->type); - frame->tag = ntohl(frame->tag); - frame->payloadSize = ntohl(frame->payloadSize); - callback(nil, frame->type, frame->tag, frame->payloadSize); - } - -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(contiguousData); -#endif - } - }); -} - - -- (void)readPayloadOfSize:(size_t)payloadSize overChannel:(dispatch_io_t)channel callback:(void(^)(NSError *error, dispatch_data_t contiguousData, const uint8_t *buffer, size_t bufferSize))callback { - __block dispatch_data_t allData = NULL; - dispatch_io_read(channel, 0, payloadSize, queue_, ^(bool done, dispatch_data_t data, int error) { - //NSLog(@"dispatch_io_read: done=%d data=%p error=%d", done, data, error); - size_t dataSize = dispatch_data_get_size(data); - - if (dataSize) { - if (!allData) { - allData = data; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_retain(allData); -#endif - } else { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_data_t allDataPrev = allData; - allData = dispatch_data_create_concat(allData, data); - dispatch_release(allDataPrev); -#else - allData = dispatch_data_create_concat(allData, data); -#endif - } - } - - if (done) { - if (error != 0) { -#if PT_DISPATCH_RETAIN_RELEASE - if (allData) dispatch_release(allData); -#endif - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], NULL, NULL, 0); - return; - } - - if (dataSize == 0) { -#if PT_DISPATCH_RETAIN_RELEASE - if (allData) dispatch_release(allData); -#endif - callback(nil, NULL, NULL, 0); - return; - } - - uint8_t *buffer = NULL; - size_t bufferSize = 0; - PT_PRECISE_LIFETIME dispatch_data_t contiguousData = NULL; - - if (allData) { - contiguousData = dispatch_data_create_map(allData, (const void **)&buffer, &bufferSize); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(allData); allData = NULL; -#endif - if (!contiguousData) { - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:ENOMEM userInfo:nil], NULL, NULL, 0); - return; - } - } - - callback(nil, contiguousData, buffer, bufferSize); -#if PT_DISPATCH_RETAIN_RELEASE - if (contiguousData) dispatch_release(contiguousData); -#endif - } - }); -} - - -- (void)readAndDiscardDataOfSize:(size_t)size overChannel:(dispatch_io_t)channel callback:(void(^)(NSError*, BOOL))callback { - dispatch_io_read(channel, 0, size, queue_, ^(bool done, dispatch_data_t data, int error) { - if (done && callback) { - size_t dataSize = data ? dispatch_data_get_size(data) : 0; - callback(error == 0 ? nil : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], dataSize == 0); - } - }); -} - - -- (void)readFramesOverChannel:(dispatch_io_t)channel onFrame:(void(^)(NSError*, uint32_t, uint32_t, uint32_t, dispatch_block_t))onFrame { - [self readFrameOverChannel:channel callback:^(NSError *error, uint32_t type, uint32_t tag, uint32_t payloadSize) { - onFrame(error, type, tag, payloadSize, ^{ - if (type != PTFrameTypeEndOfStream) { - [self readFramesOverChannel:channel onFrame:onFrame]; - } - }); - }]; -} - - -@end - - -@interface Lookin_PTDispatchData : NSObject { - dispatch_data_t dispatchData_; -} -@end -@implementation Lookin_PTDispatchData -- (id)initWithDispatchData:(dispatch_data_t)dispatchData { - if (!(self = [super init])) return nil; - dispatchData_ = dispatchData; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_retain(dispatchData_); -#endif - return self; -} -- (void)dealloc { -#if PT_DISPATCH_RETAIN_RELEASE - if (dispatchData_) dispatch_release(dispatchData_); -#endif -} -@end - -@implementation NSData (Lookin_PTProtocol) - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-getter-return-value" - -- (dispatch_data_t)createReferencingDispatchData { - // Note: The queue is used to submit the destructor. Since we only perform an - // atomic release of self, it doesn't really matter which queue is used, thus - // we use the current calling queue. - return dispatch_data_create((const void*)self.bytes, self.length, dispatch_get_main_queue(), ^{ - // trick to have the block capture the data, thus retain/releasing - [self length]; - }); -} - -#pragma clang diagnostic pop - -+ (NSData *)dataWithContentsOfDispatchData:(dispatch_data_t)data { - if (!data) { - return nil; - } - uint8_t *buffer = NULL; - size_t bufferSize = 0; - PT_PRECISE_LIFETIME dispatch_data_t contiguousData = dispatch_data_create_map(data, (const void **)&buffer, &bufferSize); - if (!contiguousData) { - return nil; - } - - Lookin_PTDispatchData *dispatchDataRef = [[Lookin_PTDispatchData alloc] initWithDispatchData:contiguousData]; - NSData *newData = [NSData dataWithBytesNoCopy:(void*)buffer length:bufferSize freeWhenDone:NO]; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(contiguousData); -#endif - static const bool kDispatchDataRefKey; - objc_setAssociatedObject(newData, (const void*)kDispatchDataRefKey, dispatchDataRef, OBJC_ASSOCIATION_RETAIN); - - return newData; -} - -@end - - -@implementation NSDictionary (Lookin_PTProtocol) - -- (dispatch_data_t)createReferencingDispatchData { - NSError *error = nil; - NSData *plistData = [NSPropertyListSerialization dataWithPropertyList:self format:NSPropertyListBinaryFormat_v1_0 options:0 error:&error]; - if (!plistData) { - NSLog(@"Failed to serialize property list: %@", error); - return nil; - } else { - return [plistData createReferencingDispatchData]; - } -} - -// Decode *data* as a peroperty list-encoded dictionary. Returns nil on failure. -+ (NSDictionary*)dictionaryWithContentsOfDispatchData:(dispatch_data_t)data { - if (!data) { - return nil; - } - uint8_t *buffer = NULL; - size_t bufferSize = 0; - PT_PRECISE_LIFETIME dispatch_data_t contiguousData = dispatch_data_create_map(data, (const void **)&buffer, &bufferSize); - if (!contiguousData) { - return nil; - } - NSDictionary *dict = [NSPropertyListSerialization propertyListWithData:[NSData dataWithBytesNoCopy:(void*)buffer length:bufferSize freeWhenDone:NO] options:NSPropertyListImmutable format:NULL error:nil]; -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(contiguousData); -#endif - return dict; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Peertalk/Lookin_PTUSBHub.h b/Sources/LookinServer/Shared/Peertalk/Lookin_PTUSBHub.h deleted file mode 100644 index 30c97a7e..00000000 --- a/Sources/LookinServer/Shared/Peertalk/Lookin_PTUSBHub.h +++ /dev/null @@ -1,88 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - - - -#include -#import - -// Lookin_PTUSBDeviceDidAttachNotification -// Posted when a device has been attached. Also posted for each device that is -// already attached when the Lookin_PTUSBHub starts listening. -// -// .userInfo = { -// DeviceID = 3; -// MessageType = Attached; -// Properties = { -// ConnectionSpeed = 480000000; -// ConnectionType = USB; -// DeviceID = 3; -// LocationID = 1234567890; -// ProductID = 1234; -// SerialNumber = 0123456789abcdef0123456789abcdef01234567; -// }; -// } -// -FOUNDATION_EXPORT NSString * const Lookin_PTUSBDeviceDidAttachNotification; - -// Lookin_PTUSBDeviceDidDetachNotification -// Posted when a device has been detached. -// -// .userInfo = { -// DeviceID = 3; -// MessageType = Detached; -// } -// -FOUNDATION_EXPORT NSString * const Lookin_PTUSBDeviceDidDetachNotification; - -// NSError domain -FOUNDATION_EXPORT NSString * const Lookin_PTUSBHubErrorDomain; - -// Error codes returned with NSError.code for NSError domain Lookin_PTUSBHubErrorDomain -typedef enum { - PTUSBHubErrorBadDevice = 2, - PTUSBHubErrorConnectionRefused = 3, -} PTUSBHubError; - -@interface Lookin_PTUSBHub : NSObject - -// Shared, implicitly opened hub. -+ (Lookin_PTUSBHub*)sharedHub; - -// Connect to a TCP *port* on a device, while the actual transport is over USB. -// Upon success, *error* is nil and *channel* is a duplex I/O channel. -// You can retrieve the underlying file descriptor using -// dispatch_io_get_descriptor(channel). The dispatch_io_t channel behaves just -// like any stream type dispatch_io_t, making it possible to use the same logic -// for both USB bridged connections and e.g. ethernet-based connections. -// -// *onStart* is called either when a connection failed, in which case the error -// argument is non-nil, or when the connection was successfully established (the -// error argument is nil). Must not be NULL. -// -// *onEnd* is called when a connection was open and just did close. If the error -// argument is non-nil, the channel closed because of an error. Pass NULL for no -// callback. -// -- (void)connectToDevice:(NSNumber*)deviceID - port:(int)port - onStart:(void(^)(NSError *error, dispatch_io_t channel))onStart - onEnd:(void(^)(NSError *error))onEnd; - -// Start listening for devices. You only need to invoke this method on custom -// instances to start receiving notifications. The shared instance returned from -// +sharedHub is always in listening mode. -// -// *onStart* is called either when the system failed to start listening, in -// which case the error argument is non-nil, or when the receiver is listening. -// Pass NULL for no callback. -// -// *onEnd* is called when listening stopped. If the error argument is non-nil, -// listening stopped because of an error. Pass NULL for no callback. -// -- (void)listenOnQueue:(dispatch_queue_t)queue - onStart:(void(^)(NSError*))onStart - onEnd:(void(^)(NSError*))onEnd; - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Peertalk/Lookin_PTUSBHub.m b/Sources/LookinServer/Shared/Peertalk/Lookin_PTUSBHub.m deleted file mode 100644 index cde0dd76..00000000 --- a/Sources/LookinServer/Shared/Peertalk/Lookin_PTUSBHub.m +++ /dev/null @@ -1,674 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -#import "Lookin_PTUSBHub.h" - - - -#import "Lookin_PTPrivate.h" - -#include -#include -#include -#include -#include - -NSString * const Lookin_PTUSBHubErrorDomain = @"PTUSBHubError"; - -typedef uint32_t USBMuxPacketType; -enum { - USBMuxPacketTypeResult = 1, - USBMuxPacketTypeConnect = 2, - USBMuxPacketTypeListen = 3, - USBMuxPacketTypeDeviceAdd = 4, - USBMuxPacketTypeDeviceRemove = 5, - // ? = 6, - // ? = 7, - USBMuxPacketTypePlistPayload = 8, -}; - -typedef uint32_t USBMuxPacketProtocol; -enum { - USBMuxPacketProtocolBinary = 0, - USBMuxPacketProtocolPlist = 1, -}; - -typedef uint32_t USBMuxReplyCode; -enum { - USBMuxReplyCodeOK = 0, - USBMuxReplyCodeBadCommand = 1, - USBMuxReplyCodeBadDevice = 2, - USBMuxReplyCodeConnectionRefused = 3, - // ? = 4, - // ? = 5, - USBMuxReplyCodeBadVersion = 6, -}; - - -typedef struct usbmux_packet { - uint32_t size; - USBMuxPacketProtocol protocol; - USBMuxPacketType type; - uint32_t tag; - char data[0]; -} __attribute__((__packed__)) usbmux_packet_t; - -static const uint32_t kUsbmuxPacketMaxPayloadSize = UINT32_MAX - (uint32_t)sizeof(usbmux_packet_t); - - -static uint32_t usbmux_packet_payload_size(usbmux_packet_t *upacket) { - return upacket->size - sizeof(usbmux_packet_t); -} - - -static void *usbmux_packet_payload(usbmux_packet_t *upacket) { - return (void*)upacket->data; -} - - -static void usbmux_packet_set_payload(usbmux_packet_t *upacket, - const void *payload, - uint32_t payloadLength) -{ - memcpy(usbmux_packet_payload(upacket), payload, payloadLength); -} - - -static usbmux_packet_t *usbmux_packet_alloc(uint32_t payloadSize) { - assert(payloadSize <= kUsbmuxPacketMaxPayloadSize); - uint32_t upacketSize = sizeof(usbmux_packet_t) + payloadSize; - usbmux_packet_t *upacket = CFAllocatorAllocate(kCFAllocatorDefault, upacketSize, 0); - memset(upacket, 0, sizeof(usbmux_packet_t)); - upacket->size = upacketSize; - return upacket; -} - - -static usbmux_packet_t *usbmux_packet_create(USBMuxPacketProtocol protocol, - USBMuxPacketType type, - uint32_t tag, - const void *payload, - uint32_t payloadSize) -{ - usbmux_packet_t *upacket = usbmux_packet_alloc(payloadSize); - if (!upacket) { - return NULL; - } - - upacket->protocol = protocol; - upacket->type = type; - upacket->tag = tag; - - if (payload && payloadSize) { - usbmux_packet_set_payload(upacket, payload, (uint32_t)payloadSize); - } - - return upacket; -} - - -static void usbmux_packet_free(usbmux_packet_t *upacket) { - CFAllocatorDeallocate(kCFAllocatorDefault, upacket); -} - - -NSString * const Lookin_PTUSBDeviceDidAttachNotification = @"Lookin_PTUSBDeviceDidAttachNotification"; -NSString * const Lookin_PTUSBDeviceDidDetachNotification = @"Lookin_PTUSBDeviceDidDetachNotification"; - -static NSString *kPlistPacketTypeListen = @"Listen"; -static NSString *kPlistPacketTypeConnect = @"Connect"; - - -// Represents a channel of communication between the host process and a remote -// (device) system. In practice, a Lookin_PTUSBChannel is connected to a usbmuxd -// endpoint which is configured to either listen for device changes (the -// PTUSBHub's channel is usually configured as a device notification listener) or -// configured as a TCP bridge (e.g. channels returned from PTUSBHub's -// connectToDevice:port:callback:). You should not create channels yourself, but -// let Lookin_PTUSBHub provide you with already configured channels. -@interface Lookin_PTUSBChannel : NSObject { - dispatch_io_t channel_; - dispatch_queue_t queue_; - uint32_t nextPacketTag_; - NSMutableDictionary *responseQueue_; - BOOL autoReadPackets_; - BOOL isReadingPackets_; -} - -// The underlying dispatch I/O channel. This is handy if you want to handle your -// own I/O logic without Lookin_PTUSBChannel. Remember to dispatch_retain() the channel -// if you plan on using it as it might be released from the Lookin_PTUSBChannel at any -// point in time. -@property (readonly) dispatch_io_t dispatchChannel; - -// The underlying file descriptor. -@property (readonly) dispatch_fd_t fileDescriptor; - -// Send data -- (void)sendDispatchData:(dispatch_data_t)data callback:(void(^)(NSError*))callback; -- (void)sendData:(NSData*)data callback:(void(^)(NSError*))callback; - -// Read data -- (void)readFromOffset:(off_t)offset length:(size_t)length callback:(void(^)(NSError *error, dispatch_data_t data))callback; - -// Close the channel, preventing further reads and writes, but letting currently -// queued reads and writes finish. -- (void)cancel; - -// Close the channel, preventing further reads and writes, immediately -// terminating any ongoing reads and writes. -- (void)stop; - -@end - - -@interface Lookin_PTUSBChannel (Private) - -+ (NSDictionary*)packetDictionaryWithPacketType:(NSString*)messageType payload:(NSDictionary*)payload; -- (BOOL)openOnQueue:(dispatch_queue_t)queue error:(NSError**)error onEnd:(void(^)(NSError *error))onEnd; -- (void)listenWithBroadcastHandler:(void(^)(NSDictionary *packet))broadcastHandler callback:(void(^)(NSError*))callback; -- (BOOL)errorFromPlistResponse:(NSDictionary*)packet error:(NSError**)error; -- (uint32_t)nextPacketTag; -- (void)sendPacketOfType:(USBMuxPacketType)type overProtocol:(USBMuxPacketProtocol)protocol tag:(uint32_t)tag payload:(NSData*)payload callback:(void(^)(NSError*))callback; -- (void)sendPacket:(NSDictionary*)packet tag:(uint32_t)tag callback:(void(^)(NSError *error))callback; -- (void)sendRequest:(NSDictionary*)packet callback:(void(^)(NSError *error, NSDictionary *responsePacket))callback; -- (void)scheduleReadPacketWithCallback:(void(^)(NSError *error, NSDictionary *packet, uint32_t packetTag))callback; -- (void)scheduleReadPacketWithBroadcastHandler:(void(^)(NSDictionary *packet))broadcastHandler; -- (void)setNeedsReadingPacket; -@end - - -@interface Lookin_PTUSBHub () { - Lookin_PTUSBChannel *channel_; -} -- (void)handleBroadcastPacket:(NSDictionary*)packet; -@end - - -@implementation Lookin_PTUSBHub - - -+ (Lookin_PTUSBHub*)sharedHub { - static Lookin_PTUSBHub *gSharedHub; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - gSharedHub = [Lookin_PTUSBHub new]; - [gSharedHub listenOnQueue:dispatch_get_main_queue() onStart:^(NSError *error) { - if (error) { - NSLog(@"Lookin_PTUSBHub failed to initialize: %@", error); - } - } onEnd:nil]; - }); - return gSharedHub; -} - - -- (id)init { - if (!(self = [super init])) return nil; - - return self; -} - - -- (void)listenOnQueue:(dispatch_queue_t)queue onStart:(void(^)(NSError*))onStart onEnd:(void(^)(NSError*))onEnd { - if (channel_) { - if (onStart) onStart(nil); - return; - } - channel_ = [Lookin_PTUSBChannel new]; - NSError *error = nil; - if ([channel_ openOnQueue:queue error:&error onEnd:onEnd]) { - [channel_ listenWithBroadcastHandler:^(NSDictionary *packet) { [self handleBroadcastPacket:packet]; } callback:onStart]; - } else if (onStart) { - onStart(error); - } -} - - -- (void)connectToDevice:(NSNumber*)deviceID port:(int)port onStart:(void(^)(NSError*, dispatch_io_t))onStart onEnd:(void(^)(NSError*))onEnd { - Lookin_PTUSBChannel *channel = [Lookin_PTUSBChannel new]; - NSError *error = nil; - - if (![channel openOnQueue:dispatch_get_main_queue() error:&error onEnd:onEnd]) { - onStart(error, nil); - return; - } - - port = ((port<<8) & 0xFF00) | (port>>8); // limit - - NSDictionary *packet = [Lookin_PTUSBChannel packetDictionaryWithPacketType:kPlistPacketTypeConnect - payload:[NSDictionary dictionaryWithObjectsAndKeys: - deviceID, @"DeviceID", - [NSNumber numberWithInt:port], @"PortNumber", - nil]]; - - [channel sendRequest:packet callback:^(NSError *error_, NSDictionary *responsePacket) { - NSError *error = error_; - [channel errorFromPlistResponse:responsePacket error:&error]; - onStart(error, (error ? nil : channel.dispatchChannel) ); - }]; -} - - -- (void)handleBroadcastPacket:(NSDictionary*)packet { - NSString *messageType = [packet objectForKey:@"MessageType"]; - - if ([@"Attached" isEqualToString:messageType]) { - [[NSNotificationCenter defaultCenter] postNotificationName:Lookin_PTUSBDeviceDidAttachNotification object:self userInfo:packet]; - } else if ([@"Detached" isEqualToString:messageType]) { - [[NSNotificationCenter defaultCenter] postNotificationName:Lookin_PTUSBDeviceDidDetachNotification object:self userInfo:packet]; - } else { - NSLog(@"Warning: Unhandled broadcast message: %@", packet); - } -} - - -@end - -#pragma mark - - -@implementation Lookin_PTUSBChannel - -+ (NSDictionary*)packetDictionaryWithPacketType:(NSString*)messageType payload:(NSDictionary*)payload { - NSDictionary *packet = nil; - - static NSString *bundleName = nil; - static NSString *bundleVersion = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - NSDictionary *infoDict = [NSBundle mainBundle].infoDictionary; - if (infoDict) { - bundleName = [infoDict objectForKey:@"CFBundleName"]; - bundleVersion = [[infoDict objectForKey:@"CFBundleVersion"] description]; - } - }); - - if (bundleName) { - packet = [NSDictionary dictionaryWithObjectsAndKeys: - messageType, @"MessageType", - bundleName, @"ProgName", - bundleVersion, @"ClientVersionString", - nil]; - } else { - packet = [NSDictionary dictionaryWithObjectsAndKeys:messageType, @"MessageType", nil]; - } - - if (payload) { - NSMutableDictionary *mpacket = [NSMutableDictionary dictionaryWithDictionary:payload]; - [mpacket addEntriesFromDictionary:packet]; - packet = mpacket; - } - - return packet; -} - - -- (id)init { - if (!(self = [super init])) return nil; - - return self; -} - - -- (void)dealloc { - //NSLog(@"dealloc %@", self); - if (channel_) { -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(channel_); -#endif - channel_ = nil; - } -} - - -- (BOOL)valid { - return !!channel_; -} - - -- (dispatch_io_t)dispatchChannel { - return channel_; -} - - -- (dispatch_fd_t)fileDescriptor { - return dispatch_io_get_descriptor(channel_); -} - - -- (BOOL)openOnQueue:(dispatch_queue_t)queue error:(NSError**)error onEnd:(void(^)(NSError*))onEnd { - assert(queue != nil); - assert(channel_ == nil); - queue_ = queue; - - // Create socket - dispatch_fd_t fd = socket(AF_UNIX, SOCK_STREAM, 0); - if (fd == -1) { - if (error) *error = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]; - return NO; - } - - // prevent SIGPIPE - int on = 1; - setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on)); - - // Connect socket - struct sockaddr_un addr; - addr.sun_family = AF_UNIX; - strcpy(addr.sun_path, "/var/run/usbmuxd"); - socklen_t socklen = sizeof(addr); - if (connect(fd, (struct sockaddr*)&addr, socklen) == -1) { - if (error) *error = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:errno userInfo:nil]; - return NO; - } - - channel_ = dispatch_io_create(DISPATCH_IO_STREAM, fd, queue_, ^(int error) { - close(fd); - if (onEnd) { - onEnd(error == 0 ? nil : [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil]); - } - }); - - return YES; -} - - -- (void)listenWithBroadcastHandler:(void(^)(NSDictionary *packet))broadcastHandler callback:(void(^)(NSError*))callback { - autoReadPackets_ = YES; - [self scheduleReadPacketWithBroadcastHandler:broadcastHandler]; - - NSDictionary *packet = [Lookin_PTUSBChannel packetDictionaryWithPacketType:kPlistPacketTypeListen payload:nil]; - - [self sendRequest:packet callback:^(NSError *error_, NSDictionary *responsePacket) { - if (!callback) - return; - - NSError *error = error_; - [self errorFromPlistResponse:responsePacket error:&error]; - - callback(error); - }]; -} - - -- (BOOL)errorFromPlistResponse:(NSDictionary*)packet error:(NSError**)error { - if (!*error) { - NSNumber *n = [packet objectForKey:@"Number"]; - - if (!n) { - *error = [NSError errorWithDomain:Lookin_PTUSBHubErrorDomain code:(n ? n.integerValue : 0) userInfo:nil]; - return NO; - } - - USBMuxReplyCode replyCode = (USBMuxReplyCode)n.integerValue; - if (replyCode != 0) { - NSString *errmessage = @"Unspecified error"; - switch (replyCode) { - case USBMuxReplyCodeBadCommand: errmessage = @"illegal command"; break; - case USBMuxReplyCodeBadDevice: errmessage = @"unknown device"; break; - case USBMuxReplyCodeConnectionRefused: errmessage = @"connection refused"; break; - case USBMuxReplyCodeBadVersion: errmessage = @"invalid version"; break; - default: break; - } - *error = [NSError errorWithDomain:Lookin_PTUSBHubErrorDomain code:replyCode userInfo:[NSDictionary dictionaryWithObject:errmessage forKey:NSLocalizedDescriptionKey]]; - return NO; - } - } - return YES; -} - - -- (uint32_t)nextPacketTag { - return ++nextPacketTag_; -} - - -- (void)sendRequest:(NSDictionary*)packet callback:(void(^)(NSError*, NSDictionary*))callback { - uint32_t tag = [self nextPacketTag]; - [self sendPacket:packet tag:tag callback:^(NSError *error) { - if (error) { - callback(error, nil); - return; - } - // TODO: timeout un-triggered callbacks in responseQueue_ - if (!self->responseQueue_) self->responseQueue_ = [NSMutableDictionary new]; - [self->responseQueue_ setObject:callback forKey:[NSNumber numberWithUnsignedInt:tag]]; - }]; - - // We are awaiting a response - [self setNeedsReadingPacket]; -} - - -- (void)setNeedsReadingPacket { - if (!isReadingPackets_) { - [self scheduleReadPacketWithBroadcastHandler:nil]; - } -} - - -- (void)scheduleReadPacketWithBroadcastHandler:(void(^)(NSDictionary *packet))broadcastHandler { - assert(isReadingPackets_ == NO); - - [self scheduleReadPacketWithCallback:^(NSError *error, NSDictionary *packet, uint32_t packetTag) { - // Interpret the package we just received - if (packetTag == 0) { - // Broadcast message - //NSLog(@"Received broadcast: %@", packet); - if (broadcastHandler) broadcastHandler(packet); - } else if (self->responseQueue_) { - // Reply - NSNumber *key = [NSNumber numberWithUnsignedInt:packetTag]; - void(^requestCallback)(NSError*,NSDictionary*) = [self->responseQueue_ objectForKey:key]; - if (requestCallback) { - [self->responseQueue_ removeObjectForKey:key]; - requestCallback(error, packet); - } else { - NSLog(@"Warning: Ignoring reply packet for which there is no registered callback. Packet => %@", packet); - } - } - - // Schedule reading another incoming package - if (self->autoReadPackets_) { - [self scheduleReadPacketWithBroadcastHandler:broadcastHandler]; - } - }]; -} - - -- (void)scheduleReadPacketWithCallback:(void(^)(NSError*, NSDictionary*, uint32_t))callback { - static usbmux_packet_t ref_upacket; - isReadingPackets_ = YES; - - // Read the first `sizeof(ref_upacket.size)` bytes off the channel_ - dispatch_io_read(channel_, 0, sizeof(ref_upacket.size), queue_, ^(bool done, dispatch_data_t data, int error) { - //NSLog(@"dispatch_io_read 0,4: done=%d data=%p error=%d", done, data, error); - - if (!done) - return; - - if (error) { - self->isReadingPackets_ = NO; - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], nil, 0); - return; - } - - // Read size of incoming usbmux_packet_t - uint32_t upacket_len = 0; - char *buffer = NULL; - size_t buffer_size = 0; - PT_PRECISE_LIFETIME_UNUSED dispatch_data_t map_data = dispatch_data_create_map(data, (const void **)&buffer, &buffer_size); // objc_precise_lifetime guarantees 'map_data' isn't released before memcpy has a chance to do its thing - assert(buffer_size == sizeof(ref_upacket.size)); - assert(sizeof(upacket_len) == sizeof(ref_upacket.size)); - memcpy((void *)&(upacket_len), (const void *)buffer, buffer_size); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(map_data); -#endif - - // Allocate a new usbmux_packet_t for the expected size - uint32_t payloadLength = upacket_len - (uint32_t)sizeof(usbmux_packet_t); - usbmux_packet_t *upacket = usbmux_packet_alloc(payloadLength); - - // Read rest of the incoming usbmux_packet_t - off_t offset = sizeof(ref_upacket.size); - dispatch_io_read(self->channel_, offset, (size_t)(upacket->size - offset), self->queue_, ^(bool done, dispatch_data_t data, int error) { - //NSLog(@"dispatch_io_read X,Y: done=%d data=%p error=%d", done, data, error); - - if (!done) { - return; - } - - self->isReadingPackets_ = NO; - - if (error) { - callback([[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:error userInfo:nil], nil, 0); - usbmux_packet_free(upacket); - return; - } - - if (upacket_len > kUsbmuxPacketMaxPayloadSize) { - callback( - [[NSError alloc] initWithDomain:Lookin_PTUSBHubErrorDomain code:1 userInfo:@{ - NSLocalizedDescriptionKey:@"Received a packet that is too large"}], - nil, - 0 - ); - usbmux_packet_free(upacket); - return; - } - - // Copy read bytes onto our usbmux_packet_t - char *buffer = NULL; - size_t buffer_size = 0; - PT_PRECISE_LIFETIME_UNUSED dispatch_data_t map_data = dispatch_data_create_map(data, (const void **)&buffer, &buffer_size); - assert(buffer_size == upacket->size - offset); - memcpy(((void *)(upacket))+offset, (const void *)buffer, buffer_size); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(map_data); -#endif - - // We only support plist protocol - if (upacket->protocol != USBMuxPacketProtocolPlist) { - callback([[NSError alloc] initWithDomain:Lookin_PTUSBHubErrorDomain code:0 userInfo:[NSDictionary dictionaryWithObject:@"Unexpected package protocol" forKey:NSLocalizedDescriptionKey]], nil, upacket->tag); - usbmux_packet_free(upacket); - return; - } - - // Only one type of packet in the plist protocol - if (upacket->type != USBMuxPacketTypePlistPayload) { - callback([[NSError alloc] initWithDomain:Lookin_PTUSBHubErrorDomain code:0 userInfo:[NSDictionary dictionaryWithObject:@"Unexpected package type" forKey:NSLocalizedDescriptionKey]], nil, upacket->tag); - usbmux_packet_free(upacket); - return; - } - - // Try to decode any payload as plist - NSError *err = nil; - NSDictionary *dict = nil; - if (usbmux_packet_payload_size(upacket)) { - dict = [NSPropertyListSerialization propertyListWithData:[NSData dataWithBytesNoCopy:usbmux_packet_payload(upacket) length:usbmux_packet_payload_size(upacket) freeWhenDone:NO] options:NSPropertyListImmutable format:NULL error:&err]; - } - - // Invoke callback - callback(err, dict, upacket->tag); - usbmux_packet_free(upacket); - }); - }); -} - - -- (void)sendPacketOfType:(USBMuxPacketType)type - overProtocol:(USBMuxPacketProtocol)protocol - tag:(uint32_t)tag - payload:(NSData*)payload - callback:(void(^)(NSError*))callback -{ - assert(payload.length <= kUsbmuxPacketMaxPayloadSize); - usbmux_packet_t *upacket = usbmux_packet_create( - protocol, - type, - tag, - payload ? payload.bytes : nil, - (uint32_t)(payload ? payload.length : 0) - ); - dispatch_data_t data = dispatch_data_create((const void*)upacket, upacket->size, queue_, ^{ - // Free packet when data is freed - usbmux_packet_free(upacket); - }); - //NSData *data1 = [NSData dataWithBytesNoCopy:(void*)upacket length:upacket->size freeWhenDone:NO]; - //[data1 writeToFile:[NSString stringWithFormat:@"/Users/rsms/c-packet-%u.data", tag] atomically:NO]; - [self sendDispatchData:data callback:callback]; -} - - -- (void)sendPacket:(NSDictionary*)packet tag:(uint32_t)tag callback:(void(^)(NSError*))callback { - NSError *error = nil; - // NSPropertyListBinaryFormat_v1_0 - NSData *plistData = [NSPropertyListSerialization dataWithPropertyList:packet format:NSPropertyListXMLFormat_v1_0 options:0 error:&error]; - if (!plistData) { - callback(error); - } else { - [self sendPacketOfType:USBMuxPacketTypePlistPayload overProtocol:USBMuxPacketProtocolPlist tag:tag payload:plistData callback:callback]; - } -} - - -- (void)sendDispatchData:(dispatch_data_t)data callback:(void(^)(NSError*))callback { - off_t offset = 0; - dispatch_io_write(channel_, offset, data, queue_, ^(bool done, dispatch_data_t data, int _errno) { - //NSLog(@"dispatch_io_write: done=%d data=%p error=%d", done, data, error); - if (!done) - return; - if (callback) { - NSError *err = nil; - if (_errno) err = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:_errno userInfo:nil]; - callback(err); - } - }); -#if PT_DISPATCH_RETAIN_RELEASE - dispatch_release(data); // Release our ref. A ref is still held by dispatch_io_write -#endif -} - -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunused-getter-return-value" - -- (void)sendData:(NSData*)data callback:(void(^)(NSError*))callback { - dispatch_data_t ddata = dispatch_data_create((const void*)data.bytes, data.length, queue_, ^{ - // trick to have the block capture and retain the data - [data length]; - }); - [self sendDispatchData:ddata callback:callback]; -} - -#pragma clang diagnostic pop - -- (void)readFromOffset:(off_t)offset length:(size_t)length callback:(void(^)(NSError *error, dispatch_data_t data))callback { - dispatch_io_read(channel_, offset, length, queue_, ^(bool done, dispatch_data_t data, int _errno) { - if (!done) - return; - - NSError *error = nil; - if (_errno != 0) { - error = [[NSError alloc] initWithDomain:NSPOSIXErrorDomain code:_errno userInfo:nil]; - } - - callback(error, data); - }); -} - - -- (void)cancel { - if (channel_) { - dispatch_io_close(channel_, 0); - } -} - - -- (void)stop { - if (channel_) { - dispatch_io_close(channel_, DISPATCH_IO_STOP); - } -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/Shared/Peertalk/Peertalk.h b/Sources/LookinServer/Shared/Peertalk/Peertalk.h deleted file mode 100644 index 82493292..00000000 --- a/Sources/LookinServer/Shared/Peertalk/Peertalk.h +++ /dev/null @@ -1,28 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// Peertalk.h -// Peertalk -// -// Created by Marek Cirkos on 12/04/2016. -// -// - - - -#import - -//! Project version number for Peertalk. -FOUNDATION_EXPORT double PeertalkVersionNumber; - -//! Project version string for Peertalk. -FOUNDATION_EXPORT const unsigned char PeertalkVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - -#import "Lookin_PTChannel.h" -#import "Lookin_PTProtocol.h" -#import "Lookin_PTUSBHub.h" - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServer/include/LookinServer.h b/Sources/LookinServer/include/LookinServer.h deleted file mode 100644 index 0d0f18e4..00000000 --- a/Sources/LookinServer/include/LookinServer.h +++ /dev/null @@ -1,7 +0,0 @@ -#ifndef LookinServer_h -#define LookinServer_h - -// LookinServer 模块的最小公开 umbrella header。 -// 内部头文件通过 Package.swift 中的 headerSearchPath 设置解析。 - -#endif /* LookinServer_h */ diff --git a/Sources/LookinServerBase/LookInsideServerLogger.m b/Sources/LookinServerBase/LookInsideServerLogger.m index 17135dee..2e490ec4 100644 --- a/Sources/LookinServerBase/LookInsideServerLogger.m +++ b/Sources/LookinServerBase/LookInsideServerLogger.m @@ -1,72 +1,16 @@ #ifdef SHOULD_COMPILE_LOOKIN_SERVER +// +// LookInsideServerLogger.m +// LookinServerBase +// +// The exported C constant only: Swift cannot define a C global. The +// LookInsideServerLogger class is implemented in Swift +// (LookinCoreImpl/LookInsideServerLogger.swift). +// + #import "LookInsideServerLogger.h" NSString *const LookInsideServerLogLevelEnvironmentKey = @"LOOKINSIDE_SERVER_LOG_LEVEL"; -static LookInsideServerLogLevel LookInsideServerLogLevelFromEnvironment(void) { - NSString *rawValue = NSProcessInfo.processInfo.environment[LookInsideServerLogLevelEnvironmentKey]; - if (rawValue.length == 0) { - return LookInsideServerLogLevelDefault; - } - - NSString *value = [rawValue stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; - if ([value isEqualToString:@"0"]) { - return LookInsideServerLogLevelDebug; - } - if ([value isEqualToString:@"1"]) { - return LookInsideServerLogLevelInfo; - } - if ([value isEqualToString:@"2"]) { - return LookInsideServerLogLevelDefault; - } - if ([value isEqualToString:@"3"]) { - return LookInsideServerLogLevelWarning; - } - if ([value isEqualToString:@"4"]) { - return LookInsideServerLogLevelError; - } - return LookInsideServerLogLevelDefault; -} - -static NSString *LookInsideServerLogLevelName(LookInsideServerLogLevel level) { - switch (level) { - case LookInsideServerLogLevelDebug: - return @"debug"; - case LookInsideServerLogLevelInfo: - return @"info"; - case LookInsideServerLogLevelDefault: - return @"default"; - case LookInsideServerLogLevelWarning: - return @"warning"; - case LookInsideServerLogLevelError: - return @"error"; - } -} - -@implementation LookInsideServerLogger - -+ (LookInsideServerLogLevel)minimumLogLevel { - static LookInsideServerLogLevel level; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - level = LookInsideServerLogLevelFromEnvironment(); - }); - return level; -} - -+ (BOOL)shouldLogAtLevel:(LookInsideServerLogLevel)level { - LookInsideServerLogLevel minimumLevel = self.minimumLogLevel; - return level >= minimumLevel; -} - -+ (void)logWithLevel:(LookInsideServerLogLevel)level message:(NSString *)message { - if (![self shouldLogAtLevel:level]) { - return; - } - NSLog(@"LookinServer [%@] - %@", LookInsideServerLogLevelName(level), message); -} - -@end - #endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServerBase/LookinIvarTrace.h b/Sources/LookinServerBase/LookinIvarTrace.h index 75388e12..c25108f4 100644 --- a/Sources/LookinServerBase/LookinIvarTrace.h +++ b/Sources/LookinServerBase/LookinIvarTrace.h @@ -1,4 +1,7 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER +// `__swift__`: Swift targets import this header through the LookinServerBase +// Clang module, which SwiftPM builds for them without the C define, so the +// declaration must not depend on it there. +#if defined(SHOULD_COMPILE_LOOKIN_SERVER) || defined(__swift__) // // LookinIvarTrace.h @@ -7,32 +10,14 @@ // Created by Li Kai on 2019/4/30. // https://lookin.work // +// The LookinIvarTrace class is plain Swift (LookinCoreImpl/LookinIvarTrace.swift, +// `@objc(LookinIvarTrace)`); `-[NSObject lks_ivarTraces]` is a Swift `@objc` +// extension member in LookinServerImpl. Only the exported C constant stays +// here (defined in LookinIvarTraceConstants.m). +// #import extern NSString *const LookinIvarTraceRelationValue_Self; -/// 如果 hostClassName 和 ivarName 均 equal,则认为两个 LookinIvarTrace 对象彼此 equal -/// 比如 A 是 B 的 superview,且 A 的 "_stageView" 指向 B,则 B 会有一个 LookinIvarTrace:hostType 为 “superview”,hostClassName 为 A 的 class,ivarName 为 “_stageView” -@interface LookinIvarTrace : NSObject - -/// 该值可能是 "superview"、"superlayer"、“self” 或 nil -@property(nonatomic, copy) NSString *relation; - -@property(nonatomic, copy) NSString *hostClassName; - -@property(nonatomic, copy) NSString *ivarName; - -#pragma mark - No Coding - -@property(nonatomic, weak) id hostObject; - -@end - -@interface NSObject (LookinServerTrace) - -@property(nonatomic, copy) NSArray *lks_ivarTraces; - -@end - #endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServerBase/LookinIvarTrace.m b/Sources/LookinServerBase/LookinIvarTrace.m deleted file mode 100644 index e272d474..00000000 --- a/Sources/LookinServerBase/LookinIvarTrace.m +++ /dev/null @@ -1,70 +0,0 @@ -#ifdef SHOULD_COMPILE_LOOKIN_SERVER - -// -// LookinIvarTrace.m -// Lookin -// -// Created by Li Kai on 2019/4/30. -// https://lookin.work -// - -#import "LookinIvarTrace.h" - -NSString *const LookinIvarTraceRelationValue_Self = @"self"; - -@implementation LookinIvarTrace - -#pragma mark - Equal - -- (NSUInteger)hash { - return self.hostClassName.hash ^ self.ivarName.hash; -} - -- (BOOL)isEqual:(id)object { - if (self == object) { - return YES; - } - if (![object isKindOfClass:[LookinIvarTrace class]]) { - return NO; - } - LookinIvarTrace *comparedObj = object; - if ([self.hostClassName isEqualToString:comparedObj.hostClassName] && [self.ivarName isEqualToString:comparedObj.ivarName]) { - return YES; - } - return NO; -} - -#pragma mark - - -- (id)copyWithZone:(NSZone *)zone { - LookinIvarTrace *newTrace = [[LookinIvarTrace allocWithZone:zone] init]; - newTrace.relation = self.relation; - newTrace.hostClassName = self.hostClassName; - newTrace.ivarName = self.ivarName; - return newTrace; -} - -#pragma mark - - -- (void)encodeWithCoder:(NSCoder *)aCoder { - [aCoder encodeObject:self.relation forKey:@"relation"]; - [aCoder encodeObject:self.hostClassName forKey:@"hostClassName"]; - [aCoder encodeObject:self.ivarName forKey:@"ivarName"]; -} - -- (instancetype)initWithCoder:(NSCoder *)aDecoder { - if (self = [super init]) { - self.relation = [aDecoder decodeObjectForKey:@"relation"]; - self.hostClassName = [aDecoder decodeObjectForKey:@"hostClassName"]; - self.ivarName = [aDecoder decodeObjectForKey:@"ivarName"]; - } - return self; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServerBase/LookinIvarTraceConstants.m b/Sources/LookinServerBase/LookinIvarTraceConstants.m new file mode 100644 index 00000000..2b44e310 --- /dev/null +++ b/Sources/LookinServerBase/LookinIvarTraceConstants.m @@ -0,0 +1,16 @@ +#ifdef SHOULD_COMPILE_LOOKIN_SERVER + +// +// LookinIvarTraceConstants.m +// Lookin +// +// The exported string constant declared in LookinIvarTrace.h. The class is +// implemented in Swift (LookinCoreImpl/LookinIvarTrace.swift); Swift cannot +// define a C global, so the constant stays here. +// + +#import "LookinIvarTrace.h" + +NSString *const LookinIvarTraceRelationValue_Self = @"self"; + +#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Sources/LookinServerBase/LookinServerBase.h b/Sources/LookinServerBase/LookinServerBase.h new file mode 100644 index 00000000..3feca237 --- /dev/null +++ b/Sources/LookinServerBase/LookinServerBase.h @@ -0,0 +1,13 @@ +// Umbrella header of the LookinServerBase module. +// +// SwiftPM does not pass a target's C defines to Swift's Clang importer, so the +// guard LookinIvarTrace.h is wrapped in is defined here first; without it a +// Swift target that imports LookinServerBase without C settings of its own +// (LookinCoreImpl, which implements LookinIvarTrace) sees an empty module. + +#ifndef SHOULD_COMPILE_LOOKIN_SERVER +#define SHOULD_COMPILE_LOOKIN_SERVER 1 +#endif + +#import "LookinIvarTrace.h" +#import "LookInsideServerLogger.h" diff --git a/Sources/LookinServerInjected/LookinServerInjected.m b/Sources/LookinServerInjected/LookinServerInjected.m deleted file mode 100644 index 1332bdb6..00000000 --- a/Sources/LookinServerInjected/LookinServerInjected.m +++ /dev/null @@ -1,48 +0,0 @@ -#import -#import -#import - -#if TARGET_OS_MAC && !TARGET_OS_IPHONE -#import -#else -#import -#endif - -extern void LookinServerStart(void); - -static void LKStartLookinServerIfNeeded(void) { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - LookinServerStart(); - }); -} - -static void LKRegisterLaunchObserverIfNeeded(void) { -#if TARGET_OS_MAC && !TARGET_OS_IPHONE - [[NSNotificationCenter defaultCenter] addObserverForName:NSApplicationDidFinishLaunchingNotification - object:nil - queue:[NSOperationQueue mainQueue] - usingBlock:^(__unused NSNotification *note) { - LKStartLookinServerIfNeeded(); - }]; -#else - [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification - object:nil - queue:[NSOperationQueue mainQueue] - usingBlock:^(__unused NSNotification *note) { - LKStartLookinServerIfNeeded(); - }]; -#endif - - // If the launch notification has already fired before injection, retry once on the main runloop. - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - LKStartLookinServerIfNeeded(); - }); -} - -__attribute__((constructor)) -static void LKInjectedBootstrap(void) { - dispatch_async(dispatch_get_main_queue(), ^{ - LKRegisterLaunchObserverIfNeeded(); - }); -} diff --git a/Sources/LookinServerSwift/LKS_SwiftTraceManager.swift b/Sources/LookinServerSwift/LKS_SwiftTraceManager.swift deleted file mode 100644 index 73b7db5c..00000000 --- a/Sources/LookinServerSwift/LKS_SwiftTraceManager.swift +++ /dev/null @@ -1,117 +0,0 @@ -#if SHOULD_COMPILE_LOOKIN_SERVER - -// - // LKS_TraceManager+Extension.swift - // LookinServer -// - // Created by Shida Zhu on 2022/8/21. -// - - import Foundation - #if canImport(AppKit) && !targetEnvironment(macCatalyst) - import AppKit - - private typealias LookinView = NSView - private typealias LookinViewController = NSViewController - private typealias LookinGestureRecognizer = NSGestureRecognizer - #endif - - #if canImport(UIKit) - import UIKit - - private typealias LookinView = UIView - private typealias LookinViewController = UIViewController - private typealias LookinGestureRecognizer = UIGestureRecognizer - #endif - #if SPM_LOOKIN_SERVER_ENABLED - import LookinServerBase - #endif - - public class LKS_SwiftTraceManager: NSObject { - @objc public static func swiftMarkIVars(ofObject hostObject: AnyObject) { - var mirror: Mirror? = Mirror(reflecting: hostObject) - var currClass: AnyClass? = type(of: hostObject) - let initialInClass: AnyClass? = currClass - - while let m = mirror, let unwrappedCurrClass = currClass { - for child in m.children { - if let child = child as? (label: String?, value: NSObject) { - let label: String? = child.label?.replacingOccurrences(of: "$__lazy_storage_$_", with: "") - let value = child.value - - guard (value is LookinView) || (value is CALayer) || (value is LookinViewController) || (value is LookinGestureRecognizer) else { - continue - } - - guard let label, label.count > 0 else { - continue - } - - let ivarTrace = LookinIvarTrace() - ivarTrace.hostObject = hostObject - - ivarTrace.hostClassName = makeDisplayClassName(superClass: unwrappedCurrClass, childClass: initialInClass) - - ivarTrace.ivarName = label - - if value === hostObject { - ivarTrace.relation = LookinIvarTraceRelationValue_Self - } else if let hostView = hostObject as? LookinView { - var ivarLayer: CALayer? = nil - if let layer = value as? CALayer { - ivarLayer = layer - } else if let view = value as? LookinView { - ivarLayer = view.layer - } - if let layer = ivarLayer, layer.superlayer === hostView.layer { - ivarTrace.relation = "superview" - } - } - value.lks_ivarTraces = (value.lks_ivarTraces ?? []) + [ivarTrace] - } - } - mirror = m.superclassMirror - currClass = unwrappedCurrClass.superclass() - } - } - - /// 比如 superClass 可能是 UIView,而 childClass 可能是 UIButton - private static func makeDisplayClassName(superClass: AnyClass, childClass: AnyClass?) -> String { - let superName = NSStringFromClass(superClass) - - guard let childClass else { - return superName - } - let childName = NSStringFromClass(childClass) - if superName == childName { - return superName - } - let superModule = queryModuleName(classname: superName) - let childModule = queryModuleName(classname: childName) - if superModule != nil, superModule == childModule { - let shortSuperName = queryShortName(classname: superName) - // ( UIKit.UIButton : UIView *) - return "\(childName) : \(shortSuperName)" - } - return "\(childName) : \(superName)" - } - - private static func queryModuleName(classname: String) -> String? { - let parts = classname.components(separatedBy: ".") - if parts.count != 2 { - return nil - } - return parts[0] - } - - /// 不包含 module name - private static func queryShortName(classname: String) -> String { - let parts = classname.components(separatedBy: ".") - if parts.count != 2 { - return classname - } - return parts[1] - } - } - -#endif /* SHOULD_COMPILE_LOOKIN_SERVER */ diff --git a/Tests/Dashboard/Fixtures/enum-list-registry.json b/Tests/Dashboard/Fixtures/enum-list-registry.json new file mode 100644 index 00000000..50bb975d --- /dev/null +++ b/Tests/Dashboard/Fixtures/enum-list-registry.json @@ -0,0 +1,88 @@ +{ + "NSBackgroundStyle": [["NSBackgroundStyleNormal", 0, 0], ["NSBackgroundStyleEmphasized", 1, 0], ["NSBackgroundStyleRaised", 2, 0], ["NSBackgroundStyleLowered", 3, 0]], + "NSBezelStyle": [["NSBezelStyleAutomatic", 0, 0], ["NSBezelStylePush", 1, 0], ["NSBezelStyleFlexiblePush", 2, 0], ["NSBezelStyleDisclosure", 5, 0], ["NSBezelStyleShadowlessSquare", 6, 0], ["NSBezelStyleCircular", 7, 0], ["NSBezelStyleTexturedSquare", 8, 0], ["NSBezelStyleHelpButton", 9, 0], ["NSBezelStyleSmallSquare", 10, 0], ["NSBezelStyleToolbar", 11, 0], ["NSBezelStyleAccessoryBarAction", 12, 0], ["NSBezelStyleAccessoryBar", 13, 0], ["NSBezelStylePushDisclosure", 14, 0], ["NSBezelStyleBadge", 15, 0]], + "NSBorderType": [["NSNoBorder", 0, 0], ["NSLineBorder", 1, 0], ["NSBezelBorder", 2, 0], ["NSGrooveBorder", 3, 0]], + "NSBoxType": [["NSBoxPrimary", 0, 0], ["NSBoxSeparator", 2, 0], ["NSBoxCustom", 4, 0]], + "NSButtonType": [["NSButtonTypeMomentaryLight", 0, 0], ["NSButtonTypePushOnPushOff", 1, 0], ["NSButtonTypeToggle", 2, 0], ["NSButtonTypeSwitch", 3, 0], ["NSButtonTypeRadio", 4, 0], ["NSButtonTypeMomentaryChange", 5, 0], ["NSButtonTypeOnOff", 6, 0], ["NSButtonTypeMomentaryPushIn", 7, 0], ["NSButtonTypeAccelerator", 8, 0], ["NSButtonTypeMultiLevelAccelerator", 9, 0]], + "NSCellImagePosition": [["NSNoImage", 0, 0], ["NSImageOnly", 1, 0], ["NSImageLeft", 2, 0], ["NSImageRight", 3, 0], ["NSImageBelow", 4, 0], ["NSImageAbove", 5, 0], ["NSImageOverlaps", 6, 0], ["NSImageLeading", 7, 0], ["NSImageTrailing", 8, 0]], + "NSCellType": [["NSNullCellType", 0, 0], ["NSTextCellType", 1, 0], ["NSImageCellType", 2, 0]], + "NSColorWellStyle": [["NSColorWellStyleDefault", 0, 0], ["NSColorWellStyleMinimal", 1, 0], ["NSColorWellStyleExpanded", 2, 0]], + "NSControlSize": [["NSControlSizeRegular", 0, 0], ["NSControlSizeSmall", 1, 0], ["NSControlSizeMini", 2, 0], ["NSControlSizeLarge", 3, 0]], + "NSControlStateValue": [["NSControlStateValueOff", 0, 0], ["NSControlStateValueOn", 1, 0], ["NSControlStateValueMixed", -1, 0]], + "NSDatePickerMode": [["NSDatePickerModeSingle", 0, 0], ["NSDatePickerModeRange", 1, 0]], + "NSDatePickerStyle": [["NSDatePickerStyleTextFieldAndStepper", 0, 0], ["NSDatePickerStyleClockAndCalendar", 1, 0], ["NSDatePickerStyleTextField", 2, 0]], + "NSEventModifierFlags": [["NSEventModifierFlagCapsLock", 65536, 0], ["NSEventModifierFlagShift", 131072, 0], ["NSEventModifierFlagControl", 262144, 0], ["NSEventModifierFlagOption", 524288, 0], ["NSEventModifierFlagCommand", 1048576, 0], ["NSEventModifierFlagNumericPad", 2097152, 0], ["NSEventModifierFlagHelp", 4194304, 0], ["NSEventModifierFlagFunction", 8388608, 0]], + "NSGridCellPlacement": [["NSGridCellPlacementInherited", 0, 0], ["NSGridCellPlacementNone", 1, 0], ["NSGridCellPlacementLeading", 2, 0], ["NSGridCellPlacementTop", 2, 0], ["NSGridCellPlacementTrailing", 3, 0], ["NSGridCellPlacementBottom", 3, 0], ["NSGridCellPlacementCenter", 4, 0], ["NSGridCellPlacementFill", 5, 0]], + "NSImageAlignment": [["NSImageAlignCenter", 0, 0], ["NSImageAlignTop", 1, 0], ["NSImageAlignTopLeft", 2, 0], ["NSImageAlignTopRight", 3, 0], ["NSImageAlignLeft", 4, 0], ["NSImageAlignBottom", 5, 0], ["NSImageAlignBottomLeft", 6, 0], ["NSImageAlignBottomRight", 7, 0], ["NSImageAlignRight", 8, 0]], + "NSImageFrameStyle": [["NSImageFrameNone", 0, 0], ["NSImageFramePhoto", 1, 0], ["NSImageFrameGrayBezel", 2, 0], ["NSImageFrameGroove", 3, 0], ["NSImageFrameButton", 4, 0]], + "NSImageScaling": [["NSImageScaleProportionallyDown", 0, 0], ["NSImageScaleAxesIndependently", 1, 0], ["NSImageScaleNone", 2, 0], ["NSImageScaleProportionallyUpOrDown", 3, 0]], + "NSLayoutAttribute": [["NSLayoutAttributeNotAnAttribute", 0, 0], ["NSLayoutAttributeLeft", 1, 0], ["NSLayoutAttributeRight", 2, 0], ["NSLayoutAttributeTop", 3, 0], ["NSLayoutAttributeBottom", 4, 0], ["NSLayoutAttributeLeading", 5, 0], ["NSLayoutAttributeTrailing", 6, 0], ["NSLayoutAttributeWidth", 7, 0], ["NSLayoutAttributeHeight", 8, 0], ["NSLayoutAttributeCenterX", 9, 0], ["NSLayoutAttributeCenterY", 10, 0], ["NSLayoutAttributeLastBaseline", 11, 0], ["NSLayoutAttributeFirstBaseline", 12, 0]], + "NSLevelIndicatorStyle": [["NSLevelIndicatorStyleRelevancy", 0, 0], ["NSLevelIndicatorStyleContinuousCapacity", 1, 0], ["NSLevelIndicatorStyleDiscreteCapacity", 2, 0], ["NSLevelIndicatorStyleRating", 3, 0]], + "NSLineBreakMode": [["NSLineBreakByWordWrapping", 0, 0], ["NSLineBreakByCharWrapping", 1, 0], ["NSLineBreakByClipping", 2, 0], ["NSLineBreakByTruncatingHead", 3, 0], ["NSLineBreakByTruncatingTail", 4, 0], ["NSLineBreakByTruncatingMiddle", 5, 0]], + "NSLineBreakStrategy": [["NSLineBreakStrategyNone", 0, 0], ["NSLineBreakStrategyPushOut", 1, 0], ["NSLineBreakStrategyHangulWordPriority", 2, 0], ["NSLineBreakStrategyStandard", 65535, 0]], + "NSProgressIndicatorStyle": [["NSProgressIndicatorStyleBar", 0, 0], ["NSProgressIndicatorStyleSpinning", 1, 0]], + "NSRectEdge": [["NSRectEdgeMinX", 0, 0], ["NSRectEdgeMinY", 1, 0], ["NSRectEdgeMaxX", 2, 0], ["NSRectEdgeMaxY", 3, 0]], + "NSScrollElasticity": [["NSScrollElasticityAutomatic", 0, 0], ["NSScrollElasticityNone", 1, 0], ["NSScrollElasticityAllowed", 2, 0]], + "NSScrollerKnobStyle": [["NSScrollerKnobStyleDefault", 0, 0], ["NSScrollerKnobStyleDark", 1, 0], ["NSScrollerKnobStyleLight", 2, 0]], + "NSScrollerStyle": [["NSScrollerStyleLegacy", 0, 0], ["NSScrollerStyleOverlay", 1, 0]], + "NSSegmentStyle": [["NSSegmentStyleAutomatic", 0, 0], ["NSSegmentStyleRounded", 1, 0], ["NSSegmentStyleRoundRect", 3, 0], ["NSSegmentStyleTexturedSquare", 4, 0], ["NSSegmentStyleSmallSquare", 6, 0], ["NSSegmentStyleSeparated", 8, 0], ["NSSegmentStyleTexturedRounded", 2, 0], ["NSSegmentStyleCapsule", 5, 0]], + "NSSegmentSwitchTracking": [["NSSegmentSwitchTrackingSelectOne", 0, 0], ["NSSegmentSwitchTrackingSelectAny", 1, 0], ["NSSegmentSwitchTrackingMomentary", 2, 0], ["NSSegmentSwitchTrackingMomentaryAccelerator", 3, 0]], + "NSSliderType": [["NSSliderTypeLinear", 0, 0], ["NSSliderTypeCircular", 1, 0]], + "NSSplitViewDividerStyle": [["NSSplitViewDividerStyleThick", 1, 0], ["NSSplitViewDividerStyleThin", 2, 0], ["NSSplitViewDividerStylePaneSplitter", 3, 0]], + "NSStackViewDistribution": [["NSStackViewDistributionGravityAreas", -1, 0], ["NSStackViewDistributionFill", 0, 0], ["NSStackViewDistributionFillEqually", 1, 0], ["NSStackViewDistributionFillProportionally", 2, 0], ["NSStackViewDistributionEqualSpacing", 3, 0], ["NSStackViewDistributionEqualCentering", 4, 0]], + "NSTabPosition": [["NSTabPositionNone", 0, 0], ["NSTabPositionTop", 1, 0], ["NSTabPositionLeft", 2, 0], ["NSTabPositionBottom", 3, 0], ["NSTabPositionRight", 4, 0]], + "NSTabViewBorderType": [["NSTabViewBorderTypeNone", 0, 0], ["NSTabViewBorderTypeLine", 1, 0], ["NSTabViewBorderTypeBezel", 2, 0]], + "NSTabViewType": [["NSTopTabsBezelBorder", 0, 0], ["NSLeftTabsBezelBorder", 1, 0], ["NSBottomTabsBezelBorder", 2, 0], ["NSRightTabsBezelBorder", 3, 0], ["NSNoTabsBezelBorder", 4, 0], ["NSNoTabsLineBorder", 5, 0], ["NSNoTabsNoBorder", 6, 0]], + "NSTableViewColumnAutoresizingStyle": [["NSTableViewNoColumnAutoresizing", 0, 0], ["NSTableViewUniformColumnAutoresizingStyle", 1, 0], ["NSTableViewSequentialColumnAutoresizingStyle", 2, 0], ["NSTableViewReverseSequentialColumnAutoresizingStyle", 3, 0], ["NSTableViewLastColumnOnlyAutoresizingStyle", 4, 0], ["NSTableViewFirstColumnOnlyAutoresizingStyle", 5, 0]], + "NSTableViewDraggingDestinationFeedbackStyle": [["NSTableViewDraggingDestinationFeedbackStyleNone", -1, 0], ["NSTableViewDraggingDestinationFeedbackStyleRegular", 0, 0], ["NSTableViewDraggingDestinationFeedbackStyleSourceList", 1, 0], ["NSTableViewDraggingDestinationFeedbackStyleGap", 2, 0]], + "NSTableViewGridLineStyle": [["NSTableViewGridNone", 0, 0], ["NSTableViewSolidVerticalGridLineMask", 1, 0], ["NSTableViewSolidHorizontalGridLineMask", 2, 0], ["NSTableViewDashedHorizontalGridLineMask", 8, 0]], + "NSTableViewRowSizeStyle": [["NSTableViewRowSizeStyleDefault", -1, 0], ["NSTableViewRowSizeStyleCustom", 0, 0], ["NSTableViewRowSizeStyleSmall", 1, 0], ["NSTableViewRowSizeStyleMedium", 2, 0], ["NSTableViewRowSizeStyleLarge", 3, 0]], + "NSTableViewSelectionHighlightStyle": [["NSTableViewSelectionHighlightStyleNone", -1, 0], ["NSTableViewSelectionHighlightStyleRegular", 0, 0], ["NSTableViewSelectionHighlightStyleSourceList", 1, 0]], + "NSTableViewStyle": [["NSTableViewStyleAutomatic", 0, 0], ["NSTableViewStyleFullWidth", 1, 0], ["NSTableViewStyleInset", 2, 0], ["NSTableViewStyleSourceList", 3, 0], ["NSTableViewStylePlain", 4, 0]], + "NSTextAlignment": [["NSTextAlignmentLeft", 0, 0], ["NSTextAlignmentCenter", 1, 0], ["NSTextAlignmentRight", 2, 0], ["NSTextAlignmentJustified", 3, 0], ["NSTextAlignmentNatural", 4, 0]], + "NSTextAlignment_AppKit": [["NSTextAlignmentLeft", 0, 0], ["NSTextAlignmentRight", 1, 0], ["NSTextAlignmentCenter", 2, 0], ["NSTextAlignmentJustified", 3, 0], ["NSTextAlignmentNatural", 4, 0]], + "NSTextFieldBezelStyle": [["NSTextFieldSquareBezel", 0, 0], ["NSTextFieldRoundedBezel", 1, 0]], + "NSTickMarkPosition": [["NSTickMarkPositionBelow", 0, 0], ["NSTickMarkPositionAbove", 1, 0], ["NSTickMarkPositionLeading", 0, 0], ["NSTickMarkPositionTrailing", 1, 0]], + "NSTitlePosition": [["NSNoTitle", 0, 0], ["NSAboveTop", 1, 0], ["NSAtTop", 2, 0], ["NSBelowTop", 3, 0], ["NSAboveBottom", 4, 0], ["NSAtBottom", 5, 0], ["NSBelowBottom", 6, 0]], + "NSTitlebarSeparatorStyle": [["NSTitlebarSeparatorStyleAutomatic", 0, 0], ["NSTitlebarSeparatorStyleNone", 1, 0], ["NSTitlebarSeparatorStyleLine", 2, 0], ["NSTitlebarSeparatorStyleShadow", 3, 0]], + "NSUserInterfaceLayoutDirection": [["NSUserInterfaceLayoutDirectionLeftToRight", 0, 0], ["NSUserInterfaceLayoutDirectionRightToLeft", 1, 0]], + "NSUserInterfaceLayoutOrientation": [["NSUserInterfaceLayoutOrientationHorizontal", 0, 0], ["NSUserInterfaceLayoutOrientationVertical", 1, 0]], + "NSVisualEffectBlendingMode": [["NSVisualEffectBlendingModeBehindWindow", 0, 0], ["NSVisualEffectBlendingModeWithinWindow", 1, 0]], + "NSVisualEffectMaterial": [["NSVisualEffectMaterialAppearanceBased", 0, 0], ["NSVisualEffectMaterialLight", 1, 0], ["NSVisualEffectMaterialDark", 2, 0], ["NSVisualEffectMaterialTitlebar", 3, 0], ["NSVisualEffectMaterialSelection", 4, 0], ["NSVisualEffectMaterialMenu", 5, 0], ["NSVisualEffectMaterialPopover", 6, 0], ["NSVisualEffectMaterialSidebar", 7, 0], ["NSVisualEffectMaterialMediumLight", 8, 0], ["NSVisualEffectMaterialUltraDark", 9, 0], ["NSVisualEffectMaterialHeaderView", 10, 0], ["NSVisualEffectMaterialSheet", 11, 0], ["NSVisualEffectMaterialWindowBackground", 12, 0], ["NSVisualEffectMaterialHUDWindow", 13, 0], ["NSVisualEffectMaterialFullScreenUI", 15, 0], ["NSVisualEffectMaterialToolTip", 17, 0], ["NSVisualEffectMaterialContentBackground", 18, 0], ["NSVisualEffectMaterialUnderWindowBackground", 21, 0], ["NSVisualEffectMaterialUnderPageBackground", 22, 0]], + "NSVisualEffectState": [["NSVisualEffectStateFollowsWindowActiveState", 0, 0], ["NSVisualEffectStateActive", 1, 0], ["NSVisualEffectStateInactive", 2, 0]], + "NSWindowAnimationBehavior": [["NSWindowAnimationBehaviorDefault", 0, 0], ["NSWindowAnimationBehaviorNone", 2, 0], ["NSWindowAnimationBehaviorDocumentWindow", 3, 0], ["NSWindowAnimationBehaviorUtilityWindow", 4, 0], ["NSWindowAnimationBehaviorAlertPanel", 5, 0]], + "NSWindowLevel": [["NSNormalWindowLevel", 0, 0], ["NSFloatingWindowLevel", 3, 0], ["NSSubmenuWindowLevel", 3, 0], ["NSTornOffMenuWindowLevel", 3, 0], ["NSModalPanelWindowLevel", 8, 0], ["NSMainMenuWindowLevel", 24, 0], ["NSStatusWindowLevel", 25, 0], ["NSPopUpMenuWindowLevel", 101, 0], ["NSScreenSaverWindowLevel", 1000, 0]], + "NSWindowTabbingMode": [["NSWindowTabbingModeAutomatic", 0, 0], ["NSWindowTabbingModePreferred", 1, 0], ["NSWindowTabbingModeDisallowed", 2, 0]], + "NSWindowTitleVisibility": [["NSWindowTitleVisible", 0, 0], ["NSWindowTitleHidden", 1, 0]], + "NSWindowToolbarStyle": [["NSWindowToolbarStyleAutomatic", 0, 0], ["NSWindowToolbarStyleExpanded", 1, 0], ["NSWindowToolbarStylePreference", 2, 0], ["NSWindowToolbarStyleUnified", 3, 0], ["NSWindowToolbarStyleUnifiedCompact", 4, 0]], + "NSWritingDirection": [["NSWritingDirectionNatural", -1, 0], ["NSWritingDirectionLeftToRight", 0, 0], ["NSWritingDirectionRightToLeft", 1, 0]], + "QMUIButtonImagePosition": [["QMUIButtonImagePositionTop", 0, 0], ["QMUIButtonImagePositionLeft", 1, 0], ["QMUIButtonImagePositionBottom", 2, 0], ["QMUIButtonImagePositionRight", 3, 0]], + "UIAccessibilityContrast": [["UIAccessibilityContrastUnspecified", -1, 0], ["UIAccessibilityContrastNormal", 0, 0], ["UIAccessibilityContrastHigh", 1, 0]], + "UIAccessibilityNavigationStyle": [["UIAccessibilityNavigationStyleAutomatic", 0, 0], ["UIAccessibilityNavigationStyleSeparate", 1, 0], ["UIAccessibilityNavigationStyleCombined", 2, 0]], + "UIBlurEffectStyle": [["UIBlurEffectStyleExtraLight", 0, 0], ["UIBlurEffectStyleLight", 1, 0], ["UIBlurEffectStyleDark", 2, 0], ["UIBlurEffectStyleRegular", 4, 10], ["UIBlurEffectStyleProminent", 5, 10], ["UIBlurEffectStyleSystemUltraThinMaterial", 6, 13], ["UIBlurEffectStyleSystemThinMaterial", 7, 13], ["UIBlurEffectStyleSystemMaterial", 8, 13], ["UIBlurEffectStyleSystemThickMaterial", 9, 13], ["UIBlurEffectStyleSystemChromeMaterial", 10, 13], ["UIBlurEffectStyleSystemUltraThinMaterialLight", 11, 13], ["UIBlurEffectStyleSystemThinMaterialLight", 12, 13], ["UIBlurEffectStyleSystemMaterialLight", 13, 13], ["UIBlurEffectStyleSystemThickMaterialLight", 14, 13], ["UIBlurEffectStyleSystemChromeMaterialLight", 15, 13], ["UIBlurEffectStyleSystemUltraThinMaterialDark", 16, 13], ["UIBlurEffectStyleSystemThinMaterialDark", 17, 13], ["UIBlurEffectStyleSystemMaterialDark", 18, 13], ["UIBlurEffectStyleSystemThickMaterialDark", 19, 13], ["UIBlurEffectStyleSystemChromeMaterialDark", 20, 13]], + "UIControlContentHorizontalAlignment": [["UIControlContentHorizontalAlignmentCenter", 0, 0], ["UIControlContentHorizontalAlignmentLeft", 1, 0], ["UIControlContentHorizontalAlignmentRight", 2, 0], ["UIControlContentHorizontalAlignmentFill", 3, 0], ["UIControlContentHorizontalAlignmentLeading", 4, 11], ["UIControlContentHorizontalAlignmentTrailing", 5, 11]], + "UIControlContentVerticalAlignment": [["UIControlContentVerticalAlignmentCenter", 0, 0], ["UIControlContentVerticalAlignmentTop", 1, 0], ["UIControlContentVerticalAlignmentBottom", 2, 0], ["UIControlContentVerticalAlignmentFill", 3, 0]], + "UIDisplayGamut": [["UIDisplayGamutUnspecified", -1, 0], ["UIDisplayGamutSRGB", 0, 0], ["UIDisplayGamutP3", 1, 0]], + "UIForceTouchCapability": [["UIForceTouchCapabilityUnknown", 0, 0], ["UIForceTouchCapabilityUnavailable", 1, 0], ["UIForceTouchCapabilityAvailable", 2, 0]], + "UIImageDynamicRange": [["UIImageDynamicRangeUnspecified", -1, 0], ["UIImageDynamicRangeStandard", 0, 0], ["UIImageDynamicRangeConstrainedHigh", 1, 0], ["UIImageDynamicRangeHigh", 2, 0]], + "UIInterfaceOrientation": [["UIInterfaceOrientationUnknown", 0, 0], ["UIInterfaceOrientationPortrait", 1, 0], ["UIInterfaceOrientationPortraitUpsideDown", 2, 0], ["UIInterfaceOrientationLandscapeLeft", 3, 0], ["UIInterfaceOrientationLandscapeRight", 4, 0]], + "UILayoutConstraintAxis": [["UILayoutConstraintAxisHorizontal", 0, 0], ["UILayoutConstraintAxisVertical", 1, 0]], + "UILegibilityWeight": [["UILegibilityWeightUnspecified", -1, 0], ["UILegibilityWeightRegular", 0, 0], ["UILegibilityWeightBold", 1, 0]], + "UISceneActivationState": [["UISceneActivationStateUnattached", -1, 0], ["UISceneActivationStateForegroundActive", 0, 0], ["UISceneActivationStateForegroundInactive", 1, 0], ["UISceneActivationStateBackground", 2, 0]], + "UISceneCaptureState": [["UISceneCaptureStateUnspecified", -1, 0], ["UISceneCaptureStateInactive", 0, 0], ["UISceneCaptureStateActive", 1, 0]], + "UIScrollViewContentInsetAdjustmentBehavior": [["UIScrollViewContentInsetAdjustmentAutomatic", 0, 0], ["UIScrollViewContentInsetAdjustmentScrollableAxes", 1, 0], ["UIScrollViewContentInsetAdjustmentNever", 2, 0], ["UIScrollViewContentInsetAdjustmentAlways", 3, 0]], + "UIStackViewAlignment": [["UIStackViewAlignmentFill", 0, 0], ["UIStackViewAlignmentLeading (Top)", 1, 0], ["UIStackViewAlignmentFirstBaseline", 2, 0], ["UIStackViewAlignmentCenter", 3, 0], ["UIStackViewAlignmentTrailing (Bottom)", 4, 0], ["UIStackViewAlignmentLastBaseline", 5, 0]], + "UIStackViewDistribution": [["UIStackViewDistributionFill", 0, 0], ["UIStackViewDistributionFillEqually", 1, 0], ["UIStackViewDistributionFillProportionally", 2, 0], ["UIStackViewDistributionEqualSpacing", 3, 0], ["UIStackViewDistributionEqualCentering", 4, 0]], + "UIStatusBarStyle": [["UIStatusBarStyleDefault", 0, 0], ["UIStatusBarStyleLightContent", 1, 0], ["UIStatusBarStyleDarkContent", 3, 0]], + "UITableViewCellSeparatorStyle": [["UITableViewCellSeparatorStyleNone", 0, 0], ["UITableViewCellSeparatorStyleSingleLine", 1, 0], ["UITableViewCellSeparatorStyleSingleLineEtched", 2, 0]], + "UITableViewStyle": [["UITableViewStylePlain", 0, 0], ["UITableViewStyleGrouped", 1, 0]], + "UITextFieldViewMode": [["UITextFieldViewModeNever", 0, 0], ["UITextFieldViewModeWhileEditing", 1, 0], ["UITextFieldViewModeUnlessEditing", 2, 0], ["UITextFieldViewModeAlways", 3, 0]], + "UITraitEnvironmentLayoutDirection": [["UITraitEnvironmentLayoutDirectionUnspecified", -1, 0], ["UITraitEnvironmentLayoutDirectionLeftToRight", 0, 0], ["UITraitEnvironmentLayoutDirectionRightToLeft", 1, 0]], + "UIUserInterfaceActiveAppearance": [["UIUserInterfaceActiveAppearanceUnspecified", -1, 0], ["UIUserInterfaceActiveAppearanceInactive", 0, 0], ["UIUserInterfaceActiveAppearanceActive", 1, 0]], + "UIUserInterfaceIdiom": [["UIUserInterfaceIdiomUnspecified", -1, 0], ["UIUserInterfaceIdiomPhone", 0, 0], ["UIUserInterfaceIdiomPad", 1, 0], ["UIUserInterfaceIdiomTV", 2, 0], ["UIUserInterfaceIdiomCarPlay", 3, 0], ["UIUserInterfaceIdiomMac", 5, 14], ["UIUserInterfaceIdiomVision", 6, 17]], + "UIUserInterfaceLevel": [["UIUserInterfaceLevelUnspecified", -1, 0], ["UIUserInterfaceLevelBase", 0, 0], ["UIUserInterfaceLevelElevated", 1, 0]], + "UIUserInterfaceSizeClass": [["UIUserInterfaceSizeClassUnspecified", 0, 0], ["UIUserInterfaceSizeClassCompact", 1, 0], ["UIUserInterfaceSizeClassRegular", 2, 0]], + "UIUserInterfaceStyle": [["UIUserInterfaceStyleUnspecified", 0, 0], ["UIUserInterfaceStyleLight", 1, 0], ["UIUserInterfaceStyleDark", 2, 0]], + "UIViewContentMode": [["UIViewContentModeScaleToFill", 0, 0], ["UIViewContentModeScaleAspectFit", 1, 0], ["UIViewContentModeScaleAspectFill", 2, 0], ["UIViewContentModeRedraw", 3, 0], ["UIViewContentModeCenter", 4, 0], ["UIViewContentModeTop", 5, 0], ["UIViewContentModeBottom", 6, 0], ["UIViewContentModeLeft", 7, 0], ["UIViewContentModeRight", 8, 0], ["UIViewContentModeTopLeft", 9, 0], ["UIViewContentModeTopRight", 10, 0], ["UIViewContentModeBottomLeft", 11, 0], ["UIViewContentModeBottomRight", 12, 0]], + "UIViewTintAdjustmentMode": [["UIViewTintAdjustmentModeAutomatic", 0, 0], ["UIViewTintAdjustmentModeNormal", 1, 0], ["UIViewTintAdjustmentModeDimmed", 2, 0]] +} diff --git a/Tests/Dashboard/LKDashboardTests-Bridging-Header.h b/Tests/Dashboard/LKDashboardTests-Bridging-Header.h new file mode 100644 index 00000000..e7816001 --- /dev/null +++ b/Tests/Dashboard/LKDashboardTests-Bridging-Header.h @@ -0,0 +1,18 @@ +// +// LKDashboardTests-Bridging-Header.h +// +// The LookinCore model the Dashboard's payload rules use, so the Dashboard +// tests can compile them with LookinCore alone. +// + +// Only the C declarations: the LookinCoreImpl Swift files compiled into the +// test binary declare the model classes themselves (`@objc(OriginalName)`). +#import "LookinDefines.h" +#import "LookinAttrIdentifiers.h" +#import "LookinAttrType.h" +#import "LookinCodingValueType.h" +#import "LookinCoreTypes.h" +#import "LookinRuntimeShim.h" +#import "NSValue+Lookin.h" +#import "LookInsideServerLogger.h" +#import "LookinIvarTrace.h" diff --git a/Tests/Dashboard/LKDashboardTests.swift b/Tests/Dashboard/LKDashboardTests.swift new file mode 100644 index 00000000..55b53155 --- /dev/null +++ b/Tests/Dashboard/LKDashboardTests.swift @@ -0,0 +1,312 @@ +import AppKit +import Foundation + +/// Coverage for the Dashboard rules that decide what is sent to the +/// inspected app and what the cards show, compiled with LookinCore alone. +/// +/// The modification payloads are compared, as keyed archives (the bytes +/// that go over the wire), with payloads built the way the Objective-C +/// `LKDashboardViewController` built them before the Swift rewrite. The +/// enum lists are compared with a dump of the Objective-C +/// `LKEnumListRegistry` (`Fixtures/enum-list-registry.json`). +@main +struct LKDashboardTests { + static func main() { + let fixturesDirectory = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? "Fixtures", isDirectory: true) + + testInbuiltPayloadForEveryBlueprintAttribute() + testInbuiltPayloadWithoutSetter() + testCustomPayload() + testCustomPayloadWithoutSetter() + testEditedGeometryValues() + testNumberValueClampsOpacity() + testRectEditRevertDetection() + testConstraintOrder() + testEnumListRegistryMatchesObjectiveC(fixturesDirectory: fixturesDirectory) + testJSONAttributeTree() + print("Dashboard tests passed") + } + + // MARK: - Modification payloads + + private static let clientVersion = "2.4.0" + + private static func makeItem() -> LookinDisplayItem { + func object(_ oid: UInt) -> LookinObject { + let object = LookinObject() + object.oid = oid + return object + } + let item = LookinDisplayItem() + item.viewObject = object(11) + item.layerObject = object(22) + item.windowObject = object(33) + item.cellObject = object(44) + return item + } + + /// `-[LKDashboardViewController modifyInbuiltAttribute:newValue:]` + /// before the rewrite. + private static func objectiveCInbuiltPayload(attribute: LookinAttribute, newValue: Any?) -> LookinAttributeModification { + let modifyingItem = attribute.targetDisplayItem! + let modification = LookinAttributeModification() + modification.clientReadableVersion = clientVersion + switch LookinDashboardBlueprint.targetKind(forAttrID: attribute.identifier) { + case .view: + modification.targetOid = modifyingItem.viewObject.oid + case .window: + modification.targetOid = modifyingItem.windowObject.oid + case .cell: + modification.targetOid = modifyingItem.cellObject.oid + case .layer: + modification.targetOid = modifyingItem.layerObject.oid + @unknown default: + fail("unknown target kind") + } + modification.setterSelector = LookinDashboardBlueprint.setter(withAttrID: attribute.identifier) + modification.attrType = attribute.attrType + modification.value = newValue + return modification + } + + private static func archive(_ object: Any) -> Data { + do { + return try NSKeyedArchiver.archivedData(withRootObject: object, requiringSecureCoding: false) + } catch { + fail("cannot archive \(object): \(error)") + } + } + + private static func sampleValue(for type: LookinAttrType) -> Any { + switch type { + case .cgRect: return NSValue(rect: NSRect(x: 1, y: 2, width: 3, height: 4)) + case .cgSize: return NSValue(size: NSSize(width: 5, height: 6)) + case .cgPoint: return NSValue(point: NSPoint(x: 7, y: 8)) + case .uiEdgeInsets: return NSValue(edgeInsets: NSEdgeInsets(top: 1, left: 2, bottom: 3, right: 4)) + case .uiColor: return [NSNumber(value: 0.1), NSNumber(value: 0.2), NSNumber(value: 0.3), NSNumber(value: 1)] + case .nsString: return "text" + case .BOOL: return NSNumber(value: true) + default: return NSNumber(value: 2.5) + } + } + + /// Every attribute the blueprint knows a setter for gets the same + /// payload as before: target object by kind, setter, type and value. + private static func testInbuiltPayloadForEveryBlueprintAttribute() { + // The attributes hold the item weakly; it lives to the end of the test. + let item = makeItem() + defer { withExtendedLifetime(item) {} } + var checked = 0 + var kinds = Set() + for groupID in LookinDashboardBlueprint.groupIDs() ?? [] { + for sectionID in LookinDashboardBlueprint.sectionIDs(forGroupID: groupID) ?? [] { + for attrID in LookinDashboardBlueprint.attrIDs(forSectionID: sectionID) ?? [] { + guard LookinDashboardBlueprint.setter(withAttrID: attrID) != nil else { continue } + let attribute = LookinAttribute() + attribute.identifier = attrID + attribute.attrType = LookinDashboardBlueprint.objectAttrType(withAttrID: attrID) + attribute.targetDisplayItem = item + let newValue = sampleValue(for: attribute.attrType) + + guard let payload = LKDashboardModification.inbuilt(attribute: attribute, newValue: newValue, clientReadableVersion: clientVersion) else { + fail("\(attrID) has a setter but no payload") + } + let expected = objectiveCInbuiltPayload(attribute: attribute, newValue: newValue) + expect(archive(payload) == archive(expected), "\(attrID): payload differs from the Objective-C one") + expect(payload.targetOid != 0, "\(attrID): no target object") + kinds.insert(LookinDashboardBlueprint.targetKind(forAttrID: attrID).rawValue) + checked += 1 + } + } + } + expect(checked > 50, "only \(checked) editable attributes found in the blueprint") + expect(kinds.contains(LookinAttrTargetKind.view.rawValue) && kinds.contains(LookinAttrTargetKind.layer.rawValue), + "the editable attributes cover neither views nor layers: \(kinds)") + } + + private static func testInbuiltPayloadWithoutSetter() { + let attribute = LookinAttribute() + attribute.identifier = LookinAttr_Class_Class_Class + attribute.attrType = .customObj + attribute.targetDisplayItem = makeItem() + expect(LKDashboardModification.inbuilt(attribute: attribute, newValue: nil, clientReadableVersion: clientVersion) == nil, + "an attribute without a setter must not be sent") + } + + private static func testCustomPayload() { + let attribute = LookinAttribute() + attribute.identifier = LookinAttr_UserCustom + attribute.attrType = .double + attribute.customSetterID = "setter-7" + let newValue = NSNumber(value: 3.25) + + guard let payload = LKDashboardModification.custom(attribute: attribute, newValue: newValue) else { + fail("a custom attribute with a setter has no payload") + } + // -[LKDashboardViewController modifyCustomAttribute:newValue:] + let expected = LookinCustomAttrModification() + expected.customSetterID = attribute.customSetterID + expected.attrType = attribute.attrType + expected.value = newValue + expect(archive(payload) == archive(expected), "custom payload differs from the Objective-C one") + } + + private static func testCustomPayloadWithoutSetter() { + let attribute = LookinAttribute() + attribute.identifier = LookinAttr_UserCustom + attribute.attrType = .nsString + expect(LKDashboardModification.custom(attribute: attribute, newValue: "x") == nil, + "a read-only custom attribute must not be sent") + attribute.customSetterID = "" + expect(LKDashboardModification.custom(attribute: attribute, newValue: "x") == nil, + "an empty setter id must not be sent") + } + + // MARK: - Edited values + + private static func testEditedGeometryValues() { + let rect = NSRect(x: 1, y: 2, width: 3, height: 4) + expect(LKDashboardModification.rect(rect, replacingField: 0, with: 9) == NSRect(x: 9, y: 2, width: 3, height: 4), "rect x") + expect(LKDashboardModification.rect(rect, replacingField: 1, with: 9) == NSRect(x: 1, y: 9, width: 3, height: 4), "rect y") + expect(LKDashboardModification.rect(rect, replacingField: 2, with: 9) == NSRect(x: 1, y: 2, width: 9, height: 4), "rect width") + expect(LKDashboardModification.rect(rect, replacingField: 3, with: 9) == NSRect(x: 1, y: 2, width: 3, height: 9), "rect height") + expect(LKDashboardModification.rect(rect, replacingField: 4, with: 9) == nil, "rect field out of range") + + let insets = NSEdgeInsets(top: 1, left: 2, bottom: 3, right: 4) + let edited = LKDashboardModification.insets(insets, replacingField: 2, with: 7) + expect(edited.map { $0.top == 1 && $0.left == 2 && $0.bottom == 7 && $0.right == 4 } == true, "insets bottom") + expect(edited.map { LKDashboardModification.insetsDiffer(insets, $0) } == true, "edited insets differ") + expect(!LKDashboardModification.insetsDiffer(insets, insets), "equal insets do not differ") + + expect(LKDashboardModification.point(NSPoint(x: 1, y: 2), replacingField: 1, with: 5) == NSPoint(x: 1, y: 5), "point y") + expect(LKDashboardModification.size(NSSize(width: 1, height: 2), replacingField: 0, with: 5) == NSSize(width: 5, height: 2), "size width") + + // The value sent for a rect edit is the NSValue the Objective-C view + // built with +valueWithRect:. + // The attribute holds its item weakly; keep the item alive. + let item = makeItem() + withExtendedLifetime(item) { + let attribute = LookinAttribute() + attribute.identifier = LookinAttr_Layout_Frame_Frame + attribute.attrType = .cgRect + attribute.targetDisplayItem = item + let newRect = LKDashboardModification.rect(rect, replacingField: 2, with: 30)! + let payload = LKDashboardModification.inbuilt(attribute: attribute, newValue: NSValue(rect: newRect), clientReadableVersion: clientVersion)! + let expected = objectiveCInbuiltPayload(attribute: attribute, newValue: NSValue(rect: NSRect(x: 1, y: 2, width: 30, height: 4))) + expect(payload.targetOid != 0, "frame edit has no target") + expect(archive(payload) == archive(expected), "frame edit payload differs from the Objective-C one") + } + + expect(LKDashboardModification.boolValue(isOn: true) == NSNumber(value: true), "switch on") + expect(LKDashboardModification.boolValue(isOn: false) == NSNumber(value: false), "switch off") + } + + private static func testNumberValueClampsOpacity() { + let opacity = LookinAttribute() + opacity.identifier = LookinAttr_ViewLayer_Visibility_Opacity + opacity.value = NSNumber(value: 0.5) + expect(LKDashboardModification.numberValue(NSNumber(value: 1.7), for: opacity) == NSNumber(value: 1.0), "opacity above 1") + expect(LKDashboardModification.numberValue(NSNumber(value: -0.2), for: opacity) == NSNumber(value: 0.0), "opacity below 0") + expect(LKDashboardModification.numberValue(NSNumber(value: 0.5), for: opacity) == nil, "unchanged opacity") + + let shadowOpacity = LookinAttribute() + shadowOpacity.identifier = LookinAttr_ViewLayer_Shadow_Opacity + shadowOpacity.value = NSNumber(value: 1.0) + expect(LKDashboardModification.numberValue(NSNumber(value: 4), for: shadowOpacity) == nil, "shadow opacity clamps to the unchanged 1") + + let radius = LookinAttribute() + radius.identifier = LookinAttr_ViewLayer_Corner_Radius + radius.value = NSNumber(value: 4) + expect(LKDashboardModification.numberValue(NSNumber(value: 12), for: radius) == NSNumber(value: 12), "radius is not clamped") + expect(LKDashboardModification.numberValue(NSNumber(value: 4), for: radius) == nil, "unchanged radius") + } + + private static func testRectEditRevertDetection() { + let old = NSRect(x: 0, y: 0, width: 10, height: 10) + let expected = NSRect(x: 0, y: 0, width: 20, height: 10) + expect(LKDashboardModification.rectEditWasReverted(old: old, expected: expected, current: NSRect(x: 0.05, y: 0, width: 10, height: 10)), + "the app set the frame back") + expect(!LKDashboardModification.rectEditWasReverted(old: old, expected: expected, current: expected), "the edit held") + expect(!LKDashboardModification.rectEditWasReverted(old: old, expected: old, current: old), "an edit to the same rect") + } + + // MARK: - Constraints + + private static func testConstraintOrder() { + func constraint(effective: Bool, type: LookinConstraintItemType, attribute: Int, constant: CGFloat) -> LookinAutoLayoutConstraint { + let constraint = LookinAutoLayoutConstraint() + constraint.effective = effective + constraint.firstItemType = type + constraint.firstAttribute = attribute + constraint.constant = constant + return constraint + } + let a = constraint(effective: false, type: .view, attribute: 1, constant: 1) + let b = constraint(effective: true, type: .layoutGuide, attribute: 2, constant: 2) + let c = constraint(effective: true, type: .view, attribute: 7, constant: 3) + let d = constraint(effective: true, type: .view, attribute: 3, constant: 4) + let e = constraint(effective: true, type: .view, attribute: 3, constant: 5) + let sorted = LKDashboardConstraintOrder.sorted([a, b, c, d, e]).map(\.constant) + expect(sorted == [4, 5, 3, 2, 1], "constraint order: \(sorted)") + } + + // MARK: - Enum lists + + private static func testEnumListRegistryMatchesObjectiveC(fixturesDirectory: URL) { + let url = fixturesDirectory.appendingPathComponent("enum-list-registry.json") + guard let data = try? Data(contentsOf: url), + let fixture = try? JSONSerialization.jsonObject(with: data) as? [String: [[Any]]] + else { + fail("cannot read \(url.path)") + } + let registry = LKEnumListRegistry.shared + expect(Set(registry.allEnumNames) == Set(fixture.keys), + "enum list names differ: \(Set(registry.allEnumNames).symmetricDifference(fixture.keys).sorted())") + for (name, expectedItems) in fixture { + let items = registry.items(forEnumName: name) ?? [] + let actual = items.map { [$0.desc, $0.value, $0.availableOSVersion] as [AnyHashable] } + let expected = expectedItems.map { item in + [item[0] as! String, (item[1] as! NSNumber).intValue, (item[2] as! NSNumber).intValue] as [AnyHashable] + } + expect(actual == expected, "\(name) differs from the Objective-C list") + } + expect(registry.desc(forEnumName: "NSLineBreakStrategy", value: 0xFFFF) == "NSLineBreakStrategyStandard", "hex value") + expect(registry.desc(forEnumName: "NSWindowLevel", value: 3) == "NSFloatingWindowLevel", "first case wins for a shared value") + expect(registry.desc(forEnumName: "NSWindowLevel", value: 999) == nil, "unknown value") + expect(registry.items(forEnumName: "NoSuchEnum") == nil, "unknown list") + } + + // MARK: - JSON attribute + + private static func testJSONAttributeTree() { + let json = #"[{"title":"a","desc":"1","details":[{"title":"a.1"},{"title":"a.2","details":[{"title":"a.2.x"}]}]},{"title":"b"},7]"# + guard let roots = LKJSONAttributeItem.rootItems(fromJSON: json) else { + fail("valid JSON gave no items") + } + expect(roots.count == 2, "non-object entries are skipped") + var flat = LKJSONAttributeItem.flatItems(of: roots) + expect(flat.map { $0.titleText ?? "" } == ["a", "a.1", "a.2", "a.2.x", "b"], "flat order") + expect(flat.map(\.indentation) == [0, 1, 1, 2, 0], "indentation") + expect(flat[0].desc == "1" && flat[1].desc == nil, "descriptions") + + flat[2].expanded = false + flat = LKJSONAttributeItem.flatItems(of: roots) + expect(flat.map { $0.titleText ?? "" } == ["a", "a.1", "a.2", "b"], "collapsed item hides its details") + + expect(LKJSONAttributeItem.rootItems(fromJSON: nil) == nil, "nil JSON") + expect(LKJSONAttributeItem.rootItems(fromJSON: #"{"title":"a"}"#) == nil, "a JSON object is not a list") + } + + // MARK: - Helpers + + private static func expect(_ condition: @autoclosure () -> Bool, _ message: String) { + if !condition() { + fail(message) + } + } + + private static func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) + exit(1) + } +} diff --git a/Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift b/Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift deleted file mode 100644 index 85c75de6..00000000 --- a/Tests/MCPBridge/LKMCPBridgeListenSocketTests.swift +++ /dev/null @@ -1,251 +0,0 @@ -import Darwin -import Foundation - -/// Regression coverage for the MCPBridge listening socket. -/// -/// The socket used to be created blocking. `LKMCPBridgeServer` drains -/// accepts in a `while true` loop that treats `EAGAIN` as "no more -/// pending connections", so on a blocking descriptor the second -/// `accept(2)` of each drain parked the server's serial state queue -/// until another client happened to connect. One connected client was -/// enough to wedge `stop()` and all connection bookkeeping behind it. -/// -/// `testSecondAcceptReturnsPromptly` is the one that reproduces it: it -/// fails (rather than hanging) if the descriptor ever goes back to -/// blocking mode. -@main -struct LKMCPBridgeListenSocketTests { - static func main() { - testDescriptorIsNonBlocking() - testAcceptWithNoPendingConnectionsReturnsImmediately() - testSecondAcceptReturnsPromptly() - testAcceptedConnectionIsBlocking() - testSocketFileIsOwnerOnly() - testStaleSocketFileIsReplaced() - print("MCPBridge listen socket tests passed") - } - - // MARK: - Tests - - private static func testDescriptorIsNonBlocking() { - let socketPath = makeTemporarySocketPath() - defer { unlink(socketPath) } - - guard let descriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { - fail("makeListening should succeed on a fresh path") - } - defer { Darwin.close(descriptor) } - - expect( - LKMCPBridgeListenSocket.isNonBlocking(descriptor: descriptor), - "listening descriptor must be non-blocking so the accept drain loop can terminate" - ) - } - - private static func testAcceptWithNoPendingConnectionsReturnsImmediately() { - let socketPath = makeTemporarySocketPath() - defer { unlink(socketPath) } - - guard let descriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { - fail("makeListening should succeed on a fresh path") - } - defer { Darwin.close(descriptor) } - - let accepted = accept(descriptor, nil, nil) - let savedErrorNumber = errno - expect(accepted < 0, "accept with no pending connection must not produce a descriptor") - expect( - savedErrorNumber == EAGAIN || savedErrorNumber == EWOULDBLOCK, - "accept with no pending connection must report EAGAIN, got errno \(savedErrorNumber)" - ) - } - - /// The actual wedge. Connect one client, accept it, then ask for - /// another the way the drain loop does. On a blocking descriptor - /// that second call never returns. - private static func testSecondAcceptReturnsPromptly() { - let socketPath = makeTemporarySocketPath() - defer { unlink(socketPath) } - - guard let listenDescriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { - fail("makeListening should succeed on a fresh path") - } - defer { Darwin.close(listenDescriptor) } - - guard let clientDescriptor = connectClient(toPath: socketPath) else { - fail("test client should connect to the listening socket") - } - defer { Darwin.close(clientDescriptor) } - - let firstAccepted = acceptWithRetry(listenDescriptor: listenDescriptor) - expect(firstAccepted >= 0, "the pending client connection must be accepted") - defer { - if firstAccepted >= 0 { Darwin.close(firstAccepted) } - } - - // Run the second accept off the main thread with a watchdog, so - // a regression reports a failure instead of hanging the suite. - let completion = DispatchSemaphore(value: 0) - let resultLock = NSLock() - var secondAcceptErrorNumber: Int32 = 0 - var secondAcceptDescriptor: Int32 = -1 - - DispatchQueue.global().async { - let accepted = accept(listenDescriptor, nil, nil) - let savedErrorNumber = errno - resultLock.lock() - secondAcceptDescriptor = accepted - secondAcceptErrorNumber = savedErrorNumber - resultLock.unlock() - completion.signal() - } - - let waitResult = completion.wait(timeout: .now() + 2) - expect( - waitResult == .success, - "the second accept blocked instead of returning EAGAIN — the drain loop would wedge the server's state queue" - ) - guard waitResult == .success else { return } - - resultLock.lock() - let accepted = secondAcceptDescriptor - let savedErrorNumber = secondAcceptErrorNumber - resultLock.unlock() - - expect(accepted < 0, "the second accept must not produce a descriptor") - expect( - savedErrorNumber == EAGAIN || savedErrorNumber == EWOULDBLOCK, - "the second accept must report EAGAIN, got errno \(savedErrorNumber)" - ) - } - - /// The listening socket must be non-blocking, but the connections - /// it hands out must not be. - /// - /// On BSD-derived systems -- macOS included -- accept(2) inherits - /// O_NONBLOCK from the listener, the opposite of Linux. Connection - /// writes are blocking by design (`LKMCPBridgeConnection.writeAll` - /// treats EAGAIN as fatal and closes), so an inherited flag turns - /// any response larger than the socket send buffer into a dropped - /// connection. Small frames still work, which is what makes this - /// fail so selectively: hierarchy reads are fine and screenshots - /// kill the connection. - private static func testAcceptedConnectionIsBlocking() { - let socketPath = makeTemporarySocketPath() - defer { unlink(socketPath) } - - guard let listenDescriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { - fail("makeListening should succeed on a fresh path") - } - defer { Darwin.close(listenDescriptor) } - - guard let clientDescriptor = connectClient(toPath: socketPath) else { - fail("test client should connect to the listening socket") - } - defer { Darwin.close(clientDescriptor) } - - let accepted = LKMCPBridgeListenSocket.acceptConnection(listenDescriptor: listenDescriptor) - expect(accepted >= 0, "the pending client connection must be accepted") - defer { Darwin.close(accepted) } - - expect( - LKMCPBridgeListenSocket.isNonBlocking(descriptor: accepted) == false, - "accepted connections must be blocking; a non-blocking one drops any frame larger than the send buffer" - ) - } - - private static func testSocketFileIsOwnerOnly() { - let socketPath = makeTemporarySocketPath() - defer { unlink(socketPath) } - - guard let descriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { - fail("makeListening should succeed on a fresh path") - } - defer { Darwin.close(descriptor) } - - var fileStatus = stat() - guard stat(socketPath, &fileStatus) == 0 else { - fail("the socket file should exist after makeListening") - } - let permissionBits = fileStatus.st_mode & 0o777 - expect( - permissionBits == LKMCPBridgeListenSocket.socketFilePermissions, - "socket must be owner-only, got \(String(permissionBits, radix: 8))" - ) - } - - /// A host that crashed leaves its socket file behind. Without the - /// unlink, every subsequent launch would fail to bind. - private static func testStaleSocketFileIsReplaced() { - let socketPath = makeTemporarySocketPath() - defer { unlink(socketPath) } - - guard let firstDescriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { - fail("makeListening should succeed on a fresh path") - } - // Close without unlinking: exactly what a crash leaves behind. - Darwin.close(firstDescriptor) - - guard let secondDescriptor = try? LKMCPBridgeListenSocket.makeListening(atPath: socketPath, backlog: 16) else { - fail("makeListening should replace a stale socket file rather than failing to bind") - } - Darwin.close(secondDescriptor) - } - - // MARK: - Helpers - - /// Socket paths are capped at 104 bytes by the kernel, so this stays - /// directly under /tmp rather than in a nested temporary directory. - private static func makeTemporarySocketPath() -> String { - let uniqueSuffix = UUID().uuidString.prefix(8) - return "/tmp/lkmcp-listen-\(uniqueSuffix).sock" - } - - private static func connectClient(toPath socketPath: String) -> Int32? { - let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) - guard descriptor >= 0 else { return nil } - var address = sockaddr_un() - address.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(socketPath.utf8) - withUnsafeMutableBytes(of: &address.sun_path) { pathBuffer in - for index in 0.. Int32 in - return addressPointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in - return Darwin.connect(descriptor, rebound, socklen_t(MemoryLayout.size)) - } - } - if connectResult != 0 { - Darwin.close(descriptor) - return nil - } - return descriptor - } - - /// `connect(2)` returning does not guarantee the kernel has already - /// queued the connection on the listener, so give it a moment before - /// concluding there is nothing to accept. - private static func acceptWithRetry(listenDescriptor: Int32) -> Int32 { - for _ in 0..<100 { - let accepted = accept(listenDescriptor, nil, nil) - if accepted >= 0 { return accepted } - if errno != EAGAIN && errno != EWOULDBLOCK { return accepted } - usleep(10_000) - } - return -1 - } - - private static func expect(_ condition: Bool, _ message: String) { - guard condition else { - fail(message) - } - } - - private static func fail(_ message: String) -> Never { - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - Foundation.exit(1) - } -} diff --git a/Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift b/Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift deleted file mode 100644 index 4ba734f9..00000000 --- a/Tests/MCPBridge/LKMCPBridgeObjectIdentifierListTests.swift +++ /dev/null @@ -1,170 +0,0 @@ -import Foundation - -/// Coverage for the `details.read` object-identifier list. -/// -/// The failure this guards against is not a wrong answer but a dead host: -/// a repeated identifier used to resolve twice to the same display item and -/// then reach `Dictionary(uniqueKeysWithValues:)`, which traps. The caller is -/// an LLM-driven MCP client, so a list carrying the same identifier twice is -/// routine input. Every de-duplication test here stands in for that crash. -@main -struct LKMCPBridgeObjectIdentifierListTests { - static func main() { - testRepeatedIdentifierIsCollapsed() - testEveryEntryIdenticalCollapsesToOne() - testFirstSeenOrderIsPreserved() - testDistinctIdentifiersAreAllKept() - testDroppedDuplicateCountIsReported() - testNonStringEntryIsRejected() - testEmptyArrayIsRejected() - testCapAppliesToDistinctCountNotRawCount() - testTooManyDistinctIdentifiersIsRejected() - print("MCPBridge object identifier list tests passed") - } - - // MARK: - De-duplication (the crash this file exists for) - - /// The direct reproduction: the exact payload shape that used to take - /// down LookInside.app. - private static func testRepeatedIdentifierIsCollapsed() { - let parsed = expectSuccess( - parse(["0x600000abc123", "0x600000abc123", "0x600000def456"]) - ) - expect( - parsed.identifiers == ["0x600000abc123", "0x600000def456"], - "a repeated identifier must appear exactly once, got \(parsed.identifiers)" - ) - } - - private static func testEveryEntryIdenticalCollapsesToOne() { - let parsed = expectSuccess( - parse(["0xabc", "0xabc", "0xabc", "0xabc"]) - ) - expect( - parsed.identifiers == ["0xabc"], - "four copies of one identifier must collapse to one, got \(parsed.identifiers)" - ) - } - - /// Responses list details in request order and agents correlate them - /// positionally, so de-duplication must not reshuffle the survivors. - private static func testFirstSeenOrderIsPreserved() { - let parsed = expectSuccess( - parse(["0xcc", "0xaa", "0xcc", "0xbb", "0xaa"]) - ) - expect( - parsed.identifiers == ["0xcc", "0xaa", "0xbb"], - "survivors must stay in first-seen order, got \(parsed.identifiers)" - ) - } - - private static func testDistinctIdentifiersAreAllKept() { - let parsed = expectSuccess(parse(["0xaa", "0xbb", "0xcc"])) - expect( - parsed.identifiers == ["0xaa", "0xbb", "0xcc"], - "a list with no repeats must pass through unchanged, got \(parsed.identifiers)" - ) - expect( - parsed.droppedDuplicateCount == 0, - "nothing should be reported dropped, got \(parsed.droppedDuplicateCount)" - ) - } - - private static func testDroppedDuplicateCountIsReported() { - let parsed = expectSuccess(parse(["0xaa", "0xaa", "0xaa", "0xbb"])) - expect( - parsed.droppedDuplicateCount == 2, - "two entries were duplicates, got \(parsed.droppedDuplicateCount)" - ) - } - - // MARK: - Rejection - - private static func testNonStringEntryIsRejected() { - let result = LKMCPBridgeObjectIdentifierList.parse( - wireValues: [.string("0xaa"), .integer(7)], - limit: 100 - ) - expect( - failure(of: result) == .notAllStrings, - "an identifier that is not a string cannot address anything and must be rejected" - ) - } - - private static func testEmptyArrayIsRejected() { - let result = LKMCPBridgeObjectIdentifierList.parse(wireValues: [], limit: 100) - expect( - failure(of: result) == .empty, - "an empty identifier array must be rejected" - ) - } - - // MARK: - Cap - - /// The cap bounds how many objects the host fetches from the target app. - /// Duplicates cost nothing downstream, so a list that only *looks* too - /// long must not be refused. - private static func testCapAppliesToDistinctCountNotRawCount() { - let wireValues = (0..<6).map { _ in LKMCPBridgeJSONValue.string("0xaa") } - + [LKMCPBridgeJSONValue.string("0xbb")] - let result = LKMCPBridgeObjectIdentifierList.parse(wireValues: wireValues, limit: 3) - let parsed = expectSuccess(result) - expect( - parsed.identifiers == ["0xaa", "0xbb"], - "seven raw entries holding two distinct ids must pass a cap of three, got \(parsed.identifiers)" - ) - } - - private static func testTooManyDistinctIdentifiersIsRejected() { - let wireValues = (0..<5).map { LKMCPBridgeJSONValue.string("0x\($0)") } - let result = LKMCPBridgeObjectIdentifierList.parse(wireValues: wireValues, limit: 3) - expect( - failure(of: result) == .tooMany(distinctCount: 5, limit: 3), - "five distinct identifiers must be rejected against a cap of three" - ) - } - - // MARK: - Helpers - - private static func parse( - _ identifiers: [String] - ) -> Result { - return LKMCPBridgeObjectIdentifierList.parse( - wireValues: identifiers.map(LKMCPBridgeJSONValue.string), - limit: 100 - ) - } - - private static func expectSuccess( - _ result: Result - ) -> LKMCPBridgeObjectIdentifierList.Parsed { - switch result { - case .success(let parsed): - return parsed - case .failure(let parseFailure): - fail("expected success, got failure \(parseFailure)") - } - } - - private static func failure( - of result: Result - ) -> LKMCPBridgeObjectIdentifierList.ParseFailure? { - switch result { - case .success: - return nil - case .failure(let parseFailure): - return parseFailure - } - } - - private static func expect(_ condition: Bool, _ message: String) { - guard condition else { - fail(message) - } - } - - private static func fail(_ message: String) -> Never { - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - Foundation.exit(1) - } -} diff --git a/Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift b/Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift deleted file mode 100644 index 6084e44f..00000000 --- a/Tests/MCPBridge/LKMCPBridgeSearchQueryTests.swift +++ /dev/null @@ -1,225 +0,0 @@ -import Foundation - -/// Coverage for the `hierarchy.find` matching core. -/// -/// Search is the one bridge route whose failure mode is silent: a field -/// that never matches, or a field name that is quietly dropped, both -/// present to the caller as "nothing in this app matched" — a statement -/// about the inspected app rather than about the request. Every test here -/// exists to make one of those silent outcomes loud. -@main -struct LKMCPBridgeSearchQueryTests { - static func main() { - testMatchesLeafClassName() - testClassNameDoesNotMatchAncestorClass() - testClassChainMatchesAncestorClass() - testMatchingIsCaseInsensitive() - testMatchesTitleAndSubtitle() - testMatchesPartialMemoryAddress() - testNarrowingFieldsExcludesOtherHits() - testMatchedFieldsReportEveryHitInCanonicalOrder() - testEmptyFieldListMeansEveryField() - testDuplicateFieldsAreCollapsed() - testNoMatchReturnsEmpty() - testAbsentStringsNeverMatch() - testUnknownWireFieldNameIsRejected() - testWireFieldNamesResolveInCanonicalOrder() - print("MCPBridge search query tests passed") - } - - // MARK: - Field semantics - - private static func testMatchesLeafClassName() { - let query = LKMCPBridgeSearchQuery(rawQuery: "UIButton") - expect( - query.matches(button()), - "a query naming the leaf class must match that node" - ) - } - - /// `className` is the narrow field: it is what lets an agent ask for - /// `UIView` without getting back every view in the app. - private static func testClassNameDoesNotMatchAncestorClass() { - let query = LKMCPBridgeSearchQuery(rawQuery: "UIControl", fields: [.className]) - expect( - query.matchedFields(in: button()).isEmpty, - "className must consider only the leaf class, not the rest of the chain" - ) - } - - private static func testClassChainMatchesAncestorClass() { - let query = LKMCPBridgeSearchQuery(rawQuery: "UIControl", fields: [.classChain]) - expect( - query.matchedFields(in: button()) == [.classChain], - "classChain must match any class in the chain so subclasses are findable by their base class" - ) - } - - private static func testMatchingIsCaseInsensitive() { - let query = LKMCPBridgeSearchQuery(rawQuery: "uibutton", fields: [.className]) - expect( - query.matches(button()), - "an agent should not have to reproduce the exact casing of a class name" - ) - } - - private static func testMatchesTitleAndSubtitle() { - let titleQuery = LKMCPBridgeSearchQuery(rawQuery: "Play", fields: [.title]) - expect(titleQuery.matches(button()), "title must be searchable") - - let subtitleQuery = LKMCPBridgeSearchQuery(rawQuery: "PlayerViewController", fields: [.subtitle]) - expect(subtitleQuery.matches(button()), "subtitle must be searchable") - } - - /// Addresses arrive from a debugger or a crash log, where the caller - /// often has only the tail of one. - private static func testMatchesPartialMemoryAddress() { - let query = LKMCPBridgeSearchQuery(rawQuery: "abc123", fields: [.memoryAddress]) - expect( - query.matches(button()), - "a substring of a memory address must match, not just the whole address" - ) - } - - // MARK: - Field selection - - private static func testNarrowingFieldsExcludesOtherHits() { - // "Play" appears in the title and in the subtitle, nowhere else. - let query = LKMCPBridgeSearchQuery(rawQuery: "Play", fields: [.className, .classChain]) - expect( - query.matchedFields(in: button()).isEmpty, - "restricting matchFields must actually exclude the fields left out" - ) - } - - private static func testMatchedFieldsReportEveryHitInCanonicalOrder() { - // "UIButton" is the leaf class, is in the chain, and is the title. - let query = LKMCPBridgeSearchQuery(rawQuery: "UIButton") - let matched = query.matchedFields(in: plainButton()) - expect( - matched == [.className, .classChain, .title], - "matchedFields must list every hit in canonical order; got \(matched)" - ) - } - - private static func testEmptyFieldListMeansEveryField() { - let query = LKMCPBridgeSearchQuery(rawQuery: "Play", fields: []) - expect( - query.fields == LKMCPBridgeSearchQuery.Field.allCases, - "an empty field list must widen to every field rather than matching nothing" - ) - } - - private static func testDuplicateFieldsAreCollapsed() { - let query = LKMCPBridgeSearchQuery(rawQuery: "UIButton", fields: [.title, .className, .title]) - expect( - query.fields == [.className, .title], - "duplicates must collapse and order must be canonical; got \(query.fields)" - ) - expect( - query.matchedFields(in: plainButton()) == [.className, .title], - "a repeated field must not be reported twice in matchedFields" - ) - } - - // MARK: - Negative paths - - private static func testNoMatchReturnsEmpty() { - let query = LKMCPBridgeSearchQuery(rawQuery: "NSTableView") - expect( - query.matchedFields(in: button()).isEmpty, - "a query matching nothing must report no matched fields" - ) - } - - /// Nodes configured through the in-app custom-debug-info hook can be - /// missing a title, a subtitle, or any backing object at all. - private static func testAbsentStringsNeverMatch() { - let bare = LKMCPBridgeSearchQuery.Candidate( - className: "", - classChain: [], - title: nil, - subtitle: nil, - memoryAddresses: [] - ) - let query = LKMCPBridgeSearchQuery(rawQuery: "anything") - expect( - query.matchedFields(in: bare).isEmpty, - "a candidate with no strings at all must not match" - ) - - // An empty query would otherwise match every empty string via - // `contains("")`, turning a search into a full tree dump. - let emptyQuery = LKMCPBridgeSearchQuery(rawQuery: "") - expect( - emptyQuery.matchedFields(in: bare).isEmpty, - "empty strings must not be considered matchable content" - ) - } - - // MARK: - Wire field names - - private static func testUnknownWireFieldNameIsRejected() { - expect( - LKMCPBridgeSearchQuery.fields(fromWireNames: ["className", "classname"]) == nil, - "a misspelled field must be rejected, not silently dropped into a no-results answer" - ) - expect( - LKMCPBridgeSearchQuery.firstUnrecognizedFieldName(in: ["className", "classname"]) == "classname", - "the rejected name must be reported so the error message can quote it" - ) - expect( - LKMCPBridgeSearchQuery.firstUnrecognizedFieldName(in: ["className", "title"]) == nil, - "a fully valid list must report no unrecognized name" - ) - } - - private static func testWireFieldNamesResolveInCanonicalOrder() { - let resolved = LKMCPBridgeSearchQuery.fields(fromWireNames: ["subtitle", "className"]) - expect( - resolved == [.className, .subtitle], - "wire names must resolve into canonical order; got \(String(describing: resolved))" - ) - } - - // MARK: - Fixtures - - /// A button whose title and subtitle carry app-supplied text, so - /// field-narrowing tests have something that matches on exactly one - /// field at a time. - private static func button() -> LKMCPBridgeSearchQuery.Candidate { - return LKMCPBridgeSearchQuery.Candidate( - className: "UIButton", - classChain: ["UIButton", "UIControl", "UIView", "UIResponder", "NSObject"], - title: "Play", - subtitle: "PlayerViewController.view", - memoryAddresses: ["0x600000abc123", "0x600000def456"] - ) - } - - /// The default shape: title is just the demangled class name, which - /// is what the host inspector shows for a view with no custom debug - /// info. - private static func plainButton() -> LKMCPBridgeSearchQuery.Candidate { - return LKMCPBridgeSearchQuery.Candidate( - className: "UIButton", - classChain: ["UIButton", "UIControl", "UIView"], - title: "UIButton", - subtitle: "SettingsViewController.view", - memoryAddresses: ["0x600000abc123"] - ) - } - - // MARK: - Helpers - - private static func expect(_ condition: Bool, _ message: String) { - guard condition else { - fail(message) - } - } - - private static func fail(_ message: String) -> Never { - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - Foundation.exit(1) - } -} diff --git a/Tests/Read/Fixtures/legacy-host.lookin b/Tests/Read/Fixtures/legacy-host.lookin new file mode 100644 index 0000000000000000000000000000000000000000..d442fd5d64231db6b7110c2749c34553667f7c6e GIT binary patch literal 11731 zcmeG?d0Z1$xA)FulF1}MSY_2HI|>2N|VtJ z;l|}Hio(H-;&IJAQ`encE}z%AV=f`V>0SAx?H7!Wwpj?oyk}xlT{gXwfc6bQX^{s zkrY^DR9dZO4ZN5dQni)30xzXTVKj0HT&hgW5nS471FD3fa1?=hqDT~ldZFH^4~j;8Q4ESjai|}PM+tBxq2!mY43vqoP&VLl zQC?dKPge{zlFMX{uZSRrtpe@HuQVuX*nw(el18o9v#Nq>wMxZmi?liwo2J&PN>vK2 zlC4$e>S_&2wsg^HJQnasIbXEYV4+4^{$)@0CWpdm}!CH2sauHN!eCUY{ddIM{O?m$hA zpsj`G;(`jkS!1!1)o3iF`3jA`S}{nYXn59Anas}8V}jbK2YjB|q+x+s1+b~Es5P<1 z=gbz@0VmJ8v;)1V)u`eXdQ+`|O|4N>K`poEd>C^Zj3%~bfIj^hxxcDYN~A(8=u~B!PF0z8sv2}kZPuwFFX@!#U+7da;2S`jMxs$@G(5+e>5_S! z`fuyTt6K2V{I;9F_A}RRI$*fYFzi=_gJ{l$7Y+j9*m519Tx6yK;haW|Xd;?~CZj1} zOjFS`VEkRUXACx5c6_x$t>qQ8w1%zG85-hLD$p-uiBZog)e23XL7`-Gb!t_yx=vv* zzZp4(6GIO?7c$wwyx`%=D9a4YK|ZU{T-1c-q4}JIlX6y^HD^#7RJY!pZO_ZqG0e&BS(e?caw(%2aoP12N2+};L)SO z9!HJ@_*Jaz1=d_T+9I(%Xm6R!)*^s5vrYxeYexIf%GVXVywZMw4xlf=Dh{?;#UZm* z90sd6Vz!E}Ub2c~pzT?&S!sEzI1d8NMHk>d8D0E8u?laR*U9#m8wXb51E|hsw8smB zxQec!pFtsh0fo4Zeg&R?gZnQSM0#vo3Z4lTJ{68 zk(|b!*b93D8$Q5>FZKgA0{%W5-MJuOA<)bQ%d-(~VIvsUD;&}`MZwS3tDo!RA^YU;{fXlznOw-z`y<=vqx??7dhiROj0fQoT#5(dGF*;a zumUCF3arE`c(NeqDqM}#@Ei(W9e7r_25T`aSZ-CUk#_(R*oxY!EQ8LZQ|dHrKI0&x z*}=?JDtM2=R-xDPUQH@EPbPIGxRcr8;B#5L%p6vsGP7*)HnSs2b;bd(1+}#VbSkz^ ztz-v)BcW;6LeH1|%E1<=B;Ux!E0oo2b|eVHSg)=$C2INBRjFFbqL^!TZFw(8ukQyg zl7@ZJCfA3H^^B!q&*ZnwIIb@!NI%cm2Dup_gT2!;c50USg?C6|xadqc-j=6@b`YXr9k8^? z(>u1od`nJkFyB{)=kznMdpml+HkfZeryZPYhWS=1CmJC@L(&w0wa_2**mhy)pTD3i zU9Hs-GK6ZtGtNowCodXUEEgSx5Kjyv4M9kuH0lTBCgt(fQxoImMj+zh7r%R_&7(nI z2c~4nH<*AwUK4t#Sfdi)2>@&AO?n=G6JUpmp*&3Ra&s^Mg8)18aFqq_ zW~Q;gu{^A*(W-!k=UK0+QStCTfG5?}vH+9m05|eZIKbZk?4_x#Q3Lz{V22u3VT9?F z@^}-gtOht3V5uQ5Cm!HFAVg_ZJGi19Y+{E4p9sb4^bH1eRkca(r}USHL`6l)Q`mY9 zYcd69L71X2sO0gw8off>fRJU(d@W?h%T^9TjtGg0hzJY~0`Wip{oBIlRR12}=hi}T zJKH>G*y+d%+>5#|aJpuMBJYB1X1u^vEI?@M+Xy*-_X6j&93h(t2(=t*Hy$TmU#d+e zeXs7_>+9=-)T}ay7wGffe@XD1@?Qh(`U~RwdoDY90$Zu5)tKbGP+>KNxKeJ^gTpNk zd{v46cExsVwd12(4qM3@SO{n3{AQunR>5>@Rcd~_k*l@;YKH%2wRSx4phd3%uwV5A zIg|w<*)a!1{(1xn>?MdSSqdfi`Mjlz3&86{sIb!&i{1ka<>vpF{)p-DN*L8uavmL@ zlP6c!8tQo1y!0VB3}WDT!~sq(I>JWn2j>i-aH`N7#iB&iAI=N%&_Fa8DN!}5K?YP0 zG2S=`ou;B05YjA!U3nD*5*yKGaPoJ;na&sJFglJ-p>wDeT?S9`Cc1|nVGL(GR^T_e z;7-^Zcg3M_p3@g6;&eF08HmeaZ`HyX&M5pAo{DGTg?I&ChyQ`MyNi^)pTK#n1&kWFMWxslvK9w5IaTgmI>9|EDkULY3)3VI7t1o;A` zV3=TxV47f|V6C7)KUQ{YoL{(AsR3p_yt){k6`>9jZW$K<# zD0C9~3VRAugad^`gd>I1giC~*gr5md2rmimibNt8QCCs4C{v^mnM94E1)}#wyG18O zmqia~3EhbfrIYAlT1$_oo9OlQF8U;Wg?=QKiM_?W#F=81xIsK!yh^-7d`x^<{D`q- ze3@t_j~T*@W9Bm(nf=Up=C(v4=^}}eWJ}Z%PBLHeq2x=+MadshJ86J4URolpmA)%o zBmGSJt@O5)m6eZGtW~j<$?9FJbyj<=E?7OVwzm$lPPJB9kF{Q8-C}*x`i70z#@i;& zX0Xi&nwGnmx*OQvUr(7Hb%Bcwq15ccGuS4HpDj5w#Igv?RwjTwpZ;$c0P8A zb}G9Gb}Q}n+Fi0I?LF<|?UnX#*{`zSZ-3cA=-}&+>M+D%s>25kM;&fD+Bt?f7B~)f zT;%wvW2+PC3+oh zu180YWDmW^0*}2O*E>3OjO{q2GjWb*DLr?16(PyjARbOY{RNoQ48+_0ES^35I4fAXE`^I1BAL&2Te~JI`03skf zpgLetz_G4G*NCp_u1mU}=tgym>Za}1-0e)DG%zl(E^tHO#UQ7k^q}!UTZ3+N_v&8U zeP;Iq-Jb-92Wx^?1)mGC3+W#+K4eG8tNZ+ckuNa``R$F?57hjk5O!{r?X#-SrRXlvis%*5t$pQv%lj_x+Zxk3MiJ8-^CLKos@T=B zSK|ERhQz%eccWi$KSRGQ{qD#2iXR>SSpu1moG>NfXrf(We&WK!3rSs)s**M&-AoQo z9+~`E3YC(UGBf2&sz<6abzSPs{yqDT?!PZhnwFckFzv_mfOJFpjse7g{sU$YIG^F2 zq0QKui850&XJ>w&<(oAu>yvC@c2@SH?4NQ%az^DG$hFTc%Uz#)FRx$T^t^NVe)+Ze zdkSm{N($B$+%HThoK@Ib)V*kQ(cyuv1Jwh!6^n`sidPrk9h5j|?x4#h;U$eFr%QcH zhnF54>^fL8_|r0*GDX=(Za=J>Ui}+_3uMchAbO$e`w~=HAA0j3N@Q*=$i7H?OHqS zQ0+clN8NDU34K?6qrP=muVHhC-87^bRvWRg)VR&$0M68rTEE)2YcJOIty@(0puV8~ zqv5v0wZo4z1T;);xH2MX#CszJBb6ifjOsjU+^CDA<3_I-gU3{i*)!I2?1ZtGxnyqb zIA+|?abJxO9zSdRowo|#+A+an0yp8(+sSWlXtZuLG@hB*d*ZT5f=TL0M<@4~ykPQ^ zDXJ-lrUp-Kn)-N}a@wJHLf)DG&eL}*-#t1#V*1hT4W}oJ!mBN*ED}P#5uOa?L*R-z9 zS-Wpt&von8yRVuF4}bng`9Dr?PTRca zqsWgoe(e46;w|=DrnKOehL)RKtGBjp8@TPn_Ws-V?C8B?%O^pftl8Ol=b}#?Kb^jd z*)?I;)7>L>-~CMg+4aw>KEJf5Y|pv9MSH*5m%Z=k{ z4sJgbd1&k5h{G*M!jEkED*UT0M(+wrL5J5KaDvFl{a$bIJ2fB&xTyGQ4^^VIn%-^;#ly5N4H`3L_WHnm2yetI$C z;*lS7e>`_ddFhvb8vptD@`RtPermeX>B_pRp;vcaOS*Ra=RrST{zd=Gqw9^o+Wxxu zhTn~ke~bO?$jzdgmwz|>{`A(g+b*})+zGq0=Wf>B3-@a7J-R>Tfy;xne?(i)Vc9?s_p<#BpFeC7aow;+xA0}Z2E#RgSc3gIg>byl@b%gS@rHMJUr zNoOchXXeU7BZ7i08PIw>ywsAygLe3AVl!?)D_=%gC`aN^kl(Y=rnLWdtr?Hzzk+yO z(X(XjSUeubG-D2r;}W@~W;mCf0OtrPT%@d0b|_P}d~#MsvK8aXK)_^`nv#$MPd*(+ z(vbE-1%6w0!vmZ1O&gE8UxSxOwkY*kph+V;)ex zW}^Z$2tFBofSy2Vz#CEmNjMLZ@THJ|S3u&Og%3J8P{axFS?3(wgy%zIU@=~b*Wk@~ z3*L&i6MjT2kwjz>g+w`_B^ro{#1di?u>+3&e34&XKJA!+H2ZD!! z$AUivPbo|ZDVky^DP>LBP%_Gna-f{3?o?kYof=4$Qq|NDN<(QWJ!POwR2?;vnn1ln zO{Zp3v#Gh%JZc#v7dBI$Q2VH3)LE((QVch!`$B5zVSPq;z2PxzhiSK&PoBeD^Bie`zHi#CY1iw=v") + } + let fixture: Data + do { + fixture = try Data(contentsOf: URL(fileURLWithPath: arguments[1])) + } catch { + fail("cannot read the fixture: \(error)") + } + + testLegacyArchiveOpens(fixture) + testArchiveKeepsClassesAndKeys(fixture) + testArchiveRoundTrips(fixture) + testUnreadableArchiveFailsAsInnerError() + testArchiveOfAnotherClassFailsAsInnerError() + testArchiveVersionLimits() + testScreenshotLookupTakesBothFromOneOid() + testToolbarIdentifiersKeepTheirValues() + testSteppingClamps() + testAppsPopoverCopy() + testLaunchAutoEnter() + testLaunchServerVersionErrors() + print("Launch / Toolbar / Read tests passed") + } + + // MARK: - .lookin archives + + private static func testLegacyArchiveOpens(_ fixture: Data) { + let file = decode(fixture) + expect(file.serverVersion == LOOKIN_SERVER_VERSION, "file server version") + let info = file.hierarchyInfo + expect(info?.serverVersion == LOOKIN_SERVER_VERSION, "hierarchy server version") + expect(info?.collapsedClassList == ["UITableViewCellContentView"], "collapsed class list") + expect(info?.colorAlias?.keys.sorted() == ["Brand"], "color alias") + + let appInfo = info?.appInfo + expect(appInfo?.appName == "Fixture App", "app name") + expect(appInfo?.appBundleIdentifier == "app.lookinside.fixture", "bundle identifier") + expect(appInfo?.deviceDescription == "iPhone Simulator", "device description") + expect(appInfo?.osDescription == "iOS 26.0", "os description") + expect(appInfo?.deviceType == .simulator, "device type") + expect(appInfo?.screenScale == 3, "screen scale") + expect(appInfo?.appIcon != nil, "app icon") + expect(appInfo?.screenshot != nil, "app screenshot") + + guard let window = info?.displayItems?.first, info?.displayItems?.count == 1 else { + fail("one root item expected") + } + expect(window.windowObject?.oid == 101, "window oid") + expect(window.layerObject?.oid == 102, "window layer oid") + expect(window.representedAsKeyWindow, "key window flag") + expect(window.subitems?.count == 2, "window subitems") + let label = window.subitems?.first + expect(label?.viewObject?.classChainList?.first == "UILabel", "label class chain") + expect(label?.alpha == 0.5, "label alpha") + expect(label?.frame == CGRect(x: 20, y: 100, width: 200, height: 40), "label frame") + expect(label?.customDisplayTitle == "Hello", "label title") + expect(window.subitems?.last?.isHidden == true, "hidden item") + + expect(Set(file.soloScreenshots?.keys.map(\.intValue) ?? []) == [102, 202], "solo screenshot oids") + expect(Set(file.groupScreenshots?.keys.map(\.intValue) ?? []) == [102], "group screenshot oids") + expect(NSImage(data: file.groupScreenshots?[102] ?? Data()) != nil, "group screenshot is an image") + } + + /// Every archived object of the Swift writer has the class name and + /// the keys the Objective-C writer used. + private static func testArchiveKeepsClassesAndKeys(_ fixture: Data) { + let reencoded = encode(decode(fixture)) + let legacyShape = archiveShape(fixture) + let swiftShape = archiveShape(reencoded) + expect(!legacyShape.isEmpty, "the fixture has archived objects") + expect(swiftShape == legacyShape, "archive classes and keys differ:\nlegacy \(legacyShape.sorted())\nswift \(swiftShape.sorted())") + expect(legacyShape.contains { $0.hasPrefix("LookinHierarchyFile:") }, "root class name") + expect( + legacyShape.contains("LookinHierarchyFile:groupScreenshots,hierarchyInfo,serverVersion,soloScreenshots"), + "LookinHierarchyFile keys" + ) + } + + private static func testArchiveRoundTrips(_ fixture: Data) { + let file = decode(encode(decode(fixture))) + expect(file.hierarchyInfo?.appInfo?.appName == "Fixture App", "round-tripped app name") + expect(file.hierarchyInfo?.displayItems?.first?.subitems?.count == 2, "round-tripped tree") + expect(file.soloScreenshots == decode(fixture).soloScreenshots, "round-tripped solo screenshots") + expect(file.groupScreenshots == decode(fixture).groupScreenshots, "round-tripped group screenshots") + } + + private static func testUnreadableArchiveFailsAsInnerError() { + expectError(Data("not an archive".utf8), code: LookinErrCode_Inner, "garbage data") + expectError(Data(), code: LookinErrCode_Inner, "empty data") + } + + private static func testArchiveOfAnotherClassFailsAsInnerError() { + let data = try! NSKeyedArchiver.archivedData(withRootObject: "text" as NSString, requiringSecureCoding: true) + expectError(data, code: LookinErrCode_Inner, "a string archive") + } + + private static func testArchiveVersionLimits() { + for (version, code) in [ + (Int32(0), LookinErrCode_ServerVersionTooLow), + (LOOKIN_SUPPORTED_SERVER_MIN - 1, LookinErrCode_ServerVersionTooLow), + (LOOKIN_SUPPORTED_SERVER_MAX + 1, LookinErrCode_ServerVersionTooHigh), + ] { + let file = LookinHierarchyFile() + file.serverVersion = version + file.hierarchyInfo = LookinHierarchyInfo() + expectError(encode(file), code: code, "server version \(version)") + } + let file = LookinHierarchyFile() + file.serverVersion = LOOKIN_SUPPORTED_SERVER_MIN + file.hierarchyInfo = LookinHierarchyInfo() + expect(decode(encode(file)).serverVersion == LOOKIN_SUPPORTED_SERVER_MIN, "oldest supported version opens") + } + + // MARK: - Reader screenshots + + private static func testScreenshotLookupTakesBothFromOneOid() { + let soloA = Data([1]), groupB = Data([2]), soloB = Data([3]) + var result = LKReadScreenshotLookup.screenshots( + forOids: [1, 2], solo: [1: soloA, 2: soloB], group: [2: groupB] + ) + expect(result.solo == soloA && result.group == nil, "first oid with a screenshot wins, alone") + + result = LKReadScreenshotLookup.screenshots(forOids: [3, 2], solo: [1: soloA, 2: soloB], group: [2: groupB]) + expect(result.solo == soloB && result.group == groupB, "both screenshots of the matching oid") + + result = LKReadScreenshotLookup.screenshots(forOids: [9], solo: [1: soloA], group: nil) + expect(result.solo == nil && result.group == nil, "no screenshot") + + result = LKReadScreenshotLookup.screenshots(forOids: [2], solo: nil, group: [2: groupB]) + expect(result.solo == nil && result.group == groupB, "group only") + } + + // MARK: - Toolbar + + private static func testToolbarIdentifiersKeepTheirValues() { + let identifiers: [(String, String)] = [ + (LKToolBarIdentifier_Dimension, "0"), (LKToolBarIdentifier_Scale, "1"), + (LKToolBarIdentifier_Setting, "2"), (LKToolBarIdentifier_Reload, "3"), + (LKToolBarIdentifier_App, "5"), (LKToolBarIdentifier_AppInReadMode, "12"), + (LKToolBarIdentifier_Add, "13"), (LKToolBarIdentifier_Remove, "14"), + (LKToolBarIdentifier_Console, "15"), (LKToolBarIdentifier_Rotation, "16"), + (LKToolBarIdentifier_Measure, "17"), (LKToolBarIdentifier_Message, "18"), + (LKToolBarIdentifier_FastMode, "19"), (LKToolBarIdentifier_SwiftUIMode, "20"), + ] + for (identifier, value) in identifiers { + expect(identifier == value, "toolbar identifier \(value)") + } + } + + private static func testSteppingClamps() { + expect(abs(LKToolbarRules.stepped(0.5, by: LKToolbarRules.step, lower: 0, upper: 1) - 0.6) < 1e-9, "step up") + expect(LKToolbarRules.stepped(0.95, by: LKToolbarRules.step, lower: 0, upper: 1) == 1, "clamped high") + expect(LKToolbarRules.stepped(0.05, by: -LKToolbarRules.step, lower: 0, upper: 1) == 0, "clamped low") + expect(LKToolbarRules.stepped(3, by: 0.1, lower: 0, upper: 1) == 1, "out of range value comes back in range") + } + + private static func testAppsPopoverCopy() { + var copy = LKToolbarRules.appsPopoverCopy(source: .reloadButton, appCount: 0) + expect(copy.title == "Connection lost" && copy.subtitle == "And no inspectable app was found", "reload, no apps") + copy = LKToolbarRules.appsPopoverCopy(source: .noConnectionTips, appCount: 1) + expect(copy.title == "Connection lost" && copy.subtitle == "Click the screenshot below to Change App", "tips, one app") + copy = LKToolbarRules.appsPopoverCopy(source: .reloadButton, appCount: 3) + expect(copy.subtitle == "Other 3 apps were found", "reload, several apps") + copy = LKToolbarRules.appsPopoverCopy(source: .appButton, appCount: 0) + expect(copy.title == "No inspectable app was found" && copy.subtitle == nil, "app button, no apps") + copy = LKToolbarRules.appsPopoverCopy(source: .appButton, appCount: 1) + expect(copy.title == "1 active app was found" && copy.subtitle == "Click the screenshot below to inspect", "app button, one app") + copy = LKToolbarRules.appsPopoverCopy(source: .appButton, appCount: 2) + expect(copy.title == "2 active apps were found", "app button, several apps") + } + + // MARK: - Launch + + private static func testLaunchAutoEnter() { + expect(LKLaunchRules.canAutoEnter(requested: true, appCount: 1, onlyAppHasServerVersionError: false, isActivated: true), "auto enter") + expect(!LKLaunchRules.canAutoEnter(requested: false, appCount: 1, onlyAppHasServerVersionError: false, isActivated: true), "not requested") + expect(!LKLaunchRules.canAutoEnter(requested: true, appCount: 2, onlyAppHasServerVersionError: false, isActivated: true), "two apps") + expect(!LKLaunchRules.canAutoEnter(requested: true, appCount: 0, onlyAppHasServerVersionError: false, isActivated: true), "no apps") + expect(!LKLaunchRules.canAutoEnter(requested: true, appCount: 1, onlyAppHasServerVersionError: true, isActivated: true), "version error") + expect(!LKLaunchRules.canAutoEnter(requested: true, appCount: 1, onlyAppHasServerVersionError: false, isActivated: false), "not activated") + } + + private static func testLaunchServerVersionErrors() { + expect(LKLaunchRules.serverVersionHelpPath(errorCode: LookinErrCode_ServerVersionTooLow) == "faq/server-version-too-low/", "too low page") + expect(LKLaunchRules.serverVersionHelpPath(errorCode: LookinErrCode_ServerVersionTooHigh) == "faq/server-version-too-high/", "too high page") + expect(LKLaunchRules.serverVersionHelpPath(errorCode: LookinErrCode_Inner) == "faq/server-version-too-high/", "other errors use the too high page") + expect( + LKLaunchRules.serverVersionErrorTitle(errorCode: LookinErrCode_ServerVersionTooLow, localizedDescription: "x") + == "The version of LookinServer linked with this iOS App is too low.", + "too low title" + ) + expect( + LKLaunchRules.serverVersionErrorTitle(errorCode: LookinErrCode_ServerVersionTooHigh, localizedDescription: "x") + == "Unable to inspect this iOS App. Current version of LookInside app is too low.", + "too high title" + ) + expect(LKLaunchRules.serverVersionErrorTitle(errorCode: -1, localizedDescription: "Custom") == "Custom", "own description") + expect(LKLaunchRules.serverVersionErrorTitle(errorCode: -1, localizedDescription: "") == "Unable to inspect this app.", "fallback title") + } + + // MARK: - Helpers + + private static func decode(_ data: Data) -> LookinHierarchyFile { + do { + return try LookinArchiveCoding.hierarchyFile(from: data) + } catch { + fail("decode failed: \(error)") + } + } + + private static func encode(_ file: LookinHierarchyFile) -> Data { + do { + return try LookinArchiveCoding.data(of: file) + } catch { + fail("encode failed: \(error)") + } + } + + private static func expectError(_ data: Data, code: Int, _ message: String) { + do { + _ = try LookinArchiveCoding.hierarchyFile(from: data) + fail("\(message): decoding should fail") + } catch { + let error = error as NSError + expect(error.domain == LookinErrorDomain && error.code == code, "\(message): got \(error.domain) \(error.code)") + } + } + + /// "ClassName:key1,key2" for every archived object, keys sorted. + private static func archiveShape(_ data: Data) -> Set { + guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + plist["$archiver"] as? String == "NSKeyedArchiver", + let top = plist["$top"] as? [String: Any], top["root"] != nil, + let objects = plist["$objects"] as? [Any] + else { + fail("not a keyed archive") + } + var shape = Set() + for case let object as [String: Any] in objects { + guard let classReference = object["$class"] else { continue } + guard let index = archiverUIDValue(classReference), + index < objects.count, + let classInfo = objects[index] as? [String: Any], let name = classInfo["$classname"] as? String + else { + fail("class reference without a name") + } + let keys = object.keys.filter { $0 != "$class" }.sorted().joined(separator: ",") + shape.insert("\(name):\(keys)") + } + return shape + } + + /// The index a keyed-archive UID refers to. The UID type has no public + /// accessor; its description reads "{value = N}". + private static func archiverUIDValue(_ uid: Any) -> Int? { + let description = String(describing: uid) + guard description.contains("CFKeyedArchiverUID"), + let range = description.range(of: "value = ") + else { return nil } + return Int(description[range.upperBound...].prefix { $0.isNumber }) + } + + private static func expect(_ condition: Bool, _ message: String) { + if !condition { + fail(message) + } + } + + private static func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) + exit(1) + } +} diff --git a/Tests/Read/LookinArchiveFixtureGenerator.m b/Tests/Read/LookinArchiveFixtureGenerator.m new file mode 100644 index 00000000..8490c628 --- /dev/null +++ b/Tests/Read/LookinArchiveFixtureGenerator.m @@ -0,0 +1,166 @@ +// +// LookinArchiveFixtureGenerator.m +// LookInside +// +// Writes Fixtures/legacy-host.lookin: a `.lookin` archive encoded exactly as +// the Objective-C LookinArchiveDocument did before the Read module moved to +// Swift (`-[LookinArchiveDocument dataOfType:error:]` at ee0827d): +// +// [NSKeyedArchiver archivedDataWithRootObject:hierarchyFile +// requiringSecureCoding:YES error:outError]; +// +// The fixture is committed; this program documents how it was made. Build +// it the way Scripts/test-launch-toolbar-read.sh builds the tests, against +// the LookinCore objects, and run it with the output path. +// + +#import +#import "LookinDefines.h" +#import "LookinCoreTypes.h" + +// The model classes are plain Swift (`@objc(OriginalName)`, LookinCoreImpl); +// this file declares the members it sets and links against the class +// symbols Swift emits. +@interface LookinObject : NSObject +@property(nonatomic, assign) unsigned long oid; +@property(nonatomic, copy) NSString *memoryAddress; +@property(nonatomic, copy) NSArray *classChainList; +@end + +@interface LookinAppInfo : NSObject +@property(nonatomic, copy) NSString *appName; +@property(nonatomic, copy) NSString *appBundleIdentifier; +@property(nonatomic, copy) NSString *deviceDescription; +@property(nonatomic, copy) NSString *osDescription; +@property(nonatomic, assign) NSUInteger osMainVersion; +@property(nonatomic, assign) LookinAppInfoDevice deviceType; +@property(nonatomic, assign) double screenWidth; +@property(nonatomic, assign) double screenHeight; +@property(nonatomic, assign) double screenScale; +@property(nonatomic, assign) int serverVersion; +@property(nonatomic, strong) NSImage *appIcon; +@property(nonatomic, strong) NSImage *screenshot; +@end + +@interface LookinDisplayItem : NSObject +@property(nonatomic, strong) LookinObject *windowObject; +@property(nonatomic, strong) LookinObject *viewObject; +@property(nonatomic, strong) LookinObject *layerObject; +@property(nonatomic, assign) CGRect frame; +@property(nonatomic, assign) CGRect bounds; +@property(nonatomic, assign) float alpha; +@property(nonatomic, assign) BOOL representedAsKeyWindow; +@property(nonatomic, assign) BOOL isHidden; +@property(nonatomic, copy) NSString *customDisplayTitle; +@property(nonatomic, strong) NSArray *subitems; +@end + +@interface LookinHierarchyInfo : NSObject +@property(nonatomic, strong) LookinAppInfo *appInfo; +@property(nonatomic, copy) NSArray *displayItems; +@property(nonatomic, assign) int serverVersion; +@property(nonatomic, copy) NSArray *collapsedClassList; +@property(nonatomic, copy) NSDictionary *colorAlias; +@end + +@interface LookinHierarchyFile : NSObject +@property(nonatomic, assign) int serverVersion; +@property(nonatomic, strong) LookinHierarchyInfo *hierarchyInfo; +@property(nonatomic, copy) NSDictionary *soloScreenshots; +@property(nonatomic, copy) NSDictionary *groupScreenshots; +@end + +static NSData *PNGData(NSInteger side, CGFloat red) { + NSBitmapImageRep *rep = [[NSBitmapImageRep alloc] initWithBitmapDataPlanes:NULL pixelsWide:side pixelsHigh:side bitsPerSample:8 samplesPerPixel:4 hasAlpha:YES isPlanar:NO colorSpaceName:NSDeviceRGBColorSpace bytesPerRow:0 bitsPerPixel:0]; + for (NSInteger x = 0; x < side; x++) { + for (NSInteger y = 0; y < side; y++) { + [rep setColor:[NSColor colorWithDeviceRed:red green:0.5 blue:0.25 alpha:1] atX:x y:y]; + } + } + return [rep representationUsingType:NSBitmapImageFileTypePNG properties:@{}]; +} + +static LookinObject *Object(unsigned long oid, NSArray *classChain) { + LookinObject *object = [LookinObject new]; + object.oid = oid; + object.classChainList = classChain; + object.memoryAddress = [NSString stringWithFormat:@"0x%lx", oid]; + return object; +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + if (argc != 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + LookinAppInfo *appInfo = [LookinAppInfo new]; + appInfo.appName = @"Fixture App"; + appInfo.appBundleIdentifier = @"app.lookinside.fixture"; + appInfo.deviceDescription = @"iPhone Simulator"; + appInfo.osDescription = @"iOS 26.0"; + appInfo.osMainVersion = 26; + appInfo.deviceType = LookinAppInfoDeviceSimulator; + appInfo.screenWidth = 402; + appInfo.screenHeight = 874; + appInfo.screenScale = 3; + appInfo.serverVersion = LOOKIN_SERVER_VERSION; + appInfo.appIcon = [[NSImage alloc] initWithData:PNGData(4, 0.9)]; + appInfo.screenshot = [[NSImage alloc] initWithData:PNGData(6, 0.1)]; + + LookinDisplayItem *window = [LookinDisplayItem new]; + window.windowObject = Object(101, @[@"UIWindow", @"UIView", @"UIResponder", @"NSObject"]); + window.layerObject = Object(102, @[@"UIWindowLayer", @"CALayer", @"NSObject"]); + window.frame = CGRectMake(0, 0, 402, 874); + window.bounds = CGRectMake(0, 0, 402, 874); + window.alpha = 1; + window.representedAsKeyWindow = YES; + + LookinDisplayItem *view = [LookinDisplayItem new]; + view.viewObject = Object(201, @[@"UILabel", @"UIView", @"UIResponder", @"NSObject"]); + view.layerObject = Object(202, @[@"_UILabelLayer", @"CALayer", @"NSObject"]); + view.frame = CGRectMake(20, 100, 200, 40); + view.bounds = CGRectMake(0, 0, 200, 40); + view.alpha = 0.5; + view.isHidden = NO; + view.customDisplayTitle = @"Hello"; + + LookinDisplayItem *hiddenView = [LookinDisplayItem new]; + hiddenView.viewObject = Object(301, @[@"UIView", @"UIResponder", @"NSObject"]); + hiddenView.layerObject = Object(302, @[@"CALayer", @"NSObject"]); + hiddenView.frame = CGRectMake(0, 0, 10, 10); + hiddenView.bounds = CGRectMake(0, 0, 10, 10); + hiddenView.isHidden = YES; + hiddenView.alpha = 1; + + window.subitems = @[view, hiddenView]; + + LookinHierarchyInfo *info = [LookinHierarchyInfo new]; + info.appInfo = appInfo; + info.displayItems = @[window]; + info.serverVersion = LOOKIN_SERVER_VERSION; + info.collapsedClassList = @[@"UITableViewCellContentView"]; + info.colorAlias = @{@"Brand": @[@1, @0, @0, @1]}; + + LookinHierarchyFile *file = [LookinHierarchyFile new]; + file.serverVersion = LOOKIN_SERVER_VERSION; + file.hierarchyInfo = info; + // iOS captures key screenshots by the layer oid. + file.soloScreenshots = @{@(202): PNGData(2, 0.2), @(102): PNGData(3, 0.3)}; + file.groupScreenshots = @{@(102): PNGData(5, 0.4)}; + + NSError *error = nil; + NSData *data = [NSKeyedArchiver archivedDataWithRootObject:file requiringSecureCoding:YES error:&error]; + if (!data) { + fprintf(stderr, "encode failed: %s\n", error.description.UTF8String); + return 1; + } + if (![data writeToFile:@(argv[1]) atomically:YES]) { + fprintf(stderr, "write failed\n"); + return 1; + } + printf("wrote %lu bytes\n", (unsigned long)data.length); + } + return 0; +} diff --git a/Tests/Security/LKSwiftUISupportAuthServerPathPolicyTests.swift b/Tests/Security/LKSwiftUISupportAuthServerPathPolicyTests.swift deleted file mode 100644 index 7ff327a2..00000000 --- a/Tests/Security/LKSwiftUISupportAuthServerPathPolicyTests.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -@main -struct LKSwiftUISupportAuthServerPathPolicyTests { - static func main() { - let environment = [ - "LOOKINSIDE_AUTH_SERVER_PATH": "/tmp/attacker-helper", - "LOOKINSIDE_AUTH_SERVER_SOCKET_PATH": "/tmp/attacker.sock", - "LOOKINSIDE_AUTH_SERVER_VERSION": "999.0.0", - "KEEP": "1", - ] - let defaultExecutable = URL(fileURLWithPath: "/default/helper") - let defaultSocket = URL(fileURLWithPath: "/default/helper.sock") - - let executable = LKSwiftUISupportAuthServerPathPolicy.executableURL( - environment: environment, - overrideKey: "LOOKINSIDE_AUTH_SERVER_PATH", - defaultURL: defaultExecutable - ) - let socket = LKSwiftUISupportAuthServerPathPolicy.socketURL( - environment: environment, - overrideKey: "LOOKINSIDE_AUTH_SERVER_SOCKET_PATH", - defaultURL: defaultSocket - ) - let launchEnvironment = LKSwiftUISupportAuthServerPathPolicy.launchEnvironment( - from: environment, - helperPathKey: "LOOKINSIDE_AUTH_SERVER_PATH", - helperVersionKey: "LOOKINSIDE_AUTH_SERVER_VERSION" - ) - - #if DEBUG - expect(executable.path == "/tmp/attacker-helper", "debug executable override") - expect(socket.path == "/tmp/attacker.sock", "debug socket override") - expect(launchEnvironment["LOOKINSIDE_AUTH_SERVER_PATH"] == "/tmp/attacker-helper", "debug keeps helper path") - expect(launchEnvironment["LOOKINSIDE_AUTH_SERVER_VERSION"] == "999.0.0", "debug keeps helper version") - expect( - LKSwiftUISupportActivationStateRefreshPolicy.startupAction == .installAndLaunch, - "debug activation refresh can install and launch helper" - ) - #else - expect(executable == defaultExecutable, "release executable uses installed helper") - expect(socket == defaultSocket, "release socket uses installed socket") - expect(launchEnvironment["LOOKINSIDE_AUTH_SERVER_PATH"] == nil, "release strips helper path") - expect(launchEnvironment["LOOKINSIDE_AUTH_SERVER_VERSION"] == nil, "release strips helper version") - expect( - LKSwiftUISupportActivationStateRefreshPolicy.startupAction == .installAndLaunch, - "release activation refresh installs and launches helper" - ) - #endif - - expect(launchEnvironment["KEEP"] == "1", "unrelated env preserved") - } - - private static func expect(_ condition: Bool, _ message: String) { - guard condition else { - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - Foundation.exit(1) - } - } -} diff --git a/Tests/Security/LKSwiftUISupportInstallerCancellationTests.swift b/Tests/Security/LKSwiftUISupportInstallerCancellationTests.swift deleted file mode 100644 index c799361a..00000000 --- a/Tests/Security/LKSwiftUISupportInstallerCancellationTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -import Foundation - -@main -struct LKSwiftUISupportInstallerCancellationTests { - static func main() throws { - let cancellation = LKInstallerCancellation() - expect(cancellation.isCancelled == false, "starts active") - - cancellation.cancel() - expect(cancellation.isCancelled, "cancel flips state") - expectThrowsCancelled("check throws after cancel") { - try cancellation.checkCancellation() - } - - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sleep") - process.arguments = ["10"] - try process.run() - cancellation.register(unzipProcess: process) - cancellation.cancel() - - let deadline = Date().addingTimeInterval(2) - while process.isRunning, Date() < deadline { - usleep(50_000) - } - expect(process.isRunning == false, "cancel terminates unzip process") - - let installer = LKSwiftUISupportInstaller.shared - installer.invalidatePublishedVersionCache() - let start = Date() - let foregroundPublishedVersion = installer.publishedVersionForInstalledHelperCheck() - expect(foregroundPublishedVersion == nil, "foreground installed-helper check uses cache only") - expect( - Date().timeIntervalSince(start) < 0.5, - "foreground installed-helper check returns without network wait" - ) - } - - private static func expect(_ condition: Bool, _ message: String) { - guard condition else { - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - Foundation.exit(1) - } - } - - private static func expectThrowsCancelled(_ message: String, _ body: () throws -> Void) { - do { - try body() - } catch is LKInstallerCancelled { - return - } catch { - FileHandle.standardError.write(Data("FAIL: \(message): \(error)\n".utf8)) - Foundation.exit(1) - } - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - Foundation.exit(1) - } -} diff --git a/Tests/Security/LKSwiftUISupportLocalLicenseProbeTests.swift b/Tests/Security/LKSwiftUISupportLocalLicenseProbeTests.swift deleted file mode 100644 index 263eb93d..00000000 --- a/Tests/Security/LKSwiftUISupportLocalLicenseProbeTests.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -@main -struct LKSwiftUISupportLocalLicenseProbeTests { - static func main() throws { - let root = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) - .appendingPathComponent("local-license-probe", isDirectory: true) - try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - - expect( - LKSwiftUISupportLocalLicenseProbe.probe(stateURL: root.appendingPathComponent("missing.json")) == .missing, - "missing state file" - ) - - let invalidJSON = root.appendingPathComponent("invalid.json") - try "nope".write(to: invalidJSON, atomically: true, encoding: .utf8) - expectUnreadable( - LKSwiftUISupportLocalLicenseProbe.probe(stateURL: invalidJSON), - "invalid json" - ) - - let emptyState = root.appendingPathComponent("empty.json") - try "{}".write(to: emptyState, atomically: true, encoding: .utf8) - expect( - LKSwiftUISupportLocalLicenseProbe.probe(stateURL: emptyState) == .missing, - "empty state" - ) - - let trialOnlyState = root.appendingPathComponent("trial.json") - try #"{ "deviceFingerprint": { "deviceID": "device-1" } }"# - .write(to: trialOnlyState, atomically: true, encoding: .utf8) - expect( - LKSwiftUISupportLocalLicenseProbe.probe(stateURL: trialOnlyState) == .missing, - "trial-only disk state" - ) - - let paidLeaseState = root.appendingPathComponent("paid-lease.json") - try #"{ "entitlementStatus": { "currentLease": { "certificateID": "cert-1" } } }"# - .write(to: paidLeaseState, atomically: true, encoding: .utf8) - expect( - LKSwiftUISupportLocalLicenseProbe.probe(stateURL: paidLeaseState) == .present, - "paid current lease" - ) - - let activationResponseState = root.appendingPathComponent("activation-response.json") - try #"{ "activationResponse": { "activation": { "activationID": "activation-1" } } }"# - .write(to: activationResponseState, atomically: true, encoding: .utf8) - expect( - LKSwiftUISupportLocalLicenseProbe.probe(stateURL: activationResponseState) == .present, - "activation response" - ) - } - - private static func expect(_ condition: Bool, _ message: String) { - guard condition else { - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - Foundation.exit(1) - } - } - - private static func expectUnreadable(_ result: LKSwiftUISupportLocalLicenseProbeResult, _ message: String) { - guard case .unreadable = result else { - FileHandle.standardError.write(Data("FAIL: \(message)\n".utf8)) - Foundation.exit(1) - } - } -} diff --git a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyConverterTests-Bridging-Header.h b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyConverterTests-Bridging-Header.h index bd931c54..edbfeac4 100644 --- a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyConverterTests-Bridging-Header.h +++ b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyConverterTests-Bridging-Header.h @@ -1,22 +1,18 @@ // // LKXcodeViewHierarchyConverterTests-Bridging-Header.h // -// The Objective-C model the converter under test builds into: the subset of -// the app's bridging header that LookInside/XcodeViewHierarchy uses, so the -// converter tests can compile LookinCore in standalone. +// The model the converter under test builds into, so the converter tests +// can compile LookinCore standalone. // +// Only the C declarations: the LookinCoreImpl Swift files compiled into the +// test binary declare the model classes themselves (`@objc(OriginalName)`). #import "LookinDefines.h" #import "LookinAttrIdentifiers.h" #import "LookinAttrType.h" -#import "LookinAttribute.h" -#import "LookinAttributesGroup.h" -#import "LookinAttributesSection.h" -#import "LookinAutoLayoutConstraint.h" -#import "LookinDashboardBlueprint.h" -#import "LookinDisplayItem.h" -#import "LookinObject.h" -#import "LookinAppInfo.h" -#import "LookinHierarchyInfo.h" -#import "LookinHierarchyFile.h" -#import "Color+Lookin.h" +#import "LookinCodingValueType.h" +#import "LookinCoreTypes.h" +#import "LookinRuntimeShim.h" +#import "NSValue+Lookin.h" +#import "LookInsideServerLogger.h" +#import "LookinIvarTrace.h" diff --git a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyConverterTests.swift b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyConverterTests.swift index 201e1375..acfe5674 100644 --- a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyConverterTests.swift +++ b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyConverterTests.swift @@ -105,7 +105,7 @@ struct LKXcodeViewHierarchyConverterTests { "top-level kinds mismatch: \(rootItems.map(\.nodeKind.rawValue))") expect(rootItems.map(\.representedAsKeyWindow) == [true, false, false, false, false], "only the application's key window is marked key") - expect(rootItems[1].hostWindowControllerObject?.oid == 0xc1, + expect(rootItems[1].hostWindowControllerObject?.oid == 0xC1, "a controller-owned window row carries its controller") expect(rootItems[2].hostWindowControllerObject == nil, "an unowned window row carries no controller") @@ -132,7 +132,7 @@ struct LKXcodeViewHierarchyConverterTests { let file = convert(uiKitSidebarFixture()) let rootItems = file.hierarchyInfo.displayItems ?? [] - expect(rootItems.map(displayedObjectIdentifier) == [0xf2, 0xf1, 0x4], + expect(rootItems.map(displayedObjectIdentifier) == [0xF2, 0xF1, 0x4], "top level should be the key window's scene, the other scene, then the sceneless window; got \(rootItems.map(displayedObjectIdentifier).map { String($0, radix: 16) })") expect(rootItems.map(\.nodeKind) == [.windowScene, .windowScene, .window], "top-level kinds mismatch: \(rootItems.map(\.nodeKind.rawValue))") @@ -317,7 +317,7 @@ struct LKXcodeViewHierarchyConverterTests { let constraints = constraintsAttribute.value as? [LookinAutoLayoutConstraint] else { fail("a view with constraints gets a constraints row carrying LookinAutoLayoutConstraint values") } - expect(constraints.map(\.constraintOid) == [0xd1, 0xd2, 0xd4], + expect(constraints.map(\.constraintOid) == [0xD1, 0xD2, 0xD4], "the active constraints naming the view, in identifier order; got \(constraints.map { String($0.constraintOid, radix: 16) })") expect(constraints.map(\.effective) == [true, false, false], "only the constraint the capture lists as affecting the view is effective; got \(constraints.map(\.effective))") @@ -326,8 +326,8 @@ struct LKXcodeViewHierarchyConverterTests { expect(constraints.map(\.secondItemType) == [.`nil`, .super, .`self`], "second item types mismatch: \(constraints.map(\.secondItemType.rawValue))") expect(constraints[0].firstAttribute == 7 && constraints[0].constant == 100 && constraints[0].priority == 1000 - && constraints[0].relation == .equal && constraints[0].secondItem == nil, - "width constraint fields mismatch") + && constraints[0].relation == .equal && constraints[0].secondItem == nil, + "width constraint fields mismatch") expect(constraints[1].secondItem?.oid == 0x10 && constraints[1].constant == 8 && constraints[1].relation == .greaterThanOrEqual, "superview constraint fields mismatch") expect(constraints[2].firstItem?.classChainList == ["UILayoutGuide", "NSObject"] && constraints[2].identifier == "guide-leading", @@ -338,8 +338,8 @@ struct LKXcodeViewHierarchyConverterTests { "sizing priorities ride along once the card exists") let rootView = item(withObjectIdentifier: 0x10, in: file) expect((attribute(LookinAttr_AutoLayout_Constraints_Constraints, of: rootView)?.value as? [LookinAutoLayoutConstraint])? - .map(\.constraintOid) == [0xd2], - "the superview lists the same constraint from its own side") + .map(\.constraintOid) == [0xD2], + "the superview lists the same constraint from its own side") } // MARK: - Layer nodes @@ -789,7 +789,7 @@ struct LKXcodeViewHierarchyConverterTests { return builder.build() } - private static let imageBytes = Data([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) + private static let imageBytes = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]) /// A UIKit capture with the class table filled in and one of each kind /// of value the cards read: scene `0xf1` → window `0x1` → root view @@ -982,7 +982,9 @@ struct LKXcodeViewHierarchyConverterTests { private static func item(withObjectIdentifier identifier: UInt, in file: LookinHierarchyFile) -> LookinDisplayItem { var found: LookinDisplayItem? walk(file.hierarchyInfo.displayItems ?? []) { item in - if found == nil, displayedObjectIdentifier(item) == identifier { found = item } + if found == nil, displayedObjectIdentifier(item) == identifier { + found = item + } } guard let found else { fail("no node with object identifier 0x\(String(identifier, radix: 16))") } return found @@ -991,7 +993,9 @@ struct LKXcodeViewHierarchyConverterTests { private static func attribute(_ identifier: String, of item: LookinDisplayItem?) -> LookinAttribute? { for group in item?.attributesGroupList ?? [] { for section in group.attrSections ?? [] { - if let attribute = section.attributes?.first(where: { $0.identifier == identifier }) { return attribute } + if let attribute = section.attributes?.first(where: { $0.identifier == identifier }) { + return attribute + } } } return nil @@ -1197,8 +1201,12 @@ struct LKXcodeViewHierarchyConverterTests { additionalGroups: [[String: Any]]? = nil ) -> [String: Any] { var object: [String: Any] = ["objectID": objectIdentifier, "className": className] - if let childGroup { object["childGroup"] = childGroup } - if let additionalGroups { object["additionalGroups"] = additionalGroups } + if let childGroup { + object["childGroup"] = childGroup + } + if let additionalGroups { + object["additionalGroups"] = additionalGroups + } return object } diff --git a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyGzipTests.swift b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyGzipTests.swift index 08f10fd0..6948d954 100644 --- a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyGzipTests.swift +++ b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyGzipTests.swift @@ -54,7 +54,7 @@ struct LKXcodeViewHierarchyGzipTests { var original = Data() original.reserveCapacity(3 * 1024 * 1024) // Semi-repetitive content: compresses well, but not to a degenerate stream. - for index in 0..<60_000 { + for index in 0 ..< 60000 { original.append(Data("{\"objectID\":\"0x\(String(index, radix: 16))\",\"className\":\"UIView\"},".utf8)) } expect(original.count > 1024 * 1024, "fixture should exceed the stream buffer") @@ -120,9 +120,15 @@ struct LKXcodeViewHierarchyGzipTests { extraField: Data? = nil ) -> Data { var flags: UInt8 = 0 - if extraField != nil { flags |= 0x04 } - if originalName != nil { flags |= 0x08 } - if comment != nil { flags |= 0x10 } + if extraField != nil { + flags |= 0x04 + } + if originalName != nil { + flags |= 0x08 + } + if comment != nil { + flags |= 0x10 + } var member = Data([0x1F, 0x8B, 0x08, flags, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03]) if let extraField { @@ -166,7 +172,7 @@ struct LKXcodeViewHierarchyGzipTests { } } guard writtenCount > 0 else { fail("fixture compression produced no output") } - return Data(destination[0.. [String: Any] { var object: [String: Any] = ["objectID": objectIdentifier, "className": className] - if let childGroup { object["childGroup"] = childGroup } - if let additionalGroups { object["additionalGroups"] = additionalGroups } + if let childGroup { + object["childGroup"] = childGroup + } + if let additionalGroups { + object["additionalGroups"] = additionalGroups + } return object } diff --git a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyPixelRecoveryTests.swift b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyPixelRecoveryTests.swift index 1f2576f1..dba7d5af 100644 --- a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyPixelRecoveryTests.swift +++ b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyPixelRecoveryTests.swift @@ -115,7 +115,7 @@ struct LKXcodeViewHierarchyPixelRecoveryTests { flippedChild.bounds = CGRect(x: 0, y: 0, width: 100, height: 40) markingAsDrawingEverywhere(flippedChild) flippedChild.anchorPoint = .zero - flippedChild.position = CGPoint(x: 0, y: 60) // the top 40 rows of the root + flippedChild.position = CGPoint(x: 0, y: 60) // the top 40 rows of the root flippedChild.isGeometryFlipped = true let band = CALayer() band.bounds = CGRect(x: 0, y: 0, width: 100, height: 20) @@ -159,10 +159,10 @@ struct LKXcodeViewHierarchyPixelRecoveryTests { private static func testNestedLayersOfAYDownTreeKeepTheirOrderAtEveryDepth() { // Positions as UIKit archives them: y measured downwards. let root = coloredLayer(width: 100, height: 100, at: .zero, color: .white) - let levelOne = coloredLayer(width: 100, height: 40, at: .zero, color: .blue) // top of the root - let bandOne = coloredLayer(width: 100, height: 10, at: .zero, color: .red) // top of level one - let levelTwo = coloredLayer(width: 100, height: 20, at: CGPoint(x: 0, y: 20), color: .yellow) // lower half of level one - let bandTwo = coloredLayer(width: 100, height: 5, at: .zero, color: .green) // top of level two + let levelOne = coloredLayer(width: 100, height: 40, at: .zero, color: .blue) // top of the root + let bandOne = coloredLayer(width: 100, height: 10, at: .zero, color: .red) // top of level one + let levelTwo = coloredLayer(width: 100, height: 20, at: CGPoint(x: 0, y: 20), color: .yellow) // lower half of level one + let bandTwo = coloredLayer(width: 100, height: 5, at: .zero, color: .green) // top of level two levelTwo.addSublayer(bandTwo) levelOne.addSublayer(bandOne) levelOne.addSublayer(levelTwo) @@ -194,7 +194,7 @@ struct LKXcodeViewHierarchyPixelRecoveryTests { private static func testLayerImagesLeaveOutTheViewsBeneath() { let root = coloredLayer(width: 100, height: 100, at: .zero, color: .white) let hosted = coloredLayer(width: 100, height: 40, at: CGPoint(x: 0, y: 60), color: .red) - hosted.borderWidth = 1 // draws more than a plain fill, so it keeps an image of its own + hosted.borderWidth = 1 // draws more than a plain fill, so it keeps an image of its own let orphan = coloredLayer(width: 100, height: 40, at: .zero, color: .blue) root.addSublayer(hosted) root.addSublayer(orphan) @@ -305,7 +305,7 @@ struct LKXcodeViewHierarchyPixelRecoveryTests { layer.bounds = CGRect(x: 0, y: 0, width: 6000, height: 4000) layer.contentsScale = 2 layer.backgroundColor = NSColor.red.cgColor - layer.borderWidth = 1 // something to draw, so the plain-colour skip does not apply + layer.borderWidth = 1 // something to draw, so the plain-colour skip does not apply guard let data = LKXcodeViewHierarchyPixelRecovery.renderingPNG( of: layer, includingSublayers: true, isFlipped: false ) else { fail("a large opaque layer should still render") } @@ -552,7 +552,9 @@ struct LKXcodeViewHierarchyPixelRecoveryTests { flippingContext: Bool ) -> RenderedOrientation? { let root = orientationFixture() - if shouldApplyGeometryFlip { applyGeometryFlip(to: root) } + if shouldApplyGeometryFlip { + applyGeometryFlip(to: root) + } guard let data = LKXcodeViewHierarchyPixelRecovery.renderingPNG( of: root, includingSublayers: true, isFlipped: flippingContext ), let bitmap = NSBitmapImageRep(data: data) else { return nil } diff --git a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyValueTests.swift b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyValueTests.swift index 4d41cc7d..5061b561 100644 --- a/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyValueTests.swift +++ b/Tests/XcodeViewHierarchy/LKXcodeViewHierarchyValueTests.swift @@ -118,7 +118,7 @@ struct LKXcodeViewHierarchyValueTests { private static func testSignedIntegerIsParsed() { let value = decode(jsonValue: "-42", format: "integer") - guard case .integer(let number) = value else { fail("integer did not decode") } + guard case let .integer(number) = value else { fail("integer did not decode") } expect(number == -42, "expected -42, got \(number)") } @@ -127,7 +127,7 @@ struct LKXcodeViewHierarchyValueTests { /// 1000 travels as "3e8". private static func testUnsignedIntegerIsHexadecimal() { let value = decode(jsonValue: "3e8", format: "uinteger") - guard case .unsignedInteger(let number) = value else { fail("uinteger did not decode") } + guard case let .unsignedInteger(number) = value else { fail("uinteger did not decode") } expect(number == 1000, "expected 1000 from hex 3e8, got \(number)") } @@ -136,14 +136,14 @@ struct LKXcodeViewHierarchyValueTests { /// Only digits a-f would have exposed the mistake. private static func testUnsignedIntegerHexIsNotReadAsDecimal() { let value = decode(jsonValue: "100", format: "uinteger") - guard case .unsignedInteger(let number) = value else { fail("uinteger did not decode") } + guard case let .unsignedInteger(number) = value else { fail("uinteger did not decode") } expect(number == 256, "uinteger '100' is hexadecimal and must be 256, got \(number)") } /// `ui` is the exception: Xcode reads it with `%u`, in decimal. private static func testShortUnsignedIntegerIsDecimal() { let value = decode(jsonValue: "100", format: "ui") - guard case .unsignedInteger(let number) = value else { fail("ui did not decode") } + guard case let .unsignedInteger(number) = value else { fail("ui did not decode") } expect(number == 100, "ui '100' is decimal and must be 100, got \(number)") } @@ -151,7 +151,7 @@ struct LKXcodeViewHierarchyValueTests { /// rather than being rejected. private static func testUnsignedMaskEncodedAsNegativeIsReinterpreted() { let value = decode(jsonValue: "-1", format: "uinteger") - guard case .unsignedInteger(let number) = value else { fail("uinteger did not decode") } + guard case let .unsignedInteger(number) = value else { fail("uinteger did not decode") } expect(number == UInt64.max, "expected UInt64.max, got \(number)") } @@ -159,7 +159,7 @@ struct LKXcodeViewHierarchyValueTests { /// must not overflow on the full 64-bit range. private static func testAllBitsSetUnsignedValueSurvives() { let value = decode(jsonValue: "ffffffffffffffff", format: "ul") - guard case .unsignedInteger(let number) = value else { fail("ul did not decode") } + guard case let .unsignedInteger(number) = value else { fail("ul did not decode") } expect(number == UInt64.max, "expected UInt64.max, got \(number)") } @@ -171,7 +171,7 @@ struct LKXcodeViewHierarchyValueTests { private static func testBase64DataIsDecoded() { let payload = Data("hierarchy".utf8) let value = decode(jsonValue: payload.base64EncodedString(), format: "public.data") - guard case .binaryData(let decoded) = value else { fail("public.data did not decode") } + guard case let .binaryData(decoded) = value else { fail("public.data did not decode") } expect(decoded == payload, "round-tripped data should match") } @@ -182,7 +182,7 @@ struct LKXcodeViewHierarchyValueTests { jsonValue: ["className": "NSLayoutConstraint", "memoryAddress": "0x1060b7100"], format: "objectInfo" ) - guard case .objectReference(let reference) = value else { fail("objectInfo did not decode") } + guard case let .objectReference(reference) = value else { fail("objectInfo did not decode") } expect(reference.className == "NSLayoutConstraint", "class name mismatch: \(reference.className)") expect(reference.objectIdentifier == "0x1060b7100", "identifier mismatch: \(reference.objectIdentifier)") } @@ -203,7 +203,7 @@ struct LKXcodeViewHierarchyValueTests { ] as [String: Any], format: "image" ) - guard case .image(let image) = value else { fail("image structure did not decode") } + guard case let .image(image) = value else { fail("image structure did not decode") } expect(image.encodedData == pixels, "image bytes should round-trip") guard let metadata = image.metadata else { fail("image metadata was dropped") } expect(metadata.width == 23.5 && metadata.height == 21.0, "image metadata size mismatch") @@ -215,7 +215,7 @@ struct LKXcodeViewHierarchyValueTests { private static func testImageStringFormIsDecoded() { let pixels = Data([0x89, 0x50, 0x4E, 0x47]) let value = decode(jsonValue: pixels.base64EncodedString(), format: "public.png") - guard case .image(let image) = value else { fail("public.png did not decode") } + guard case let .image(image) = value else { fail("public.png did not decode") } expect(image.encodedData == pixels, "image bytes should round-trip") expect(image.metadata == nil, "string-form images carry no metadata") } @@ -231,7 +231,7 @@ struct LKXcodeViewHierarchyValueTests { ] as [String: Any], format: "color" ) - guard case .color(let color) = value else { fail("color did not decode") } + guard case let .color(color) = value else { fail("color did not decode") } expect(color.colorSpaceName == "kCGColorSpaceSRGB", "colour space mismatch") expect(color.components == [0, 0, 0, 1], "components mismatch: \(color.components)") } @@ -250,7 +250,7 @@ struct LKXcodeViewHierarchyValueTests { ] as [String: Any], format: "font" ) - guard case .font(let font) = value else { fail("font did not decode") } + guard case let .font(font) = value else { fail("font did not decode") } expect(font.fontName == ".SFUI-Regular", "font name mismatch") expect(font.pointSize == 17, "point size should be 17, got \(font.pointSize)") expect(font.lineHeight == nil, "absent lineHeight should stay nil") @@ -260,7 +260,7 @@ struct LKXcodeViewHierarchyValueTests { private static func testCustomFormatIsPreservedVerbatim() { let value = decode(jsonValue: ["a", "b"], format: "custom") - guard case .custom(let text) = value else { fail("custom format did not pass through") } + guard case let .custom(text) = value else { fail("custom format did not pass through") } expect(text.contains("a") && text.contains("b"), "custom payload should be preserved: \(text)") } @@ -268,7 +268,7 @@ struct LKXcodeViewHierarchyValueTests { /// has never seen. Keeping them as text is what lets the file still open. private static func testUnknownStructuredSpecifierIsPreserved() { let value = decode(jsonValue: ["something": "new"], format: "materialEffect") - guard case .custom(let text) = value else { fail("unknown structure should be preserved as custom") } + guard case let .custom(text) = value else { fail("unknown structure should be preserved as custom") } expect(text.contains("new"), "unknown structure payload should survive: \(text)") } diff --git a/docs/mcp.md b/docs/mcp.md deleted file mode 100644 index 277875dc..00000000 --- a/docs/mcp.md +++ /dev/null @@ -1,181 +0,0 @@ -# Use LookInside with MCP clients - -LookInside 2.3.11 and later include a local [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server. It lets MCP-compatible AI clients inspect the iOS or macOS app that is open in LookInside. - -The server is bundled inside the signed LookInside app. There is no separate package to install and no API key or network endpoint to configure. - -## Before you configure a client - -Make sure all of the following are true: - -1. Install an official LookInside 2.3.11 or later release as `/Applications/LookInside.app`. -2. Add the [LookInside server package](https://github.com/LookInsideApp/LookInside-Release) to the Debug build of the app you want to inspect. -3. Run the target app. -4. Open LookInside and connect to the running app from the launch window. -5. Keep LookInside running while the MCP client uses its tools. - -Verify that the bundled executable is present: - -```sh -test -x /Applications/LookInside.app/Contents/Resources/lookinside-mcp \ - && echo "LookInside MCP server is installed" -``` - -If LookInside is installed somewhere else, replace the absolute path in the examples below. Use the executable inside the same LookInside app that you run so the host and MCP server stay on matching versions. - -## Configure an MCP client - -LookInside uses the local `stdio` MCP transport. The command is: - -```text -/Applications/LookInside.app/Contents/Resources/lookinside-mcp -``` - -It does not need arguments, environment variables, credentials, or OAuth. - -### Codex - -Add the server with the Codex CLI: - -```sh -codex mcp add lookinside -- /Applications/LookInside.app/Contents/Resources/lookinside-mcp -codex mcp get lookinside -``` - -Start a new Codex session after adding the server. In the Codex terminal UI, `/mcp` shows the active servers. The Codex app, CLI, and IDE extension share MCP configuration for the same Codex host. - -The equivalent `~/.codex/config.toml` entry is: - -```toml -[mcp_servers.lookinside] -command = "/Applications/LookInside.app/Contents/Resources/lookinside-mcp" -``` - -See the [official Codex MCP documentation](https://learn.chatgpt.com/docs/extend/mcp?surface=cli) for more client-side options. - -### Claude Code - -Add LookInside as a user-scoped local server: - -```sh -claude mcp add --transport stdio --scope user lookinside \ - -- /Applications/LookInside.app/Contents/Resources/lookinside-mcp -claude mcp get lookinside -``` - -Use `/mcp` inside Claude Code to inspect or reconnect the server. See the [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp) for scope and approval options. - -### Claude Desktop - -Open **Settings > Developer > Edit Config**, then add this entry to `~/Library/Application Support/Claude/claude_desktop_config.json`: - -```json -{ - "mcpServers": { - "lookinside": { - "command": "/Applications/LookInside.app/Contents/Resources/lookinside-mcp", - "args": [] - } - } -} -``` - -Merge the `lookinside` entry into the existing `mcpServers` object if the file already contains other servers. Quit and reopen Claude Desktop after saving it. - -See the [MCP guide for connecting local servers](https://modelcontextprotocol.io/docs/develop/connect-local-servers) for configuration and log locations. - -### Other clients - -Cursor, Windsurf, VS Code, and other MCP clients can use the same executable. Add a local `stdio` server named `lookinside` with: - -- **Command:** `/Applications/LookInside.app/Contents/Resources/lookinside-mcp` -- **Arguments:** none -- **Environment:** none - -The surrounding JSON or settings UI differs by client, so use that client's local `stdio` server format. - -## Verify the connection - -After restarting or opening a new client session, ask the agent: - -> Use LookInside's `ping_host` tool and report the connected host version. Do not use a shell command. - -A successful response looks like: - -```text -Host MCPBridge alive (LookInside 2.3.11). -``` - -Then verify that the current inspection session is visible: - -> Use LookInside to list the attached targets. Report the app name, device, OS, and license state. - -`list_targets` returns an empty list when LookInside is running but has not opened a target. Return to the LookInside launch window, connect to the app, and retry. - -## Recommended inspection workflow - -Agents get the most reliable results when they follow this order: - -1. Call `ping_host` to verify that LookInside is running. -2. Call `list_targets` and choose a target whose `licenseState` allows inspection. -3. Call `refresh_hierarchy` if the user navigated, presented a sheet, or otherwise changed the target app since the last read. -4. Use `take_screenshot` without an object identifier for a visual overview. -5. Use `find_views` when looking for a specific class, label, or address. Prefer it over loading a large complete tree. -6. Use `get_hierarchy` with a bounded `depth`, or scope it to a `rootObjectIdentifier` returned by an earlier call. -7. Call `read_view_details` before `read_attributes` when inspecting newly discovered object identifiers. -8. Re-find views after a hierarchy refresh. Object identifiers from the previous tree may no longer resolve. - -For example: - -> Use LookInside to inspect the first attached target. Refresh its hierarchy, take a window screenshot, find visible button views, and summarize each button's frame and title. Do not mutate the app. - -## Available tools - -| Area | Tools | Purpose | -| --- | --- | --- | -| Connection | `ping_host`, `list_targets` | Check the host and discover attached apps. | -| Hierarchy | `get_hierarchy`, `find_views` | Read or search the current view tree. | -| Details | `read_attributes`, `read_view_details`, `list_class_methods` | Inspect properties and available Objective-C selectors. | -| Visuals | `take_screenshot` | Capture the key window or one view as PNG. | -| Refresh | `refresh_hierarchy` | Reload the tree after the target UI changes. | -| Snapshots | `capture_snapshot`, `list_snapshots`, `drop_snapshot`, `diff_snapshots` | Compare structure, frames, and visibility across two points in time. | -| Mutation | `invoke_method`, `modify_attribute` | Call a zero-argument selector or change a supported attribute in the Debug app. | - -The MCP client receives each tool's complete input schema and description when it connects. Let the client use those schemas instead of guessing argument names or value shapes. - -## Compare UI changes - -Snapshots are useful when you want to understand the effect of a navigation or interaction without comparing two large trees manually: - -1. Call `capture_snapshot` with a label such as `before`. -2. Change the target app, or ask the user to perform the interaction. -3. Call `capture_snapshot` again with a label such as `after`. -4. Call `diff_snapshots` with the two returned snapshot identifiers. - -Example prompt: - -> Capture a LookInside hierarchy snapshot named `before`. Ask me to perform the interaction, then capture `after` and diff the two snapshots. Summarize added, removed, moved, resized, and visibility-changed views. - -Snapshots live only for the lifetime of that MCP server process. Capture both snapshots in the same client session. - -## Safety and privacy - -- Treat the target app as a development or debugging environment. `invoke_method` and `modify_attribute` can change its state immediately. -- Ask before using mutation tools unless the user explicitly requested the change. -- Prefer `list_class_methods` before `invoke_method`; method invocation currently supports zero-argument Objective-C selectors. -- Secure text attributes are redacted. Screenshot capture refuses regions containing detected secure input unless the caller explicitly acknowledges the risk. -- Do not acknowledge secure-content screenshots unless the user understands what is visible and has asked for that capture. -- The MCP server is local and does not configure credentials. Your AI client still applies its own data-handling and tool-approval policies to any hierarchy, attributes, or screenshots it reads. - -## Troubleshooting - -| Symptom | What to check | -| --- | --- | -| The client cannot start `lookinside` | Confirm the command is an absolute path and the bundled executable passes the `test -x` check above. Official releases include it; a local source build may not. | -| `ping_host` says LookInside is not running | Launch the same LookInside app bundle referenced by the MCP command, then retry. | -| `list_targets` returns `[]` | Run the target app and connect to it from the LookInside launch window. Opening LookInside alone is not enough. | -| A target reports a blocked license state | Resolve activation in the LookInside app, reconnect the target, and run `list_targets` again. | -| Results describe the previous screen | Call `refresh_hierarchy`, then locate the views again instead of reusing old object identifiers. | -| `read_attributes` reports uncached details | Call `read_view_details` for that object identifier, then retry. | -| A screenshot is blank | Capture a descendant that performs the drawing; upper window, theme-frame, or hosting views can render no pixels by themselves. | -| The server was added but does not appear | Restart or open a new MCP client session and use the client's MCP status command or panel. | From 81a7845fcb0f4362324bd28ea3846e112acbc2a2 Mon Sep 17 00:00:00 2001 From: Lakr <2333owo@gmail.com> Date: Wed, 7 Oct 2026 22:01:31 +0900 Subject: [PATCH 2/3] ci: build the app and run the gesture and activation tests Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index abb94c5b..df3a78ac 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,6 +38,25 @@ jobs: - name: Build SwiftPM Products run: swift build -c debug --product LookinShared + - name: Test Gesture Capture Models + run: bash Scripts/test-gesture-models.sh + + - name: Test Activation Package + working-directory: Packages/LookInsideActivation + run: swift test + + - name: Build LookInside + run: | + xcodebuild \ + -project LookInside.xcodeproj \ + -scheme LookInside \ + -configuration Debug \ + -destination 'generic/platform=macOS' \ + -derivedDataPath "$RUNNER_TEMP/DerivedData" \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO \ + build + secret_scan: name: Secret Scan runs-on: ubuntu-latest From 5a08d3d00b14acf31563a9ba29a270fc50e50d84 Mon Sep 17 00:00:00 2001 From: Lakr <2333owo@gmail.com> Date: Wed, 7 Oct 2026 22:54:51 +0900 Subject: [PATCH 3/3] fix: take the marketing version from Version.xcconfig The app target hard-coded MARKETING_VERSION = 2.3.13, which hid the xcconfig default (3.0.0). Release builds pass the version on the command line, so only local builds were affected. Co-Authored-By: Claude Opus 5.5 --- LookInside.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index f232624e..45a5e15e 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -650,7 +650,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.3.13; + MARKETING_VERSION = "$(inherited)"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)"; PRODUCT_NAME = LookInside; @@ -720,7 +720,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 2.3.13; + MARKETING_VERSION = "$(inherited)"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)"; PRODUCT_NAME = LookInside;