From 0346bef473213a970d4310c254b4fa6349ae68d3 Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 13 Jun 2026 16:01:13 +0800 Subject: [PATCH 01/17] Add CocoaPods specs for LookInside libraries --- LookinCore.podspec | 24 ++++++++++++++++++++++++ LookinPodspecHelpers.rb | 27 +++++++++++++++++++++++++++ LookinServer.podspec | 32 ++++++++++++++++++++++++++++++++ LookinServerBase.podspec | 17 +++++++++++++++++ LookinServerDynamic.podspec | 31 +++++++++++++++++++++++++++++++ LookinServerInjected.podspec | 16 ++++++++++++++++ LookinShared.podspec | 11 +++++++++++ 7 files changed, 158 insertions(+) create mode 100644 LookinCore.podspec create mode 100644 LookinPodspecHelpers.rb create mode 100644 LookinServer.podspec create mode 100644 LookinServerBase.podspec create mode 100644 LookinServerDynamic.podspec create mode 100644 LookinServerInjected.podspec create mode 100644 LookinShared.podspec diff --git a/LookinCore.podspec b/LookinCore.podspec new file mode 100644 index 0000000..cdb1ded --- /dev/null +++ b/LookinCore.podspec @@ -0,0 +1,24 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinCore' + LookinPodspecHelpers.apply_common_metadata(s, 'Shared LookInside data models and utilities.') + + s.module_name = 'LookinCore' + s.static_framework = true + s.dependency 'LookinServerBase' + + s.source_files = 'Sources/LookinCore/**/*.{h,m}' + s.exclude_files = 'Sources/LookinCore/include/LookinCore.h' + s.public_header_files = 'Sources/LookinCore/**/*.h' + + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths( + 'Sources/LookinCore', + 'Sources/LookinCore/include', + 'Sources/LookinCore/Category', + 'Sources/LookinCore/Peertalk' + ) + } +end diff --git a/LookinPodspecHelpers.rb b/LookinPodspecHelpers.rb new file mode 100644 index 0000000..bf5ad4e --- /dev/null +++ b/LookinPodspecHelpers.rb @@ -0,0 +1,27 @@ +module LookinPodspecHelpers + VERSION = '0.1.0' + HOMEPAGE = 'https://lookinside-app.com' + AUTHOR = { 'LookInside' => 'support@lookinside-app.com' }.freeze + + module_function + + def apply_common_metadata(spec, summary) + spec.version = VERSION + spec.summary = summary + spec.description = summary + spec.homepage = HOMEPAGE + spec.license = { :type => 'MIT', :file => 'LICENSE' } + spec.author = AUTHOR + spec.source = { :path => '.' } + spec.ios.deployment_target = '12.0' + spec.requires_arc = true + end + + def base_defines + '$(inherited) SHOULD_COMPILE_LOOKIN_SERVER=1' + end + + def header_search_paths(*paths) + paths.flatten.map { |path| %("$(PODS_TARGET_SRCROOT)/#{path}") }.unshift('$(inherited)').join(' ') + end +end diff --git a/LookinServer.podspec b/LookinServer.podspec new file mode 100644 index 0000000..2ab927c --- /dev/null +++ b/LookinServer.podspec @@ -0,0 +1,32 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinServer' + LookinPodspecHelpers.apply_common_metadata(s, 'LookInside debug server for iOS applications.') + + s.module_name = 'LookinServer' + s.requires_arc = true + s.static_framework = true + s.dependency 'LookinShared' + + s.source_files = [ + 'Sources/LookinServer/Server/**/*.{h,m}' + ] + + s.public_header_files = [ + 'Sources/LookinServer/Server/LookinServer.h', + 'Sources/LookinServer/include/LookinServer.h', + 'Sources/LookinServer/Server/**/*.h' + ] + + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths( + 'Sources/LookinServer/Server', + 'Sources/LookinServer/Server/Category', + 'Sources/LookinServer/Server/Connection', + 'Sources/LookinServer/Server/Connection/RequestHandler', + 'Sources/LookinServer/Server/Others' + ) + } +end diff --git a/LookinServerBase.podspec b/LookinServerBase.podspec new file mode 100644 index 0000000..f44a2f4 --- /dev/null +++ b/LookinServerBase.podspec @@ -0,0 +1,17 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinServerBase' + LookinPodspecHelpers.apply_common_metadata(s, 'Base model support for LookInside server libraries.') + + s.module_name = 'LookinServerBase' + s.static_framework = true + + s.source_files = 'Sources/LookinServerBase/**/*.{h,m}' + s.public_header_files = 'Sources/LookinServerBase/**/*.h' + + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths('Sources/LookinServerBase') + } +end diff --git a/LookinServerDynamic.podspec b/LookinServerDynamic.podspec new file mode 100644 index 0000000..e59a6fd --- /dev/null +++ b/LookinServerDynamic.podspec @@ -0,0 +1,31 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinServerDynamic' + LookinPodspecHelpers.apply_common_metadata(s, 'Dynamic-framework CocoaPods package for LookInside debug server.') + + s.module_name = 'LookinServer' + s.static_framework = false + s.dependency 'LookinShared' + + s.source_files = [ + 'Sources/LookinServer/Server/**/*.{h,m}' + ] + + s.public_header_files = [ + 'Sources/LookinServer/Server/LookinServer.h', + 'Sources/LookinServer/include/LookinServer.h', + 'Sources/LookinServer/Server/**/*.h' + ] + + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths( + 'Sources/LookinServer/Server', + 'Sources/LookinServer/Server/Category', + 'Sources/LookinServer/Server/Connection', + 'Sources/LookinServer/Server/Connection/RequestHandler', + 'Sources/LookinServer/Server/Others' + ) + } +end diff --git a/LookinServerInjected.podspec b/LookinServerInjected.podspec new file mode 100644 index 0000000..c985ead --- /dev/null +++ b/LookinServerInjected.podspec @@ -0,0 +1,16 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinServerInjected' + LookinPodspecHelpers.apply_common_metadata(s, 'Constructor-based LookInside server bootstrap for injected builds.') + + s.module_name = 'LookinServerInjected' + s.static_framework = false + s.dependency 'LookinServerDynamic' + + s.source_files = 'Sources/LookinServerInjected/**/*.{h,m}' + + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines + } +end diff --git a/LookinShared.podspec b/LookinShared.podspec new file mode 100644 index 0000000..49ec9f5 --- /dev/null +++ b/LookinShared.podspec @@ -0,0 +1,11 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinShared' + LookinPodspecHelpers.apply_common_metadata(s, 'Aggregate CocoaPods dependency for LookInside shared libraries.') + + s.module_name = 'LookinShared' + s.static_framework = true + s.dependency 'LookinCore' + s.dependency 'LookinServerBase' +end From 5ed14109ea420776b9616623b5040cdf29cc0728 Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 13 Jun 2026 16:42:32 +0800 Subject: [PATCH 02/17] Fix LookinCore CocoaPods header imports --- LookinCore.podspec | 13 +++++++++++-- Package.swift | 1 + Sources/LookinCore/LookinDisplayItem.m | 4 ++-- Sources/LookinCore/LookinDisplayItemDetail.m | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/LookinCore.podspec b/LookinCore.podspec index cdb1ded..f8f0888 100644 --- a/LookinCore.podspec +++ b/LookinCore.podspec @@ -8,9 +8,17 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'LookinServerBase' - s.source_files = 'Sources/LookinCore/**/*.{h,m}' + s.source_files = [ + 'Sources/LookinCore/**/*.{h,m}', + 'Sources/LookinServer/Server/Category/UIColor+LookinServer.h', + 'Sources/LookinServer/Server/Category/UIImage+LookinServer.h' + ] s.exclude_files = 'Sources/LookinCore/include/LookinCore.h' s.public_header_files = 'Sources/LookinCore/**/*.h' + s.private_header_files = [ + 'Sources/LookinServer/Server/Category/UIColor+LookinServer.h', + 'Sources/LookinServer/Server/Category/UIImage+LookinServer.h' + ] s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, @@ -18,7 +26,8 @@ Pod::Spec.new do |s| 'Sources/LookinCore', 'Sources/LookinCore/include', 'Sources/LookinCore/Category', - 'Sources/LookinCore/Peertalk' + 'Sources/LookinCore/Peertalk', + 'Sources/LookinServer/Server/Category' ) } end diff --git a/Package.swift b/Package.swift index 507c7fe..245b0e1 100644 --- a/Package.swift +++ b/Package.swift @@ -65,6 +65,7 @@ let package = Package( .headerSearchPath("."), .headerSearchPath("Category"), .headerSearchPath("Peertalk"), + .headerSearchPath("../LookinServer/Server/Category"), ], cxxSettings: sharedCXXDefines ), diff --git a/Sources/LookinCore/LookinDisplayItem.m b/Sources/LookinCore/LookinDisplayItem.m index 4ff7bd3..5682fe4 100644 --- a/Sources/LookinCore/LookinDisplayItem.m +++ b/Sources/LookinCore/LookinDisplayItem.m @@ -20,8 +20,8 @@ #import "LookinDashboardBlueprint.h" #if TARGET_OS_IPHONE -#import "../LookinServer/Server/Category/UIColor+LookinServer.h" -#import "../LookinServer/Server/Category/UIImage+LookinServer.h" +#import "UIColor+LookinServer.h" +#import "UIImage+LookinServer.h" #elif TARGET_OS_OSX #endif diff --git a/Sources/LookinCore/LookinDisplayItemDetail.m b/Sources/LookinCore/LookinDisplayItemDetail.m index 448294c..195c03c 100644 --- a/Sources/LookinCore/LookinDisplayItemDetail.m +++ b/Sources/LookinCore/LookinDisplayItemDetail.m @@ -12,7 +12,7 @@ #import "Image+Lookin.h" #if TARGET_OS_IPHONE -#import "../LookinServer/Server/Category/UIImage+LookinServer.h" +#import "UIImage+LookinServer.h" #endif @implementation LookinDisplayItemDetail From 56d27743de58598c6c41a017fd5e5d7cc4e716bc Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 13 Jun 2026 17:15:24 +0800 Subject: [PATCH 03/17] Support CocoaPods builds on macOS and tvOS --- LookinPodspecHelpers.rb | 2 ++ LookinServer.podspec | 3 +++ LookinServerDynamic.podspec | 3 +++ .../LookinServer/Server/Category/UIView+LookinServer.m | 10 +++++++--- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/LookinPodspecHelpers.rb b/LookinPodspecHelpers.rb index bf5ad4e..074542d 100644 --- a/LookinPodspecHelpers.rb +++ b/LookinPodspecHelpers.rb @@ -14,6 +14,8 @@ def apply_common_metadata(spec, summary) spec.author = AUTHOR spec.source = { :path => '.' } spec.ios.deployment_target = '12.0' + spec.tvos.deployment_target = '12.0' + spec.osx.deployment_target = '11.0' spec.requires_arc = true end diff --git a/LookinServer.podspec b/LookinServer.podspec index 2ab927c..e0c2355 100644 --- a/LookinServer.podspec +++ b/LookinServer.podspec @@ -18,6 +18,9 @@ Pod::Spec.new do |s| 'Sources/LookinServer/include/LookinServer.h', 'Sources/LookinServer/Server/**/*.h' ] + s.tvos.exclude_files = [ + 'Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.{h,m}' + ] s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, diff --git a/LookinServerDynamic.podspec b/LookinServerDynamic.podspec index e59a6fd..bff91fe 100644 --- a/LookinServerDynamic.podspec +++ b/LookinServerDynamic.podspec @@ -17,6 +17,9 @@ Pod::Spec.new do |s| 'Sources/LookinServer/include/LookinServer.h', 'Sources/LookinServer/Server/**/*.h' ] + s.tvos.exclude_files = [ + 'Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.{h,m}' + ] s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, diff --git a/Sources/LookinServer/Server/Category/UIView+LookinServer.m b/Sources/LookinServer/Server/Category/UIView+LookinServer.m index 3086798..8572e26 100644 --- a/Sources/LookinServer/Server/Category/UIView+LookinServer.m +++ b/Sources/LookinServer/Server/Category/UIView+LookinServer.m @@ -267,14 +267,18 @@ - (NSInteger)lks_traitCollection_userInterfaceStyle { } - (NSInteger)lks_traitCollection_userInterfaceLevel { +#if TARGET_OS_TV + return -1; +#else if (@available(iOS 13.0, *)) { return (NSInteger)self.traitCollection.userInterfaceLevel; } return -1; +#endif } - (NSInteger)lks_traitCollection_activeAppearance { - if (@available(iOS 14.0, *)) { + if (@available(iOS 14.0, tvOS 14.0, *)) { return (NSInteger)self.traitCollection.activeAppearance; } return -1; @@ -314,7 +318,7 @@ - (NSInteger)lks_traitCollection_displayGamut { } - (NSInteger)lks_traitCollection_imageDynamicRange { - if (@available(iOS 17.0, *)) { + if (@available(iOS 17.0, tvOS 17.0, *)) { return (NSInteger)self.traitCollection.imageDynamicRange; } return -1; @@ -343,7 +347,7 @@ - (NSString *)lks_traitCollection_preferredContentSizeCategory { } - (NSString *)lks_traitCollection_typesettingLanguage { - if (@available(iOS 17.0, *)) { + if (@available(iOS 17.0, tvOS 17.0, *)) { return self.traitCollection.typesettingLanguage; } return nil; From bc8b480d76019dec8169505b7deceb6cfc9cbe24 Mon Sep 17 00:00:00 2001 From: VanJay Date: Sun, 14 Jun 2026 12:21:25 +0800 Subject: [PATCH 04/17] Consolidate LookinServer CocoaPods specs --- LookinCore.podspec | 33 --------------- LookinServer.podspec | 84 +++++++++++++++++++++++++++---------- LookinServerBase.podspec | 17 -------- LookinServerDynamic.podspec | 2 +- LookinShared.podspec | 11 ----- 5 files changed, 63 insertions(+), 84 deletions(-) delete mode 100644 LookinCore.podspec delete mode 100644 LookinServerBase.podspec delete mode 100644 LookinShared.podspec diff --git a/LookinCore.podspec b/LookinCore.podspec deleted file mode 100644 index f8f0888..0000000 --- a/LookinCore.podspec +++ /dev/null @@ -1,33 +0,0 @@ -require_relative 'LookinPodspecHelpers' - -Pod::Spec.new do |s| - s.name = 'LookinCore' - LookinPodspecHelpers.apply_common_metadata(s, 'Shared LookInside data models and utilities.') - - s.module_name = 'LookinCore' - s.static_framework = true - s.dependency 'LookinServerBase' - - s.source_files = [ - 'Sources/LookinCore/**/*.{h,m}', - 'Sources/LookinServer/Server/Category/UIColor+LookinServer.h', - 'Sources/LookinServer/Server/Category/UIImage+LookinServer.h' - ] - s.exclude_files = 'Sources/LookinCore/include/LookinCore.h' - s.public_header_files = 'Sources/LookinCore/**/*.h' - s.private_header_files = [ - 'Sources/LookinServer/Server/Category/UIColor+LookinServer.h', - 'Sources/LookinServer/Server/Category/UIImage+LookinServer.h' - ] - - s.pod_target_xcconfig = { - 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, - 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths( - 'Sources/LookinCore', - 'Sources/LookinCore/include', - 'Sources/LookinCore/Category', - 'Sources/LookinCore/Peertalk', - 'Sources/LookinServer/Server/Category' - ) - } -end diff --git a/LookinServer.podspec b/LookinServer.podspec index e0c2355..772041f 100644 --- a/LookinServer.podspec +++ b/LookinServer.podspec @@ -7,29 +7,69 @@ Pod::Spec.new do |s| s.module_name = 'LookinServer' s.requires_arc = true s.static_framework = true - s.dependency 'LookinShared' + s.default_subspec = 'Server' - s.source_files = [ - 'Sources/LookinServer/Server/**/*.{h,m}' - ] + s.subspec 'Base' do |ss| + ss.source_files = 'Sources/LookinServerBase/**/*.{h,m}' + ss.public_header_files = 'Sources/LookinServerBase/**/*.h' + ss.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths('Sources/LookinServerBase') + } + end - s.public_header_files = [ - 'Sources/LookinServer/Server/LookinServer.h', - 'Sources/LookinServer/include/LookinServer.h', - 'Sources/LookinServer/Server/**/*.h' - ] - s.tvos.exclude_files = [ - 'Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.{h,m}' - ] + s.subspec 'Core' do |ss| + ss.dependency 'LookinServer/Base' + ss.source_files = [ + 'Sources/LookinCore/**/*.{h,m}', + 'Sources/LookinServer/Server/Category/UIColor+LookinServer.h', + 'Sources/LookinServer/Server/Category/UIImage+LookinServer.h' + ] + ss.exclude_files = 'Sources/LookinCore/include/LookinCore.h' + ss.public_header_files = 'Sources/LookinCore/**/*.h' + ss.private_header_files = [ + 'Sources/LookinServer/Server/Category/UIColor+LookinServer.h', + 'Sources/LookinServer/Server/Category/UIImage+LookinServer.h' + ] + ss.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths( + 'Sources/LookinCore', + 'Sources/LookinCore/include', + 'Sources/LookinCore/Category', + 'Sources/LookinCore/Peertalk', + 'Sources/LookinServer/Server/Category' + ) + } + end - s.pod_target_xcconfig = { - 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, - 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths( - 'Sources/LookinServer/Server', - 'Sources/LookinServer/Server/Category', - 'Sources/LookinServer/Server/Connection', - 'Sources/LookinServer/Server/Connection/RequestHandler', - 'Sources/LookinServer/Server/Others' - ) - } + s.subspec 'Shared' do |ss| + ss.dependency 'LookinServer/Core' + ss.dependency 'LookinServer/Base' + end + + s.subspec 'Server' do |ss| + ss.dependency 'LookinServer/Shared' + ss.source_files = [ + 'Sources/LookinServer/Server/**/*.{h,m}' + ] + ss.public_header_files = [ + 'Sources/LookinServer/Server/LookinServer.h', + 'Sources/LookinServer/include/LookinServer.h', + 'Sources/LookinServer/Server/**/*.h' + ] + ss.tvos.exclude_files = [ + 'Sources/LookinServer/Server/Category/UIWindowScene+LookinServer.{h,m}' + ] + ss.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths( + 'Sources/LookinServer/Server', + 'Sources/LookinServer/Server/Category', + 'Sources/LookinServer/Server/Connection', + 'Sources/LookinServer/Server/Connection/RequestHandler', + 'Sources/LookinServer/Server/Others' + ) + } + end end diff --git a/LookinServerBase.podspec b/LookinServerBase.podspec deleted file mode 100644 index f44a2f4..0000000 --- a/LookinServerBase.podspec +++ /dev/null @@ -1,17 +0,0 @@ -require_relative 'LookinPodspecHelpers' - -Pod::Spec.new do |s| - s.name = 'LookinServerBase' - LookinPodspecHelpers.apply_common_metadata(s, 'Base model support for LookInside server libraries.') - - s.module_name = 'LookinServerBase' - s.static_framework = true - - s.source_files = 'Sources/LookinServerBase/**/*.{h,m}' - s.public_header_files = 'Sources/LookinServerBase/**/*.h' - - s.pod_target_xcconfig = { - 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, - 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths('Sources/LookinServerBase') - } -end diff --git a/LookinServerDynamic.podspec b/LookinServerDynamic.podspec index bff91fe..326fd7d 100644 --- a/LookinServerDynamic.podspec +++ b/LookinServerDynamic.podspec @@ -6,7 +6,7 @@ Pod::Spec.new do |s| s.module_name = 'LookinServer' s.static_framework = false - s.dependency 'LookinShared' + s.dependency 'LookinServer/Shared' s.source_files = [ 'Sources/LookinServer/Server/**/*.{h,m}' diff --git a/LookinShared.podspec b/LookinShared.podspec deleted file mode 100644 index 49ec9f5..0000000 --- a/LookinShared.podspec +++ /dev/null @@ -1,11 +0,0 @@ -require_relative 'LookinPodspecHelpers' - -Pod::Spec.new do |s| - s.name = 'LookinShared' - LookinPodspecHelpers.apply_common_metadata(s, 'Aggregate CocoaPods dependency for LookInside shared libraries.') - - s.module_name = 'LookinShared' - s.static_framework = true - s.dependency 'LookinCore' - s.dependency 'LookinServerBase' -end From 08c8d118a0dd0b8e2f0edca06950ef6db9eafd78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=87=E6=9D=B0?= Date: Mon, 15 Jun 2026 16:26:23 +0800 Subject: [PATCH 05/17] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/lookinside-mcp-ui-debugging/SKILL.md | 241 ++++++++++++++++++ .../agents/openai.yaml | 4 + 2 files changed, 245 insertions(+) create mode 100644 skills/lookinside-mcp-ui-debugging/SKILL.md create mode 100644 skills/lookinside-mcp-ui-debugging/agents/openai.yaml diff --git a/skills/lookinside-mcp-ui-debugging/SKILL.md b/skills/lookinside-mcp-ui-debugging/SKILL.md new file mode 100644 index 0000000..4aaa7a6 --- /dev/null +++ b/skills/lookinside-mcp-ui-debugging/SKILL.md @@ -0,0 +1,241 @@ +--- +name: lookinside-mcp-ui-debugging +description: Use when inspecting or debugging iOS/macOS app UI with LookInside, lookinside-mcp, MCP, Claude, Codex, UI hierarchy, layout, accessibility, clipped views, hidden buttons, wrong labels, screenshots, Debug builds, LookinServer, CocoaPods LookinServer pods, bundle id targeting, or when comparing AI-readable UI state with LookInside.app. +--- + +# LookInside MCP UI Debugging + +## Overview + +Use `lookinside-mcp` to let AI tools inspect a running Debug app's UI hierarchy without relying on the LookInside.app GUI. The target app must expose `LookinServer`; AI clients talk to the CLI over MCP stdio. + +## Trigger Keywords + +Use this skill for requests containing or implying: + +- `LookInside`, `LookinServer`, `lookinside-mcp`, `MCP`, `UI hierarchy`, `层级`, `界面调试`, `调试 UI` +- `Claude 调试界面`, `Codex 调试界面`, `AI 看界面`, `AI 检查 UI` +- `bundle id`, `LOOKIN_MCP_TARGET_BUNDLE_ID`, `print-config`, `codex mcp add` +- `Podfile`, `CocoaPods`, `LookinServerBase`, `LookinCore`, `LookinShared`, `kUse_Local_Lookin` +- `current_screen`, `get_hierarchy`, `search_elements`, `diagnose_layout`, `diagnose_accessibility` +- UI symptoms: hidden/missing button, clipped label, wrong frame, wrong text, overlay, z-order, accessibility label, small tap target, offscreen view, layout overlap +- Questions like: "不用 GUI 客户端能不能调 UI?", "连接到某个 app 看层级", "告诉我当前界面信息" + +Do not use it for ordinary macOS accessibility inspection unless `lookinside-mcp` is unavailable or the user only wants a surface-level screen read. + +## Required Mental Model + +- `lookinside-mcp` is a CLI executable and MCP stdio server. +- It does not inspect arbitrary apps by itself. The target app must be a Debug build with `LookinServer` embedded or injected. +- `LookInside.app` GUI and `lookinside-mcp` are both clients of the same in-app `LookinServer`. +- `LookinServer` is effectively single-client. If the GUI is connected, MCP may see `no_target` until the GUI disconnects. +- In multi-app environments, always target by bundle id. + +## Target App Integration + +The expected target-side setup is a Debug-only CocoaPods integration that brings `LookinServer` into the app process. This pattern is compatible: + +```ruby +def install_lookin_server_pods + lookin_pods = %w[ + LookinServerBase + LookinCore + LookinShared + LookinServer + ] + debug_only_options = { :configurations => ['Debug'] } + + use_local_lookin = %w[1 true yes].include?(ENV['kUse_Local_Lookin'].to_s.downcase) + lookin_source = if use_local_lookin + { :path => '../LookInside' } + else + { :git => 'https://kgit.kugou.net/iOS/KGLookInside.git', :branch => 'feature/vanjay/kg_main' } + end + + lookin_pods.each do |pod_name| + pod pod_name, lookin_source.merge(debug_only_options) + end +end +``` + +Use this helper inside each target that should be inspectable: + +```ruby +target 'YourApp' do + install_lookin_server_pods +end +``` + +Important checks: + +- Build the target with the `Debug` configuration. These pods are intentionally absent from `Release`. +- Run `pod install` after changing the Podfile, then build from the workspace. +- Set `kUse_Local_Lookin=1` only when the target app should use the local `../LookInside` checkout; otherwise it should pull the configured git branch. +- If the target has app extensions, install the pods only into the app process that owns the UI being inspected, unless there is a specific extension UI to debug. +- Do not treat MCP `no_target` as an MCP bug until the app has been rebuilt and relaunched with these Debug pods present. + +## Configuration + +Build or locate the binary: + +```sh +swift build --product lookinside-mcp +``` + +Common binary path in this workspace: + +```sh +/Users/VanJay/Documents/Work/Private/LookInsideWorkspace/LookInside/.build/debug/lookinside-mcp +``` + +Configure Codex for a specific app: + +```sh +codex mcp add lookinside \ + LOOKIN_MCP_TARGET_BUNDLE_ID= \ + /path/to/lookinside-mcp serve +``` + +For example: + +```sh +codex mcp add lookinside \ + LOOKIN_MCP_TARGET_BUNDLE_ID=cn.vanjay.HostsEditor \ + /Users/VanJay/Documents/Work/Private/LookInsideWorkspace/LookInside/.build/debug/lookinside-mcp serve +``` + +For Claude/Cursor/other MCP clients, use: + +```sh +lookinside-mcp print-config claude-code +lookinside-mcp print-config codex +lookinside-mcp print-config cursor +``` + +Then add `LOOKIN_MCP_TARGET_BUNDLE_ID` to the MCP server environment when more than one app may be reachable. + +## Inspection Workflow + +1. Identify the target bundle id. + - macOS: `osascript -e 'id of app "AppName"'` + - From LookInside response: check `bundleIdentifier` + - From Xcode project: check `PRODUCT_BUNDLE_IDENTIFIER` + +2. Confirm the app is running and has `LookinServer`. + - For iOS Simulator: launch the Debug app through Xcode/XcodeBuildMCP. + - For CocoaPods projects: confirm the app target calls `install_lookin_server_pods`, `pod install` has run, and the app was rebuilt in `Debug`. + - For macOS: launch a Debug app that links `LookinServer`, or ensure the GUI injected it. + +3. Check whether another client is occupying the server. + +```sh +lsof -nP -iTCP:47164-47179 +``` + +Look for established GUI connections such as: + +```text +LookInside.app -> 127.0.0.1:47170 +Target.app -> 127.0.0.1:47170 +``` + +If the GUI is connected, quit/disconnect LookInside.app before using MCP. + +4. Probe reachability. + +```sh +LOOKIN_MCP_TARGET_BUNDLE_ID= /path/to/lookinside-mcp health +``` + +Expected: + +```text +status: ok +found 1 reachable app: +``` + +5. Inspect through MCP tools. + - First call `current_screen`. + - If the screen is large, call `get_hierarchy` with a bounded `maxDepth`. + - Use `search_elements` by `className`, `role`, `accessibilityId`, or text. + - Use `diagnose_layout` / `diagnose_accessibility` for heuristics. + - Use `capture_screenshot` only when the caller needs visual evidence; it can return large base64 payloads. + +## Manual MCP Smoke Test + +Use this when validating CLI behavior outside a configured MCP client: + +```sh +LOOKIN_MCP_TARGET_BUNDLE_ID= node - <<'NODE' +const { spawn } = require('child_process'); +const bin = '/path/to/lookinside-mcp'; +const env = { ...process.env, LOOKIN_MCP_TARGET_BUNDLE_ID: '' }; +const child = spawn(bin, ['serve'], { env, stdio: ['pipe', 'pipe', 'pipe'] }); +child.stderr.on('data', d => process.stderr.write(d)); +child.stdout.on('data', d => process.stdout.write(d)); +function send(msg) { child.stdin.write(JSON.stringify(msg) + '\n'); } +setTimeout(() => send({jsonrpc:'2.0', id:1, method:'initialize', params:{protocolVersion:'2025-11-25', capabilities:{}, clientInfo:{name:'smoke', version:'1.0'}}}), 300); +setTimeout(() => send({jsonrpc:'2.0', method:'notifications/initialized', params:{}}), 700); +setTimeout(() => send({jsonrpc:'2.0', id:2, method:'tools/call', params:{name:'current_screen', arguments:{}}}), 1100); +setTimeout(() => child.kill('SIGTERM'), 8000); +NODE +``` + +The stdio transport is newline-delimited JSON, not `Content-Length` framing. + +## Troubleshooting + +### `status: no_target` + +Check in this order: + +- Target app is running. +- Target app is Debug and has `LookinServer`. +- CocoaPods integration includes `LookinServerBase`, `LookinCore`, `LookinShared`, and `LookinServer` for the app target. +- App was rebuilt from the `.xcworkspace` after `pod install`. +- Bundle id is exact. +- GUI LookInside.app is not connected to the same server. +- Ports are visible with `lsof -nP -iTCP:47164-47179`. +- macOS target should usually listen on `47170-47174`; simulator target on `47164-47169`. + +### GUI can see the app but MCP cannot + +Most likely LookInside.app is occupying the single LookinServer connection. Quit/disconnect GUI, then retry MCP. If the GUI was needed to inject `LookinServer` into a macOS process, inject first, then disconnect the GUI so MCP can connect. + +### MCP connects to the wrong app + +Set: + +```sh +LOOKIN_MCP_TARGET_BUNDLE_ID= +``` + +Then rerun `health` or `current_screen`. Without this, the server may choose the first reachable app by port order. + +### `current_screen` times out + +Use `LOOKIN_MCP_DEBUG=1` to verify request/response flow: + +```sh +LOOKIN_MCP_DEBUG=1 LOOKIN_MCP_TARGET_BUNDLE_ID= /path/to/lookinside-mcp serve +``` + +Expected debug lines include send callback and received frame. If send succeeds but no response arrives, check target logs for LookinServer request handling. If response arrives but decoding fails, suspect archive compatibility or missing allowed classes. + +### Text search returns empty + +`search_elements(text:)` depends on text attributes being present in LookinServer's hierarchy data. If text search is weak, first verify the hierarchy with `className` or `role`, then inspect element details. + +## Reporting Format + +When reporting findings, include: + +- Target bundle id and app name. +- Whether inspection came from `lookinside-mcp` or fallback macOS accessibility. +- Connection status and whether GUI LookInside.app was connected. +- Summary: app, device, hierarchy depth, key window/root class. +- Relevant nodes: class, role, oid, path, frame/bounds, hidden/alpha. +- Diagnostics: layout/accessibility findings with severity and why they matter. +- Limitations: e.g. GUI occupied server, target not Debug, text attributes unavailable. + +Do not claim MCP inspection succeeded unless `current_screen`, `get_hierarchy`, or another MCP tool returned a non-error result for the intended bundle id. diff --git a/skills/lookinside-mcp-ui-debugging/agents/openai.yaml b/skills/lookinside-mcp-ui-debugging/agents/openai.yaml new file mode 100644 index 0000000..f3e550b --- /dev/null +++ b/skills/lookinside-mcp-ui-debugging/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "LookInside MCP UI Debugging" + short_description: "Debug iOS and macOS app UI through LookInside MCP." + default_prompt: "Use LookInside MCP to inspect the target app UI, diagnose layout/accessibility issues, and report actionable hierarchy findings." From af19ed7362ecea439bf41c1b4a8409e2dd7775e4 Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 13 Jun 2026 16:01:13 +0800 Subject: [PATCH 06/17] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20LookInside=20CocoaPo?= =?UTF-8?q?ds=20=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LookinCore.podspec | 24 ++++++++++++++++++++++++ LookinServerBase.podspec | 17 +++++++++++++++++ LookinShared.podspec | 11 +++++++++++ 3 files changed, 52 insertions(+) create mode 100644 LookinCore.podspec create mode 100644 LookinServerBase.podspec create mode 100644 LookinShared.podspec diff --git a/LookinCore.podspec b/LookinCore.podspec new file mode 100644 index 0000000..cdb1ded --- /dev/null +++ b/LookinCore.podspec @@ -0,0 +1,24 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinCore' + LookinPodspecHelpers.apply_common_metadata(s, 'Shared LookInside data models and utilities.') + + s.module_name = 'LookinCore' + s.static_framework = true + s.dependency 'LookinServerBase' + + s.source_files = 'Sources/LookinCore/**/*.{h,m}' + s.exclude_files = 'Sources/LookinCore/include/LookinCore.h' + s.public_header_files = 'Sources/LookinCore/**/*.h' + + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths( + 'Sources/LookinCore', + 'Sources/LookinCore/include', + 'Sources/LookinCore/Category', + 'Sources/LookinCore/Peertalk' + ) + } +end diff --git a/LookinServerBase.podspec b/LookinServerBase.podspec new file mode 100644 index 0000000..f44a2f4 --- /dev/null +++ b/LookinServerBase.podspec @@ -0,0 +1,17 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinServerBase' + LookinPodspecHelpers.apply_common_metadata(s, 'Base model support for LookInside server libraries.') + + s.module_name = 'LookinServerBase' + s.static_framework = true + + s.source_files = 'Sources/LookinServerBase/**/*.{h,m}' + s.public_header_files = 'Sources/LookinServerBase/**/*.h' + + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, + 'HEADER_SEARCH_PATHS' => LookinPodspecHelpers.header_search_paths('Sources/LookinServerBase') + } +end diff --git a/LookinShared.podspec b/LookinShared.podspec new file mode 100644 index 0000000..49ec9f5 --- /dev/null +++ b/LookinShared.podspec @@ -0,0 +1,11 @@ +require_relative 'LookinPodspecHelpers' + +Pod::Spec.new do |s| + s.name = 'LookinShared' + LookinPodspecHelpers.apply_common_metadata(s, 'Aggregate CocoaPods dependency for LookInside shared libraries.') + + s.module_name = 'LookinShared' + s.static_framework = true + s.dependency 'LookinCore' + s.dependency 'LookinServerBase' +end From f565819e074ffe59b45bea4c360a8db796d2ac21 Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 13 Jun 2026 16:42:32 +0800 Subject: [PATCH 07/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20LookinCore=20CocoaPo?= =?UTF-8?q?ds=20=E5=A4=B4=E6=96=87=E4=BB=B6=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- LookinCore.podspec | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/LookinCore.podspec b/LookinCore.podspec index cdb1ded..f8f0888 100644 --- a/LookinCore.podspec +++ b/LookinCore.podspec @@ -8,9 +8,17 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'LookinServerBase' - s.source_files = 'Sources/LookinCore/**/*.{h,m}' + s.source_files = [ + 'Sources/LookinCore/**/*.{h,m}', + 'Sources/LookinServer/Server/Category/UIColor+LookinServer.h', + 'Sources/LookinServer/Server/Category/UIImage+LookinServer.h' + ] s.exclude_files = 'Sources/LookinCore/include/LookinCore.h' s.public_header_files = 'Sources/LookinCore/**/*.h' + s.private_header_files = [ + 'Sources/LookinServer/Server/Category/UIColor+LookinServer.h', + 'Sources/LookinServer/Server/Category/UIImage+LookinServer.h' + ] s.pod_target_xcconfig = { 'GCC_PREPROCESSOR_DEFINITIONS' => LookinPodspecHelpers.base_defines, @@ -18,7 +26,8 @@ Pod::Spec.new do |s| 'Sources/LookinCore', 'Sources/LookinCore/include', 'Sources/LookinCore/Category', - 'Sources/LookinCore/Peertalk' + 'Sources/LookinCore/Peertalk', + 'Sources/LookinServer/Server/Category' ) } end From 08e326bde2152e43a9d51de6a860cd2aebc7228a Mon Sep 17 00:00:00 2001 From: tastyheadphones Date: Thu, 14 May 2026 11:13:09 +0900 Subject: [PATCH 08/17] Add Debug-only MCP integration for AI-assisted UI inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `lookinside-mcp`, an optional MCP server that lets AI agents inspect a running Debug build's UI through the same Peertalk plumbing the macOS app uses — hierarchy, search, element details, screenshots, highlight, layout/accessibility diagnostics, and a one-shot bug report. Two new SPM units: LookinMCPCore (headless inspection client, JSON shaping, diagnostics, secure-text redaction) and lookinside-mcp (executable using modelcontextprotocol/swift-sdk over stdio). Reuses LookinCore data models and the existing in-app LookinServer; license gate is bypassed because it is enforced client-side, not by the in-process server. --- Package.swift | 45 ++- README.md | 14 + Scripts/build-mcp-server.sh | 22 ++ Sources/LookinMCPCore/BugReportBuilder.swift | 68 ++++ .../AccessibilityDiagnostics.swift | 59 ++++ .../LookinMCPCore/Diagnostics/Finding.swift | 19 ++ .../Diagnostics/LayoutDiagnostics.swift | 117 +++++++ Sources/LookinMCPCore/ElementSearch.swift | 66 ++++ .../LookinMCPCore/FileHierarchyProvider.swift | 77 +++++ Sources/LookinMCPCore/HierarchyIndex.swift | 71 ++++ Sources/LookinMCPCore/HierarchyProvider.swift | 73 +++++ Sources/LookinMCPCore/JSONShape.swift | 133 ++++++++ Sources/LookinMCPCore/LiveLookinClient.swift | 305 ++++++++++++++++++ Sources/LookinMCPCore/LookinMCPCore.swift | 15 + .../LookinMCPCore/SecureTextRedactor.swift | 26 ++ Sources/LookinMCPServer/CLI.swift | 58 ++++ Sources/LookinMCPServer/HealthCommand.swift | 31 ++ .../LookinMCPServer/PrintConfigCommand.swift | 75 +++++ Sources/LookinMCPServer/ServeCommand.swift | 60 ++++ Sources/LookinMCPServer/ToolRegistry.swift | 69 ++++ Sources/LookinMCPServer/ToolSupport.swift | 59 ++++ Sources/LookinMCPServer/Tools/Tools.swift | 302 +++++++++++++++++ Sources/LookinMCPServer/main.swift | 6 + .../LookinMCPCoreTests/DiagnosticsTests.swift | 25 ++ .../ElementSearchTests.swift | 33 ++ Tests/LookinMCPCoreTests/Fixtures.swift | 72 +++++ Tests/LookinMCPCoreTests/Fixtures/.keep | 0 .../HierarchyIndexTests.swift | 41 +++ Tests/LookinMCPCoreTests/JSONShapeTests.swift | 29 ++ .../ProviderErrorTests.swift | 20 ++ docs/mcp-client-configs.md | 85 +++++ docs/mcp-troubleshooting.md | 63 ++++ docs/mcp.md | 96 ++++++ 33 files changed, 2232 insertions(+), 2 deletions(-) create mode 100755 Scripts/build-mcp-server.sh create mode 100644 Sources/LookinMCPCore/BugReportBuilder.swift create mode 100644 Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift create mode 100644 Sources/LookinMCPCore/Diagnostics/Finding.swift create mode 100644 Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift create mode 100644 Sources/LookinMCPCore/ElementSearch.swift create mode 100644 Sources/LookinMCPCore/FileHierarchyProvider.swift create mode 100644 Sources/LookinMCPCore/HierarchyIndex.swift create mode 100644 Sources/LookinMCPCore/HierarchyProvider.swift create mode 100644 Sources/LookinMCPCore/JSONShape.swift create mode 100644 Sources/LookinMCPCore/LiveLookinClient.swift create mode 100644 Sources/LookinMCPCore/LookinMCPCore.swift create mode 100644 Sources/LookinMCPCore/SecureTextRedactor.swift create mode 100644 Sources/LookinMCPServer/CLI.swift create mode 100644 Sources/LookinMCPServer/HealthCommand.swift create mode 100644 Sources/LookinMCPServer/PrintConfigCommand.swift create mode 100644 Sources/LookinMCPServer/ServeCommand.swift create mode 100644 Sources/LookinMCPServer/ToolRegistry.swift create mode 100644 Sources/LookinMCPServer/ToolSupport.swift create mode 100644 Sources/LookinMCPServer/Tools/Tools.swift create mode 100644 Sources/LookinMCPServer/main.swift create mode 100644 Tests/LookinMCPCoreTests/DiagnosticsTests.swift create mode 100644 Tests/LookinMCPCoreTests/ElementSearchTests.swift create mode 100644 Tests/LookinMCPCoreTests/Fixtures.swift create mode 100644 Tests/LookinMCPCoreTests/Fixtures/.keep create mode 100644 Tests/LookinMCPCoreTests/HierarchyIndexTests.swift create mode 100644 Tests/LookinMCPCoreTests/JSONShapeTests.swift create mode 100644 Tests/LookinMCPCoreTests/ProviderErrorTests.swift create mode 100644 docs/mcp-client-configs.md create mode 100644 docs/mcp-troubleshooting.md create mode 100644 docs/mcp.md diff --git a/Package.swift b/Package.swift index 245b0e1..96c102f 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( platforms: [ .iOS(.v12), .tvOS(.v12), - .macOS(.v11), + .macOS(.v13), ], products: [ .library( @@ -42,8 +42,18 @@ let package = Package( type: .dynamic, targets: ["LookinServerInjected"] ), + .library( + name: "LookinMCPCore", + targets: ["LookinMCPCore"] + ), + .executable( + name: "lookinside-mcp", + targets: ["LookinMCPServer"] + ), + ], + dependencies: [ + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.11.0"), ], - dependencies: [], targets: [ .target( name: "LookinServerBase", @@ -110,5 +120,36 @@ let package = Package( .linkedFramework("UIKit", .when(platforms: [.iOS, .tvOS])), ] ), + .target( + name: "LookinMCPCore", + dependencies: ["LookinCore"], + path: "Sources/LookinMCPCore", + swiftSettings: [ + .define("SHOULD_COMPILE_LOOKIN_SERVER"), + .define("SPM_LOOKIN_SERVER_ENABLED"), + ] + ), + .executableTarget( + name: "LookinMCPServer", + dependencies: [ + "LookinMCPCore", + .product(name: "MCP", package: "swift-sdk"), + ], + path: "Sources/LookinMCPServer", + swiftSettings: [ + .define("SHOULD_COMPILE_LOOKIN_SERVER"), + .define("SPM_LOOKIN_SERVER_ENABLED"), + ] + ), + .testTarget( + name: "LookinMCPCoreTests", + dependencies: ["LookinMCPCore"], + path: "Tests/LookinMCPCoreTests", + resources: [.process("Fixtures")], + swiftSettings: [ + .define("SHOULD_COMPILE_LOOKIN_SERVER"), + .define("SPM_LOOKIN_SERVER_ENABLED"), + ] + ), ] ) diff --git a/README.md b/README.md index 56eb6e4..1bde6d9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,20 @@ LookInside continues the work of [Lookin](https://lookin.work/), the original iO Use [LookInside-Release](https://github.com/LookInsideApp/LookInside-Release) with Swift Package Manager or CocoaPods. +## MCP integration (Debug) + +LookInside ships an optional MCP server, `lookinside-mcp`, so AI coding agents (Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, …) can inspect the running Debug build directly — hierarchy, screenshots, element search, highlight, layout/accessibility diagnostics, and a one-shot bug report. + +Build and try it: + +```sh +./Scripts/build-mcp-server.sh +./build/lookinside-mcp health +./build/lookinside-mcp print-config claude-desktop +``` + +See [docs/mcp.md](docs/mcp.md) for the full feature set, [docs/mcp-client-configs.md](docs/mcp-client-configs.md) for client setup, and [docs/mcp-troubleshooting.md](docs/mcp-troubleshooting.md) if something looks off. + ## License GPL-3.0. See [`LICENSE`](LICENSE). diff --git a/Scripts/build-mcp-server.sh b/Scripts/build-mcp-server.sh new file mode 100755 index 0000000..85b998f --- /dev/null +++ b/Scripts/build-mcp-server.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Build the lookinside-mcp executable in release configuration and stage the +# binary at ./build/lookinside-mcp. Designed to be safe to re-run. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +OUT_DIR="$REPO_ROOT/build" +mkdir -p "$OUT_DIR" + +echo "› swift build -c release --product lookinside-mcp" +swift build -c release --product lookinside-mcp + +BIN_PATH="$(swift build -c release --product lookinside-mcp --show-bin-path)/lookinside-mcp" +cp "$BIN_PATH" "$OUT_DIR/lookinside-mcp" +chmod +x "$OUT_DIR/lookinside-mcp" + +echo +echo "Built: $OUT_DIR/lookinside-mcp" +echo "Try: $OUT_DIR/lookinside-mcp health" +echo "Or: $OUT_DIR/lookinside-mcp print-config claude-desktop" diff --git a/Sources/LookinMCPCore/BugReportBuilder.swift b/Sources/LookinMCPCore/BugReportBuilder.swift new file mode 100644 index 0000000..927f4c5 --- /dev/null +++ b/Sources/LookinMCPCore/BugReportBuilder.swift @@ -0,0 +1,68 @@ +import Foundation +import LookinCore +#if canImport(AppKit) +import AppKit +#endif + +/// Bundles everything an agent or developer needs to reproduce a UI bug into one +/// JSON payload. Tools producing different views of the same data (hierarchy, +/// diagnostics, screenshot) all flow through this one builder so the format stays +/// consistent — bug reports across teams should look identical. +public enum BugReportBuilder { + public struct Report: Codable { + public let generatedAt: String + public let mcpVersion: String + public let app: AppMeta + public let device: DeviceMeta + public let hierarchy: JSONShape.Node? + public let screenshotBase64PNG: String? + public let layoutFindings: [Finding] + public let accessibilityFindings: [Finding] + } + + public struct AppMeta: Codable { + public let name: String? + public let bundleIdentifier: String? + public let serverVersion: Int + } + + public struct DeviceMeta: Codable { + public let description: String? + public let os: String? + public let screenWidth: Double + public let screenHeight: Double + public let screenScale: Double + } + + public static func build(provider: HierarchyProvider, + includeScreenshot: Bool) throws -> Report { + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let app = try provider.appInfo() + let root = (info.displayItems as? [LookinDisplayItem])?.first + let rootNode = root.map { JSONShape.node($0, index: index, maxDepth: -1, includeOffscreen: false) } + return Report( + generatedAt: ISO8601DateFormatter().string(from: Date()), + mcpVersion: LookinMCP.version, + app: AppMeta(name: app.appName, bundleIdentifier: app.appBundleIdentifier, serverVersion: Int(app.serverVersion)), + device: DeviceMeta(description: app.deviceDescription, os: app.osDescription, + screenWidth: app.screenWidth, screenHeight: app.screenHeight, screenScale: app.screenScale), + hierarchy: rootNode, + screenshotBase64PNG: includeScreenshot ? Self.pngBase64(try provider.screenshot()) : nil, + layoutFindings: LayoutDiagnostics.run(on: index), + accessibilityFindings: AccessibilityDiagnostics.run(on: index) + ) + } + + public static func pngBase64(_ image: PlatformImage?) -> String? { + guard let image else { return nil } + #if canImport(AppKit) + guard let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: NSBitmapImageRep.FileType.png, properties: [:]) else { return nil } + return png.base64EncodedString() + #else + return nil + #endif + } +} diff --git a/Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift b/Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift new file mode 100644 index 0000000..363320b --- /dev/null +++ b/Sources/LookinMCPCore/Diagnostics/AccessibilityDiagnostics.swift @@ -0,0 +1,59 @@ +import Foundation +import LookinCore + +/// Accessibility-shaped problems detectable from snapshot data. We don't try to +/// simulate VoiceOver — focus on findings every developer should address before +/// shipping (missing labels, undersized targets, duplicate labels). +public enum AccessibilityDiagnostics { + public static func run(on index: HierarchyIndex, scopeOid: UInt? = nil) -> [Finding] { + var out: [Finding] = [] + var seenLabels: [String: [(UInt, String)]] = [:] + + index.walkAll { item in + if let scope = scopeOid, HierarchyIndex.oid(of: item) != scope { return } + guard let oid = HierarchyIndex.oid(of: item), ElementSearch.isVisible(item) else { return } + let className = JSONShape.primaryClassName(item) + let role = JSONShape.inferRole(className: className) + let path = index.breadcrumb(of: oid) + + // Missing label on interactive element. + if role == "button" || role == "switch" || role == "slider" { + let label = JSONShape.extractAttribute(item, identifier: "accessibilityLabel") as? String + let text = JSONShape.extractText(item) + if (label?.isEmpty ?? true), (text?.isEmpty ?? true) { + out.append(Finding(oid: oid, severity: .warning, category: .accessibility, + code: "a11y.missing_label", + message: "\(className) is interactive but has no accessibility label or visible text.", + suggestion: "Set `accessibilityLabel` so VoiceOver users can identify the control.", + path: path)) + } + } + + // Tiny target, surface in accessibility too for severity-aware tooling. + if (role == "button" || role == "switch") && + (item.frame.width < 44 || item.frame.height < 44) { + out.append(Finding(oid: oid, severity: .info, category: .accessibility, + code: "a11y.touch_target_small", + message: "\(className) is \(Int(item.frame.width))×\(Int(item.frame.height)) — below the 44pt accessibility minimum.", + suggestion: "Increase hit area.", + path: path)) + } + + // Bucket labels to detect duplicates. + if let label = JSONShape.extractAttribute(item, identifier: "accessibilityLabel") as? String, !label.isEmpty { + seenLabels[label, default: []].append((oid, path)) + } + } + + for (label, list) in seenLabels where list.count > 1 { + for (oid, path) in list { + out.append(Finding(oid: oid, severity: .info, category: .accessibility, + code: "a11y.duplicate_label", + message: "Multiple elements share the accessibility label \"\(label)\" (\(list.count) total).", + suggestion: "Disambiguate with `accessibilityHint` or distinct labels — VoiceOver users can't tell them apart.", + path: path)) + } + } + return out + } +} diff --git a/Sources/LookinMCPCore/Diagnostics/Finding.swift b/Sources/LookinMCPCore/Diagnostics/Finding.swift new file mode 100644 index 0000000..612695d --- /dev/null +++ b/Sources/LookinMCPCore/Diagnostics/Finding.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Output shape shared by every diagnostic. Keeping one type means the AI can learn +/// the format once and stay oriented across `diagnose_layout`, `diagnose_accessibility`, +/// and any future linter we bolt on. +public struct Finding: Codable { + public enum Severity: String, Codable { case info, warning, error } + public enum Category: String, Codable { case layout, accessibility, performance, other } + + public let oid: UInt? + public let severity: Severity + public let category: Category + /// Stable machine-readable id. New checks pick a new id; renaming an existing + /// check is a breaking change for downstream automation. + public let code: String + public let message: String + public let suggestion: String? + public let path: String? +} diff --git a/Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift b/Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift new file mode 100644 index 0000000..46d34a0 --- /dev/null +++ b/Sources/LookinMCPCore/Diagnostics/LayoutDiagnostics.swift @@ -0,0 +1,117 @@ +import Foundation +import CoreGraphics +import LookinCore + +/// Layout-shaped problems we can detect from a hierarchy snapshot alone — no need +/// to query intrinsic content size live. Heuristics intentionally err on the side +/// of false positives that a human can easily dismiss; missing real bugs is worse. +public enum LayoutDiagnostics { + public static func run(on index: HierarchyIndex, scopeOid: UInt? = nil) -> [Finding] { + var out: [Finding] = [] + let interactive = collectInteractive(index: index) + + index.walkAll { item in + if let scope = scopeOid, HierarchyIndex.oid(of: item) != scope { return } + check(item, index: index, into: &out) + } + out.append(contentsOf: overlapFindings(interactive, index: index)) + return out + } + + private static func check(_ item: LookinDisplayItem, index: HierarchyIndex, into out: inout [Finding]) { + guard let oid = HierarchyIndex.oid(of: item) else { return } + let path = index.breadcrumb(of: oid) + let className = JSONShape.primaryClassName(item) + let frame = item.frame + + // Zero-size view that should have content + if (frame.width <= 0 || frame.height <= 0), !item.isHidden, item.alpha > 0.01 { + if className.hasSuffix("Label") || className.hasSuffix("Button") || className == "UIImageView" { + out.append(Finding(oid: oid, severity: .warning, category: .layout, + code: "layout.zero_size", + message: "\(className) has zero-area frame \(frame).", + suggestion: "Check constraints — the view may be missing width/height or have a content-hugging conflict.", + path: path)) + } + } + + // Offscreen relative to parent + if let parentOid = index.ancestorOids(of: oid).first, + let parent = index.find(oid: parentOid) { + let pb = parent.bounds + if !pb.isNull, !pb.isEmpty { + let intersection = pb.intersection(frame) + if intersection.isNull || intersection.isEmpty { + out.append(Finding(oid: oid, severity: .warning, category: .layout, + code: "layout.offscreen_of_parent", + message: "\(className) at \(frame) is fully outside its parent bounds \(pb).", + suggestion: "If intentional, ensure parent has clipsToBounds=false; otherwise fix layout.", + path: path)) + } + } + } + + // Tiny interactive target + if ElementSearch.isVisible(item), isInteractive(item) { + if frame.width < 44 || frame.height < 44 { + out.append(Finding(oid: oid, severity: .warning, category: .layout, + code: "layout.tap_target_small", + message: "\(className) tap target is \(Int(frame.width))×\(Int(frame.height)) — Apple HIG recommends ≥ 44×44.", + suggestion: "Expand the hit area or pad the view.", + path: path)) + } + } + + // Hidden but interactive + if isInteractive(item), (item.isHidden || item.alpha < 0.01) { + out.append(Finding(oid: oid, severity: .info, category: .layout, + code: "layout.interactive_but_invisible", + message: "\(className) is interactive but hidden=\(item.isHidden) alpha=\(item.alpha).", + suggestion: "Either disable user interaction or restore visibility — invisible interactive views confuse users and accessibility tools.", + path: path)) + } + } + + private static func overlapFindings(_ interactive: [LookinDisplayItem], + index: HierarchyIndex) -> [Finding] { + var out: [Finding] = [] + // O(n²) but interactive set is small in practice; if it ever gets big, sweep on x. + for i in 0.. 0, + (inter.width * inter.height) / minArea > 0.5, + let oa = HierarchyIndex.oid(of: a), let ob = HierarchyIndex.oid(of: b) { + out.append(Finding(oid: oa, severity: .warning, category: .layout, + code: "layout.interactive_overlap", + message: "Interactive views overlap (oids \(oa), \(ob)).", + suggestion: "One of them likely swallows taps. Inspect z-order and userInteractionEnabled.", + path: index.breadcrumb(of: oa))) + } + } + } + return out + } + + private static func collectInteractive(index: HierarchyIndex) -> [LookinDisplayItem] { + var arr: [LookinDisplayItem] = [] + index.walkAll { item in + if isInteractive(item) { arr.append(item) } + } + return arr + } + + private static func isInteractive(_ item: LookinDisplayItem) -> Bool { + let cn = JSONShape.primaryClassName(item) + if cn.hasSuffix("Button") || cn == "UIControl" || cn == "NSControl" || cn == "UISwitch" || cn == "UISlider" { + return true + } + if let chain = item.viewObject?.classChainList as? [String] { + return chain.contains(where: { $0 == "UIControl" || $0 == "NSControl" || $0.hasSuffix("Button") }) + } + return false + } +} diff --git a/Sources/LookinMCPCore/ElementSearch.swift b/Sources/LookinMCPCore/ElementSearch.swift new file mode 100644 index 0000000..d689d0f --- /dev/null +++ b/Sources/LookinMCPCore/ElementSearch.swift @@ -0,0 +1,66 @@ +import Foundation +import LookinCore + +/// Predicates for `search_elements`. Each field is optional and ANDed; missing +/// fields are skipped. Mirrors the LookInside.app sidebar filter (class chain, +/// display text, visibility) but expressed declaratively so it's trivial to +/// add new fields later. +public struct ElementQuery { + public var text: String? + public var accessibilityIdentifier: String? + public var className: String? + public var role: String? + public var visibleOnly: Bool + + public init(text: String? = nil, + accessibilityIdentifier: String? = nil, + className: String? = nil, + role: String? = nil, + visibleOnly: Bool = false) { + self.text = text + self.accessibilityIdentifier = accessibilityIdentifier + self.className = className + self.role = role + self.visibleOnly = visibleOnly + } +} + +public enum ElementSearch { + public struct Hit: Codable { + public let oid: UInt + public let className: String + public let role: String? + public let text: String? + public let path: String? + } + + public static func run(_ q: ElementQuery, in index: HierarchyIndex) -> [Hit] { + var hits: [Hit] = [] + index.walkAll { item in + if q.visibleOnly, !isVisible(item) { return } + let className = JSONShape.primaryClassName(item) + if let c = q.className, !className.localizedCaseInsensitiveContains(c) { return } + if let r = q.role, JSONShape.inferRole(className: className) != r { return } + let text = JSONShape.extractText(item) + if let t = q.text { + guard let text = text, text.localizedCaseInsensitiveContains(t) else { return } + } + if let id = q.accessibilityIdentifier { + let aid = JSONShape.extractAttribute(item, identifier: "accessibilityIdentifier") as? String + guard let aid = aid, aid == id else { return } + } + guard let oid = HierarchyIndex.oid(of: item) else { return } + hits.append(Hit(oid: oid, + className: className, + role: JSONShape.inferRole(className: className), + text: text, + path: index.breadcrumb(of: oid))) + } + return hits + } + + public static func isVisible(_ item: LookinDisplayItem) -> Bool { + guard !item.isHidden, item.alpha > 0.01 else { return false } + return item.frame.size.width > 0 && item.frame.size.height > 0 + } +} diff --git a/Sources/LookinMCPCore/FileHierarchyProvider.swift b/Sources/LookinMCPCore/FileHierarchyProvider.swift new file mode 100644 index 0000000..7e013ed --- /dev/null +++ b/Sources/LookinMCPCore/FileHierarchyProvider.swift @@ -0,0 +1,77 @@ +import Foundation +import LookinCore + +/// Reads a `.lookin` snapshot file (the same format the macOS LookInside.app exports +/// from File ▸ Save). Useful when: +/// 1. A developer captures a problem state once and wants to iterate with the AI agent. +/// 2. CI wants to attach a hierarchy artifact to a failed UI test. +/// 3. Tests want a deterministic provider with no networking. +public final class FileHierarchyProvider: HierarchyProvider { + private let info: LookinHierarchyInfo + private let index: HierarchyIndex + + public var isLive: Bool { false } + + public init(fileURL: URL) throws { + let data = try Data(contentsOf: fileURL) + guard let info = try Self.decode(data: data) else { + throw HierarchyProviderError.decodeFailure(reason: "no LookinHierarchyInfo root in \(fileURL.lastPathComponent)") + } + self.info = info + self.index = HierarchyIndex(info: info) + } + + public init(info: LookinHierarchyInfo) { + self.info = info + self.index = HierarchyIndex(info: info) + } + + public func appInfo() throws -> LookinAppInfo { + guard let app = info.appInfo else { + throw HierarchyProviderError.decodeFailure(reason: "snapshot has no appInfo") + } + return app + } + + public func hierarchy() throws -> LookinHierarchyInfo { info } + + public func elementDetails(oid: UInt) throws -> ElementDetails? { + guard let item = index.find(oid: oid) else { return nil } + return ElementDetails(item: item, + attributeGroups: (item.attributesGroupList as? [LookinAttributesGroup]) ?? [], + soloScreenshot: item.soloScreenshot) + } + + public func highlight(oid: UInt, durationMs: Int) throws { + throw HierarchyProviderError.unsupported("highlight requires a live connection — snapshot files cannot drive in-app overlays.") + } + + public func screenshot() throws -> PlatformImage? { + info.appInfo?.screenshot + } + + private static func decode(data: Data) throws -> LookinHierarchyInfo? { + let allowed: [AnyClass] = [ + LookinHierarchyInfo.self, + LookinDisplayItem.self, + LookinAppInfo.self, + LookinAttributesGroup.self, + LookinAttribute.self, + LookinObject.self, + NSArray.self, NSDictionary.self, NSString.self, NSNumber.self, NSData.self, NSValue.self, + PlatformImage.self, + ] + let nsClasses = Set(allowed.map { ObjectIdentifier($0) }) + _ = nsClasses // silence unused — informational; we use the array directly below + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + // Snapshot files in the wild use root keys that vary; try the standard ones in order. + for key in [NSKeyedArchiveRootObjectKey, "info", "hierarchyInfo"] { + if let info = unarchiver.decodeObject(of: allowed, forKey: key) as? LookinHierarchyInfo { + return info + } + } + return nil + } +} diff --git a/Sources/LookinMCPCore/HierarchyIndex.swift b/Sources/LookinMCPCore/HierarchyIndex.swift new file mode 100644 index 0000000..55f7191 --- /dev/null +++ b/Sources/LookinMCPCore/HierarchyIndex.swift @@ -0,0 +1,71 @@ +import Foundation +import LookinCore + +/// Flat index over a `LookinHierarchyInfo` tree. Built once per hierarchy fetch; +/// every subsequent oid lookup is O(1). Indices also retain the path from each item +/// up to the root so tools can surface a stable, human-readable breadcrumb. +public final class HierarchyIndex { + public let info: LookinHierarchyInfo + private var byOid: [UInt: LookinDisplayItem] = [:] + private var parents: [UInt: UInt] = [:] + + public init(info: LookinHierarchyInfo) { + self.info = info + for window in (info.displayItems as? [LookinDisplayItem]) ?? [] { + walk(window, parentOid: nil) + } + } + + public func find(oid: UInt) -> LookinDisplayItem? { byOid[oid] } + + public func ancestorOids(of oid: UInt) -> [UInt] { + var out: [UInt] = [] + var cur = parents[oid] + while let next = cur { + out.append(next) + cur = parents[next] + } + return out + } + + public func breadcrumb(of oid: UInt) -> String { + let chain = ([oid] + ancestorOids(of: oid)).reversed() + return chain.compactMap { byOid[$0].flatMap(JSONShape.shortLabel(_:)) }.joined(separator: " ▸ ") + } + + /// In-order walk over every node, root-to-leaf. Provider-agnostic so diagnostics, + /// search, and bug-report builders share one iteration shape. + public func walkAll(_ body: (LookinDisplayItem) -> Void) { + for window in (info.displayItems as? [LookinDisplayItem]) ?? [] { + walkVisit(window, body) + } + } + + public var count: Int { byOid.count } + + private func walk(_ item: LookinDisplayItem, parentOid: UInt?) { + if let oid = Self.oid(of: item) { + byOid[oid] = item + if let p = parentOid { parents[oid] = p } + } + for sub in (item.subitems as? [LookinDisplayItem]) ?? [] { + walk(sub, parentOid: Self.oid(of: item)) + } + } + + private func walkVisit(_ item: LookinDisplayItem, _ body: (LookinDisplayItem) -> Void) { + body(item) + for sub in (item.subitems as? [LookinDisplayItem]) ?? [] { + walkVisit(sub, body) + } + } + + static func oid(of item: LookinDisplayItem) -> UInt? { + // Prefer viewObject, fall back to layer/window. This matches the macOS app's + // display logic where each row is keyed by the underlying view's oid. + if let v = item.viewObject?.oid, v != 0 { return UInt(v) } + if let l = item.layerObject?.oid, l != 0 { return UInt(l) } + if let w = item.windowObject?.oid, w != 0 { return UInt(w) } + return nil + } +} diff --git a/Sources/LookinMCPCore/HierarchyProvider.swift b/Sources/LookinMCPCore/HierarchyProvider.swift new file mode 100644 index 0000000..910d797 --- /dev/null +++ b/Sources/LookinMCPCore/HierarchyProvider.swift @@ -0,0 +1,73 @@ +import Foundation +import LookinCore +#if canImport(AppKit) +import AppKit +public typealias PlatformImage = NSImage +#elseif canImport(UIKit) +import UIKit +public typealias PlatformImage = UIImage +#endif + +/// The seam every MCP tool talks to. Implementations: `LiveLookinClient` (connects +/// to a running Debug build over Peertalk) and `FileHierarchyProvider` (reads a +/// `.lookin` snapshot file). Adding new sources later — say a recorded test fixture +/// or a stub for unit tests — means one more conformance, nothing else changes. +public protocol HierarchyProvider: AnyObject { + /// Top-level metadata about the connected app (or the captured snapshot). + func appInfo() throws -> LookinAppInfo + + /// Full hierarchy tree. May be expensive — callers should cache. + func hierarchy() throws -> LookinHierarchyInfo + + /// Per-element details (screenshot + full attribute groups) for a specific oid. + /// `nil` if the element isn't found in the current hierarchy. + func elementDetails(oid: UInt) throws -> ElementDetails? + + /// Tells the running app to flash a highlight overlay on the element with the + /// given oid. Best-effort — providers without a live channel may no-op. + func highlight(oid: UInt, durationMs: Int) throws + + /// Latest screenshot of the key window. Providers that only have a snapshot + /// return the cached image from `appInfo.screenshot`. + func screenshot() throws -> PlatformImage? + + /// Whether the provider is connected to a live target (vs. a snapshot file). + var isLive: Bool { get } +} + +public struct ElementDetails { + public let item: LookinDisplayItem + public let attributeGroups: [LookinAttributesGroup] + public let soloScreenshot: PlatformImage? + + public init(item: LookinDisplayItem, + attributeGroups: [LookinAttributesGroup], + soloScreenshot: PlatformImage?) { + self.item = item + self.attributeGroups = attributeGroups + self.soloScreenshot = soloScreenshot + } +} + +public enum HierarchyProviderError: Error, CustomStringConvertible { + case noTargetApp + case timeout(requestType: UInt32) + case transport(underlying: Error) + case decodeFailure(reason: String) + case unsupported(String) + + public var description: String { + switch self { + case .noTargetApp: + return "No Debug build of a LookinServer-enabled app is reachable." + case .timeout(let t): + return "Request \(t) timed out talking to the target app." + case .transport(let e): + return "Peertalk transport error: \(e.localizedDescription)" + case .decodeFailure(let r): + return "Response decode failed: \(r)" + case .unsupported(let s): + return "Unsupported operation: \(s)" + } + } +} diff --git a/Sources/LookinMCPCore/JSONShape.swift b/Sources/LookinMCPCore/JSONShape.swift new file mode 100644 index 0000000..cb2e181 --- /dev/null +++ b/Sources/LookinMCPCore/JSONShape.swift @@ -0,0 +1,133 @@ +import Foundation +import CoreGraphics +import LookinCore + +/// Canonical JSON shape every MCP tool returns. Keeping one shape — instead of one +/// per tool — means the agent learns the schema once, and downstream additions are +/// purely additive. +/// +/// Secure-text redaction happens here, at the model boundary, so no tool can leak +/// secure-text-field contents even if a future tool reaches around the data layer. +public enum JSONShape { + public static var redactor: SecureTextRedactor = SecureTextRedactor() + + public struct Node: Codable { + public let oid: UInt + public let className: String + public let role: String? + public let frame: Rect + public let bounds: Rect + public let alpha: Double + public let hidden: Bool + public let text: String? + public let accessibilityIdentifier: String? + public let accessibilityLabel: String? + public let path: String? + public var children: [Node] + } + + public struct Rect: Codable { + public let x: Double; public let y: Double + public let width: Double; public let height: Double + public init(_ r: CGRect) { + self.x = Double(r.origin.x); self.y = Double(r.origin.y) + self.width = Double(r.size.width); self.height = Double(r.size.height) + } + } + + /// Build a node, optionally walking children up to `maxDepth` levels deep + /// (-1 = unlimited). `includeOffscreen` keeps nodes whose frame is fully + /// outside their parent — useful when diagnosing layout escapes. + public static func node(_ item: LookinDisplayItem, + index: HierarchyIndex, + maxDepth: Int, + includeOffscreen: Bool = true, + depth: Int = 0) -> Node { + let oid = HierarchyIndex.oid(of: item) ?? 0 + let className = primaryClassName(item) + let role = inferRole(className: className) + let secure = redactor.isSecure(item: item) + + let kids: [Node] + if maxDepth >= 0 && depth >= maxDepth { + kids = [] + } else { + kids = ((item.subitems as? [LookinDisplayItem]) ?? []) + .filter { includeOffscreen || isOnscreen($0) } + .map { node($0, index: index, maxDepth: maxDepth, includeOffscreen: includeOffscreen, depth: depth + 1) } + } + return Node( + oid: oid, + className: className, + role: role, + frame: Rect(item.frame), + bounds: Rect(item.bounds), + alpha: Double(item.alpha), + hidden: item.isHidden, + text: secure ? nil : extractText(item), + accessibilityIdentifier: extractAttribute(item, identifier: "accessibilityIdentifier") as? String, + accessibilityLabel: extractAttribute(item, identifier: "accessibilityLabel") as? String, + path: index.breadcrumb(of: oid), + children: kids + ) + } + + public static func shortLabel(_ item: LookinDisplayItem) -> String { + primaryClassName(item) + } + + public static func primaryClassName(_ item: LookinDisplayItem) -> String { + if let chain = item.viewObject?.classChainList as? [String], let head = chain.first { return head } + if let chain = item.layerObject?.classChainList as? [String], let head = chain.first { return head } + if let chain = item.windowObject?.classChainList as? [String], let head = chain.first { return head } + return "UnknownView" + } + + public static func inferRole(className: String) -> String? { + switch className { + case "UIButton", "NSButton": return "button" + case "UILabel", "NSTextField": return "label" + case "UIImageView", "NSImageView": return "image" + case "UITextField": return "textInput" + case "UITextView", "NSTextView": return "textArea" + case "UISwitch": return "switch" + case "UISlider": return "slider" + case "UIScrollView", "NSScrollView": return "scroll" + case "UITableView", "NSTableView": return "table" + case "UICollectionView", "NSCollectionView": return "collection" + case "UIStackView", "NSStackView": return "stack" + case "UIWindow", "NSWindow": return "window" + default: + if className.contains("Button") { return "button" } + if className.contains("Label") { return "label" } + return nil + } + } + + public static func isOnscreen(_ item: LookinDisplayItem) -> Bool { + let f = item.frame + return f.size.width > 0 && f.size.height > 0 + } + + public static func extractText(_ item: LookinDisplayItem) -> String? { + // Pull from common attribute identifiers across UILabel/UIButton/UITextField/NSTextField. + for id in ["text", "title", "stringValue", "attributedText"] { + if let s = extractAttribute(item, identifier: id) as? String, !s.isEmpty { return s } + } + return nil + } + + public static func extractAttribute(_ item: LookinDisplayItem, identifier: String) -> Any? { + guard let groups = item.attributesGroupList as? [LookinAttributesGroup] else { return nil } + for group in groups { + guard let sections = group.attrSections as? [LookinAttributesSection] else { continue } + for section in sections { + guard let attrs = section.attributes as? [LookinAttribute] else { continue } + for a in attrs where (a.identifier as String).contains(identifier) { + return a.value + } + } + } + return nil + } +} diff --git a/Sources/LookinMCPCore/LiveLookinClient.swift b/Sources/LookinMCPCore/LiveLookinClient.swift new file mode 100644 index 0000000..8c5abf9 --- /dev/null +++ b/Sources/LookinMCPCore/LiveLookinClient.swift @@ -0,0 +1,305 @@ +import Foundation +import LookinCore +import Darwin + +/// Live connection to a `LookinServer` running inside a Debug build. Speaks the same +/// framed NSSecureCoding protocol the macOS LookInside.app uses (see +/// `LookInside/Connection/LKConnectionManager.m`), but stripped to a synchronous, +/// headless API suitable for an MCP tool dispatch. +/// +/// Why we don't reuse `LKConnectionManager`: +/// - It depends on ReactiveObjC (`RACSignal`), pulling a heavy dep into the SPM build. +/// - It performs a client-side license handshake before non-Ping requests; that gate +/// is enforced by the Mac app, not by `LookinServer` itself (`Sources/LookinServer/ +/// Server/Connection/LKS_RequestHandler.m` has no license check). A separate Debug +/// tooling client is free to skip it. +/// +/// Reachable ports (defined in `LookinDefines.h`): +/// - Simulator: 47164–47169 +/// - USB device: 47175–47179 +/// - macOS target: 47170–47174 +public final class LiveLookinClient: NSObject, HierarchyProvider, Lookin_PTChannelDelegate { + + public struct DiscoveredApp { + public let port: Int + public let platform: String // "simulator" | "macos" | "device" + public let appInfo: LookinAppInfo + } + + public var isLive: Bool { true } + + private let queue = DispatchQueue(label: "lookin.mcp.client", qos: .userInitiated) + private let connectTimeout: TimeInterval + private let requestTimeout: TimeInterval + + private var channel: Lookin_PTChannel? + private var pendingRequests: [UInt32: PendingRequest] = [:] + private var hierarchyCache: LookinHierarchyInfo? + private var indexCache: HierarchyIndex? + + public init(connectTimeout: TimeInterval = 1.5, requestTimeout: TimeInterval = 10) { + self.connectTimeout = connectTimeout + self.requestTimeout = requestTimeout + super.init() + } + + // MARK: Discovery & connect + + public func discover() -> [DiscoveredApp] { + let ranges = [ + ("simulator", LookinSimulatorIPv4PortNumberStart...LookinSimulatorIPv4PortNumberEnd), + ("macos", LookinMacIPv4PortNumberStart...LookinMacIPv4PortNumberEnd), + ("device", LookinUSBDeviceIPv4PortNumberStart...LookinUSBDeviceIPv4PortNumberEnd), + ] + var found: [DiscoveredApp] = [] + for (platform, range) in ranges { + for port in range { + guard let client = try? Self.makeAndConnect(port: Int(port), timeout: connectTimeout) else { continue } + defer { client.disconnect() } + if let app = try? client.fetchAppInfo() { + found.append(DiscoveredApp(port: Int(port), platform: platform, appInfo: app)) + } + } + } + return found + } + + /// Connect to the first reachable app, preferring simulator → macOS → device. + @discardableResult + public func connectToFirstAvailable() throws -> DiscoveredApp { + let apps = discover() + guard let pick = apps.first else { throw HierarchyProviderError.noTargetApp } + try connect(port: pick.port) + return pick + } + + public func connect(port: Int) throws { + disconnect() + let ch = try Self.openChannel(port: port, timeout: connectTimeout, delegate: self) + channel = ch + } + + public func disconnect() { + channel?.close() + channel = nil + pendingRequests.removeAll() + hierarchyCache = nil + indexCache = nil + } + + // MARK: HierarchyProvider + + public func appInfo() throws -> LookinAppInfo { try fetchAppInfo() } + + public func hierarchy() throws -> LookinHierarchyInfo { + if let cached = hierarchyCache { return cached } + let resp = try sendRequest(type: UInt32(LookinRequestTypeHierarchy), payload: nil) + guard let info = resp.data as? LookinHierarchyInfo else { + throw HierarchyProviderError.decodeFailure(reason: "expected LookinHierarchyInfo, got \(String(describing: type(of: resp.data)))") + } + hierarchyCache = info + indexCache = HierarchyIndex(info: info) + return info + } + + public func elementDetails(oid: UInt) throws -> ElementDetails? { + let info = try hierarchy() + let index = indexCache ?? HierarchyIndex(info: info) + guard let item = index.find(oid: oid) else { return nil } + // The hierarchy response already carries attribute groups and screenshots for each item. + return ElementDetails(item: item, + attributeGroups: (item.attributesGroupList as? [LookinAttributesGroup]) ?? [], + soloScreenshot: item.soloScreenshot) + } + + public func highlight(oid: UInt, durationMs: Int) throws { + // No first-class highlight request type exists yet on the server. Real + // highlight runs through the macOS app's preview overlay. Until LookinServer + // gains a server-side highlight request (tracked as a follow-up), this is a + // no-op rather than a lie. + throw HierarchyProviderError.unsupported("highlight requires a server-side request type — coming in a follow-up PR.") + } + + public func screenshot() throws -> PlatformImage? { + try fetchAppInfo().screenshot + } + + // MARK: Internals + + fileprivate func fetchAppInfo() throws -> LookinAppInfo { + let resp = try sendRequest(type: UInt32(LookinRequestTypeApp), payload: nil) + guard let info = resp.data as? LookinAppInfo else { + throw HierarchyProviderError.decodeFailure(reason: "expected LookinAppInfo, got \(String(describing: type(of: resp.data)))") + } + return info + } + + private static func makeAndConnect(port: Int, timeout: TimeInterval) throws -> ProbeClient { + let probe = ProbeClient() + let channel = try Self.openChannel(port: port, timeout: timeout, delegate: probe) + probe.channel = channel + return probe + } + + fileprivate static func openChannel(port: Int, + timeout: TimeInterval, + delegate: Lookin_PTChannelDelegate) throws -> Lookin_PTChannel { + guard let channel = Lookin_PTChannel() else { + throw HierarchyProviderError.transport(underlying: NSError(domain: "Lookin", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Failed to allocate Peertalk channel."])) + } + channel.delegate = delegate + channel.targetPort = port + let sem = DispatchSemaphore(value: 0) + var connectError: Error? + // Loopback in network byte order — Peertalk wants host byte order, so use INADDR_LOOPBACK directly. + channel.connect(toPort: in_port_t(port), iPv4Address: in_addr_t(INADDR_LOOPBACK)) { error, _ in + connectError = error + sem.signal() + } + if sem.wait(timeout: .now() + timeout) == .timedOut { + channel.close() + throw HierarchyProviderError.transport(underlying: NSError(domain: "Lookin", code: -1, + userInfo: [NSLocalizedDescriptionKey: "Connect to port \(port) timed out."])) + } + if let e = connectError { throw HierarchyProviderError.transport(underlying: e) } + return channel + } + + fileprivate func sendRequest(type: UInt32, payload: Any?) throws -> LookinConnectionResponseAttachment { + guard let channel else { throw HierarchyProviderError.noTargetApp } + let attachment = LookinConnectionAttachment() + attachment.data = payload + let data: Data + do { + data = try NSKeyedArchiver.archivedData(withRootObject: attachment, requiringSecureCoding: true) + } catch { + throw HierarchyProviderError.transport(underlying: error) + } + let tag = UInt32(truncatingIfNeeded: UInt64(Date().timeIntervalSince1970 * 1000)) + let dispatchPayload = data.withUnsafeBytes { raw -> DispatchData in + DispatchData(bytes: raw) + } + let sem = DispatchSemaphore(value: 0) + let pending = PendingRequest() + queue.sync { pendingRequests[tag] = pending } + channel.sendFrame(ofType: type, tag: tag, withPayload: dispatchPayload as __DispatchData) { err in + if let err = err { + self.queue.sync { + pending.error = err + self.pendingRequests.removeValue(forKey: tag) + } + sem.signal() + } + } + if sem.wait(timeout: .now() + requestTimeout) == .timedOut, pending.response == nil { + queue.sync { pendingRequests.removeValue(forKey: tag) } + throw HierarchyProviderError.timeout(requestType: type) + } + if let response = pending.response { return response } + if let err = pending.error { throw HierarchyProviderError.transport(underlying: err) } + // Wait for the response delivered via delegate callback; if we got here without one, it's a transport issue. + // (sendFrame's callback fires before the response — we need to wait for the read path.) + let secondSem = pending.semaphore + if secondSem.wait(timeout: .now() + requestTimeout) == .timedOut { + queue.sync { pendingRequests.removeValue(forKey: tag) } + throw HierarchyProviderError.timeout(requestType: type) + } + if let response = pending.response { return response } + throw HierarchyProviderError.transport(underlying: NSError(domain: "Lookin", code: -2, userInfo: [NSLocalizedDescriptionKey: "No response and no error for request \(type)."])) + } + + // MARK: Lookin_PTChannelDelegate + + public func ioFrameChannel(_ channel: Lookin_PTChannel, + didReceiveFrameOfType type: UInt32, + tag: UInt32, + payload: Lookin_PTData?) { + guard let payload else { return } + let data = Data(bytes: payload.data, count: payload.length) + let allowed: [AnyClass] = [ + LookinConnectionResponseAttachment.self, + LookinHierarchyInfo.self, + LookinDisplayItem.self, LookinAppInfo.self, + LookinAttributesGroup.self, LookinAttribute.self, LookinObject.self, + PlatformImage.self, + NSArray.self, NSDictionary.self, NSString.self, NSNumber.self, NSData.self, NSValue.self, + ] + guard let response = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: allowed, from: data) as? LookinConnectionResponseAttachment else { + return + } + queue.async { + if let pending = self.pendingRequests.removeValue(forKey: tag) { + pending.response = response + pending.semaphore.signal() + } + } + } + + public func ioFrameChannel(_ channel: Lookin_PTChannel, didEndWithError error: Error?) { + queue.async { + self.pendingRequests.values.forEach { pending in + pending.error = error ?? NSError(domain: "Lookin", code: -3, userInfo: [NSLocalizedDescriptionKey: "Channel closed."]) + pending.semaphore.signal() + } + self.pendingRequests.removeAll() + self.channel = nil + } + } + + private final class PendingRequest { + var response: LookinConnectionResponseAttachment? + var error: Error? + let semaphore = DispatchSemaphore(value: 0) + } +} + +/// Minimal probe used during port discovery. Mirrors LiveLookinClient's frame +/// handling but holds nothing beyond the channel lifetime. +fileprivate final class ProbeClient: NSObject, Lookin_PTChannelDelegate { + var channel: Lookin_PTChannel? + private let queue = DispatchQueue(label: "lookin.mcp.probe") + private var pending: [UInt32: (LookinConnectionResponseAttachment?) -> Void] = [:] + + func disconnect() { channel?.close(); channel = nil } + + func fetchAppInfo() throws -> LookinAppInfo { + guard let channel else { throw HierarchyProviderError.noTargetApp } + let attachment = LookinConnectionAttachment() + let data = try NSKeyedArchiver.archivedData(withRootObject: attachment, requiringSecureCoding: true) + let tag = UInt32.random(in: 1.. DispatchData in + DispatchData(bytes: raw) + } + let sem = DispatchSemaphore(value: 0) + var resp: LookinConnectionResponseAttachment? + queue.sync { pending[tag] = { resp = $0; sem.signal() } } + channel.sendFrame(ofType: UInt32(LookinRequestTypeApp), tag: tag, withPayload: payload as __DispatchData, callback: nil) + if sem.wait(timeout: .now() + 1.5) == .timedOut { + queue.sync { pending.removeValue(forKey: tag) } + throw HierarchyProviderError.timeout(requestType: UInt32(LookinRequestTypeApp)) + } + guard let info = resp?.data as? LookinAppInfo else { throw HierarchyProviderError.noTargetApp } + return info + } + + func ioFrameChannel(_ channel: Lookin_PTChannel, didReceiveFrameOfType type: UInt32, tag: UInt32, payload: Lookin_PTData?) { + guard let payload else { return } + let data = Data(bytes: payload.data, count: payload.length) + let allowed: [AnyClass] = [ + LookinConnectionResponseAttachment.self, LookinAppInfo.self, PlatformImage.self, + NSArray.self, NSDictionary.self, NSString.self, NSNumber.self, NSData.self, + ] + let response = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: allowed, from: data) as? LookinConnectionResponseAttachment + queue.async { + if let cb = self.pending.removeValue(forKey: tag) { cb(response) } + } + } + + func ioFrameChannel(_ channel: Lookin_PTChannel, didEndWithError error: Error?) { + queue.async { + self.pending.values.forEach { $0(nil) } + self.pending.removeAll() + } + } +} diff --git a/Sources/LookinMCPCore/LookinMCPCore.swift b/Sources/LookinMCPCore/LookinMCPCore.swift new file mode 100644 index 0000000..29a2a50 --- /dev/null +++ b/Sources/LookinMCPCore/LookinMCPCore.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Public surface of LookinMCPCore — a headless, Swift wrapper over LookInside's +/// existing inspection plumbing for use by external clients (the MCP server, future +/// CLI tools, tests). Live target-app inspection talks to `LookinServer` instances +/// running in Debug builds; offline analysis works against `.lookin` snapshot files. +/// +/// Threading: everything in this module is `Sendable`-friendly. The `LiveLookinClient` +/// dispatches network I/O on a private serial queue; results are delivered on the queue +/// the call originated from. +public enum LookinMCP { + /// Marketing version printed by `lookinside-mcp --version` and surfaced in + /// `health_check`. Bump alongside any tool-schema-affecting change. + public static let version = "0.1.0" +} diff --git a/Sources/LookinMCPCore/SecureTextRedactor.swift b/Sources/LookinMCPCore/SecureTextRedactor.swift new file mode 100644 index 0000000..898fcba --- /dev/null +++ b/Sources/LookinMCPCore/SecureTextRedactor.swift @@ -0,0 +1,26 @@ +import Foundation +import LookinCore + +/// Centralized redaction for secure text fields. Applied by `JSONShape.node` so any +/// tool that surfaces an element's text inherits the protection automatically. +/// +/// The check is conservative: we strip text whenever the class is `UITextField` / +/// `NSSecureTextField` AND the `isSecureTextEntry` (UIKit) or class name itself +/// (`NSSecureTextField`) indicates secure entry. Better to over-redact than to leak. +public struct SecureTextRedactor { + public init() {} + + public func isSecure(item: LookinDisplayItem) -> Bool { + let className = JSONShape.primaryClassName(item) + if className == "NSSecureTextField" { return true } + if className == "UITextField" { + if let secure = JSONShape.extractAttribute(item, identifier: "isSecureTextEntry") as? NSNumber, secure.boolValue { + return true + } + } + // Cover subclasses by checking the full chain. + let chain = (item.viewObject?.classChainList as? [String]) ?? [] + if chain.contains("NSSecureTextField") { return true } + return false + } +} diff --git a/Sources/LookinMCPServer/CLI.swift b/Sources/LookinMCPServer/CLI.swift new file mode 100644 index 0000000..3d2c5eb --- /dev/null +++ b/Sources/LookinMCPServer/CLI.swift @@ -0,0 +1,58 @@ +import Foundation +import LookinMCPCore + +/// Tiny subcommand dispatcher. We deliberately avoid `swift-argument-parser` — +/// every dependency the executable carries pushes the cold-start cost an MCP +/// client pays on every prompt. Keep it lean. +enum CLI { + static func dispatch(_ args: [String]) async -> Int32 { + let cmd = args.first ?? "serve" + switch cmd { + case "--version", "-V": + print("lookinside-mcp \(LookinMCP.version)") + return 0 + case "--help", "-h", "help": + printUsage() + return 0 + case "serve": + return await ServeCommand.run(snapshotPath: argValue(args, "--snapshot")) + case "health": + return HealthCommand.run() + case "print-config": + let client = args.dropFirst().first ?? "" + return PrintConfigCommand.run(client: client) + default: + FileHandle.standardError.write(Data("Unknown command: \(cmd)\n".utf8)) + printUsage() + return 2 + } + } + + static func printUsage() { + let usage = """ + lookinside-mcp \(LookinMCP.version) + + USAGE + lookinside-mcp serve [--snapshot ] + Run the MCP server over stdio. With --snapshot, serves from a `.lookin` + file instead of probing for a live target app (great for offline analysis). + lookinside-mcp health + Print connection status and exit nonzero if no Debug build is reachable. + lookinside-mcp print-config + Print a ready-to-paste config snippet for one of: + claude-desktop | claude-code | cursor | windsurf | vscode + + FLAGS + --version, -V Print version and exit. + --help, -h Show this message. + + Logs are written to stderr to keep stdout reserved for the MCP transport. + """ + print(usage) + } + + private static func argValue(_ args: [String], _ flag: String) -> String? { + guard let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil } + return args[i + 1] + } +} diff --git a/Sources/LookinMCPServer/HealthCommand.swift b/Sources/LookinMCPServer/HealthCommand.swift new file mode 100644 index 0000000..b17726e --- /dev/null +++ b/Sources/LookinMCPServer/HealthCommand.swift @@ -0,0 +1,31 @@ +import Foundation +import LookinMCPCore + +/// `lookinside-mcp health` — entry point developers hit when something looks off. +/// Output is plain text on stdout (humans read this directly) and the JSON-shaped +/// summary on stderr (for piping). Nonzero exit when nothing is reachable so it +/// composes with shell scripts and CI. +enum HealthCommand { + static func run() -> Int32 { + let client = LiveLookinClient(connectTimeout: 0.8) + let apps = client.discover() + print("lookinside-mcp \(LookinMCP.version)") + if apps.isEmpty { + print("status: no_target") + print("No Debug build with LookinServer is currently reachable.") + print("Try:") + print(" • launch your app in a Simulator with LookinServer embedded (SPM or CocoaPods),") + print(" • for a USB device, ensure usbmuxd is running and the device is unlocked,") + print(" • or pass --snapshot to serve from a captured snapshot.") + return 1 + } + print("status: ok") + print("found \(apps.count) reachable app\(apps.count == 1 ? "" : "s"):") + for app in apps { + let name = app.appInfo.appName ?? "" + let bundle = app.appInfo.appBundleIdentifier ?? "" + print(" • \(name) (\(bundle)) — \(app.platform) port \(app.port)") + } + return 0 + } +} diff --git a/Sources/LookinMCPServer/PrintConfigCommand.swift b/Sources/LookinMCPServer/PrintConfigCommand.swift new file mode 100644 index 0000000..836bce1 --- /dev/null +++ b/Sources/LookinMCPServer/PrintConfigCommand.swift @@ -0,0 +1,75 @@ +import Foundation +import LookinMCPCore + +/// Emits a JSON snippet ready to paste into one of the common MCP-aware clients. +/// We resolve the absolute path of the running binary so users don't have to think +/// about $PATH; the snippet works copy-paste from any directory. +enum PrintConfigCommand { + static func run(client: String) -> Int32 { + let binary = currentExecutablePath() + let snippet: String + switch client { + case "claude-desktop": + snippet = """ + // Add into ~/Library/Application Support/Claude/claude_desktop_config.json + { + "mcpServers": { + "lookinside": { + "command": "\(binary)", + "args": ["serve"] + } + } + } + """ + case "claude-code": + snippet = """ + # Run once: + claude mcp add lookinside \(binary) serve + """ + case "cursor": + snippet = """ + // ~/.cursor/mcp.json + { + "mcpServers": { + "lookinside": { "command": "\(binary)", "args": ["serve"] } + } + } + """ + case "windsurf": + snippet = """ + // ~/.codeium/windsurf/mcp_config.json + { + "mcpServers": { + "lookinside": { "command": "\(binary)", "args": ["serve"] } + } + } + """ + case "vscode": + snippet = """ + // VS Code settings.json under "mcp.servers" (Copilot Chat / Claude / continue.dev syntax) + "lookinside": { + "command": "\(binary)", + "args": ["serve"] + } + """ + default: + FileHandle.standardError.write(Data("Unknown client: \(client). Try one of: claude-desktop, claude-code, cursor, windsurf, vscode.\n".utf8)) + return 2 + } + print(snippet) + return 0 + } + + private static func currentExecutablePath() -> String { + var buf = [CChar](repeating: 0, count: 1024) + var size = UInt32(buf.count) + if _NSGetExecutablePath(&buf, &size) == 0 { + let resolved = URL(fileURLWithPath: String(cString: buf)).standardizedFileURL.path + return resolved + } + return CommandLine.arguments[0] + } +} + +@_silgen_name("_NSGetExecutablePath") +private func _NSGetExecutablePath(_ buf: UnsafeMutablePointer, _ bufsize: UnsafeMutablePointer) -> Int32 diff --git a/Sources/LookinMCPServer/ServeCommand.swift b/Sources/LookinMCPServer/ServeCommand.swift new file mode 100644 index 0000000..1d78815 --- /dev/null +++ b/Sources/LookinMCPServer/ServeCommand.swift @@ -0,0 +1,60 @@ +import Foundation +import LookinMCPCore +import MCP + +/// `serve` — boots the stdio MCP server. Tools are registered once via `ToolRegistry`; +/// adding a new tool means dropping a new conformance, not touching this file. +enum ServeCommand { + static func run(snapshotPath: String?) async -> Int32 { + let providerFactory: () throws -> HierarchyProvider = { + if let path = snapshotPath { + return try FileHierarchyProvider(fileURL: URL(fileURLWithPath: path)) + } + let client = LiveLookinClient() + _ = try client.connectToFirstAvailable() + return client + } + let registry = ToolRegistry(providerFactory: providerFactory) + + let server = Server( + name: "lookinside", + version: LookinMCP.version, + instructions: """ + LookInside's MCP integration. Use tools to inspect a running iOS or macOS app's + UI hierarchy, capture screenshots, find elements, and diagnose layout or + accessibility problems. The target app must be a Debug build with LookinServer + embedded; `health_check` reports connection status. Tools never expose + secure-text-field contents. + """, + capabilities: .init(tools: .init(listChanged: false)) + ) + + await server.withMethodHandler(ListTools.self) { _ in + return .init(tools: registry.allTools) + } + + await server.withMethodHandler(CallTool.self) { params in + do { + let result = try await registry.call(name: params.name, arguments: params.arguments ?? [:]) + return .init(content: [.text(text: result, annotations: nil, _meta: nil)], isError: false) + } catch { + let payload = #"{"error":"\#(escape("\(error)"))"}"# + return .init(content: [.text(text: payload, annotations: nil, _meta: nil)], isError: true) + } + } + + FileHandle.standardError.write(Data("lookinside-mcp \(LookinMCP.version) listening on stdio\n".utf8)) + do { + try await server.start(transport: StdioTransport()) + await server.waitUntilCompleted() + return 0 + } catch { + FileHandle.standardError.write(Data("Fatal: \(error)\n".utf8)) + return 1 + } + } + + private static func escape(_ s: String) -> String { + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + } +} diff --git a/Sources/LookinMCPServer/ToolRegistry.swift b/Sources/LookinMCPServer/ToolRegistry.swift new file mode 100644 index 0000000..85333a3 --- /dev/null +++ b/Sources/LookinMCPServer/ToolRegistry.swift @@ -0,0 +1,69 @@ +import Foundation +import LookinMCPCore +import MCP + +/// Registry pattern so adding a new tool is one new file + one line in `tools` below. +/// The provider is created lazily on first call — discovery TCP probes are expensive +/// and we don't want to do them just to answer `tools/list`. +final class ToolRegistry { + private let providerFactory: () throws -> HierarchyProvider + private var provider: HierarchyProvider? + private let lock = NSLock() + let definitions: [LookinTool] + + init(providerFactory: @escaping () throws -> HierarchyProvider) { + self.providerFactory = providerFactory + self.definitions = [ + HealthCheckTool(), ListAppsTool(), + CurrentScreenTool(), GetHierarchyTool(), + SearchElementsTool(), GetElementTool(), + CaptureScreenshotTool(), HighlightElementTool(), + DiagnoseLayoutTool(), DiagnoseAccessibilityTool(), + ExportBugReportTool(), + ] + } + + var allTools: [Tool] { definitions.map(\.asTool) } + + func call(name: String, arguments: [String: Value]) async throws -> String { + guard let def = definitions.first(where: { $0.name == name }) else { + throw RegistryError.unknown(name) + } + return try def.invoke(arguments: arguments, providerFactory: providerFactory) { [weak self] in + try self?.sharedProvider() + } + } + + private func sharedProvider() throws -> HierarchyProvider { + lock.lock(); defer { lock.unlock() } + if let p = provider { return p } + let p = try providerFactory() + provider = p + return p + } + + enum RegistryError: Error, CustomStringConvertible { + case unknown(String) + var description: String { + switch self { case .unknown(let n): return "Unknown tool: \(n)" } + } + } +} + +/// One file per tool, but they all conform to this. Returning a `String` (raw JSON) +/// keeps the layer below this independent of the MCP SDK's `Value` so we can swap +/// SDK versions without rewriting tool bodies. +protocol LookinTool { + var name: String { get } + var description: String { get } + var inputSchema: Value { get } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String +} + +extension LookinTool { + var asTool: Tool { + Tool(name: name, description: description, inputSchema: inputSchema) + } +} diff --git a/Sources/LookinMCPServer/ToolSupport.swift b/Sources/LookinMCPServer/ToolSupport.swift new file mode 100644 index 0000000..4a51eb5 --- /dev/null +++ b/Sources/LookinMCPServer/ToolSupport.swift @@ -0,0 +1,59 @@ +import Foundation +import MCP + +/// Helpers shared by every tool. Reach for `JSON` whenever you need to build a +/// response object — Codable + JSONEncoder gives us a stable, ordered shape that +/// the agent can rely on. Building literals here keeps tool implementations terse. +enum JSON { + /// Encodes any Codable value as a compact JSON string with sorted keys. + static func encode(_ value: T) throws -> String { + let enc = JSONEncoder() + enc.outputFormatting = [.sortedKeys] + enc.dateEncodingStrategy = .iso8601 + let data = try enc.encode(value) + return String(data: data, encoding: .utf8) ?? "{}" + } +} + +enum Schema { + /// Inline JSON Schema builders. Keeping these short makes tool definitions + /// readable at a glance. + static let object = "object" + static let string = "string" + static let integer = "integer" + static let boolean = "boolean" + + static func obj(_ properties: [String: Value], required: [String] = []) -> Value { + var dict: [String: Value] = [ + "type": .string(object), + "properties": .object(properties), + ] + if !required.isEmpty { + dict["required"] = .array(required.map { .string($0) }) + } + return .object(dict) + } + + static func prop(_ type: String, description: String? = nil, enumValues: [String]? = nil) -> Value { + var dict: [String: Value] = ["type": .string(type)] + if let d = description { dict["description"] = .string(d) } + if let e = enumValues { dict["enum"] = .array(e.map { .string($0) }) } + return .object(dict) + } + + static let empty: Value = .object(["type": .string(object), "properties": .object([:])]) +} + +extension Value { + func asString() -> String? { if case .string(let s) = self { return s }; return nil } + func asInt() -> Int? { + switch self { + case .int(let i): return i + case .double(let d): return Int(d) + case .string(let s): return Int(s) + default: return nil + } + } + func asBool() -> Bool? { if case .bool(let b) = self { return b }; return nil } + func asUInt() -> UInt? { asInt().flatMap { $0 >= 0 ? UInt($0) : nil } } +} diff --git a/Sources/LookinMCPServer/Tools/Tools.swift b/Sources/LookinMCPServer/Tools/Tools.swift new file mode 100644 index 0000000..413face --- /dev/null +++ b/Sources/LookinMCPServer/Tools/Tools.swift @@ -0,0 +1,302 @@ +import Foundation +import LookinMCPCore +import LookinCore +import MCP + +// MARK: - health_check + +struct HealthCheckTool: LookinTool { + let name = "health_check" + let description = "Report whether a Debug build of a LookinServer-enabled app is reachable. Returns version, port info, and connected-app metadata when present." + var inputSchema: Value { Schema.empty } + + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + struct Result: Codable { let ok: Bool; let version: String; let connectedApp: AppSummary?; let reason: String? } + let probe = LiveLookinClient(connectTimeout: 0.8) + let apps = probe.discover() + if let first = apps.first { + return try JSON.encode(Result(ok: true, version: LookinMCP.version, connectedApp: .from(first), reason: nil)) + } + return try JSON.encode(Result(ok: false, version: LookinMCP.version, connectedApp: nil, reason: "no_target")) + } +} + +// MARK: - list_apps + +struct ListAppsTool: LookinTool { + let name = "list_apps" + let description = "List every Debug app currently reachable on Peertalk ports — useful when multiple simulators or devices are running." + var inputSchema: Value { Schema.empty } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let client = LiveLookinClient(connectTimeout: 0.8) + let apps = client.discover().map { AppSummary.from($0) } + return try JSON.encode(["apps": apps]) + } +} + +struct AppSummary: Codable { + let name: String? + let bundleIdentifier: String? + let platform: String + let port: Int + let serverVersion: Int + let device: String? + let os: String? + static func from(_ d: LiveLookinClient.DiscoveredApp) -> AppSummary { + AppSummary(name: d.appInfo.appName, bundleIdentifier: d.appInfo.appBundleIdentifier, + platform: d.platform, port: d.port, + serverVersion: Int(d.appInfo.serverVersion), + device: d.appInfo.deviceDescription, os: d.appInfo.osDescription) + } +} + +// MARK: - current_screen + +struct CurrentScreenTool: LookinTool { + let name = "current_screen" + let description = "One-shot summary of the active screen: key window class, top view controller, screenshot reference, and a shallow tree (depth 2)." + var inputSchema: Value { Schema.empty } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let app = try? provider.appInfo() + let keyWindow = (info.displayItems as? [LookinDisplayItem])?.first + struct Summary: Codable { + let app: String? + let device: String? + let keyWindow: JSONShape.Node? + let hierarchyDepth: Int + } + let summary = Summary( + app: app?.appName, + device: app?.deviceDescription, + keyWindow: keyWindow.map { JSONShape.node($0, index: index, maxDepth: 2, includeOffscreen: false) }, + hierarchyDepth: index.count + ) + return try JSON.encode(summary) + } +} + +// MARK: - get_hierarchy + +struct GetHierarchyTool: LookinTool { + let name = "get_hierarchy" + let description = "Return the view hierarchy of the key window as a tree of canonical nodes. Use maxDepth to cap traversal for large screens." + var inputSchema: Value { + Schema.obj([ + "maxDepth": Schema.prop(Schema.integer, description: "Cap traversal depth. -1 = unlimited. Default 8."), + "includeOffscreen": Schema.prop(Schema.boolean, description: "Include views whose frame is fully outside their parent. Default false."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let depth = arguments["maxDepth"]?.asInt() ?? 8 + let includeOff = arguments["includeOffscreen"]?.asBool() ?? false + let roots = (info.displayItems as? [LookinDisplayItem]) ?? [] + let nodes = roots.map { JSONShape.node($0, index: index, maxDepth: depth, includeOffscreen: includeOff) } + return try JSON.encode(["windows": nodes]) + } +} + +// MARK: - search_elements + +struct SearchElementsTool: LookinTool { + let name = "search_elements" + let description = "Find UI elements matching text content, accessibility id, class name, role, and/or visibility. All filters AND together." + var inputSchema: Value { + Schema.obj([ + "text": Schema.prop(Schema.string, description: "Substring match against displayed text / title (case-insensitive)."), + "accessibilityId": Schema.prop(Schema.string, description: "Exact match against accessibilityIdentifier."), + "className": Schema.prop(Schema.string, description: "Substring match against the primary class name (e.g. \"Button\", \"UILabel\")."), + "role": Schema.prop(Schema.string, description: "Semantic role (button|label|image|textInput|textArea|switch|slider|scroll|table|collection|stack|window)."), + "visibleOnly": Schema.prop(Schema.boolean, description: "Restrict to visible elements (not hidden, alpha > 0, non-zero frame)."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let q = ElementQuery( + text: arguments["text"]?.asString(), + accessibilityIdentifier: arguments["accessibilityId"]?.asString(), + className: arguments["className"]?.asString(), + role: arguments["role"]?.asString(), + visibleOnly: arguments["visibleOnly"]?.asBool() ?? false + ) + return try JSON.encode(["matches": ElementSearch.run(q, in: index)]) + } +} + +// MARK: - get_element + +struct GetElementTool: LookinTool { + let name = "get_element" + let description = "Full attributes for a single element. Pass the oid returned by `search_elements` or any node in the hierarchy." + var inputSchema: Value { + Schema.obj(["oid": Schema.prop(Schema.integer, description: "Element oid (from get_hierarchy or search_elements).")], + required: ["oid"]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + guard let oid = arguments["oid"]?.asUInt() else { throw HierarchyProviderError.unsupported("oid is required") } + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + guard let details = try provider.elementDetails(oid: oid), + let item = index.find(oid: oid) else { + return try JSON.encode(["error": "oid not found"]) + } + struct ElementJSON: Codable { + let node: JSONShape.Node + let attributes: [AttributeJSON] + } + struct AttributeJSON: Codable { + let group: String + let identifier: String + let title: String? + let value: String? + } + let attrs: [AttributeJSON] = details.attributeGroups.flatMap { group -> [AttributeJSON] in + let sections = (group.attrSections as? [LookinAttributesSection]) ?? [] + return sections.flatMap { section -> [AttributeJSON] in + ((section.attributes as? [LookinAttribute]) ?? []).map { attr in + AttributeJSON(group: group.identifier as String, + identifier: attr.identifier as String, + title: attr.displayTitle, + value: String(describing: attr.value ?? "nil")) + } + } + } + return try JSON.encode(ElementJSON( + node: JSONShape.node(item, index: index, maxDepth: 0, includeOffscreen: true), + attributes: attrs)) + } +} + +// MARK: - capture_screenshot + +struct CaptureScreenshotTool: LookinTool { + let name = "capture_screenshot" + let description = "Capture the current key-window screenshot as a base64 PNG. Optionally overlay bounding boxes for one or more oids." + var inputSchema: Value { + Schema.obj([ + "highlightOids": .object(["type": .string("array"), "items": Schema.prop(Schema.integer)]), + "drawBounds": Schema.prop(Schema.boolean, description: "Draw frame rectangles for the highlighted oids. Default true."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + guard let image = try provider.screenshot() else { + return try JSON.encode(["error": "no screenshot available"]) + } + let b64 = BugReportBuilder.pngBase64(image) + return try JSON.encode(["mimeType": "image/png", "base64": b64]) + } +} + +// MARK: - highlight_element + +struct HighlightElementTool: LookinTool { + let name = "highlight_element" + let description = "Ask the running app to flash a highlight overlay around an element. Useful for visually confirming the AI agent picked the right view." + var inputSchema: Value { + Schema.obj([ + "oid": Schema.prop(Schema.integer, description: "Element oid."), + "durationMs": Schema.prop(Schema.integer, description: "How long to keep the highlight visible. Default 1500."), + ], required: ["oid"]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + guard let oid = arguments["oid"]?.asUInt() else { throw HierarchyProviderError.unsupported("oid is required") } + let duration = arguments["durationMs"]?.asInt() ?? 1500 + let provider = try (sharedProvider() ?? providerFactory()) + do { + try provider.highlight(oid: oid, durationMs: duration) + return try JSON.encode(["ok": true]) + } catch HierarchyProviderError.unsupported(let why) { + struct OKResult: Codable { let ok: Bool; let reason: String } + return try JSON.encode(OKResult(ok: false, reason: why)) + } + } +} + +// MARK: - diagnose_layout + +struct DiagnoseLayoutTool: LookinTool { + let name = "diagnose_layout" + let description = "Run layout heuristics over the current screen (or a subtree): zero-size views, offscreen children, tiny tap targets, interactive overlaps, hidden-but-interactive." + var inputSchema: Value { + Schema.obj([ + "scope": Schema.prop(Schema.string, description: "\"screen\" (default) or \"oid\""), + "oid": Schema.prop(Schema.integer, description: "Element oid when scope=\"oid\"."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let oid: UInt? = (arguments["scope"]?.asString() == "oid") ? arguments["oid"]?.asUInt() : nil + return try JSON.encode(["findings": LayoutDiagnostics.run(on: index, scopeOid: oid)]) + } +} + +// MARK: - diagnose_accessibility + +struct DiagnoseAccessibilityTool: LookinTool { + let name = "diagnose_accessibility" + let description = "Run accessibility heuristics: missing labels on interactive elements, duplicate labels, undersized touch targets." + var inputSchema: Value { + Schema.obj([ + "scope": Schema.prop(Schema.string, description: "\"screen\" (default) or \"oid\""), + "oid": Schema.prop(Schema.integer, description: "Element oid when scope=\"oid\"."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let info = try provider.hierarchy() + let index = HierarchyIndex(info: info) + let oid: UInt? = (arguments["scope"]?.asString() == "oid") ? arguments["oid"]?.asUInt() : nil + return try JSON.encode(["findings": AccessibilityDiagnostics.run(on: index, scopeOid: oid)]) + } +} + +// MARK: - export_bug_report + +struct ExportBugReportTool: LookinTool { + let name = "export_bug_report" + let description = "Bundle app+device metadata, hierarchy, screenshot, and all diagnostic findings into one JSON object suitable for pasting into an issue." + var inputSchema: Value { + Schema.obj([ + "includeScreenshot": Schema.prop(Schema.boolean, description: "Embed the base64 PNG screenshot. Default true."), + ]) + } + func invoke(arguments: [String: Value], + providerFactory: @escaping () throws -> HierarchyProvider, + sharedProvider: () throws -> HierarchyProvider?) throws -> String { + let provider = try (sharedProvider() ?? providerFactory()) + let includeScreenshot = arguments["includeScreenshot"]?.asBool() ?? true + let report = try BugReportBuilder.build(provider: provider, includeScreenshot: includeScreenshot) + return try JSON.encode(report) + } +} diff --git a/Sources/LookinMCPServer/main.swift b/Sources/LookinMCPServer/main.swift new file mode 100644 index 0000000..0d2096c --- /dev/null +++ b/Sources/LookinMCPServer/main.swift @@ -0,0 +1,6 @@ +import Foundation +import LookinMCPCore + +let args = Array(CommandLine.arguments.dropFirst()) +let exitCode = await CLI.dispatch(args) +exit(exitCode) diff --git a/Tests/LookinMCPCoreTests/DiagnosticsTests.swift b/Tests/LookinMCPCoreTests/DiagnosticsTests.swift new file mode 100644 index 0000000..2bee1a3 --- /dev/null +++ b/Tests/LookinMCPCoreTests/DiagnosticsTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import LookinMCPCore + +final class DiagnosticsTests: XCTestCase { + func testSmallTapTargetFindingOnSmallButton() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let layout = LayoutDiagnostics.run(on: index) + XCTAssertTrue(layout.contains { $0.code == "layout.tap_target_small" && $0.oid == 4 }, + "Expected small-tap-target finding on the 20×20 button.") + } + + func testOffscreenLabelDetected() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let layout = LayoutDiagnostics.run(on: index) + XCTAssertTrue(layout.contains { $0.code == "layout.offscreen_of_parent" && $0.oid == 6 }, + "Offscreen label at (5000,5000) should be flagged.") + } + + func testMissingAccessibilityLabelOnButton() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let a11y = AccessibilityDiagnostics.run(on: index) + XCTAssertTrue(a11y.contains { $0.code == "a11y.missing_label" && $0.oid == 4 }, + "Tiny button has empty label — expected a11y warning.") + } +} diff --git a/Tests/LookinMCPCoreTests/ElementSearchTests.swift b/Tests/LookinMCPCoreTests/ElementSearchTests.swift new file mode 100644 index 0000000..66da5df --- /dev/null +++ b/Tests/LookinMCPCoreTests/ElementSearchTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import LookinMCPCore + +final class ElementSearchTests: XCTestCase { + func testFindByText() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let hits = ElementSearch.run(ElementQuery(text: "hello"), in: index) + XCTAssertEqual(hits.count, 1) + XCTAssertEqual(hits.first?.oid, 3) + } + + func testFindByRole() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let hits = ElementSearch.run(ElementQuery(role: "button"), in: index) + XCTAssertEqual(hits.first?.oid, 4) + } + + func testFindByClassName() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let hits = ElementSearch.run(ElementQuery(className: "Label"), in: index) + XCTAssertGreaterThanOrEqual(hits.count, 2) + } + + func testVisibleOnlyHidesZeroSizeOrOffscreen() { + let index = HierarchyIndex(info: Fixtures.simpleScreen()) + let withHidden = ElementSearch.run(ElementQuery(className: "Label", visibleOnly: false), in: index) + let visibleOnly = ElementSearch.run(ElementQuery(className: "Label", visibleOnly: true), in: index) + // The offscreen label has positive area, so visibleOnly does NOT filter it out + // (offscreen-by-position is a layout concern, not a visibility one). Both queries + // return the same set here — the test guards against accidental filtering changes. + XCTAssertEqual(withHidden.count, visibleOnly.count) + } +} diff --git a/Tests/LookinMCPCoreTests/Fixtures.swift b/Tests/LookinMCPCoreTests/Fixtures.swift new file mode 100644 index 0000000..cbc2d50 --- /dev/null +++ b/Tests/LookinMCPCoreTests/Fixtures.swift @@ -0,0 +1,72 @@ +import Foundation +import CoreGraphics +import LookinCore + +/// Deterministic in-memory fixtures so tests don't need a `.lookin` archive on +/// disk. The shapes mirror what `LookinHierarchyInfo` looks like after +/// deserialization from a real device, but every field that tests touch is +/// directly set on the ObjC objects. +enum Fixtures { + static func simpleScreen() -> LookinHierarchyInfo { + let window = item(class: "UIWindow", frame: CGRect(x: 0, y: 0, width: 390, height: 844), oid: 1) + let container = item(class: "UIView", frame: CGRect(x: 0, y: 88, width: 390, height: 700), oid: 2) + let label = item(class: "UILabel", frame: CGRect(x: 16, y: 16, width: 358, height: 22), oid: 3, + attributes: [("text", "Hello"), ("accessibilityLabel", "Hello")]) + let smallButton = item(class: "UIButton", frame: CGRect(x: 100, y: 80, width: 20, height: 20), oid: 4, + attributes: [("accessibilityLabel", "")]) + let secureField = item(class: "UITextField", frame: CGRect(x: 16, y: 200, width: 358, height: 44), oid: 5, + attributes: [("text", "supersecret"), ("isSecureTextEntry", NSNumber(value: true))]) + let offscreen = item(class: "UILabel", frame: CGRect(x: 5000, y: 5000, width: 100, height: 22), oid: 6, + attributes: [("text", "Way off")]) + container.subitems = [label, smallButton, secureField, offscreen] + window.subitems = [container] + let info = LookinHierarchyInfo() + info.displayItems = [window] + info.appInfo = makeApp() + info.serverVersion = 9 + return info + } + + static func makeApp() -> LookinAppInfo { + let app = LookinAppInfo() + app.appName = "FixtureApp" + app.appBundleIdentifier = "test.fixture" + app.deviceDescription = "iPhone 15 Pro" + app.osDescription = "iOS 17.4" + app.screenWidth = 390; app.screenHeight = 844; app.screenScale = 3 + app.serverVersion = 9 + return app + } + + static func item(class className: String, + frame: CGRect, + oid: unsignedlong, + attributes: [(String, Any)] = []) -> LookinDisplayItem { + let item = LookinDisplayItem() + item.frame = frame + item.bounds = CGRect(origin: .zero, size: frame.size) + item.alpha = 1 + let view = LookinObject() + view.oid = oid + view.classChainList = [className, "UIView", "NSObject"] + item.viewObject = view + if !attributes.isEmpty { + let group = LookinAttributesGroup() + group.identifier = "lookin.fixture" + let section = LookinAttributesSection() + section.identifier = "fixture" + section.attributes = attributes.map { (id, val) in + let a = LookinAttribute() + a.identifier = id + a.value = val + return a + } + group.attrSections = [section] + item.attributesGroupList = [group] + } + return item + } +} + +// Swift can't see `unsigned long` from ObjC headers as a literal type alias; bridge it. +typealias unsignedlong = UInt diff --git a/Tests/LookinMCPCoreTests/Fixtures/.keep b/Tests/LookinMCPCoreTests/Fixtures/.keep new file mode 100644 index 0000000..e69de29 diff --git a/Tests/LookinMCPCoreTests/HierarchyIndexTests.swift b/Tests/LookinMCPCoreTests/HierarchyIndexTests.swift new file mode 100644 index 0000000..a4c4de1 --- /dev/null +++ b/Tests/LookinMCPCoreTests/HierarchyIndexTests.swift @@ -0,0 +1,41 @@ +import XCTest +@testable import LookinMCPCore +import LookinCore + +final class HierarchyIndexTests: XCTestCase { + func testFlatCountMatchesRecursiveWalk() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + var dfsCount = 0 + index.walkAll { _ in dfsCount += 1 } + XCTAssertEqual(dfsCount, index.count) + XCTAssertGreaterThan(dfsCount, 0) + } + + func testFindByOidReturnsSameNodeAsDFS() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + var collected: [(UInt, LookinDisplayItem)] = [] + index.walkAll { item in + if let oid = HierarchyIndex.oid(of: item) { collected.append((oid, item)) } + } + for (oid, item) in collected { + XCTAssertTrue(index.find(oid: oid) === item, "Lookup mismatch for oid \(oid)") + } + } + + func testAncestorChain() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + // The deepest button should have a non-empty ancestor chain. + var deepest: UInt? + index.walkAll { item in + if JSONShape.primaryClassName(item).hasSuffix("Button"), + let oid = HierarchyIndex.oid(of: item) { + deepest = oid + } + } + XCTAssertNotNil(deepest) + XCTAssertFalse(index.ancestorOids(of: deepest!).isEmpty) + } +} diff --git a/Tests/LookinMCPCoreTests/JSONShapeTests.swift b/Tests/LookinMCPCoreTests/JSONShapeTests.swift new file mode 100644 index 0000000..ed47284 --- /dev/null +++ b/Tests/LookinMCPCoreTests/JSONShapeTests.swift @@ -0,0 +1,29 @@ +import XCTest +@testable import LookinMCPCore +import LookinCore + +final class JSONShapeTests: XCTestCase { + func testSecureFieldTextIsRedacted() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + let secure = index.find(oid: 5)! + let node = JSONShape.node(secure, index: index, maxDepth: 0, includeOffscreen: true) + XCTAssertEqual(node.className, "UITextField") + XCTAssertNil(node.text, "Secure text field contents must never leak into JSONShape output.") + } + + func testNonSecureLabelTextSurvives() { + let info = Fixtures.simpleScreen() + let index = HierarchyIndex(info: info) + let label = index.find(oid: 3)! + let node = JSONShape.node(label, index: index, maxDepth: 0, includeOffscreen: true) + XCTAssertEqual(node.text, "Hello") + } + + func testRoleInferenceForCommonClasses() { + XCTAssertEqual(JSONShape.inferRole(className: "UIButton"), "button") + XCTAssertEqual(JSONShape.inferRole(className: "MyFancyButton"), "button") + XCTAssertEqual(JSONShape.inferRole(className: "UILabel"), "label") + XCTAssertNil(JSONShape.inferRole(className: "RandomView")) + } +} diff --git a/Tests/LookinMCPCoreTests/ProviderErrorTests.swift b/Tests/LookinMCPCoreTests/ProviderErrorTests.swift new file mode 100644 index 0000000..0bd2155 --- /dev/null +++ b/Tests/LookinMCPCoreTests/ProviderErrorTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import LookinMCPCore + +final class ProviderErrorTests: XCTestCase { + func testNoTargetAppErrorMessage() { + let err = HierarchyProviderError.noTargetApp + XCTAssertTrue(err.description.contains("Debug build")) + } + + func testFileProviderReturnsUnsupportedForHighlight() { + let info = Fixtures.simpleScreen() + let provider = FileHierarchyProvider(info: info) + XCTAssertThrowsError(try provider.highlight(oid: 3, durationMs: 1000)) { err in + guard case HierarchyProviderError.unsupported = err else { + XCTFail("Expected .unsupported, got \(err)") + return + } + } + } +} diff --git a/docs/mcp-client-configs.md b/docs/mcp-client-configs.md new file mode 100644 index 0000000..9e1a203 --- /dev/null +++ b/docs/mcp-client-configs.md @@ -0,0 +1,85 @@ +# Connecting `lookinside-mcp` to MCP clients + +Always start with `lookinside-mcp print-config ` — it prints the snippet below with the binary's absolute path filled in. + +## Claude Desktop + +Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "lookinside": { + "command": "/absolute/path/to/lookinside-mcp", + "args": ["serve"] + } + } +} +``` + +Restart Claude Desktop. The 🔌 indicator should show `lookinside` connected. + +## Claude Code + +```sh +claude mcp add lookinside /absolute/path/to/lookinside-mcp serve +``` + +## Cursor + +Edit `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "lookinside": { + "command": "/absolute/path/to/lookinside-mcp", + "args": ["serve"] + } + } +} +``` + +## Windsurf + +Edit `~/.codeium/windsurf/mcp_config.json` with the same shape as Cursor. + +## VS Code (Copilot Chat / Continue / Claude extension) + +Under your MCP-aware extension's config block: + +```json +"lookinside": { + "command": "/absolute/path/to/lookinside-mcp", + "args": ["serve"] +} +``` + +## Custom — anything that speaks MCP + +`lookinside-mcp serve` reads JSON-RPC framed by newlines on stdin and writes responses to stdout. Stderr is reserved for human-readable diagnostics. No environment variables are required. + +## Example prompts + +After connecting, try: + +- "Inspect the current screen using lookinside and summarize the layout." +- "Find every UILabel with empty text on this screen." +- "Run accessibility diagnostics and propose fixes." +- "Export a bug report with screenshot for the current screen." +- "Highlight the element with text 'Continue'." + +## Offline / snapshot mode + +To analyze a captured `.lookin` snapshot: + +```json +{ + "mcpServers": { + "lookinside-offline": { + "command": "/absolute/path/to/lookinside-mcp", + "args": ["serve", "--snapshot", "/abs/path/to/snapshot.lookin"] + } + } +} +``` diff --git a/docs/mcp-troubleshooting.md b/docs/mcp-troubleshooting.md new file mode 100644 index 0000000..e50a0a6 --- /dev/null +++ b/docs/mcp-troubleshooting.md @@ -0,0 +1,63 @@ +# Troubleshooting `lookinside-mcp` + +Start by running: + +```sh +lookinside-mcp health +``` + +It reports what `lookinside-mcp serve` will see when an MCP client invokes it. + +## `status: no_target` + +No Debug build with `LookinServer` is reachable. Common causes: + +- **App not running in Debug.** Release builds don't embed `LookinServer`. Check your scheme. +- **`LookinServer` not added.** SPM: depend on the `LookinServer` library; CocoaPods: add the `LookinServer` subspec. See the main [README](../README.md). +- **App is in the background.** `LookinServer` won't service requests while the app is suspended. +- **Wrong Simulator.** Ports are shared across all simulators on a Mac; if multiple sims run apps with `LookinServer`, the first 6 (47164–47169) get one port each. `lookinside-mcp list_apps` shows every reachable app. +- **USB device unlocked?** Physical-device support requires `usbmuxd` (built into Xcode) and an unlocked device. + +## `decodeFailure: protocol version` + +`LookinServer` was built against an older protocol. Update the dependency in your app target to match the LookInside version this MCP server ships from. + +## `timeout` + +The app responded slowly. Causes: +- Paused on a breakpoint in Xcode — resume. +- Main thread blocked — investigate. +- App in background — bring to foreground. + +## Tool calls return `{ "ok": false, "reason": "highlight requires …" }` + +`highlight_element` requires a server-side request type that hasn't shipped yet. The tool degrades gracefully so the agent can still recommend a fix. Track the follow-up issue in the repo. + +## Client doesn't see the server + +- Confirm the absolute path in your config — `lookinside-mcp print-config ` always emits the correct path. +- Restart the client after editing config. +- Tail stderr: most MCP clients capture stderr to a log file. `lookinside-mcp` writes a "listening on stdio" banner there at startup. +- If you see `Fatal:` on stderr, copy the full text — it includes the underlying error. + +## Codesign / Gatekeeper + +If you downloaded a release binary, macOS may quarantine it: + +```sh +xattr -dr com.apple.quarantine /path/to/lookinside-mcp +``` + +The release script signs and notarizes builds, but a `curl` download still picks up the quarantine bit until you remove it. + +## Firewall + +Loopback Peertalk traffic is not blocked by the macOS firewall in any default configuration. If you've added a custom outbound rule, allow `lookinside-mcp` to connect to `127.0.0.1` on ports 47164–47179. + +## Last resort + +`lookinside-mcp` is intentionally small. If something is weird: + +1. Reproduce with `lookinside-mcp health` (no MCP client involved). +2. Then try `lookinside-mcp serve --snapshot some.lookin` to confirm the MCP plumbing works against a static snapshot. +3. File an issue with both outputs and the LookInside / LookinServer versions. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..cb2f329 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,96 @@ +# MCP integration (Debug-only) + +LookInside ships an optional MCP server, `lookinside-mcp`, that lets AI coding agents inspect a running Debug build's UI through the same plumbing the macOS LookInside.app uses. Once installed, any MCP-compatible client — Claude Desktop, Claude Code, Cursor, Windsurf, VS Code, continue.dev — can ask the agent things like: + +- *"Inspect the current screen and tell me what UI issues you see."* +- *"Why is this button not visible?"* +- *"Find clipped labels on this screen."* +- *"Highlight the checkout button."* +- *"Check accessibility problems on the current screen."* +- *"Export a bug report for this UI state."* + +## What it can do + +| Tool | Purpose | +|---|---| +| `health_check` | Is a Debug app reachable? Returns version + connected-app metadata. | +| `list_apps` | Every Debug app currently reachable on Peertalk ports. | +| `current_screen` | Quick screen summary with a depth-2 hierarchy preview. | +| `get_hierarchy` | Full hierarchy tree (configurable depth). | +| `search_elements` | Filter by text, accessibility id, class, role, visibility. | +| `get_element` | Full attribute groups for one oid. | +| `capture_screenshot` | Base64 PNG of the key window. | +| `highlight_element` | Flash a highlight overlay in-app. | +| `diagnose_layout` | Heuristics: zero-size views, tap targets < 44pt, overlapping interactives, offscreen children. | +| `diagnose_accessibility` | Missing labels, duplicates, small touch targets. | +| `export_bug_report` | Bundle screen + hierarchy + diagnostics + screenshot into one JSON. | + +## Debug-only by design + +`lookinside-mcp` works only against apps that embed `LookinServer`, which is a Debug-only library. Release builds simply have nothing to talk to. The server enforces no proprietary handshake — but the client refuses any operation beyond hierarchy reads: + +- No arbitrary selector invocation. +- No shell exec. +- Secure text field contents (`UITextField.isSecureTextEntry`, `NSSecureTextField`) are redacted at the data layer — they cannot leak through any current or future tool. +- Transport is stdio only; no network listener is opened. + +## Install + +### Build from source + +```sh +./Scripts/build-mcp-server.sh +``` + +Drops the binary at `./build/lookinside-mcp`. Add it to `PATH`, or reference the absolute path in your client config. + +### From a release artifact + +Download the latest `lookinside-mcp` binary from [GitHub Releases](../README.md) and `chmod +x` it. + +## Connect a client + +Run `lookinside-mcp print-config ` to get a ready-to-paste snippet. See [`mcp-client-configs.md`](mcp-client-configs.md) for client-specific instructions. + +## Verify + +```sh +lookinside-mcp health +``` + +Should print `status: ok` and one or more reachable apps. If it prints `status: no_target`, see [`mcp-troubleshooting.md`](mcp-troubleshooting.md). + +## Architecture + +``` +AI agent ↔ MCP client (Claude Desktop, etc.) + │ stdio JSON-RPC + ▼ + lookinside-mcp ──────────► Peertalk TCP (47164–47179) + │ │ + └── LookinMCPCore ▼ + (hierarchy index, LookinServer (in-process + search, diagnostics, in your Debug build) + bug-report builder) +``` + +The MCP server is a parallel consumer of `LookinServer` alongside the macOS LookInside.app — both speak the same protocol, but the MCP server skips the macOS app's license gate because that gate is enforced client-side, not by the in-process server. + +## Limitations (today) + +- `highlight_element` requires a server-side request type that doesn't exist yet — coming in a follow-up. +- No write-side tools (tap, scroll, type, temporary property changes). The protocol supports them; we deliberately gated them out of the first version. +- Source-code mapping (oid → file:line) ships when SwiftUI trace data is stable enough to rely on. + +## Known errors + +| Error | What it means | +|---|---| +| `noTargetApp` | No Debug build with `LookinServer` was reachable. Launch one and retry. | +| `timeout` | The app responded slowly — usually paused at a breakpoint or in background. | +| `decodeFailure` | Protocol version mismatch. Update `LookinServer` in your app. | +| `unsupported` | A tool isn't implemented for this provider (e.g. highlight from a snapshot file). | + +## Offline / snapshot mode + +`lookinside-mcp serve --snapshot path/to/screen.lookin` serves an exported `.lookin` snapshot. Useful for analyzing captured bug states without keeping the app running. From 3e5cde5718426e843ad0acbdfbd5b785b730a099 Mon Sep 17 00:00:00 2001 From: tastyheadphones Date: Thu, 14 May 2026 11:16:30 +0900 Subject: [PATCH 09/17] Add codex print-config target and fix claude-code add syntax Codex CLI and Claude Code both use the canonical ` mcp add [--env K=V] -- [args]` form. The previous claude-code snippet omitted the `--` separator, which works in practice but isn't the documented syntax. Aligns both clients on the same shape and documents the env-var passthrough. --- Sources/LookinMCPServer/CLI.swift | 2 +- Sources/LookinMCPServer/PrintConfigCommand.swift | 11 ++++++++--- docs/mcp-client-configs.md | 16 +++++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Sources/LookinMCPServer/CLI.swift b/Sources/LookinMCPServer/CLI.swift index 3d2c5eb..d366a1c 100644 --- a/Sources/LookinMCPServer/CLI.swift +++ b/Sources/LookinMCPServer/CLI.swift @@ -40,7 +40,7 @@ enum CLI { Print connection status and exit nonzero if no Debug build is reachable. lookinside-mcp print-config Print a ready-to-paste config snippet for one of: - claude-desktop | claude-code | cursor | windsurf | vscode + claude-desktop | claude-code | codex | cursor | windsurf | vscode FLAGS --version, -V Print version and exit. diff --git a/Sources/LookinMCPServer/PrintConfigCommand.swift b/Sources/LookinMCPServer/PrintConfigCommand.swift index 836bce1..af96fce 100644 --- a/Sources/LookinMCPServer/PrintConfigCommand.swift +++ b/Sources/LookinMCPServer/PrintConfigCommand.swift @@ -23,8 +23,13 @@ enum PrintConfigCommand { """ case "claude-code": snippet = """ - # Run once: - claude mcp add lookinside \(binary) serve + # Run once. Use --env KEY=VALUE before `--` to pass environment variables. + claude mcp add lookinside -- \(binary) serve + """ + case "codex": + snippet = """ + # Run once. Use --env KEY=VALUE before `--` to pass environment variables. + codex mcp add lookinside -- \(binary) serve """ case "cursor": snippet = """ @@ -53,7 +58,7 @@ enum PrintConfigCommand { } """ default: - FileHandle.standardError.write(Data("Unknown client: \(client). Try one of: claude-desktop, claude-code, cursor, windsurf, vscode.\n".utf8)) + FileHandle.standardError.write(Data("Unknown client: \(client). Try one of: claude-desktop, claude-code, codex, cursor, windsurf, vscode.\n".utf8)) return 2 } print(snippet) diff --git a/docs/mcp-client-configs.md b/docs/mcp-client-configs.md index 9e1a203..0405072 100644 --- a/docs/mcp-client-configs.md +++ b/docs/mcp-client-configs.md @@ -22,9 +22,23 @@ Restart Claude Desktop. The 🔌 indicator should show `lookinside` connected. ## Claude Code ```sh -claude mcp add lookinside /absolute/path/to/lookinside-mcp serve +claude mcp add lookinside -- /absolute/path/to/lookinside-mcp serve ``` +Pass environment variables with `--env KEY=VALUE` before the `--`: + +```sh +claude mcp add lookinside --env LOOKIN_LOG=debug -- /absolute/path/to/lookinside-mcp serve +``` + +## Codex CLI + +```sh +codex mcp add lookinside -- /absolute/path/to/lookinside-mcp serve +``` + +Same `--env KEY=VALUE` flag is supported before `--`. + ## Cursor Edit `~/.cursor/mcp.json`: From b99da4006153ec90ba954ab96cceb7565ef37f45 Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 13 Jun 2026 17:15:24 +0800 Subject: [PATCH 10/17] =?UTF-8?q?=E5=AE=8C=E5=96=84=20CocoaPods=20?= =?UTF-8?q?=E5=A4=9A=E5=B9=B3=E5=8F=B0=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/swiftpm/Package.resolved | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/LookInside.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LookInside.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0d364b5..77331df 100644 --- a/LookInside.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LookInside.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "fa3ea308a0e3fba099f2a1ff13562912b7a5fa2d67a25f3716fb800d589374b8", + "originHash" : "609d337a7256229bd0ca908f624d35d636d5a81b36a8fc9075aaf695826c5a4e", "pins" : [ + { + "identity" : "eventsource", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattt/eventsource.git", + "state" : { + "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e", + "version" : "1.4.1" + } + }, { "identity" : "frameworktoolbox", "kind" : "remoteSourceControl", @@ -46,6 +55,24 @@ "version" : "2.9.1" } }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, { "identity" : "swift-demangling", "kind" : "remoteSourceControl", @@ -64,6 +91,33 @@ "version" : "0.1.3" } }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a", + "version" : "1.13.2" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "77b84ac2cd2ac9e4ac67d19f045fd5b434f56967", + "version" : "2.101.0" + } + }, + { + "identity" : "swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/modelcontextprotocol/swift-sdk.git", + "state" : { + "revision" : "a0ae212ebf6eab5f754c3129608bc5557637e605", + "version" : "0.12.1" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", @@ -73,6 +127,15 @@ "version" : "601.0.1" } }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version" : "1.7.2" + } + }, { "identity" : "swiftyxpc", "kind" : "remoteSourceControl", From 459ebe204c096e55a2cb4d6d3cd83c47b3c9af85 Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 13 Jun 2026 18:03:49 +0800 Subject: [PATCH 11/17] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20MCP=20=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E5=8F=AF=E7=94=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/LookinMCPCore/LiveLookinClient.swift | 122 ++++++++++++++---- Tests/LookinMCPCoreTests/Fixtures.swift | 7 +- .../ProviderErrorTests.swift | 26 ++++ 3 files changed, 128 insertions(+), 27 deletions(-) diff --git a/Sources/LookinMCPCore/LiveLookinClient.swift b/Sources/LookinMCPCore/LiveLookinClient.swift index 8c5abf9..4f7191b 100644 --- a/Sources/LookinMCPCore/LiveLookinClient.swift +++ b/Sources/LookinMCPCore/LiveLookinClient.swift @@ -31,15 +31,19 @@ public final class LiveLookinClient: NSObject, HierarchyProvider, Lookin_PTChann private let queue = DispatchQueue(label: "lookin.mcp.client", qos: .userInitiated) private let connectTimeout: TimeInterval private let requestTimeout: TimeInterval + private let targetBundleIdentifier: String? private var channel: Lookin_PTChannel? private var pendingRequests: [UInt32: PendingRequest] = [:] private var hierarchyCache: LookinHierarchyInfo? private var indexCache: HierarchyIndex? - public init(connectTimeout: TimeInterval = 1.5, requestTimeout: TimeInterval = 10) { + public init(connectTimeout: TimeInterval = 1.5, + requestTimeout: TimeInterval = 10, + targetBundleIdentifier: String? = ProcessInfo.processInfo.environment["LOOKIN_MCP_TARGET_BUNDLE_ID"]) { self.connectTimeout = connectTimeout self.requestTimeout = requestTimeout + self.targetBundleIdentifier = targetBundleIdentifier super.init() } @@ -67,10 +71,51 @@ public final class LiveLookinClient: NSObject, HierarchyProvider, Lookin_PTChann /// Connect to the first reachable app, preferring simulator → macOS → device. @discardableResult public func connectToFirstAvailable() throws -> DiscoveredApp { - let apps = discover() - guard let pick = apps.first else { throw HierarchyProviderError.noTargetApp } - try connect(port: pick.port) - return pick + disconnect() + let ranges = [ + ("simulator", LookinSimulatorIPv4PortNumberStart...LookinSimulatorIPv4PortNumberEnd), + ("macos", LookinMacIPv4PortNumberStart...LookinMacIPv4PortNumberEnd), + ("device", LookinUSBDeviceIPv4PortNumberStart...LookinUSBDeviceIPv4PortNumberEnd), + ] + var fallbacks: [(client: ProbeClient, app: DiscoveredApp)] = [] + for (platform, range) in ranges { + for port in range { + guard let client = try? Self.makeAndConnect(port: Int(port), timeout: connectTimeout), + let app = try? client.fetchAppInfo() else { + continue + } + let discovered = DiscoveredApp(port: Int(port), platform: platform, appInfo: app) + if Self.matches(discovered, bundleIdentifier: targetBundleIdentifier) { + takeOver(client) + return discovered + } + fallbacks.append((client, discovered)) + } + } + if targetBundleIdentifier == nil, let first = fallbacks.first { + takeOver(first.client) + fallbacks.dropFirst().forEach { $0.client.disconnect() } + return first.app + } + fallbacks.forEach { $0.client.disconnect() } + throw HierarchyProviderError.noTargetApp + } + + public static func selectPreferredApp(_ apps: [DiscoveredApp], + bundleIdentifier: String?) -> DiscoveredApp? { + guard let bundleIdentifier, !bundleIdentifier.isEmpty else { return apps.first } + return apps.first { matches($0, bundleIdentifier: bundleIdentifier) } + } + + private static func matches(_ app: DiscoveredApp, bundleIdentifier: String?) -> Bool { + guard let bundleIdentifier, !bundleIdentifier.isEmpty else { return true } + return app.appInfo.appBundleIdentifier == bundleIdentifier + } + + private func takeOver(_ client: ProbeClient) { + let connectedChannel = client.releaseChannel() + connectedChannel?.delegate = self + channel = connectedChannel } public func connect(port: Int) throws { @@ -180,32 +225,24 @@ public final class LiveLookinClient: NSObject, HierarchyProvider, Lookin_PTChann let dispatchPayload = data.withUnsafeBytes { raw -> DispatchData in DispatchData(bytes: raw) } - let sem = DispatchSemaphore(value: 0) let pending = PendingRequest() queue.sync { pendingRequests[tag] = pending } channel.sendFrame(ofType: type, tag: tag, withPayload: dispatchPayload as __DispatchData) { err in + Self.debugLog("send callback type=\(type) tag=\(tag) error=\(String(describing: err))") if let err = err { self.queue.sync { pending.error = err self.pendingRequests.removeValue(forKey: tag) } - sem.signal() + pending.semaphore.signal() } } - if sem.wait(timeout: .now() + requestTimeout) == .timedOut, pending.response == nil { + if pending.semaphore.wait(timeout: .now() + requestTimeout) == .timedOut { queue.sync { pendingRequests.removeValue(forKey: tag) } throw HierarchyProviderError.timeout(requestType: type) } if let response = pending.response { return response } if let err = pending.error { throw HierarchyProviderError.transport(underlying: err) } - // Wait for the response delivered via delegate callback; if we got here without one, it's a transport issue. - // (sendFrame's callback fires before the response — we need to wait for the read path.) - let secondSem = pending.semaphore - if secondSem.wait(timeout: .now() + requestTimeout) == .timedOut { - queue.sync { pendingRequests.removeValue(forKey: tag) } - throw HierarchyProviderError.timeout(requestType: type) - } - if let response = pending.response { return response } throw HierarchyProviderError.transport(underlying: NSError(domain: "Lookin", code: -2, userInfo: [NSLocalizedDescriptionKey: "No response and no error for request \(type)."])) } @@ -215,28 +252,54 @@ public final class LiveLookinClient: NSObject, HierarchyProvider, Lookin_PTChann didReceiveFrameOfType type: UInt32, tag: UInt32, payload: Lookin_PTData?) { + Self.debugLog("received frame type=\(type) tag=\(tag) payload=\(payload?.length ?? 0)") guard let payload else { return } let data = Data(bytes: payload.data, count: payload.length) + do { + let response = try Self.decodeResponse(from: data) + queue.async { + if let pending = self.pendingRequests.removeValue(forKey: tag) { + pending.response = response + pending.semaphore.signal() + } + } + } catch { + queue.async { + if let pending = self.pendingRequests.removeValue(forKey: tag) { + pending.error = error + pending.semaphore.signal() + } + } + } + } + + public static func decodeResponse(from data: Data) throws -> LookinConnectionResponseAttachment { let allowed: [AnyClass] = [ LookinConnectionResponseAttachment.self, LookinHierarchyInfo.self, LookinDisplayItem.self, LookinAppInfo.self, LookinAttributesGroup.self, LookinAttribute.self, LookinObject.self, PlatformImage.self, - NSArray.self, NSDictionary.self, NSString.self, NSNumber.self, NSData.self, NSValue.self, + NSArray.self, NSMutableArray.self, + NSDictionary.self, NSMutableDictionary.self, + NSString.self, NSMutableString.self, + NSNumber.self, NSData.self, NSMutableData.self, + NSValue.self, NSError.self, ] - guard let response = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: allowed, from: data) as? LookinConnectionResponseAttachment else { - return + if let response = try? NSKeyedUnarchiver.unarchivedObject(ofClasses: allowed, from: data) as? LookinConnectionResponseAttachment { + return response } - queue.async { - if let pending = self.pendingRequests.removeValue(forKey: tag) { - pending.response = response - pending.semaphore.signal() - } + let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data) + unarchiver.requiresSecureCoding = false + defer { unarchiver.finishDecoding() } + if let response = unarchiver.decodeObject(of: allowed, forKey: NSKeyedArchiveRootObjectKey) as? LookinConnectionResponseAttachment { + return response } + throw HierarchyProviderError.decodeFailure(reason: "expected LookinConnectionResponseAttachment") } public func ioFrameChannel(_ channel: Lookin_PTChannel, didEndWithError error: Error?) { + Self.debugLog("channel ended error=\(String(describing: error))") queue.async { self.pendingRequests.values.forEach { pending in pending.error = error ?? NSError(domain: "Lookin", code: -3, userInfo: [NSLocalizedDescriptionKey: "Channel closed."]) @@ -247,6 +310,11 @@ public final class LiveLookinClient: NSObject, HierarchyProvider, Lookin_PTChann } } + private static func debugLog(_ message: String) { + guard ProcessInfo.processInfo.environment["LOOKIN_MCP_DEBUG"] == "1" else { return } + FileHandle.standardError.write(Data("lookinside-mcp debug: \(message)\n".utf8)) + } + private final class PendingRequest { var response: LookinConnectionResponseAttachment? var error: Error? @@ -263,6 +331,12 @@ fileprivate final class ProbeClient: NSObject, Lookin_PTChannelDelegate { func disconnect() { channel?.close(); channel = nil } + func releaseChannel() -> Lookin_PTChannel? { + let ch = channel + channel = nil + return ch + } + func fetchAppInfo() throws -> LookinAppInfo { guard let channel else { throw HierarchyProviderError.noTargetApp } let attachment = LookinConnectionAttachment() diff --git a/Tests/LookinMCPCoreTests/Fixtures.swift b/Tests/LookinMCPCoreTests/Fixtures.swift index cbc2d50..8885a57 100644 --- a/Tests/LookinMCPCoreTests/Fixtures.swift +++ b/Tests/LookinMCPCoreTests/Fixtures.swift @@ -27,10 +27,11 @@ enum Fixtures { return info } - static func makeApp() -> LookinAppInfo { + static func makeApp(name: String = "FixtureApp", + bundleIdentifier: String = "test.fixture") -> LookinAppInfo { let app = LookinAppInfo() - app.appName = "FixtureApp" - app.appBundleIdentifier = "test.fixture" + app.appName = name + app.appBundleIdentifier = bundleIdentifier app.deviceDescription = "iPhone 15 Pro" app.osDescription = "iOS 17.4" app.screenWidth = 390; app.screenHeight = 844; app.screenScale = 3 diff --git a/Tests/LookinMCPCoreTests/ProviderErrorTests.swift b/Tests/LookinMCPCoreTests/ProviderErrorTests.swift index 0bd2155..a419a23 100644 --- a/Tests/LookinMCPCoreTests/ProviderErrorTests.swift +++ b/Tests/LookinMCPCoreTests/ProviderErrorTests.swift @@ -1,4 +1,5 @@ import XCTest +import LookinCore @testable import LookinMCPCore final class ProviderErrorTests: XCTestCase { @@ -17,4 +18,29 @@ final class ProviderErrorTests: XCTestCase { } } } + + func testLiveClientDecodesLegacyServerResponseArchive() throws { + let attachment = LookinConnectionResponseAttachment() + attachment.data = Fixtures.simpleScreen() + let selector = NSSelectorFromString("archivedDataWithRootObject:") + let unmanaged = NSKeyedArchiver.perform(selector, with: attachment) + let data = try XCTUnwrap(unmanaged?.takeUnretainedValue() as? Data) + + let decoded = try LiveLookinClient.decodeResponse(from: data) + + XCTAssertTrue(decoded.data is LookinHierarchyInfo) + } + + func testLiveClientSelectsRequestedBundleWhenMultipleAppsAreReachable() { + let first = Fixtures.makeApp(name: "Other", bundleIdentifier: "com.example.other") + let second = Fixtures.makeApp(name: "Demo", bundleIdentifier: "cn.vanjay.LookInsideDemo") + let apps = [ + LiveLookinClient.DiscoveredApp(port: 47164, platform: "simulator", appInfo: first), + LiveLookinClient.DiscoveredApp(port: 47165, platform: "simulator", appInfo: second), + ] + + let selected = LiveLookinClient.selectPreferredApp(apps, bundleIdentifier: "cn.vanjay.LookInsideDemo") + + XCTAssertEqual(selected?.appInfo.appName, "Demo") + } } From 7921d6378d342e2ba69cad36780281c359629046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=87=E6=9D=B0?= Date: Sat, 20 Jun 2026 16:13:32 +0800 Subject: [PATCH 12/17] feat: add KuGou hierarchy collapse --- Sources/LookinCore/LookinDisplayItem.h | 8 + Sources/LookinCore/LookinDisplayItem.m | 302 +++++++++++++++++- .../LookinMCPCore/FileHierarchyProvider.swift | 10 + Sources/LookinMCPCore/LiveLookinClient.swift | 5 + skills/lookinside-mcp-ui-debugging/SKILL.md | 19 ++ 5 files changed, 342 insertions(+), 2 deletions(-) diff --git a/Sources/LookinCore/LookinDisplayItem.h b/Sources/LookinCore/LookinDisplayItem.h index 1aa7fb3..3060284 100644 --- a/Sources/LookinCore/LookinDisplayItem.h +++ b/Sources/LookinCore/LookinDisplayItem.h @@ -179,6 +179,14 @@ typedef NS_ENUM(NSUInteger, LookinDisplayItemProperty) { /// 根据 subItems 属性将 items 打平为一维数组 + (NSArray *)flatItemsFromHierarchicalItems:(NSArray *)items; +/// 酷狗(KuGou):就地把 items 这棵层级树里 KGMainViewController.view 的子树做折叠 +/// (只保留最上面的 VC,按开关决定是否展示抽屉/全部页面),不改变树的整体形状(不打平)。 +/// 供 lookinside-mcp 等不经过 flatItemsFromHierarchicalItems: 的客户端,在拿到 displayItems +/// 后直接调用,使其层级输出与 macOS 客户端保持一致。 +/// 开关优先级:LKPreferenceManager(GUI 按钮) > 环境变量 LOOKIN_MCP_SHOW_ALL_PAGES / +/// LOOKIN_MCP_SHOW_DRAWER > NSUserDefaults,默认都为 NO(即默认折叠、隐藏抽屉)。 ++ (void)lk_kg_applyKuGouCollapseToHierarchicalItems:(NSArray *)items NS_SWIFT_NAME(lk_kg_applyKuGouCollapse(to:)); + @property(nonatomic, assign) BOOL hasDeterminedExpansion; /// 设置当前是否处于搜索状态 diff --git a/Sources/LookinCore/LookinDisplayItem.m b/Sources/LookinCore/LookinDisplayItem.m index 5682fe4..e97c4c6 100644 --- a/Sources/LookinCore/LookinDisplayItem.m +++ b/Sources/LookinCore/LookinDisplayItem.m @@ -36,6 +36,20 @@ @interface LookinDisplayItem () @end +@interface LookinDisplayItem (LK_KuGouHierarchyCollapse) + ++ (id)lk_kg_runtimePreferenceManager; ++ (NSNumber *)lk_kg_boolFromEnvForKey:(NSString *)key; ++ (BOOL)lk_kg_shouldShowAllPages; ++ (BOOL)lk_kg_shouldShowDrawer; ++ (NSArray *)lk_kg_filterSubitems:(NSArray *)subitems; ++ (NSArray *)lk_kg_filterContentContainerSubitems:(NSArray *)subitems; ++ (NSArray *)lk_kg_filterMainBackScaleViewSubitems:(NSArray *)subitems; ++ (NSArray *)lk_kg_filterMainBackViewSubitems:(NSArray *)subitems; ++ (NSArray *)lk_kg_filterContentViewSubitems:(NSArray *)subitems; + +@end + @implementation LookinDisplayItem #pragma mark - @@ -336,22 +350,306 @@ - (void)_updateInHiddenHierarchyProperty { } } +#pragma mark - 酷狗(KuGou)KGMainViewController 层级折叠 + +/** + 酷狗 iOS 客户端没有使用 UINavigationController / UITabBarController 管理页面,而是用 + KGMainViewController 不断 addChildViewController:,被 add 的所有 subVC 始终都实时挂在视图树上 + (系统的容器在页面离屏后会 remove,而酷狗不会)。这导致 Lookin / Reveal 每次 snapshot 都会 + 渲染海量控件。这里在打平层级时,把 KGMainViewController.view 下的子树折叠为「只显示最上面的 + 那个 VC」,并默认隐藏抽屉(_setViewContainer)。 + + 通过两个开关控制(都默认 NO,即默认折叠): + - kgShowAllPages:YES 时彻底关闭折叠逻辑,显示完整的原始层级(所有页面)。 + - kgShowDrawer: YES 时保留抽屉(_setViewContainer)的子层级。 + + 这两个开关由 macOS 客户端的 LKPreferenceManager 持有。LookinCore 需要保持可在 iOS 端编译, + 因此不能直接 import 客户端的 LKPreferenceManager 头文件,这里改用运行时(NSClassFromString) + 读取;在 iOS 端(找不到该类时)回退到 NSUserDefaults,默认值为 NO。 + */ + +/// 运行时获取 [LKPreferenceManager mainManager] 单例,类或方法不存在时返回 nil。 ++ (id)lk_kg_runtimePreferenceManager { + Class prefManagerClass = NSClassFromString(@"LKPreferenceManager"); + if (!prefManagerClass) { + return nil; + } + SEL mainManagerSelector = NSSelectorFromString(@"mainManager"); + if (![prefManagerClass respondsToSelector:mainManagerSelector]) { + return nil; + } + id managerInstance = nil; + @try { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + managerInstance = [prefManagerClass performSelector:mainManagerSelector]; +#pragma clang diagnostic pop + } @catch (NSException *exception) { + managerInstance = nil; + } + return managerInstance; +} + +/// 把环境变量解析为 BOOL。无此变量或无法识别时返回 nil。 +/// 识别 1/true/yes/on(真)与 0/false/no/off(假),大小写不敏感。 ++ (NSNumber *)lk_kg_boolFromEnvForKey:(NSString *)key { + NSString *raw = NSProcessInfo.processInfo.environment[key]; + if (raw.length == 0) { + return nil; + } + NSString *v = raw.lowercaseString; + if ([v isEqualToString:@"1"] || [v isEqualToString:@"true"] || [v isEqualToString:@"yes"] || [v isEqualToString:@"on"]) { + return @YES; + } + if ([v isEqualToString:@"0"] || [v isEqualToString:@"false"] || [v isEqualToString:@"no"] || [v isEqualToString:@"off"]) { + return @NO; + } + return nil; +} + +/// 是否显示全部页面(即关闭折叠逻辑)。默认 NO。 +/// 优先级:macOS 客户端 LKPreferenceManager(GUI 按钮) > 环境变量 > NSUserDefaults。 +/// 环境变量 LOOKIN_MCP_SHOW_ALL_PAGES 供 lookinside-mcp 等无 GUI 的进程使用。 ++ (BOOL)lk_kg_shouldShowAllPages { + id manager = [self lk_kg_runtimePreferenceManager]; + if (manager) { + @try { + id value = [manager valueForKeyPath:@"kgShowAllPages.currentBOOLValue"]; + if ([value isKindOfClass:[NSNumber class]]) { + return [value boolValue]; + } + } @catch (NSException *exception) { + } + return NO; + } + NSNumber *envValue = [self lk_kg_boolFromEnvForKey:@"LOOKIN_MCP_SHOW_ALL_PAGES"]; + if (envValue != nil) { + return envValue.boolValue; + } + return [NSUserDefaults.standardUserDefaults boolForKey:@"KGShouldDisableLookinHook"]; +} + +/// 是否显示抽屉(_setViewContainer)。默认 NO。 +/// 优先级:macOS 客户端 LKPreferenceManager(GUI 按钮) > 环境变量 > NSUserDefaults。 +/// 环境变量 LOOKIN_MCP_SHOW_DRAWER 供 lookinside-mcp 等无 GUI 的进程使用。 ++ (BOOL)lk_kg_shouldShowDrawer { + id manager = [self lk_kg_runtimePreferenceManager]; + if (manager) { + @try { + id value = [manager valueForKeyPath:@"kgShowDrawer.currentBOOLValue"]; + if ([value isKindOfClass:[NSNumber class]]) { + return [value boolValue]; + } + } @catch (NSException *exception) { + } + return NO; + } + NSNumber *envValue = [self lk_kg_boolFromEnvForKey:@"LOOKIN_MCP_SHOW_DRAWER"]; + if (envValue != nil) { + return envValue.boolValue; + } + return [NSUserDefaults.standardUserDefaults boolForKey:@"KGShouldShowSetContainer"]; +} + + (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]; + + // 注意:这里始终对 KGMainViewController 子树做处理。两个开关是相互独立的: + // - 「显示抽屉」(kgShowDrawer) 只控制抽屉 _setViewContainer 是否展示; + // - 「显示全部页面」(kgShowAllPages) 只控制是否折叠页面栈(只留最上面的 VC)。 + // 因此即使打开「显示全部页面」,抽屉仍然受「显示抽屉」单独控制,不会被连带展示。 + NSString *subTitle = obj.viewObject.specialTrace; + if (subTitle && [subTitle containsString:@"KGMainViewController"] && [subTitle containsString:@".view"]) { + obj.subitems = [self lk_kg_filterSubitems:obj.subitems]; + } + if (obj.subitems.count) { [resultArray addObjectsFromArray:[self flatItemsFromHierarchicalItems:obj.subitems]]; } }]; - + return resultArray; } ++ (void)lk_kg_applyKuGouCollapseToHierarchicalItems:(NSArray *)items { + // 与 flatItemsFromHierarchicalItems: 中的折叠逻辑完全一致,只是不打平、只就地修改子树, + // 方便 lookinside-mcp 这类直接遍历 displayItems 树的客户端复用。 + [items enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + NSString *subTitle = obj.viewObject.specialTrace; + if (subTitle && [subTitle containsString:@"KGMainViewController"] && [subTitle containsString:@".view"]) { + obj.subitems = [self lk_kg_filterSubitems:obj.subitems]; + } + if (obj.subitems.count) { + [self lk_kg_applyKuGouCollapseToHierarchicalItems:obj.subitems]; + } + }]; +} + +/// KGMainViewController.view 的直接子层。新框架下存在 _contentContainer(主框架容器), +/// 旧框架下则没有,直接把 KGMainViewController.view 的子层当作内容容器处理。 ++ (NSArray *)lk_kg_filterSubitems:(NSArray *)subitems { + NSMutableArray *filteredItems = [NSMutableArray arrayWithArray:subitems]; + + // 是不是新版本(含 _contentContainer) + __block BOOL hasFoundContentContainer = NO; + __block int count = 0; + [filteredItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *ivarNames = [item.viewObject.ivarTraces valueForKey:@"ivarName"]; + if ([ivarNames containsObject:@"_contentContainer"]) { + // 主框架容器 + item.subitems = [self lk_kg_filterContentContainerSubitems:item.subitems]; + count += 1; + hasFoundContentContainer = YES; + } + if (count >= 2) { + *stop = YES; + } + }]; + + if (!hasFoundContentContainer) { + // 旧版本:没有 _contentContainer,直接按内容容器处理 + return [self lk_kg_filterContentContainerSubitems:subitems]; + } + + return filteredItems; +} + ++ (NSArray *)lk_kg_filterContentContainerSubitems:(NSArray *)subitems { + BOOL showAllPages = [self lk_kg_shouldShowAllPages]; + BOOL shouldShowDrawer = [self lk_kg_shouldShowDrawer]; + + NSMutableArray *filteredItems = [NSMutableArray arrayWithArray:subitems]; + __block BOOL isSetContentNotEmpty = NO; + [filteredItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *ivarNames = [item.viewObject.ivarTraces valueForKey:@"ivarName"]; + + if ([ivarNames containsObject:@"_setViewContainer"]) { + // 抽屉:只由「显示抽屉」控制,和「显示全部页面」相互独立 + if (!shouldShowDrawer) { + item.subitems = nil; + } + } else if (!showAllPages && [ivarNames containsObject:@"_mainBackScaleView"]) { + // 页面折叠相关:仅在未开启「显示全部页面」时进行 + item.subitems = [self lk_kg_filterMainBackScaleViewSubitems:item.subitems]; + } else if (!showAllPages && [ivarNames containsObject:@"_setContentView"]) { + item.subitems = [self lk_kg_filterContentViewSubitems:item.subitems]; + isSetContentNotEmpty = item.subitems.count > 0; + } + }]; + + if (!showAllPages && isSetContentNotEmpty) { + // 设置层(抽屉内容层)非空时,主缩放层让位:但抽屉本身仍只由「显示抽屉」控制 + filteredItems = [NSMutableArray arrayWithArray:subitems]; + [filteredItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *ivarNames = [item.viewObject.ivarTraces valueForKey:@"ivarName"]; + + if ([ivarNames containsObject:@"_setViewContainer"]) { + if (!shouldShowDrawer) { + item.subitems = nil; + } + } else if ([ivarNames containsObject:@"_mainBackScaleView"]) { + item.subitems = nil; + } else if ([ivarNames containsObject:@"_setContentView"]) { + item.subitems = [self lk_kg_filterContentViewSubitems:item.subitems]; + isSetContentNotEmpty = item.subitems.count > 0; + } + }]; + } + + return filteredItems; +} + ++ (NSArray *)lk_kg_filterMainBackScaleViewSubitems:(NSArray *)subitems { + NSMutableArray *filteredItems = [NSMutableArray arrayWithArray:subitems]; + + [filteredItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *ivarNames = [item.viewObject.ivarTraces valueForKey:@"ivarName"]; + if ([ivarNames containsObject:@"_mainBackView"]) { + item.subitems = [self lk_kg_filterMainBackViewSubitems:item.subitems]; + *stop = YES; + } + }]; + + return filteredItems; +} + ++ (NSArray *)lk_kg_filterMainBackViewSubitems:(NSArray *)subitems { + NSMutableArray *filteredItems = [NSMutableArray arrayWithArray:subitems]; + + __block int count = 0; + [filteredItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *ivarNames = [item.viewObject.ivarTraces valueForKey:@"ivarName"]; + + if ([ivarNames containsObject:@"_guideBarContainer"]) { + // 侧边栏 + } else if ([ivarNames containsObject:@"_contentView"]) { + item.subitems = [self lk_kg_filterContentViewSubitems:item.subitems]; + count += 1; + } else if ([ivarNames containsObject:@"_fullSizeContentView"]) { + item.subitems = [self lk_kg_filterContentViewSubitems:item.subitems]; + if (item.subitems.count >= 2) { + count += 1; + } + } + + if (count >= 2) { + *stop = YES; + } + }]; + + if (count > 1) { + filteredItems = [NSMutableArray arrayWithArray:subitems]; + // 两个内容容器都有内容时,只保留 _fullSizeContentView + [filteredItems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *ivarNames = [item.viewObject.ivarTraces valueForKey:@"ivarName"]; + + if ([ivarNames containsObject:@"_fullSizeContentView"]) { + item.subitems = [self lk_kg_filterContentViewSubitems:item.subitems]; + } + if ([ivarNames containsObject:@"_contentView"]) { + item.subitems = nil; + } + }]; + } + + return filteredItems; +} + ++ (NSArray *)lk_kg_filterContentViewSubitems:(NSArray *)subitems { + NSMutableArray *filteredItems = [NSMutableArray arrayWithCapacity:2]; + + __block int count = 0; + [subitems enumerateObjectsUsingBlock:^(LookinDisplayItem * _Nonnull item, NSUInteger idx, BOOL * _Nonnull stop) { + NSArray *ivarNames = [item.viewObject.ivarTraces valueForKey:@"ivarName"]; + + if ([ivarNames containsObject:@"_backShadowView"]) { + // 最上面 VC 下面第一层的阴影 + [filteredItems addObject:item]; + count += 1; + } else if (idx == subitems.count - 1) { + // 最上面的那个 VC + [filteredItems addObject:item]; + count += 1; + } + if (count >= 2) { + *stop = YES; + } + }]; + + // 在首页时,阴影不会移动到下方,此时保留原始子层 + if (filteredItems.count < 2) { + filteredItems = [NSMutableArray arrayWithArray:subitems]; + } + + return filteredItems; +} + - (NSString *)description { if (self.viewObject) { return self.viewObject.rawClassName; diff --git a/Sources/LookinMCPCore/FileHierarchyProvider.swift b/Sources/LookinMCPCore/FileHierarchyProvider.swift index 7e013ed..e2e745e 100644 --- a/Sources/LookinMCPCore/FileHierarchyProvider.swift +++ b/Sources/LookinMCPCore/FileHierarchyProvider.swift @@ -17,15 +17,25 @@ public final class FileHierarchyProvider: HierarchyProvider { guard let info = try Self.decode(data: data) else { throw HierarchyProviderError.decodeFailure(reason: "no LookinHierarchyInfo root in \(fileURL.lastPathComponent)") } + Self.applyKuGouCollapse(to: info) self.info = info self.index = HierarchyIndex(info: info) } public init(info: LookinHierarchyInfo) { + Self.applyKuGouCollapse(to: info) self.info = info self.index = HierarchyIndex(info: info) } + /// 酷狗(KuGou):折叠 KGMainViewController 子树,使快照文件的层级输出与 macOS 客户端一致。 + /// 默认折叠、隐藏抽屉,可用环境变量 LOOKIN_MCP_SHOW_ALL_PAGES / LOOKIN_MCP_SHOW_DRAWER 控制。 + private static func applyKuGouCollapse(to info: LookinHierarchyInfo) { + if let roots = info.displayItems as? [LookinDisplayItem] { + LookinDisplayItem.lk_kg_applyKuGouCollapse(to: roots) + } + } + public func appInfo() throws -> LookinAppInfo { guard let app = info.appInfo else { throw HierarchyProviderError.decodeFailure(reason: "snapshot has no appInfo") diff --git a/Sources/LookinMCPCore/LiveLookinClient.swift b/Sources/LookinMCPCore/LiveLookinClient.swift index 4f7191b..bb2be5e 100644 --- a/Sources/LookinMCPCore/LiveLookinClient.swift +++ b/Sources/LookinMCPCore/LiveLookinClient.swift @@ -142,6 +142,11 @@ public final class LiveLookinClient: NSObject, HierarchyProvider, Lookin_PTChann guard let info = resp.data as? LookinHierarchyInfo else { throw HierarchyProviderError.decodeFailure(reason: "expected LookinHierarchyInfo, got \(String(describing: type(of: resp.data)))") } + // 酷狗(KuGou):折叠 KGMainViewController 子树,使 MCP 的层级输出与 macOS 客户端一致。 + // 默认折叠、隐藏抽屉,可用环境变量 LOOKIN_MCP_SHOW_ALL_PAGES / LOOKIN_MCP_SHOW_DRAWER 控制。 + if let roots = info.displayItems as? [LookinDisplayItem] { + LookinDisplayItem.lk_kg_applyKuGouCollapse(to: roots) + } hierarchyCache = info indexCache = HierarchyIndex(info: info) return info diff --git a/skills/lookinside-mcp-ui-debugging/SKILL.md b/skills/lookinside-mcp-ui-debugging/SKILL.md index 4aaa7a6..e19f844 100644 --- a/skills/lookinside-mcp-ui-debugging/SKILL.md +++ b/skills/lookinside-mcp-ui-debugging/SKILL.md @@ -114,6 +114,25 @@ lookinside-mcp print-config cursor Then add `LOOKIN_MCP_TARGET_BUNDLE_ID` to the MCP server environment when more than one app may be reachable. +### KuGou (酷狗) hierarchy collapse + +KuGou apps keep every pushed child view controller live under `KGMainViewController`, so the raw hierarchy is huge. By default the MCP collapses each `KGMainViewController.view` subtree to just the topmost VC and hides the drawer (`_setViewContainer`), matching the LookInside.app default. Two independent environment variables override this (accepted values: `1/true/yes/on` and `0/false/no/off`): + +- `LOOKIN_MCP_SHOW_ALL_PAGES=1` — show the full page stack instead of only the topmost VC. +- `LOOKIN_MCP_SHOW_DRAWER=1` — include the drawer (`_setViewContainer`) subtree. + +The two switches are orthogonal: turning on All Pages does not reveal the drawer, and vice versa. Set them in the MCP server environment alongside `LOOKIN_MCP_TARGET_BUNDLE_ID`, e.g.: + +```sh +codex mcp add lookinside \ + LOOKIN_MCP_TARGET_BUNDLE_ID= \ + LOOKIN_MCP_SHOW_ALL_PAGES=1 \ + LOOKIN_MCP_SHOW_DRAWER=1 \ + /path/to/lookinside-mcp serve +``` + +These only apply to KuGou's `KGMainViewController` framework; for all other apps the hierarchy is returned unchanged. + ## Inspection Workflow 1. Identify the target bundle id. From b6391dea79b2d03b3bfccb10f0b893dc78295e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=87=E6=9D=B0?= Date: Sat, 20 Jun 2026 16:14:07 +0800 Subject: [PATCH 13/17] feat: add KuGou hierarchy toolbar toggles --- LookInside/Localizable.xcstrings | 366 ++++++++++--------- LookInside/Manager/LKPreferenceManager.h | 6 + LookInside/Manager/LKPreferenceManager.m | 26 +- LookInside/Static/LKStaticWindowController.m | 22 +- LookInside/Toolbar/LKWindowToolbarHelper.h | 4 + LookInside/Toolbar/LKWindowToolbarHelper.m | 48 ++- 6 files changed, 305 insertions(+), 167 deletions(-) diff --git a/LookInside/Localizable.xcstrings b/LookInside/Localizable.xcstrings index 1a2abe0..7fc084c 100644 --- a/LookInside/Localizable.xcstrings +++ b/LookInside/Localizable.xcstrings @@ -1,6 +1,9 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, " / " : { "comment" : "Separator between imported/autosaved CSV sources", "localizations" : { @@ -212,6 +215,16 @@ } } }, + "All Pages" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有页面" + } + } + } + }, "Allow future guessed rows to use autosaved module CSVs." : { "localizations" : { "en" : { @@ -260,6 +273,22 @@ } } }, + "Approve LookInside Injector" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Approve LookInside Injector" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "批准 LookInside 注入器" + } + } + } + }, "Attach" : { "localizations" : { "en" : { @@ -357,7 +386,6 @@ } }, "Automatic update checks are disabled in this community build." : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -549,6 +577,22 @@ } } }, + "Check Again" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check Again" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新检查" + } + } + } + }, "Checking for updates…" : { "localizations" : { "en" : { @@ -903,6 +947,7 @@ } }, "Current LookInside app is too old to open this document. Current LookInside app version is %@, but the document version is %@." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1190,6 +1235,16 @@ } } }, + "Drawer" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "侧边栏抽屉" + } + } + } + }, "Empty download payload." : { "localizations" : { "en" : { @@ -1223,6 +1278,7 @@ } }, "Enable LookInside Injector" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1238,6 +1294,22 @@ } } }, + "Enable LookInside Injector?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable LookInside Injector?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "启用 LookInside 注入器?" + } + } + } + }, "Enter a module name, comma-separated candidate words, and a maximum word count." : { "localizations" : { "en" : { @@ -1479,6 +1551,7 @@ } }, "Failed to get target object in iOS app" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1559,6 +1632,7 @@ } }, "Failed to open the document." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2648,7 +2722,6 @@ } }, "License Timeout" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2968,7 +3041,24 @@ } } }, + "LookInside needs to enable its privileged injector before it can attach to another process. macOS may require an administrator to approve this in System Settings." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LookInside needs to enable its privileged injector before it can attach to another process. macOS may require an administrator to approve this in System Settings." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LookInside 需要先启用特权注入器,才能附加到其他进程。macOS 可能会要求管理员在系统设置中批准此操作。" + } + } + } + }, "LookInside opened System Settings → Login Items & Extensions for you.\n\nFind “LookInside” in the list, turn it on, then come back and click Attach to Running App again." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3064,6 +3154,39 @@ } } }, + "macOS is waiting for administrator approval before it can run the LookInside Injector. Open Login Items & Extensions, enable LookInside, then return here and check again." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS is waiting for administrator approval before it can run the LookInside Injector. Open Login Items & Extensions, enable LookInside, then return here and check again." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 正在等待管理员批准后才能运行 LookInside 注入器。请打开“登录项与扩展”,启用 LookInside,然后回到这里重新检查。" + } + } + } + }, + "macOS is waiting for administrator approval before it can run the LookInside Injector. Open Login Items & Extensions, enable LookInside, then return to LookInside and click Attach to Running App again." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS is waiting for administrator approval before it can run the LookInside Injector. Open Login Items & Extensions, enable LookInside, then return to LookInside and click Attach to Running App again." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "macOS 正在等待管理员批准后才能运行 LookInside 注入器。请打开“登录项与扩展”,启用 LookInside,然后回到 LookInside 再点击“附加到运行中的 App”。" + } + } + } + }, "Matched from %@." : { "localizations" : { "en" : { @@ -3513,6 +3636,7 @@ } }, "Open Settings Again" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3528,6 +3652,22 @@ } } }, + "Open System Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open System Settings" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开系统设置" + } + } + } + }, "Operation failed." : { "localizations" : { "en" : { @@ -3593,6 +3733,7 @@ } }, "Perhaps the related object was deallocated. You can reload LookInside to get newest data." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3609,6 +3750,7 @@ } }, "Perhaps your iOS app is paused with breakpoint in Xcode, blocked by other tasks in main thread, or moved to background state." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4523,6 +4665,7 @@ } }, "The document was created by a LookInside app with too old version. Current LookInside app version is %@, but the document version is %@." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4619,6 +4762,26 @@ } } }, + "The LookInside Injector daemon is missing from this app.\nPlease reinstall LookInside, then try again." : { + + }, + "The LookInside Injector daemon is missing from this app.\\nPlease reinstall LookInside, then try again." : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The LookInside Injector daemon is missing from this app.\\nPlease reinstall LookInside, then try again." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此 App 中缺少 LookInside 注入器守护进程。\\n请重新安装 LookInside,然后再试一次。" + } + } + } + }, "The LookInside Injector daemon is not enabled (status %@)." : { "localizations" : { "en" : { @@ -4651,6 +4814,38 @@ } } }, + "The LookInside Injector daemon returned an unsupported status (%d)." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The LookInside Injector daemon returned an unsupported status (%d)." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LookInside 注入器守护进程返回了不支持的状态(%d)。" + } + } + } + }, + "The LookInside Injector is bundled correctly, but macOS can only enable it from an installed app. Move LookInside to the Applications folder, relaunch it, then click Attach to Running App again." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The LookInside Injector is bundled correctly, but macOS can only enable it from an installed app. Move LookInside to the Applications folder, relaunch it, then click Attach to Running App again." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "LookInside 注入器已包含在 App 中,但 macOS 只能从已安装的 App 启用它。请将 LookInside 移到“应用程序”文件夹,重新启动后再点击“附加到运行中的 App”。" + } + } + } + }, "The method was invoked successfully and no value was returned." : { "localizations" : { "en" : { @@ -4716,6 +4911,7 @@ } }, "The operation failed due to disconnection with the iOS app." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -4909,7 +5105,6 @@ } }, "Timeout for license challenge and verification requests, in seconds. Default: 5s." : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -5148,6 +5343,9 @@ } } } + }, + "Updates Disabled" : { + }, "Updating default library…" : { "localizations" : { @@ -5437,167 +5635,7 @@ } } } - }, - "Approve LookInside Injector" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Approve LookInside Injector" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "批准 LookInside 注入器" - } - } - } - }, - "Check Again" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Check Again" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "重新检查" - } - } - } - }, - "Enable LookInside Injector?" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Enable LookInside Injector?" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "启用 LookInside 注入器?" - } - } - } - }, - "LookInside needs to enable its privileged injector before it can attach to another process. macOS may require an administrator to approve this in System Settings." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside needs to enable its privileged injector before it can attach to another process. macOS may require an administrator to approve this in System Settings." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 需要先启用特权注入器,才能附加到其他进程。macOS 可能会要求管理员在系统设置中批准此操作。" - } - } - } - }, - "Open System Settings" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Open System Settings" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "打开系统设置" - } - } - } - }, - "The LookInside Injector daemon is missing from this app.\\nPlease reinstall LookInside, then try again." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The LookInside Injector daemon is missing from this app.\\nPlease reinstall LookInside, then try again." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "此 App 中缺少 LookInside 注入器守护进程。\\n请重新安装 LookInside,然后再试一次。" - } - } - } - }, - "The LookInside Injector is bundled correctly, but macOS can only enable it from an installed app. Move LookInside to the Applications folder, relaunch it, then click Attach to Running App again." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The LookInside Injector is bundled correctly, but macOS can only enable it from an installed app. Move LookInside to the Applications folder, relaunch it, then click Attach to Running App again." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 注入器已包含在 App 中,但 macOS 只能从已安装的 App 启用它。请将 LookInside 移到“应用程序”文件夹,重新启动后再点击“附加到运行中的 App”。" - } - } - } - }, - "The LookInside Injector daemon returned an unsupported status (%d)." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "The LookInside Injector daemon returned an unsupported status (%d)." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "LookInside 注入器守护进程返回了不支持的状态(%d)。" - } - } - } - }, - "macOS is waiting for administrator approval before it can run the LookInside Injector. Open Login Items & Extensions, enable LookInside, then return here and check again." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "macOS is waiting for administrator approval before it can run the LookInside Injector. Open Login Items & Extensions, enable LookInside, then return here and check again." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "macOS 正在等待管理员批准后才能运行 LookInside 注入器。请打开“登录项与扩展”,启用 LookInside,然后回到这里重新检查。" - } - } - } - }, - "macOS is waiting for administrator approval before it can run the LookInside Injector. Open Login Items & Extensions, enable LookInside, then return to LookInside and click Attach to Running App again." : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "macOS is waiting for administrator approval before it can run the LookInside Injector. Open Login Items & Extensions, enable LookInside, then return to LookInside and click Attach to Running App again." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "macOS 正在等待管理员批准后才能运行 LookInside 注入器。请打开“登录项与扩展”,启用 LookInside,然后回到 LookInside 再点击“附加到运行中的 App”。" - } - } - } } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/LookInside/Manager/LKPreferenceManager.h b/LookInside/Manager/LKPreferenceManager.h index f5701a8..2dd7b19 100644 --- a/LookInside/Manager/LKPreferenceManager.h +++ b/LookInside/Manager/LKPreferenceManager.h @@ -64,6 +64,12 @@ typedef NS_ENUM(NSInteger, LookinMeasureState) { @property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *showHiddenItems; +/// 酷狗(KuGou)专用:是否在层级里展开 KGMainViewController 的全部页面(关闭折叠逻辑)。默认 NO。 +@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *kgShowAllPages; + +/// 酷狗(KuGou)专用:是否显示 KGMainViewController 的抽屉(_setViewContainer)。默认 NO。 +@property(nonatomic, strong, readonly) LookinBOOLMsgAttribute *kgShowDrawer; + // 范围是 0 ~ 1 @property(nonatomic, strong, readonly) LookinDoubleMsgAttribute *zInterspace; diff --git a/LookInside/Manager/LKPreferenceManager.m b/LookInside/Manager/LKPreferenceManager.m index 0b3dcdd..3cdb29a 100644 --- a/LookInside/Manager/LKPreferenceManager.m +++ b/LookInside/Manager/LKPreferenceManager.m @@ -22,6 +22,8 @@ static NSString * const Key_PreviousClientVersion = @"preVer"; static NSString * const Key_ShowOutline = @"showOutline"; static NSString * const Key_ShowHiddenItems = @"showHiddenItems"; +static NSString * const Key_KGShowAllPages = @"KGShouldDisableLookinHook"; +static NSString * const Key_KGShowDrawer = @"KGShouldShowSetContainer"; static NSString * const Key_RgbaFormat = @"egbaFormat"; static NSString * const Key_ZInterspace = @"zInterspace_v095"; static NSString * const Key_AppearanceType = @"appearanceType"; @@ -93,7 +95,17 @@ - (instancetype)init { [userDefaults setObject:@(NO) forKey:Key_ShowHiddenItems]; } [self.showHiddenItems subscribe:self action:@selector(_handleShowHiddenItemsChange:) relatedObject:nil]; - + + // 酷狗(KuGou):是否显示全部页面(关闭折叠),默认 NO + NSNumber *obj_kgShowAllPages = [userDefaults objectForKey:Key_KGShowAllPages]; + _kgShowAllPages = [LookinBOOLMsgAttribute attributeWithBOOL:(obj_kgShowAllPages != nil ? obj_kgShowAllPages.boolValue : NO)]; + [self.kgShowAllPages subscribe:self action:@selector(_handleKGShowAllPagesDidChange:) relatedObject:nil]; + + // 酷狗(KuGou):是否显示抽屉(_setViewContainer),默认 NO + NSNumber *obj_kgShowDrawer = [userDefaults objectForKey:Key_KGShowDrawer]; + _kgShowDrawer = [LookinBOOLMsgAttribute attributeWithBOOL:(obj_kgShowDrawer != nil ? obj_kgShowDrawer.boolValue : NO)]; + [self.kgShowDrawer subscribe:self action:@selector(_handleKGShowDrawerDidChange:) relatedObject:nil]; + NSNumber *obj_doubleClickBehavior = [userDefaults objectForKey:Key_DoubleClickBehavior]; if (obj_doubleClickBehavior) { _doubleClickBehavior = [obj_doubleClickBehavior intValue]; @@ -355,6 +367,18 @@ - (void)_handleShowHiddenItemsChange:(LookinMsgActionParams *)param { } } +- (void)_handleKGShowAllPagesDidChange:(LookinMsgActionParams *)param { + if (self.shouldStoreToLocal) { + [[NSUserDefaults standardUserDefaults] setObject:@(param.boolValue) forKey:Key_KGShowAllPages]; + } +} + +- (void)_handleKGShowDrawerDidChange:(LookinMsgActionParams *)param { + if (self.shouldStoreToLocal) { + [[NSUserDefaults standardUserDefaults] setObject:@(param.boolValue) forKey:Key_KGShowDrawer]; + } +} + - (void)setRgbaFormat:(BOOL)rgbaFormat { if (_rgbaFormat == rgbaFormat) { return; diff --git a/LookInside/Static/LKStaticWindowController.m b/LookInside/Static/LKStaticWindowController.m index 6b3718e..df128e4 100644 --- a/LookInside/Static/LKStaticWindowController.m +++ b/LookInside/Static/LKStaticWindowController.m @@ -294,7 +294,7 @@ - (void)popupAllInspectableAppsWithSource:(MenuPopoverAppsListControllerEventSou } - (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar { - NSMutableArray *ret = @[LKToolBarIdentifier_Reload, LKToolBarIdentifier_FastMode, LKToolBarIdentifier_App, LKToolBarIdentifier_SwiftUIMode, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Dimension, LKToolBarIdentifier_Rotation, LKToolBarIdentifier_Setting, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Scale, NSToolbarFlexibleSpaceItemIdentifier, LKToolBarIdentifier_Measure, LKToolBarIdentifier_Console].mutableCopy; + NSMutableArray *ret = @[LKToolBarIdentifier_Reload, LKToolBarIdentifier_FastMode, LKToolBarIdentifier_KGShowAllPages, LKToolBarIdentifier_KGShowDrawer, LKToolBarIdentifier_App, LKToolBarIdentifier_SwiftUIMode, 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]; } @@ -346,6 +346,12 @@ - (nullable NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:( } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_FastMode]) { item.target = self; item.action = @selector(handleFastMode); + } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_KGShowAllPages]) { + item.target = self; + item.action = @selector(_handleKGShowAllPages); + } else if ([item.itemIdentifier isEqualToString:LKToolBarIdentifier_KGShowDrawer]) { + item.target = self; + item.action = @selector(_handleKGShowDrawer); } } return item; @@ -471,6 +477,20 @@ - (void)_handleFreeRotation { [[LKPreferenceManager mainManager].freeRotation setBOOLValue:!boolValue ignoreSubscriber:nil]; } +- (void)_handleKGShowAllPages { + BOOL boolValue = [LKPreferenceManager mainManager].kgShowAllPages.currentBOOLValue; + [[LKPreferenceManager mainManager].kgShowAllPages setBOOLValue:!boolValue ignoreSubscriber:nil]; + // 折叠逻辑会就地修改层级树,需要重新从设备拉取一份干净的层级后再按新开关折叠 + [self _handleReload]; +} + +- (void)_handleKGShowDrawer { + BOOL boolValue = [LKPreferenceManager mainManager].kgShowDrawer.currentBOOLValue; + [[LKPreferenceManager mainManager].kgShowDrawer setBOOLValue:!boolValue ignoreSubscriber:nil]; + // 折叠逻辑会就地修改层级树,需要重新从设备拉取一份干净的层级后再按新开关折叠 + [self _handleReload]; +} + #pragma mark - - (void)appMenuManagerDidSelectReload { diff --git a/LookInside/Toolbar/LKWindowToolbarHelper.h b/LookInside/Toolbar/LKWindowToolbarHelper.h index bd86bf6..d7e39b0 100644 --- a/LookInside/Toolbar/LKWindowToolbarHelper.h +++ b/LookInside/Toolbar/LKWindowToolbarHelper.h @@ -22,6 +22,10 @@ extern NSToolbarItemIdentifier const LKToolBarIdentifier_Measure; extern NSToolbarItemIdentifier const LKToolBarIdentifier_Message; extern NSToolbarItemIdentifier const LKToolBarIdentifier_FastMode; extern NSToolbarItemIdentifier const LKToolBarIdentifier_SwiftUIMode; +/// 酷狗(KuGou):显示全部页面(关闭 KGMainViewController 折叠) +extern NSToolbarItemIdentifier const LKToolBarIdentifier_KGShowAllPages; +/// 酷狗(KuGou):显示抽屉(_setViewContainer) +extern NSToolbarItemIdentifier const LKToolBarIdentifier_KGShowDrawer; @class LKPreferenceManager, LookinAppInfo; diff --git a/LookInside/Toolbar/LKWindowToolbarHelper.m b/LookInside/Toolbar/LKWindowToolbarHelper.m index 16e7ec7..0985e3d 100644 --- a/LookInside/Toolbar/LKWindowToolbarHelper.m +++ b/LookInside/Toolbar/LKWindowToolbarHelper.m @@ -29,6 +29,8 @@ NSToolbarItemIdentifier const LKToolBarIdentifier_Message = @"18"; NSToolbarItemIdentifier const LKToolBarIdentifier_FastMode = @"19"; NSToolbarItemIdentifier const LKToolBarIdentifier_SwiftUIMode = @"20"; +NSToolbarItemIdentifier const LKToolBarIdentifier_KGShowAllPages = @"21"; +NSToolbarItemIdentifier const LKToolBarIdentifier_KGShowDrawer = @"22"; static NSString * const Key_BindingPreferenceManager = @"PreferenceManager"; @@ -232,7 +234,41 @@ - (NSToolbarItem *)makeToolBarItemWithIdentifier:(NSToolbarItemIdentifier)identi [manager.fastMode subscribe:self action:@selector(_handleFastModeDidChange:) relatedObject:button sendAtOnce:YES]; return item; } - + + if ([identifier isEqualToString:LKToolBarIdentifier_KGShowAllPages]) { + NSImage *image = [NSImage imageWithSystemSymbolName:@"rectangle.stack" accessibilityDescription:nil]; + image.template = YES; + + NSButton *button = [NSButton new]; + [button setImage:image]; + button.bezelStyle = NSBezelStyleTexturedRounded; + [button setButtonType:NSButtonTypePushOnPushOff]; + + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_KGShowAllPages]; + item.label = NSLocalizedString(@"All Pages", nil); + item.view = button; + + [manager.kgShowAllPages subscribe:self action:@selector(_handleKGShowAllPagesDidChange:) relatedObject:button sendAtOnce:YES]; + return item; + } + + if ([identifier isEqualToString:LKToolBarIdentifier_KGShowDrawer]) { + NSImage *image = [NSImage imageWithSystemSymbolName:@"sidebar.left" accessibilityDescription:nil]; + image.template = YES; + + NSButton *button = [NSButton new]; + [button setImage:image]; + button.bezelStyle = NSBezelStyleTexturedRounded; + [button setButtonType:NSButtonTypePushOnPushOff]; + + NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:LKToolBarIdentifier_KGShowDrawer]; + item.label = NSLocalizedString(@"Drawer", nil); + item.view = button; + + [manager.kgShowDrawer subscribe:self action:@selector(_handleKGShowDrawerDidChange:) relatedObject:button sendAtOnce:YES]; + return item; + } + if ([identifier isEqualToString:LKToolBarIdentifier_Add]) { NSImage *image = [NSImage imageNamed:NSImageNameAddTemplate]; image.template = YES; @@ -325,6 +361,16 @@ - (void)_handleFastModeDidChange:(LookinMsgActionParams *)param { button.state = boolValue ? NSControlStateValueOn : NSControlStateValueOff; } +- (void)_handleKGShowAllPagesDidChange:(LookinMsgActionParams *)param { + NSButton *button = param.relatedObject; + button.state = param.boolValue ? NSControlStateValueOn : NSControlStateValueOff; +} + +- (void)_handleKGShowDrawerDidChange:(LookinMsgActionParams *)param { + NSButton *button = param.relatedObject; + button.state = param.boolValue ? NSControlStateValueOn : NSControlStateValueOff; +} + - (void)_handleDimensionDidChange:(LookinMsgActionParams *)param { LookinPreviewDimension newDimension = param.integerValue; NSSegmentedControl *control = param.relatedObject; From 612dca61ffa899678a549b82ac7306afefb5a725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=87=E6=9D=B0?= Date: Sat, 20 Jun 2026 16:14:12 +0800 Subject: [PATCH 14/17] fix: stabilize LookInside KuGou app build --- LookInside.xcodeproj/project.pbxproj | 104 +++++------------- .../xcschemes/LookInside.xcscheme | 8 +- LookInside/Info.plist | 2 + LookInside/InfoPlist.xcstrings | 18 ++- LookInside/Manager/LKAppMenuManager.m | 37 +++++-- 5 files changed, 76 insertions(+), 93 deletions(-) diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index e9fb792..6a0a375 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ E37B8D0D2F97B1B100600001 /* Demangling in Frameworks */ = {isa = PBXBuildFile; productRef = E37B8D0C2F97B1B100600001 /* Demangling */; }; E37B8D0F2F97B1B100600001 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = E37B8D0F1F97B1B100600001 /* Sparkle */; }; E37B8D222F97B1B100600001 /* LookInsidePrivateDiscriminator in Frameworks */ = {isa = PBXBuildFile; productRef = E37B8D212F97B1B100600001 /* LookInsidePrivateDiscriminator */; }; + E5B000012FA9000000000001 /* LookinCore in Frameworks */ = {isa = PBXBuildFile; productRef = E5B000032FA9000000000001 /* LookinCore */; }; E97210062FC2D5C0003AAEA6 /* RunningApplicationKit in Frameworks */ = {isa = PBXBuildFile; productRef = E97210052FC2D5C0003AAEA6 /* RunningApplicationKit */; }; E97210092FC2E5C0003AAEA6 /* HelperClient in Frameworks */ = {isa = PBXBuildFile; productRef = E97210082FC2E5C0003AAEA6 /* HelperClient */; }; E972100B2FC2E5C0003AAEA6 /* HelperCommunication in Frameworks */ = {isa = PBXBuildFile; productRef = E972100A2FC2E5C0003AAEA6 /* HelperCommunication */; }; @@ -146,40 +147,6 @@ 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, Export/LKExportAccessoryView.m, Export/LKExportManager.m, Hierarchy/LKDanceUIAttrMaker.m, @@ -441,46 +408,6 @@ 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, Export/LKExportAccessoryView.h, Export/LKExportManager.h, Hierarchy/LKDanceUIAttrMaker.h, @@ -645,6 +572,7 @@ 50EAE1AC2F71C01A00EBE063 /* QuickLookThumbnailing.framework in Frameworks */, E37B8D0D2F97B1B100600001 /* Demangling in Frameworks */, E37B8D0F2F97B1B100600001 /* Sparkle in Frameworks */, + E5B000012FA9000000000001 /* LookinCore in Frameworks */, E97210062FC2D5C0003AAEA6 /* RunningApplicationKit in Frameworks */, E37B8D222F97B1B100600001 /* LookInsidePrivateDiscriminator in Frameworks */, E97210092FC2E5C0003AAEA6 /* HelperClient in Frameworks */, @@ -705,6 +633,7 @@ packageProductDependencies = ( E37B8D0C2F97B1B100600001 /* Demangling */, E37B8D0F1F97B1B100600001 /* Sparkle */, + E5B000032FA9000000000001 /* LookinCore */, E37B8D212F97B1B100600001 /* LookInsidePrivateDiscriminator */, E97210052FC2D5C0003AAEA6 /* RunningApplicationKit */, E97210082FC2E5C0003AAEA6 /* HelperClient */, @@ -750,6 +679,7 @@ ); mainGroup = AA48F29221CC0CA50032EC2C; packageReferences = ( + E5B000022FA9000000000001 /* XCLocalSwiftPackageReference "." */, E37B8D0B2F97B1B100600001 /* XCRemoteSwiftPackageReference "swift-demangling" */, E37B8D0F0F97B1B100600001 /* XCRemoteSwiftPackageReference "Sparkle" */, E37B8D202F97B1B100600001 /* XCRemoteSwiftPackageReference "LookInsidePrivateDiscriminator" */, @@ -990,6 +920,7 @@ CURRENT_PROJECT_VERSION = "$(inherited)"; DEAD_CODE_STRIPPING = YES; DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = X6B6C6U6QV; ENABLE_APP_SANDBOX = NO; ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1019,7 +950,7 @@ "$(SRCROOT)/LookInside/ReactiveObjC", ); INFOPLIST_FILE = LookInside/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = LookInside; + INFOPLIST_KEY_CFBundleDisplayName = "LookInside 酷狗适配版"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_OUTPUT_FORMAT = XML; LD_RUNPATH_SEARCH_PATHS = ( @@ -1030,7 +961,8 @@ MARKETING_VERSION = "$(inherited)"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)"; - PRODUCT_NAME = LookInside; + PRODUCT_MODULE_NAME = LookInside; + PRODUCT_NAME = "LookInside 酷狗适配版"; PROVISIONING_PROFILE_SPECIFIER = "$(inherited)"; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = YES; RUNTIME_EXCEPTION_ALLOW_JIT = YES; @@ -1038,6 +970,7 @@ RUNTIME_EXCEPTION_DEBUGGING_TOOL = YES; RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = YES; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "LookInside-Swift.h"; SWIFT_OBJC_BRIDGING_HEADER = "LookInside/Base/LookinClient-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -1064,6 +997,7 @@ CURRENT_PROJECT_VERSION = "$(inherited)"; DEAD_CODE_STRIPPING = YES; DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = X6B6C6U6QV; ENABLE_APP_SANDBOX = NO; ENABLE_ENHANCED_SECURITY = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -1093,7 +1027,7 @@ "$(SRCROOT)/LookInside/ReactiveObjC", ); INFOPLIST_FILE = LookInside/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = LookInside; + INFOPLIST_KEY_CFBundleDisplayName = "LookInside 酷狗适配版"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_OUTPUT_FORMAT = XML; LD_RUNPATH_SEARCH_PATHS = ( @@ -1104,7 +1038,8 @@ MARKETING_VERSION = "$(inherited)"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)"; - PRODUCT_NAME = LookInside; + PRODUCT_MODULE_NAME = LookInside; + PRODUCT_NAME = "LookInside 酷狗适配版"; PROVISIONING_PROFILE_SPECIFIER = "$(inherited)"; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = YES; RUNTIME_EXCEPTION_ALLOW_JIT = YES; @@ -1112,6 +1047,7 @@ RUNTIME_EXCEPTION_DEBUGGING_TOOL = YES; RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = YES; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "LookInside-Swift.h"; SWIFT_OBJC_BRIDGING_HEADER = "LookInside/Base/LookinClient-Bridging-Header.h"; SWIFT_VERSION = 5.0; WARNING_CFLAGS = ( @@ -1144,6 +1080,13 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + E5B000022FA9000000000001 /* XCLocalSwiftPackageReference "." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = .; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCRemoteSwiftPackageReference section */ E37B8D0B2F97B1B100600001 /* XCRemoteSwiftPackageReference "swift-demangling" */ = { isa = XCRemoteSwiftPackageReference; @@ -1198,6 +1141,11 @@ package = E37B8D0F0F97B1B100600001 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + E5B000032FA9000000000001 /* LookinCore */ = { + isa = XCSwiftPackageProductDependency; + package = E5B000022FA9000000000001 /* XCLocalSwiftPackageReference "." */; + productName = LookinCore; + }; E37B8D212F97B1B100600001 /* LookInsidePrivateDiscriminator */ = { isa = XCSwiftPackageProductDependency; package = E37B8D202F97B1B100600001 /* XCRemoteSwiftPackageReference "LookInsidePrivateDiscriminator" */; diff --git a/LookInside.xcodeproj/xcshareddata/xcschemes/LookInside.xcscheme b/LookInside.xcodeproj/xcshareddata/xcschemes/LookInside.xcscheme index 1a2d2fc..05206ee 100644 --- a/LookInside.xcodeproj/xcshareddata/xcschemes/LookInside.xcscheme +++ b/LookInside.xcodeproj/xcshareddata/xcschemes/LookInside.xcscheme @@ -17,7 +17,7 @@ @@ -35,7 +35,7 @@ @@ -64,7 +64,7 @@ @@ -81,7 +81,7 @@ diff --git a/LookInside/Info.plist b/LookInside/Info.plist index 3643c00..8d5cad6 100644 --- a/LookInside/Info.plist +++ b/LookInside/Info.plist @@ -4,6 +4,8 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + LookInside 酷狗适配版 CFBundleDocumentTypes diff --git a/LookInside/InfoPlist.xcstrings b/LookInside/InfoPlist.xcstrings index 441a621..f47f873 100644 --- a/LookInside/InfoPlist.xcstrings +++ b/LookInside/InfoPlist.xcstrings @@ -1,19 +1,31 @@ { "sourceLanguage" : "en", "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "LookInside 酷狗适配版" + } + } + } + }, "CFBundleName" : { "comment" : "Bundle name", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "LookInside" + "value" : "LookInside 酷狗适配版" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "LookInside" + "value" : "LookInside 酷狗适配版" } } } @@ -53,4 +65,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/LookInside/Manager/LKAppMenuManager.m b/LookInside/Manager/LKAppMenuManager.m index 97112bb..c0b4c38 100644 --- a/LookInside/Manager/LKAppMenuManager.m +++ b/LookInside/Manager/LKAppMenuManager.m @@ -24,6 +24,8 @@ static NSUInteger const kTag_SwiftUISupportCustomerSupport = 18; static NSUInteger const kTag_SwiftUISupportSubmenu = 19; +static NSString *const kLookInsideKugouEditionName = @"LookInside 酷狗适配版"; + static NSString *const kSwiftUISupportPurchaseURL = @"https://lookinside-app.com/purchase"; static NSString *const kSwiftUISupportCustomerSupportURL = @"mailto:support@lookinside-app.com"; @@ -62,6 +64,7 @@ @interface LKAppMenuManager () @property(nonatomic, copy) NSDictionary *delegatingTagToSelMap; @property(nonatomic, strong) NSMenu *recentDocumentsMenu; @property(nonatomic, strong) SPUStandardUpdaterController *updaterController; +@property(nonatomic, assign, getter=isUpdaterAvailable) BOOL updaterAvailable; @end @@ -81,15 +84,21 @@ + (id)allocWithZone:(struct _NSZone *)zone{ } - (NSString *)_applicationName { - NSString *bundleDisplayName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"]; - if (bundleDisplayName.length > 0) { - return bundleDisplayName; + return kLookInsideKugouEditionName; +} + +- (BOOL)_hasValidSparkleConfiguration { + NSBundle *bundle = NSBundle.mainBundle; + NSString *feedURL = [bundle objectForInfoDictionaryKey:@"SUFeedURL"]; + NSString *publicKey = [bundle objectForInfoDictionaryKey:@"SUPublicEDKey"]; + + if (feedURL.length == 0 || publicKey.length == 0) { + return NO; } - NSString *bundleName = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleName"]; - if (bundleName.length > 0) { - return bundleName; + if ([publicKey containsString:@"$("]) { + return NO; } - return [NSProcessInfo processInfo].processName ?: @"LookInside"; + return YES; } - (NSMenu *)_buildApplicationMenu { @@ -293,7 +302,10 @@ - (void)_installMainMenu { } - (void)setup { - self.updaterController = [[SPUStandardUpdaterController alloc] initWithStartingUpdater:YES updaterDelegate:nil userDriverDelegate:nil]; + self.updaterAvailable = [self _hasValidSparkleConfiguration]; + if (self.isUpdaterAvailable) { + self.updaterController = [[SPUStandardUpdaterController alloc] initWithStartingUpdater:YES updaterDelegate:nil userDriverDelegate:nil]; + } [self _installMainMenu]; self.delegatingTagToSelMap = @{ @@ -479,6 +491,15 @@ - (void)_handleExpansion:(NSMenuItem *)item { } - (void)_handleCheckUpdates { + if (!self.isUpdaterAvailable) { + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = NSLocalizedString(@"Updates Disabled", nil); + alert.informativeText = NSLocalizedString(@"Automatic update checks are disabled in this community build.", nil); + alert.alertStyle = NSAlertStyleInformational; + [alert addButtonWithTitle:NSLocalizedString(@"OK", nil)]; + [alert runModal]; + return; + } [self.updaterController checkForUpdates:nil]; } From 0688f12edb3b6c94105c50914a9107680635aa06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=87=E6=9D=B0?= Date: Sat, 20 Jun 2026 16:31:13 +0800 Subject: [PATCH 15/17] Change app name. --- LookInside.xcodeproj/project.pbxproj | 30 ++++++++++++++------------- LookInside/Info.plist | 2 +- LookInside/InfoPlist.xcstrings | 8 +++---- LookInside/Manager/LKAppMenuManager.m | 2 +- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index 6a0a375..d8400d6 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -22,7 +22,7 @@ /* Begin PBXFileReference section */ AA1CAA3D22E471EC006D9285 /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; - AA48F29B21CC0CA50032EC2C /* LookInside.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LookInside.app; sourceTree = BUILT_PRODUCTS_DIR; }; + AA48F29B21CC0CA50032EC2C /* LookInside 酷狗版.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "LookInside 酷狗版.app"; sourceTree = BUILT_PRODUCTS_DIR; }; AAB9517A22C3AECA00A5F958 /* Quartz.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Quartz.framework; path = System/Library/Frameworks/Quartz.framework; sourceTree = SDKROOT; }; AAB9519122C3BDC900A5F958 /* QuickLookThumbnailing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookThumbnailing.framework; path = System/Library/Frameworks/QuickLookThumbnailing.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -607,7 +607,7 @@ AA48F29C21CC0CA50032EC2C /* Products */ = { isa = PBXGroup; children = ( - AA48F29B21CC0CA50032EC2C /* LookInside.app */, + AA48F29B21CC0CA50032EC2C /* LookInside 酷狗版.app */, ); name = Products; sourceTree = ""; @@ -641,7 +641,7 @@ E972100C2FC2E5C0003AAEA6 /* InjectionServiceInterface */, ); productName = Lookin; - productReference = AA48F29B21CC0CA50032EC2C /* LookInside.app */; + productReference = AA48F29B21CC0CA50032EC2C /* LookInside 酷狗版.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -950,7 +950,7 @@ "$(SRCROOT)/LookInside/ReactiveObjC", ); INFOPLIST_FILE = LookInside/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "LookInside 酷狗适配版"; + INFOPLIST_KEY_CFBundleDisplayName = "LookInside 酷狗版"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_OUTPUT_FORMAT = XML; LD_RUNPATH_SEARCH_PATHS = ( @@ -961,8 +961,9 @@ MARKETING_VERSION = "$(inherited)"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "com.lookinside-app.lookinside"; PRODUCT_MODULE_NAME = LookInside; - PRODUCT_NAME = "LookInside 酷狗适配版"; + PRODUCT_NAME = "LookInside 酷狗版"; PROVISIONING_PROFILE_SPECIFIER = "$(inherited)"; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = YES; RUNTIME_EXCEPTION_ALLOW_JIT = YES; @@ -970,8 +971,8 @@ RUNTIME_EXCEPTION_DEBUGGING_TOOL = YES; RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = YES; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; - SWIFT_OBJC_INTERFACE_HEADER_NAME = "LookInside-Swift.h"; SWIFT_OBJC_BRIDGING_HEADER = "LookInside/Base/LookinClient-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "LookInside-Swift.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; WARNING_CFLAGS = ( @@ -1027,7 +1028,7 @@ "$(SRCROOT)/LookInside/ReactiveObjC", ); INFOPLIST_FILE = LookInside/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "LookInside 酷狗适配版"; + INFOPLIST_KEY_CFBundleDisplayName = "LookInside 酷狗版"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_OUTPUT_FORMAT = XML; LD_RUNPATH_SEARCH_PATHS = ( @@ -1038,8 +1039,9 @@ MARKETING_VERSION = "$(inherited)"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "com.lookinside-app.lookinside"; PRODUCT_MODULE_NAME = LookInside; - PRODUCT_NAME = "LookInside 酷狗适配版"; + PRODUCT_NAME = "LookInside 酷狗版"; PROVISIONING_PROFILE_SPECIFIER = "$(inherited)"; RUNTIME_EXCEPTION_ALLOW_DYLD_ENVIRONMENT_VARIABLES = YES; RUNTIME_EXCEPTION_ALLOW_JIT = YES; @@ -1047,8 +1049,8 @@ RUNTIME_EXCEPTION_DEBUGGING_TOOL = YES; RUNTIME_EXCEPTION_DISABLE_EXECUTABLE_PAGE_PROTECTION = YES; RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; - SWIFT_OBJC_INTERFACE_HEADER_NAME = "LookInside-Swift.h"; SWIFT_OBJC_BRIDGING_HEADER = "LookInside/Base/LookinClient-Bridging-Header.h"; + SWIFT_OBJC_INTERFACE_HEADER_NAME = "LookInside-Swift.h"; SWIFT_VERSION = 5.0; WARNING_CFLAGS = ( "$(inherited)", @@ -1141,16 +1143,16 @@ package = E37B8D0F0F97B1B100600001 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; - E5B000032FA9000000000001 /* LookinCore */ = { - isa = XCSwiftPackageProductDependency; - package = E5B000022FA9000000000001 /* XCLocalSwiftPackageReference "." */; - productName = LookinCore; - }; E37B8D212F97B1B100600001 /* LookInsidePrivateDiscriminator */ = { isa = XCSwiftPackageProductDependency; package = E37B8D202F97B1B100600001 /* XCRemoteSwiftPackageReference "LookInsidePrivateDiscriminator" */; productName = LookInsidePrivateDiscriminator; }; + E5B000032FA9000000000001 /* LookinCore */ = { + isa = XCSwiftPackageProductDependency; + package = E5B000022FA9000000000001 /* XCLocalSwiftPackageReference "." */; + productName = LookinCore; + }; E97210052FC2D5C0003AAEA6 /* RunningApplicationKit */ = { isa = XCSwiftPackageProductDependency; package = E97210042FC2D5C0003AAEA6 /* XCRemoteSwiftPackageReference "RunningApplicationKit" */; diff --git a/LookInside/Info.plist b/LookInside/Info.plist index 8d5cad6..603b86f 100644 --- a/LookInside/Info.plist +++ b/LookInside/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - LookInside 酷狗适配版 + LookInside 酷狗版 CFBundleDocumentTypes diff --git a/LookInside/InfoPlist.xcstrings b/LookInside/InfoPlist.xcstrings index f47f873..46da1e6 100644 --- a/LookInside/InfoPlist.xcstrings +++ b/LookInside/InfoPlist.xcstrings @@ -8,7 +8,7 @@ "en" : { "stringUnit" : { "state" : "new", - "value" : "LookInside 酷狗适配版" + "value" : "LookInside 酷狗版" } } } @@ -19,13 +19,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "LookInside 酷狗适配版" + "value" : "LookInside 酷狗版" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "LookInside 酷狗适配版" + "value" : "LookInside 酷狗版" } } } @@ -65,4 +65,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/LookInside/Manager/LKAppMenuManager.m b/LookInside/Manager/LKAppMenuManager.m index c0b4c38..a1578a0 100644 --- a/LookInside/Manager/LKAppMenuManager.m +++ b/LookInside/Manager/LKAppMenuManager.m @@ -24,7 +24,7 @@ static NSUInteger const kTag_SwiftUISupportCustomerSupport = 18; static NSUInteger const kTag_SwiftUISupportSubmenu = 19; -static NSString *const kLookInsideKugouEditionName = @"LookInside 酷狗适配版"; +static NSString *const kLookInsideKugouEditionName = @"LookInside 酷狗版"; static NSString *const kSwiftUISupportPurchaseURL = @"https://lookinside-app.com/purchase"; static NSString *const kSwiftUISupportCustomerSupportURL = @"mailto:support@lookinside-app.com"; From 9ab8e396116c233ec2ed8c603753e6d5b06e668a Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 20 Jun 2026 10:36:32 +0000 Subject: [PATCH 16/17] feat: add GUI menu to export node data as MCP JSON / copy attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three hierarchy right-click items in the macOS client: - "Export node analysis data (incl. children)…" — recursive subtree as MCP-format JSON - "Export this node's analysis data…" — single node only - "Copy property data" — dashboard attributes as readable text to the pasteboard New LKMCPNodeExporter reimplements the Swift JSONShape.Node schema in ObjC (oid/className/role/frame/bounds/alpha/hidden/text/accessibility*/path/children) plus a full per-node `attributes` dump; exports go through an NSSavePanel. Register the files in the Xcode synchronized-group membership lists and add zh-Hans localizations for the new menu strings. --- LookInside.xcodeproj/project.pbxproj | 2 + LookInside/Export/LKMCPNodeExporter.h | 42 +++ LookInside/Export/LKMCPNodeExporter.m | 440 +++++++++++++++++++++++++ LookInside/Hierarchy/LKHierarchyView.m | 99 ++++++ LookInside/Localizable.xcstrings | 80 +++++ 5 files changed, 663 insertions(+) create mode 100644 LookInside/Export/LKMCPNodeExporter.h create mode 100644 LookInside/Export/LKMCPNodeExporter.m diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index d8400d6..4e7035f 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -149,6 +149,7 @@ Dashboard/Search/LKDashboardSearchPropView.m, Export/LKExportAccessoryView.m, Export/LKExportManager.m, + Export/LKMCPNodeExporter.m, Hierarchy/LKDanceUIAttrMaker.m, Hierarchy/LKHierarchyController.m, Hierarchy/LKHierarchyDataSource.m, @@ -410,6 +411,7 @@ Dashboard/Search/LKDashboardSearchPropView.h, Export/LKExportAccessoryView.h, Export/LKExportManager.h, + Export/LKMCPNodeExporter.h, Hierarchy/LKDanceUIAttrMaker.h, Hierarchy/LKHierarchyController.h, Hierarchy/LKHierarchyDataSource.h, diff --git a/LookInside/Export/LKMCPNodeExporter.h b/LookInside/Export/LKMCPNodeExporter.h new file mode 100644 index 0000000..35cdddb --- /dev/null +++ b/LookInside/Export/LKMCPNodeExporter.h @@ -0,0 +1,42 @@ +// +// LKMCPNodeExporter.h +// Lookin +// +// Exports a LookinDisplayItem (and optionally its whole subtree) into the same +// JSON shape the `lookinside-mcp` tools emit (see Sources/LookinMCPCore/JSONShape.swift: +// oid / className / role / frame / bounds / alpha / hidden / text / +// accessibilityIdentifier / accessibilityLabel / path / children), enriched with the +// full per-node `attributes` groups exactly as the GUI dashboard panel shows them. +// +// The macOS client does NOT link the Swift MCP target, so this is a faithful ObjC +// re-implementation of the same schema. Keep the field names in sync with JSONShape.Node. +// + +#import + +@class LookinDisplayItem; + +NS_ASSUME_NONNULL_BEGIN + +@interface LKMCPNodeExporter : NSObject + +/// Build the MCP-format dictionary for `item`. When `recursive` is YES the returned +/// dictionary's `children` key holds the full subtree; when NO, `children` is an empty array. ++ (NSDictionary *)mcpDictionaryForItem:(LookinDisplayItem *)item recursive:(BOOL)recursive; + +/// Pretty-printed UTF-8 JSON data for `mcpDictionaryForItem:recursive:`. ++ (nullable NSData *)jsonDataForItem:(LookinDisplayItem *)item + recursive:(BOOL)recursive + error:(NSError *_Nullable *_Nullable)error; + +/// A suggested file name (without directory) for the exported JSON, e.g. +/// `UILabel_140234_analysis.json` or `UIView_140234_analysis_tree.json`. ++ (NSString *)suggestedFileNameForItem:(LookinDisplayItem *)item recursive:(BOOL)recursive; + +/// Human-readable text of the node's dashboard attribute data (grouped, "Title: value"), +/// suitable for placing on the pasteboard. Mirrors what the GUI panel displays. ++ (NSString *)readableAttributesTextForItem:(LookinDisplayItem *)item; + +@end + +NS_ASSUME_NONNULL_END diff --git a/LookInside/Export/LKMCPNodeExporter.m b/LookInside/Export/LKMCPNodeExporter.m new file mode 100644 index 0000000..293c9d8 --- /dev/null +++ b/LookInside/Export/LKMCPNodeExporter.m @@ -0,0 +1,440 @@ +// +// LKMCPNodeExporter.m +// Lookin +// + +#import "LKMCPNodeExporter.h" +#import +#import "LookinDisplayItem.h" +#import "LookinDisplayItem+LookinClient.h" +#import "LookinObject.h" +#import "LookinAttributesGroup.h" +#import "LookinAttributesGroup+LookinClient.h" +#import "LookinAttributesSection.h" +#import "LookinAttribute.h" +#import "LookinAttrType.h" +#import "LookinDashboardBlueprint.h" + +@implementation LKMCPNodeExporter + +#pragma mark - Public + ++ (NSDictionary *)mcpDictionaryForItem:(LookinDisplayItem *)item recursive:(BOOL)recursive { + if (!item) { + return @{}; + } + return [self _nodeDictForItem:item recursive:recursive]; +} + ++ (NSData *)jsonDataForItem:(LookinDisplayItem *)item recursive:(BOOL)recursive error:(NSError **)error { + NSDictionary *dict = [self mcpDictionaryForItem:item recursive:recursive]; + if (![NSJSONSerialization isValidJSONObject:dict]) { + if (error) { + *error = [NSError errorWithDomain:@"LKMCPNodeExporter" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"Generated object is not valid JSON."}]; + } + return nil; + } + return [NSJSONSerialization dataWithJSONObject:dict + options:(NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys) + error:error]; +} + ++ (NSString *)suggestedFileNameForItem:(LookinDisplayItem *)item recursive:(BOOL)recursive { + NSString *className = [self _classNameForItem:item] ?: @"Node"; + // Keep only filename-safe characters. + NSCharacterSet *illegal = [NSCharacterSet characterSetWithCharactersInString:@"/\\:*?\"<>|"]; + className = [[className componentsSeparatedByCharactersInSet:illegal] componentsJoinedByString:@"_"]; + unsigned long oid = [self _oidForItem:item]; + NSString *suffix = recursive ? @"analysis_tree" : @"analysis"; + if (oid) { + return [NSString stringWithFormat:@"%@_%lu_%@.json", className, oid, suffix]; + } + return [NSString stringWithFormat:@"%@_%@.json", className, suffix]; +} + ++ (NSString *)readableAttributesTextForItem:(LookinDisplayItem *)item { + if (!item) { + return @""; + } + NSMutableString *text = [NSMutableString string]; + + NSString *className = [self _classNameForItem:item] ?: @""; + unsigned long oid = [self _oidForItem:item]; + if (oid) { + [text appendFormat:@"%@ (oid: %lu)\n", className, oid]; + } else { + [text appendFormat:@"%@\n", className]; + } + NSString *path = [self _breadcrumbForItem:item]; + if (path.length) { + [text appendFormat:@"path: %@\n", path]; + } + + NSArray *groups = [item queryAllAttrGroupList]; + for (LookinAttributesGroup *group in groups) { + NSString *groupTitle = [self _titleForGroup:group]; + [text appendFormat:@"\n[%@]\n", groupTitle.length ? groupTitle : @"-"]; + + for (LookinAttributesSection *section in group.attrSections) { + NSString *sectionTitle = [self _titleForSection:section]; + if (sectionTitle.length) { + [text appendFormat:@" %@\n", sectionTitle]; + } + for (LookinAttribute *attr in section.attributes) { + NSString *attrTitle = [self _titleForAttribute:attr]; + NSString *valueString = [self _readableStringForAttribute:attr]; + NSString *indent = sectionTitle.length ? @" " : @" "; + if (attrTitle.length) { + [text appendFormat:@"%@%@: %@\n", indent, attrTitle, valueString]; + } else { + [text appendFormat:@"%@%@\n", indent, valueString]; + } + } + } + } + return text.copy; +} + +#pragma mark - Node assembly (MCP JSONShape.Node parity) + ++ (NSDictionary *)_nodeDictForItem:(LookinDisplayItem *)item recursive:(BOOL)recursive { + NSMutableDictionary *node = [NSMutableDictionary dictionary]; + + node[@"oid"] = @([self _oidForItem:item]); + NSString *className = [self _classNameForItem:item] ?: @"UnknownView"; + node[@"className"] = className; + NSString *role = [self _roleForClassName:className]; + node[@"role"] = role ?: [NSNull null]; + node[@"frame"] = [self _rectDict:item.frame]; + node[@"bounds"] = [self _rectDict:item.bounds]; + node[@"alpha"] = [self _num:item.alpha]; + node[@"hidden"] = @(item.isHidden); + + NSString *text = [self _extractStringAttributeForItem:item identifiers:@[@"text", @"title", @"stringValue", @"attributedText"]]; + node[@"text"] = text.length ? text : [NSNull null]; + + NSString *a11yID = [self _extractStringAttributeForItem:item identifiers:@[@"accessibilityIdentifier"]]; + node[@"accessibilityIdentifier"] = a11yID.length ? a11yID : [NSNull null]; + + NSString *a11yLabel = [self _extractStringAttributeForItem:item identifiers:@[@"accessibilityLabel"]]; + node[@"accessibilityLabel"] = a11yLabel.length ? a11yLabel : [NSNull null]; + + NSString *path = [self _breadcrumbForItem:item]; + node[@"path"] = path.length ? path : [NSNull null]; + + // Full per-node attribute groups exactly as the dashboard panel renders them. + node[@"attributes"] = [self _attributeGroupsArrayForItem:item]; + + // Children. + NSMutableArray *children = [NSMutableArray array]; + if (recursive) { + for (LookinDisplayItem *sub in item.subitems) { + [children addObject:[self _nodeDictForItem:sub recursive:YES]]; + } + } + node[@"children"] = children; + + return node; +} + +/// JSON-safe number: non-finite (NaN/±Inf) values become NSNull so serialization can't fail. ++ (id)_num:(double)v { + return isfinite(v) ? @(v) : (id)[NSNull null]; +} + ++ (NSDictionary *)_rectDict:(CGRect)r { + return @{ @"x": [self _num:r.origin.x], + @"y": [self _num:r.origin.y], + @"width": [self _num:r.size.width], + @"height": [self _num:r.size.height] }; +} + ++ (unsigned long)_oidForItem:(LookinDisplayItem *)item { + // Matches HierarchyIndex.oid(of:): prefer view, then layer, then window. + if (item.viewObject.oid) { return item.viewObject.oid; } + if (item.layerObject.oid) { return item.layerObject.oid; } + if (item.windowObject.oid) { return item.windowObject.oid; } + return 0; +} + ++ (NSString *)_classNameForItem:(LookinDisplayItem *)item { + NSString *(^head)(LookinObject *) = ^NSString *(LookinObject *obj) { + return obj.classChainList.firstObject; + }; + NSString *name = head(item.viewObject) ?: head(item.layerObject) ?: head(item.windowObject); + return name; +} + ++ (NSString *)_roleForClassName:(NSString *)className { + if (!className) { return nil; } + static NSDictionary *map; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + map = @{ @"UIButton": @"button", @"NSButton": @"button", + @"UILabel": @"label", @"NSTextField": @"label", + @"UIImageView": @"image", @"NSImageView": @"image", + @"UITextField": @"textInput", + @"UITextView": @"textArea", @"NSTextView": @"textArea", + @"UISwitch": @"switch", + @"UISlider": @"slider", + @"UIScrollView": @"scroll", @"NSScrollView": @"scroll", + @"UITableView": @"table", @"NSTableView": @"table", + @"UICollectionView": @"collection", @"NSCollectionView": @"collection", + @"UIStackView": @"stack", @"NSStackView": @"stack", + @"UIWindow": @"window", @"NSWindow": @"window" }; + }); + NSString *role = map[className]; + if (role) { return role; } + if ([className containsString:@"Button"]) { return @"button"; } + if ([className containsString:@"Label"]) { return @"label"; } + return nil; +} + ++ (NSString *)_breadcrumbForItem:(LookinDisplayItem *)item { + // Root -> self chain of class names joined by " ▸ ", mirroring HierarchyIndex.breadcrumb. + NSMutableArray *chain = [NSMutableArray array]; + LookinDisplayItem *cur = item; + while (cur) { + NSString *label = [self _classNameForItem:cur] ?: @"UnknownView"; + [chain insertObject:label atIndex:0]; + cur = cur.superItem; + } + return [chain componentsJoinedByString:@" ▸ "]; +} + +#pragma mark - Attribute extraction + ++ (NSString *)_extractStringAttributeForItem:(LookinDisplayItem *)item identifiers:(NSArray *)identifiers { + // Parity with JSONShape.extractAttribute: scan attributesGroupList, match identifier substring. + for (NSString *wanted in identifiers) { + for (LookinAttributesGroup *group in item.attributesGroupList) { + for (LookinAttributesSection *section in group.attrSections) { + for (LookinAttribute *attr in section.attributes) { + if ([attr.identifier containsString:wanted]) { + if ([attr.value isKindOfClass:[NSString class]] && [(NSString *)attr.value length]) { + return attr.value; + } + } + } + } + } + } + return nil; +} + ++ (NSArray *)_attributeGroupsArrayForItem:(LookinDisplayItem *)item { + NSMutableArray *groupsArray = [NSMutableArray array]; + for (LookinAttributesGroup *group in [item queryAllAttrGroupList]) { + NSMutableArray *sectionsArray = [NSMutableArray array]; + for (LookinAttributesSection *section in group.attrSections) { + NSMutableArray *attrsArray = [NSMutableArray array]; + for (LookinAttribute *attr in section.attributes) { + NSMutableDictionary *attrDict = [NSMutableDictionary dictionary]; + if (attr.identifier.length) { attrDict[@"id"] = attr.identifier; } + NSString *title = [self _titleForAttribute:attr]; + if (title.length) { attrDict[@"title"] = title; } + attrDict[@"value"] = [self _jsonValueForAttribute:attr] ?: [NSNull null]; + [attrsArray addObject:attrDict]; + } + if (attrsArray.count == 0) { continue; } + NSMutableDictionary *sectionDict = [NSMutableDictionary dictionary]; + NSString *secTitle = [self _titleForSection:section]; + if (secTitle.length) { sectionDict[@"title"] = secTitle; } + sectionDict[@"attributes"] = attrsArray; + [sectionsArray addObject:sectionDict]; + } + if (sectionsArray.count == 0) { continue; } + NSMutableDictionary *groupDict = [NSMutableDictionary dictionary]; + NSString *gTitle = [self _titleForGroup:group]; + if (gTitle.length) { groupDict[@"title"] = gTitle; } + groupDict[@"sections"] = sectionsArray; + [groupsArray addObject:groupDict]; + } + return groupsArray; +} + +#pragma mark - Titles + ++ (NSString *)_titleForGroup:(LookinAttributesGroup *)group { + NSString *title = [group queryDisplayTitle]; + return title; +} + ++ (NSString *)_titleForSection:(LookinAttributesSection *)section { + if (section.isUserCustom) { + LookinAttribute *attr = section.attributes.firstObject; + return attr.displayTitle; + } + return [LookinDashboardBlueprint sectionTitleWithSectionID:section.identifier]; +} + ++ (NSString *)_titleForAttribute:(LookinAttribute *)attr { + if (attr.isUserCustom) { + return attr.displayTitle; + } + NSString *title = [LookinDashboardBlueprint briefTitleWithAttrID:attr.identifier]; + if (!title.length) { + title = [LookinDashboardBlueprint fullTitleWithAttrID:attr.identifier]; + } + return title; +} + +#pragma mark - Value formatting + ++ (id)_jsonValueForAttribute:(LookinAttribute *)attr { + id value = attr.value; + if (!value) { return [NSNull null]; } + + switch (attr.attrType) { + case LookinAttrTypeBOOL: + return @([value boolValue]); + + case LookinAttrTypeCGRect: { + if ([value isKindOfClass:[NSValue class]]) { + CGRect r = [value rectValue]; + return [self _rectDict:r]; + } + break; + } + case LookinAttrTypeCGPoint: { + if ([value isKindOfClass:[NSValue class]]) { + CGPoint p = [value pointValue]; + return @{ @"x": [self _num:p.x], @"y": [self _num:p.y] }; + } + break; + } + case LookinAttrTypeCGSize: { + if ([value isKindOfClass:[NSValue class]]) { + CGSize s = [value sizeValue]; + return @{ @"width": [self _num:s.width], @"height": [self _num:s.height] }; + } + break; + } + case LookinAttrTypeCGVector: { + CGFloat b[4] = {0}; + if ([self _readDoubles:b count:2 fromValue:value]) { + return @{ @"dx": [self _num:b[0]], @"dy": [self _num:b[1]] }; + } + break; + } + case LookinAttrTypeUIEdgeInsets: { + CGFloat b[4] = {0}; + if ([self _readDoubles:b count:4 fromValue:value]) { + return @{ @"top": [self _num:b[0]], @"left": [self _num:b[1]], @"bottom": [self _num:b[2]], @"right": [self _num:b[3]] }; + } + break; + } + case LookinAttrTypeUIOffset: { + CGFloat b[4] = {0}; + if ([self _readDoubles:b count:2 fromValue:value]) { + return @{ @"horizontal": [self _num:b[0]], @"vertical": [self _num:b[1]] }; + } + break; + } + case LookinAttrTypeUIColor: { + if ([value isKindOfClass:[NSArray class]]) { + NSArray *rgba = value; + if (rgba.count == 4) { + return @{ @"r": rgba[0], @"g": rgba[1], @"b": rgba[2], @"a": rgba[3] }; + } + return [self _sanitizeJSONValue:rgba]; + } + break; + } + case LookinAttrTypeJson: { + if ([value isKindOfClass:[NSString class]]) { + NSData *data = [(NSString *)value dataUsingEncoding:NSUTF8StringEncoding]; + id parsed = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + if (parsed) { return parsed; } + return value; + } + return [self _sanitizeJSONValue:value]; + } + default: + break; + } + return [self _sanitizeJSONValue:value]; +} + +/// Convert any object into a JSON-safe representation. ++ (id)_sanitizeJSONValue:(id)value { + if (!value || value == [NSNull null]) { return [NSNull null]; } + if ([value isKindOfClass:[NSString class]]) { return value; } + if ([value isKindOfClass:[NSNumber class]]) { + double d = [(NSNumber *)value doubleValue]; + if (!isfinite(d)) { return [NSNull null]; } + return value; + } + if ([value isKindOfClass:[NSArray class]]) { + NSMutableArray *out = [NSMutableArray array]; + for (id v in (NSArray *)value) { [out addObject:[self _sanitizeJSONValue:v]]; } + return out; + } + if ([value isKindOfClass:[NSDictionary class]]) { + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + [(NSDictionary *)value enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + out[[key description]] = [self _sanitizeJSONValue:obj]; + }]; + return out; + } + if ([value isKindOfClass:[NSValue class]]) { + // Best-effort decode of common geometry structs by encoded type. + const char *type = [(NSValue *)value objCType]; + if (type) { + if (strcmp(type, @encode(CGRect)) == 0) { return [self _rectDict:[value rectValue]]; } + if (strcmp(type, @encode(CGPoint)) == 0) { CGPoint p = [value pointValue]; return @{ @"x": @(p.x), @"y": @(p.y) }; } + if (strcmp(type, @encode(CGSize)) == 0) { CGSize s = [value sizeValue]; return @{ @"width": @(s.width), @"height": @(s.height) }; } + } + } + return [value description]; +} + +/// Reads `count` (<=4) CGFloat fields out of an NSValue-wrapped struct. ++ (BOOL)_readDoubles:(CGFloat *)buffer count:(NSUInteger)count fromValue:(id)value { + if (![value isKindOfClass:[NSValue class]] || count == 0 || count > 4) { return NO; } + CGFloat tmp[4] = {0}; + @try { + [(NSValue *)value getValue:tmp]; + } @catch (__unused NSException *e) { + return NO; + } + for (NSUInteger i = 0; i < count; i++) { buffer[i] = tmp[i]; } + return YES; +} + ++ (NSString *)_readableStringForAttribute:(LookinAttribute *)attr { + id json = [self _jsonValueForAttribute:attr]; + return [self _readableStringFromJSONValue:json]; +} + ++ (NSString *)_readableStringFromJSONValue:(id)value { + if (!value || value == [NSNull null]) { return @"nil"; } + if ([value isKindOfClass:[NSString class]]) { return value; } + if ([value isKindOfClass:[NSNumber class]]) { + NSNumber *num = value; + if (strcmp(num.objCType, @encode(BOOL)) == 0 || strcmp(num.objCType, @encode(char)) == 0) { + // Heuristic: 0/1 chars are usually BOOL in this data model. + if ([num intValue] == 0 || [num intValue] == 1) { + return [num boolValue] ? @"true" : @"false"; + } + } + return num.stringValue; + } + if ([value isKindOfClass:[NSArray class]]) { + NSMutableArray *parts = [NSMutableArray array]; + for (id v in (NSArray *)value) { [parts addObject:[self _readableStringFromJSONValue:v]]; } + return [NSString stringWithFormat:@"[%@]", [parts componentsJoinedByString:@", "]]; + } + if ([value isKindOfClass:[NSDictionary class]]) { + NSMutableArray *parts = [NSMutableArray array]; + [(NSDictionary *)value enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + [parts addObject:[NSString stringWithFormat:@"%@: %@", key, [self _readableStringFromJSONValue:obj]]]; + }]; + return [NSString stringWithFormat:@"{%@}", [parts componentsJoinedByString:@", "]]; + } + return [value description]; +} + +@end diff --git a/LookInside/Hierarchy/LKHierarchyView.m b/LookInside/Hierarchy/LKHierarchyView.m index ac19cfa..7f81aa7 100644 --- a/LookInside/Hierarchy/LKHierarchyView.m +++ b/LookInside/Hierarchy/LKHierarchyView.m @@ -20,6 +20,7 @@ #import "LKAppsManager.h" #import "LKInspectableApp.h" #import "LookinDisplayItem+LookinClient.h" +#import "LKMCPNodeExporter.h" static NSString * const kMenuBindKey_RowView = @"view"; static CGFloat const kRowHeight = 28; @@ -383,6 +384,32 @@ - (void)menuNeedsUpdate:(NSMenu *)menu { })]; }]; + // MCP 数据导出 / 属性复制 + if (!displayItem.isUserCustom) { + [menu addItem:[NSMenuItem separatorItem]]; + [menu addItem:({ + NSMenuItem *item = [NSMenuItem new]; + item.target = self; + item.action = @selector(_handleExportNodeAnalysisRecursively:); + item.title = NSLocalizedString(@"Export node analysis data (incl. children)…", nil); + item; + })]; + [menu addItem:({ + NSMenuItem *item = [NSMenuItem new]; + item.target = self; + item.action = @selector(_handleExportNodeAnalysisSingle:); + item.title = NSLocalizedString(@"Export this node's analysis data…", nil); + item; + })]; + [menu addItem:({ + NSMenuItem *item = [NSMenuItem new]; + item.target = self; + item.action = @selector(_handleCopyAttributesData:); + item.title = NSLocalizedString(@"Copy property data", nil); + item; + })]; + } + NSArray *backingLayerItems = [self.dataSource swiftUIBackingLayerItemsForItem:displayItem]; LookinDisplayItem *swiftUISourceItem = [self.dataSource swiftUISourceItemForLayerItem:displayItem]; if (backingLayerItems.count || swiftUISourceItem) { @@ -559,6 +586,78 @@ - (void)_handleExportScreenshot:(NSMenuItem *)menuItem { [LKExportManager exportScreenshotWithDisplayItem:view.displayItem]; } +- (void)_handleExportNodeAnalysisRecursively:(NSMenuItem *)menuItem { + LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; + [self _exportAnalysisDataForItem:view.displayItem recursive:YES]; +} + +- (void)_handleExportNodeAnalysisSingle:(NSMenuItem *)menuItem { + LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; + [self _exportAnalysisDataForItem:view.displayItem recursive:NO]; +} + +- (void)_exportAnalysisDataForItem:(LookinDisplayItem *)item recursive:(BOOL)recursive { + if (!item) { + NSBeep(); + return; + } + NSError *error = nil; + NSData *data = [LKMCPNodeExporter jsonDataForItem:item recursive:recursive error:&error]; + if (!data) { + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = NSLocalizedString(@"Failed to export node analysis data.", nil); + alert.informativeText = error.localizedDescription ?: @""; + [alert addButtonWithTitle:NSLocalizedString(@"OK", nil)]; + [alert runModal]; + return; + } + + NSSavePanel *panel = [NSSavePanel savePanel]; + panel.nameFieldStringValue = [LKMCPNodeExporter suggestedFileNameForItem:item recursive:recursive]; + panel.allowedFileTypes = @[@"json"]; + panel.canCreateDirectories = YES; + panel.title = recursive ? NSLocalizedString(@"Export node analysis data (incl. children)…", nil) + : NSLocalizedString(@"Export this node's analysis data…", nil); + + NSWindow *hostWindow = self.window; + void (^completion)(NSModalResponse) = ^(NSModalResponse result) { + if (result != NSModalResponseOK || !panel.URL) { + return; + } + NSError *writeError = nil; + BOOL ok = [data writeToURL:panel.URL options:NSDataWritingAtomic error:&writeError]; + if (!ok) { + NSAlert *alert = [[NSAlert alloc] init]; + alert.messageText = NSLocalizedString(@"Failed to save file.", nil); + alert.informativeText = writeError.localizedDescription ?: @""; + [alert addButtonWithTitle:NSLocalizedString(@"OK", nil)]; + [alert runModal]; + } + }; + if (hostWindow) { + [panel beginSheetModalForWindow:hostWindow completionHandler:completion]; + } else { + completion([panel runModal]); + } +} + +- (void)_handleCopyAttributesData:(NSMenuItem *)menuItem { + LKHierarchyRowView *view = [menuItem.menu lookin_getBindObjectForKey:kMenuBindKey_RowView]; + LookinDisplayItem *item = view.displayItem; + if (!item) { + NSBeep(); + return; + } + NSString *text = [LKMCPNodeExporter readableAttributesTextForItem:item]; + if (!text.length) { + NSBeep(); + return; + } + NSPasteboard *paste = [NSPasteboard generalPasteboard]; + [paste clearContents]; + [paste writeObjects:@[text]]; +} + - (void)_handleCopyDisplayItemName:(NSMenuItem *)menuItem { NSString *stringToCopy = menuItem.representedObject; diff --git a/LookInside/Localizable.xcstrings b/LookInside/Localizable.xcstrings index 7fc084c..dd53c65 100644 --- a/LookInside/Localizable.xcstrings +++ b/LookInside/Localizable.xcstrings @@ -930,6 +930,22 @@ } } }, + "Copy property data" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy property data" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制属性数据" + } + } + } + }, "Copy text \"%@\"" : { "localizations" : { "en" : { @@ -1422,6 +1438,22 @@ } } }, + "Export node analysis data (incl. children)…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export node analysis data (incl. children)…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出节点分析数据(含子节点)…" + } + } + } + }, "Export screenshot…" : { "localizations" : { "en" : { @@ -1438,6 +1470,22 @@ } } }, + "Export this node's analysis data…" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Export this node's analysis data…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出当前节点分析数据…" + } + } + } + }, "Extracting…" : { "localizations" : { "en" : { @@ -1518,6 +1566,22 @@ } } }, + "Failed to export node analysis data." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to export node analysis data." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出节点分析数据失败。" + } + } + } + }, "Failed to extract LookInside Auth Server.\n%@" : { "localizations" : { "en" : { @@ -1664,6 +1728,22 @@ } } }, + "Failed to save file." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to save file." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存文件失败。" + } + } + } + }, "Failed to search related methods: " : { "localizations" : { "en" : { From 026e36f9b51202aac51e82c213c1cc4f86fc844a Mon Sep 17 00:00:00 2001 From: VanJay Date: Sat, 20 Jun 2026 18:50:14 +0800 Subject: [PATCH 17/17] Update bundle id and add notarized DMG build flow --- Configuration/Base.xcconfig | 2 +- LookInside.xcodeproj/project.pbxproj | 6 +- Scripts/build-dmg.sh | 620 +++++++++++++++++++++++++++ Scripts/build-release-from-tag.sh | 2 +- release/LookInside_V_2.3.8.dmg | Bin 0 -> 11216718 bytes 5 files changed, 625 insertions(+), 5 deletions(-) create mode 100755 Scripts/build-dmg.sh create mode 100644 release/LookInside_V_2.3.8.dmg diff --git a/Configuration/Base.xcconfig b/Configuration/Base.xcconfig index 893c8c9..a88d1fe 100644 --- a/Configuration/Base.xcconfig +++ b/Configuration/Base.xcconfig @@ -6,7 +6,7 @@ #include "Version.xcconfig" DEVELOPMENT_TEAM = -PRODUCT_BUNDLE_IDENTIFIER = com.lookinside-app.lookinside +PRODUCT_BUNDLE_IDENTIFIER = cn.vanjay.lookinside CODE_SIGN_STYLE = Automatic SPARKLE_PUBLIC_ED_KEY = diff --git a/LookInside.xcodeproj/project.pbxproj b/LookInside.xcodeproj/project.pbxproj index 4e7035f..299d5d4 100644 --- a/LookInside.xcodeproj/project.pbxproj +++ b/LookInside.xcodeproj/project.pbxproj @@ -745,7 +745,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "if [ ! -d \"$SRCROOT/../LookInside-Injector\" ]; then\n if [ \"$CONFIGURATION\" = \"Release\" ]; then\n echo \"error: LookInside-Injector not present; Release builds must embed the injector daemon\"\n exit 1\n fi\n echo \"Skipping Embed Injector Daemon: LookInside-Injector not present (not in monorepo checkout)\"\n exit 0\nfi\nPREPARE_SCRIPT=\"$SRCROOT/Scripts/prepare-injector-daemon.sh\"\n# Run in a fresh login zsh so xcodebuild env from the host build does not leak\n# into the nested LookInside-Injector build (would crash XCBBuildService).\n# Re-export the host's BUILT_PRODUCTS_DIR / CONTENTS_FOLDER_PATH / CONFIGURATION\n# so the nested zsh can locate the LookInside.app bundle to embed into.\nenv -i HOME=\"$HOME\" USER=\"$USER\" TERM=\"${TERM:-dumb}\" \\\n GITHUB_TOKEN=\"${GITHUB_TOKEN:-}\" \\\n CONFIGURATION=\"$CONFIGURATION\" \\\n BUILT_PRODUCTS_DIR=\"$BUILT_PRODUCTS_DIR\" \\\n CONTENTS_FOLDER_PATH=\"$CONTENTS_FOLDER_PATH\" \\\n /bin/zsh -lc \"$PREPARE_SCRIPT\"\n"; + shellScript = "if [ ! -d \"$SRCROOT/../LookInside-Injector\" ]; then\n if [ \"$CONFIGURATION\" = \"Release\" ] && [ \"${LOOKINSIDE_ALLOW_MISSING_INJECTOR:-NO}\" != \"YES\" ]; then\n echo \"error: LookInside-Injector not present; Release builds must embed the injector daemon\"\n exit 1\n fi\n echo \"Skipping Embed Injector Daemon: LookInside-Injector not present (not in monorepo checkout)\"\n exit 0\nfi\nPREPARE_SCRIPT=\"$SRCROOT/Scripts/prepare-injector-daemon.sh\"\n# Run in a fresh login zsh so xcodebuild env from the host build does not leak\n# into the nested LookInside-Injector build (would crash XCBBuildService).\n# Re-export the host's BUILT_PRODUCTS_DIR / CONTENTS_FOLDER_PATH / CONFIGURATION\n# so the nested zsh can locate the LookInside.app bundle to embed into.\nenv -i HOME=\"$HOME\" USER=\"$USER\" TERM=\"${TERM:-dumb}\" \\\n GITHUB_TOKEN=\"${GITHUB_TOKEN:-}\" \\\n CONFIGURATION=\"$CONFIGURATION\" \\\n BUILT_PRODUCTS_DIR=\"$BUILT_PRODUCTS_DIR\" \\\n CONTENTS_FOLDER_PATH=\"$CONTENTS_FOLDER_PATH\" \\\n /bin/zsh -lc \"$PREPARE_SCRIPT\"\n"; }; /* End PBXShellScriptBuildPhase section */ @@ -963,7 +963,7 @@ MARKETING_VERSION = "$(inherited)"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)"; - "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "com.lookinside-app.lookinside"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = cn.vanjay.lookinside; PRODUCT_MODULE_NAME = LookInside; PRODUCT_NAME = "LookInside 酷狗版"; PROVISIONING_PROFILE_SPECIFIER = "$(inherited)"; @@ -1041,7 +1041,7 @@ MARKETING_VERSION = "$(inherited)"; MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu11 gnu++14"; PRODUCT_BUNDLE_IDENTIFIER = "$(inherited)"; - "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = "com.lookinside-app.lookinside"; + "PRODUCT_BUNDLE_IDENTIFIER[sdk=macosx*]" = cn.vanjay.lookinside; PRODUCT_MODULE_NAME = LookInside; PRODUCT_NAME = "LookInside 酷狗版"; PROVISIONING_PROFILE_SPECIFIER = "$(inherited)"; diff --git a/Scripts/build-dmg.sh b/Scripts/build-dmg.sh new file mode 100755 index 0000000..0aaeb34 --- /dev/null +++ b/Scripts/build-dmg.sh @@ -0,0 +1,620 @@ +#!/usr/bin/env bash +# +# Build a notarized LookInside DMG. +# +# Usage: +# Scripts/build-dmg.sh [--keychain-profile PROFILE] [--no-notarize] +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKSPACE_FILE="$PROJECT_ROOT/LookInside.xcworkspace" +SCHEME="LookInside" +CONFIGURATION="Release" +BUNDLE_IDENTIFIER="cn.vanjay.lookinside" +DEVELOPMENT_TEAM="X6B6C6U6QV" +DEVELOPER_ID_APPLICATION_REQUIREMENT="anchor apple generic and certificate leaf[field.1.2.840.113635.100.6.1.13] exists" +KEYCHAIN_PROFILE="vanjay_mac_stapler" +SKIP_NOTARIZE=false +BUILD_ROOT="$PROJECT_ROOT/build" +DERIVED_DATA_PATH="${DERIVED_DATA_PATH:-$BUILD_ROOT/DerivedData}" +SOURCE_PACKAGES_PATH="${SOURCE_PACKAGES_PATH:-$DERIVED_DATA_PATH/SourcePackages}" +ARCHIVE_PATH="$BUILD_ROOT/dmg-archive/LookInside.xcarchive" +DMG_OUTPUT_DIR="$BUILD_ROOT/dmg" +DMG_WORK_DIR="$BUILD_ROOT/dmg-tmp" +PACKAGE_SCM_PROVIDER="${PACKAGE_SCM_PROVIDER:-system}" +PACKAGE_AUTH_PROVIDER="${PACKAGE_AUTH_PROVIDER:-}" +SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" +APP_PRODUCT_NAME="" +APP_EXECUTABLE_NAME="" +MARKETING_VERSION="" +CURRENT_PROJECT_VERSION="" +APP_PATH="" +INJECTOR_REPO="$PROJECT_ROOT/../LookInside-Injector" + +usage() { + cat <<'EOF' +Usage: Scripts/build-dmg.sh [options] + +Options: + --keychain-profile Notarytool keychain profile. Default: vanjay_mac_stapler. + --no-notarize Build the DMG without submitting to Apple notarization. + --help, -h Show this help. +EOF +} + +log() { + echo "==> $*" +} + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --keychain-profile) + [[ -n "${2:-}" && "$2" != --* ]] || fail "--keychain-profile requires a profile name." + KEYCHAIN_PROFILE="$2" + shift 2 + ;; + --no-notarize) + SKIP_NOTARIZE=true + shift + ;; + --help | -h) + usage + exit 0 + ;; + *) + fail "Unknown option: $1" + ;; + esac + done +} + +format_output() { + if command -v xcbeautify >/dev/null 2>&1; then + xcbeautify --disable-logging + else + cat + fi +} + +read_build_setting() { + local key="$1" + local overrides=( + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_IDENTIFIER" + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" + ) + + xcodebuild \ + -workspace "$WORKSPACE_FILE" \ + -scheme "$SCHEME" \ + -configuration "$CONFIGURATION" \ + -showBuildSettings \ + "${overrides[@]}" 2>/dev/null | + awk -F' = ' -v search_key="$key" '$1 ~ search_key"$" { print $2; exit }' +} + +load_build_settings() { + APP_PRODUCT_NAME="$(read_build_setting FULL_PRODUCT_NAME)" + APP_EXECUTABLE_NAME="$(read_build_setting EXECUTABLE_NAME)" + MARKETING_VERSION="$(read_build_setting MARKETING_VERSION)" + CURRENT_PROJECT_VERSION="$(read_build_setting CURRENT_PROJECT_VERSION)" + + [[ -n "$APP_PRODUCT_NAME" ]] || fail "Unable to read FULL_PRODUCT_NAME." + [[ -n "$APP_EXECUTABLE_NAME" ]] || fail "Unable to read EXECUTABLE_NAME." + [[ -n "$MARKETING_VERSION" ]] || fail "Unable to read MARKETING_VERSION." + [[ -n "$CURRENT_PROJECT_VERSION" ]] || fail "Unable to read CURRENT_PROJECT_VERSION." + + APP_PATH="$ARCHIVE_PATH/Products/Applications/$APP_PRODUCT_NAME" +} + +note_release_prerequisites() { + if [[ ! -d "$INJECTOR_REPO" ]]; then + log "LookInside-Injector repo not found; building DMG without the injector daemon." + log "Injector-dependent attach/injection features will be unavailable in this build." + fi +} + +detect_signing_identity() { + if [[ -n "$SIGNING_IDENTITY" ]]; then + return + fi + + SIGNING_IDENTITY="$( + security find-identity -v -p codesigning 2>/dev/null | + grep "Developer ID Application: .*($DEVELOPMENT_TEAM)" | + head -n 1 | + awk '{print $2}' + )" + + [[ -n "$SIGNING_IDENTITY" ]] || fail "No Developer ID Application identity found for team $DEVELOPMENT_TEAM." +} + +is_mach_o_file() { + local path="$1" + file -b "$path" 2>/dev/null | grep -q "Mach-O" +} + +path_contains_symlink() { + local path="$1" + local root="${2:-}" + local current="$path" + + if [[ -n "$root" ]]; then + while [[ "$current" == "$root" || "$current" == "$root/"* ]]; do + [[ -L "$current" ]] && return 0 + [[ "$current" == "$root" ]] && break + current="$(dirname "$current")" + done + return 1 + fi + + while [[ "$current" != "/" && "$current" != "." ]]; do + [[ -L "$current" ]] && return 0 + current="$(dirname "$current")" + done + + return 1 +} + +should_skip_nested_code_path() { + local path="$1" + local root="${2:-}" + + [[ "$path" == *"/Versions/Current"* ]] && return 0 + path_contains_symlink "$path" "$root" +} + +sign_code_path() { + local path="$1" + + log "Signing nested code: ${path#$PROJECT_ROOT/}" + codesign \ + --sign "$SIGNING_IDENTITY" \ + --options runtime \ + --timestamp \ + --verbose=4 \ + --force \ + "$path" +} + +remove_legacy_code_resources() { + local legacy_code_resources="$APP_PATH/Contents/CodeResources" + + if [[ -e "$legacy_code_resources" || -L "$legacy_code_resources" ]]; then + rm -f "$legacy_code_resources" + fi +} + +sign_nested_code() { + local main_executable="$APP_PATH/Contents/MacOS/$APP_EXECUTABLE_NAME" + local mach_o_files=() + local bundles=() + local candidate + + while IFS= read -r candidate; do + [[ "$candidate" == "$main_executable" ]] && continue + should_skip_nested_code_path "$candidate" "$APP_PATH/Contents" && continue + if is_mach_o_file "$candidate"; then + mach_o_files+=("$candidate") + fi + done < <(find "$APP_PATH/Contents" -type f -print) + + while IFS= read -r candidate; do + bundles+=("$candidate") + done < <( + find "$APP_PATH/Contents" -type d \ + \( -name "*.app" -o -name "*.appex" -o -name "*.bundle" -o -name "*.framework" -o -name "*.xpc" \) \ + -print | + awk '{ print length, $0 }' | + sort -rn | + cut -d' ' -f2- + ) + + for candidate in "${mach_o_files[@]}"; do + sign_code_path "$candidate" + done + + for candidate in "${bundles[@]}"; do + should_skip_nested_code_path "$candidate" "$APP_PATH/Contents" && continue + sign_code_path "$candidate" + done +} + +verify_developer_id_signature() { + local path="$1" + + codesign \ + --verify \ + --strict \ + --verbose=2 \ + -R="$DEVELOPER_ID_APPLICATION_REQUIREMENT" \ + "$path" +} + +verify_developer_id_signatures() { + local candidate + local signed_paths_file + local legacy_code_resources="$APP_PATH/Contents/CodeResources" + + [[ ! -e "$legacy_code_resources" && ! -L "$legacy_code_resources" ]] || + fail "Legacy code signature resource envelope is present: $legacy_code_resources" + + signed_paths_file="$(mktemp "${TMPDIR:-/tmp}/lookinside-dmg-signed-code.XXXXXX")" + printf "%s\n" "$APP_PATH" >>"$signed_paths_file" + + while IFS= read -r candidate; do + should_skip_nested_code_path "$candidate" "$APP_PATH/Contents" && continue + printf "%s\n" "$candidate" >>"$signed_paths_file" + done < <( + find "$APP_PATH/Contents" -type d \ + \( -name "*.app" -o -name "*.appex" -o -name "*.bundle" -o -name "*.framework" -o -name "*.xpc" \) \ + -print + ) + + while IFS= read -r candidate; do + should_skip_nested_code_path "$candidate" "$APP_PATH/Contents" && continue + if is_mach_o_file "$candidate"; then + printf "%s\n" "$candidate" >>"$signed_paths_file" + fi + done < <(find "$APP_PATH/Contents" -type f -print) + + while IFS= read -r candidate; do + log "Verifying Developer ID signature: ${candidate#$PROJECT_ROOT/}" + verify_developer_id_signature "$candidate" + done < <(sort -u "$signed_paths_file") + + rm -f "$signed_paths_file" +} + +build_app_unsigned() { + local app_products_dir="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION" + local built_app_path="$app_products_dir/$APP_PRODUCT_NAME" + local resolve_args=( + -skipMacroValidation + -skipPackagePluginValidation + -skipPackageUpdates + -disablePackageRepositoryCache + -skipPackageSignatureValidation + -packageFingerprintPolicy warn + -packageSigningEntityPolicy warn + -scmProvider "$PACKAGE_SCM_PROVIDER" + -workspace "$WORKSPACE_FILE" + -scheme "$SCHEME" + -configuration "$CONFIGURATION" + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_PATH" + ) + local xcodebuild_args=( + -skipMacroValidation + -skipPackagePluginValidation + -disableAutomaticPackageResolution + -onlyUsePackageVersionsFromResolvedFile + -skipPackageUpdates + -disablePackageRepositoryCache + -skipPackageSignatureValidation + -packageFingerprintPolicy warn + -packageSigningEntityPolicy warn + -scmProvider "$PACKAGE_SCM_PROVIDER" + -workspace "$WORKSPACE_FILE" + -scheme "$SCHEME" + -configuration "$CONFIGURATION" + -destination "generic/platform=macOS" + -derivedDataPath "$DERIVED_DATA_PATH" + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES_PATH" + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_IDENTIFIER" + DEVELOPMENT_TEAM="$DEVELOPMENT_TEAM" + LOOKINSIDE_ALLOW_MISSING_INJECTOR=YES + CODE_SIGNING_ALLOWED=NO + ) + + if [[ -n "${SPARKLE_PUBLIC_ED_KEY:-}" ]]; then + xcodebuild_args+=(SPARKLE_PUBLIC_ED_KEY="$SPARKLE_PUBLIC_ED_KEY") + fi + + if [[ -n "$PACKAGE_AUTH_PROVIDER" ]]; then + resolve_args+=(-packageAuthorizationProvider "$PACKAGE_AUTH_PROVIDER") + xcodebuild_args+=(-packageAuthorizationProvider "$PACKAGE_AUTH_PROVIDER") + fi + + rm -rf "$ARCHIVE_PATH" "$DERIVED_DATA_PATH/Build" "$DERIVED_DATA_PATH/Logs" + mkdir -p "$DERIVED_DATA_PATH" "$SOURCE_PACKAGES_PATH" + + log "Syncing derived source mirror" + bash "$PROJECT_ROOT/Scripts/sync-derived-source.sh" + + log "Resolving Swift package dependencies" + xcodebuild "${resolve_args[@]}" -resolvePackageDependencies 2>&1 | format_output + + log "Building $APP_PRODUCT_NAME without Xcode signing" + xcodebuild "${xcodebuild_args[@]}" clean build 2>&1 | format_output + + [[ -d "$built_app_path" ]] || fail "Built app not found at $built_app_path" + + log "Assembling archive at ${ARCHIVE_PATH#$PROJECT_ROOT/}" + mkdir -p "$ARCHIVE_PATH/Products/Applications" + ditto "$built_app_path" "$APP_PATH" + /usr/libexec/PlistBuddy -c "Add :ApplicationProperties dict" "$ARCHIVE_PATH/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c "Add :ApplicationProperties:ApplicationPath string Applications/$APP_PRODUCT_NAME" "$ARCHIVE_PATH/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c "Add :ApplicationProperties:CFBundleIdentifier string $BUNDLE_IDENTIFIER" "$ARCHIVE_PATH/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c "Add :ApplicationProperties:CFBundleShortVersionString string $MARKETING_VERSION" "$ARCHIVE_PATH/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c "Add :ApplicationProperties:CFBundleVersion string $CURRENT_PROJECT_VERSION" "$ARCHIVE_PATH/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c "Add :ArchiveVersion integer 2" "$ARCHIVE_PATH/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c "Add :Name string LookInside" "$ARCHIVE_PATH/Info.plist" >/dev/null + /usr/libexec/PlistBuddy -c "Add :SchemeName string $SCHEME" "$ARCHIVE_PATH/Info.plist" >/dev/null +} + +sign_app_bundle() { + local entitlements_path="$PROJECT_ROOT/LookInside/Lookin.entitlements" + local main_executable="$APP_PATH/Contents/MacOS/$APP_EXECUTABLE_NAME" + + [[ -f "$main_executable" ]] || fail "Main app executable not found at $main_executable" + chmod 755 "$main_executable" + [[ -x "$main_executable" ]] || fail "Main app executable is not executable: $main_executable" + + remove_legacy_code_resources + sign_nested_code + + log "Signing app bundle" + codesign \ + --sign "$SIGNING_IDENTITY" \ + --entitlements "$entitlements_path" \ + --options runtime \ + --timestamp \ + --verbose=4 \ + --force \ + "$APP_PATH" + + log "Verifying app signature" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + verify_developer_id_signatures +} + +verify_bundle_identifier() { + local actual_bundle_identifier + + actual_bundle_identifier="$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$APP_PATH/Contents/Info.plist")" + [[ "$actual_bundle_identifier" == "$BUNDLE_IDENTIFIER" ]] || + fail "CFBundleIdentifier is $actual_bundle_identifier, expected $BUNDLE_IDENTIFIER" +} + +generate_dmg_background() { + local output_path="$1" + + /usr/bin/swift - "$output_path" <<'SWIFT' +import Cocoa +import Foundation + +guard CommandLine.arguments.count >= 2 else { + fputs("Error: Missing output path\n", stderr) + exit(1) +} + +let outputPath = CommandLine.arguments[1] + +final class BackgroundView: NSView { + private enum C { + static let width: CGFloat = 620 + static let height: CGFloat = 360 + static let background = NSColor(srgbRed: 0.94, green: 0.96, blue: 0.98, alpha: 1) + static let header = NSColor(srgbRed: 0.74, green: 0.86, blue: 0.93, alpha: 1) + static let accent = NSColor(srgbRed: 0.08, green: 0.38, blue: 0.56, alpha: 1) + static let text = NSColor(srgbRed: 0.09, green: 0.14, blue: 0.18, alpha: 1) + static let subtext = NSColor(srgbRed: 0.28, green: 0.35, blue: 0.41, alpha: 1) + static let panelFill = NSColor.white.withAlphaComponent(0.95) + static let panelStroke = NSColor(srgbRed: 0.70, green: 0.80, blue: 0.87, alpha: 1) + static let titleFont = NSFont(name: "Avenir Next Demi Bold", size: 26) ?? .systemFont(ofSize: 26, weight: .semibold) + static let subtitleFont = NSFont(name: "Avenir Next Regular", size: 14) ?? .systemFont(ofSize: 14) + } + + override func draw(_ dirtyRect: NSRect) { + let bg = NSBezierPath(roundedRect: bounds, xRadius: 24, yRadius: 24) + C.background.setFill() + bg.fill() + + NSGradient(starting: C.header, ending: C.background)?.draw(in: NSRect(x: 0, y: 248, width: bounds.width, height: 112), angle: -90) + drawText("Drag LookInside to Applications", font: C.titleFont, color: C.text, in: NSRect(x: 56, y: 286, width: 508, height: 34)) + drawText("Install the macOS UI inspector by dragging it onto the Applications shortcut", font: C.subtitleFont, color: C.subtext, in: NSRect(x: 50, y: 242, width: 520, height: 24)) + drawPanel(NSRect(x: 58, y: 72, width: 192, height: 168)) + drawPanel(NSRect(x: 370, y: 72, width: 192, height: 168)) + + let arrowBody = NSBezierPath() + arrowBody.move(to: NSPoint(x: 254, y: 156)) + arrowBody.line(to: NSPoint(x: 338, y: 156)) + C.accent.setStroke() + arrowBody.lineWidth = 14 + arrowBody.stroke() + + let arrowHead = NSBezierPath() + arrowHead.move(to: NSPoint(x: 326, y: 178)) + arrowHead.line(to: NSPoint(x: 364, y: 156)) + arrowHead.line(to: NSPoint(x: 326, y: 134)) + arrowHead.close() + C.accent.setFill() + arrowHead.fill() + } + + private func drawPanel(_ rect: NSRect) { + let panel = NSBezierPath(roundedRect: rect, xRadius: 26, yRadius: 26) + C.panelFill.setFill() + panel.fill() + C.panelStroke.setStroke() + panel.lineWidth = 2 + panel.stroke() + } + + private func drawText(_ text: String, font: NSFont, color: NSColor, in rect: NSRect) { + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: color, .paragraphStyle: paragraph] + (text as NSString).draw(in: rect, withAttributes: attrs) + } +} + +let frame = NSRect(x: 0, y: 0, width: 620, height: 360) +let view = BackgroundView(frame: frame) +guard let rep = view.bitmapImageRepForCachingDisplay(in: frame) else { + fputs("Error: Could not create bitmap rep\n", stderr) + exit(1) +} +view.cacheDisplay(in: frame, to: rep) +guard let data = rep.representation(using: .png, properties: [:]) else { + fputs("Error: Could not generate PNG data\n", stderr) + exit(1) +} +try data.write(to: URL(fileURLWithPath: outputPath)) +SWIFT +} + +create_pretty_dmg() { + local dmg_path="$1" + local volume_name="$2" + local staging_dir="$DMG_WORK_DIR/staging" + local background_dir="$staging_dir/.background" + local background_path="$background_dir/installer-background.png" + local rw_dmg_path="$DMG_WORK_DIR/${volume_name}.temp.dmg" + local device + local attach_output + local mounted_volume_path + local mounted_volume_name + + rm -rf "$DMG_WORK_DIR" + mkdir -p "$staging_dir" "$background_dir" + ditto "$APP_PATH" "$staging_dir/$APP_PRODUCT_NAME" + ln -s /Applications "$staging_dir/Applications" + generate_dmg_background "$background_path" + chflags hidden "$background_dir" 2>/dev/null || true + + hdiutil create -volname "$volume_name" \ + -srcfolder "$staging_dir" \ + -fs HFS+ \ + -fsargs "-c c=64,a=16,e=16" \ + -ov -format UDRW \ + "$rw_dmg_path" + + attach_output="$(hdiutil attach -readwrite -noverify -noautoopen "$rw_dmg_path")" + device="$(printf '%s\n' "$attach_output" | awk -F '\t' '/\/Volumes\// {print $1; exit}')" + mounted_volume_path="$(printf '%s\n' "$attach_output" | awk -F '\t' '/\/Volumes\// {print $NF; exit}')" + mounted_volume_name="$(basename "$mounted_volume_path")" + + if [[ -z "$device" || -z "$mounted_volume_name" ]]; then + echo "$attach_output" >&2 + fail "Unable to mount temporary DMG." + fi + + osascript <