diff --git a/Topit.xcodeproj/project.pbxproj b/Topit.xcodeproj/project.pbxproj index 324a452..7e7c20e 100644 --- a/Topit.xcodeproj/project.pbxproj +++ b/Topit.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 18BC298B2CEC21B100FF22A8 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 18BC298A2CEC21B100FF22A8 /* Sparkle */; }; + 18D6C25C2CEF5FA00026BFB3 /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = 18D6C25B2CEF5FA00026BFB3 /* KeyboardShortcuts */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -41,6 +42,7 @@ buildActionMask = 2147483647; files = ( 18BC298B2CEC21B100FF22A8 /* Sparkle in Frameworks */, + 18D6C25C2CEF5FA00026BFB3 /* KeyboardShortcuts in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -84,6 +86,7 @@ name = Topit; packageProductDependencies = ( 18BC298A2CEC21B100FF22A8 /* Sparkle */, + 18D6C25B2CEF5FA00026BFB3 /* KeyboardShortcuts */, ); productName = Topit; productReference = 18221D732CEA07BA009F773F /* Topit.app */; @@ -116,6 +119,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 18BC29892CEC21B100FF22A8 /* XCRemoteSwiftPackageReference "Sparkle" */, + 18D6C25A2CEF5FA00026BFB3 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */, ); preferredProjectObjectVersion = 77; productRefGroup = 18221D742CEA07BA009F773F /* Products */; @@ -274,20 +278,21 @@ CODE_SIGN_ENTITLEMENTS = Topit/Topit.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"Topit/Preview Content\""; DEVELOPMENT_TEAM = L4T783637F; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Topit/Info.plist; + INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 lihaoyun6. All rights reserved."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 0.1.0; + MACOSX_DEPLOYMENT_TARGET = 12.3; + MARKETING_VERSION = 0.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.lihaoyun6.Topit; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -303,20 +308,21 @@ CODE_SIGN_ENTITLEMENTS = Topit/Topit.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 11; DEVELOPMENT_ASSET_PATHS = "\"Topit/Preview Content\""; DEVELOPMENT_TEAM = L4T783637F; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Topit/Info.plist; + INFOPLIST_KEY_LSUIElement = YES; INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2024 lihaoyun6. All rights reserved."; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 0.1.0; + MACOSX_DEPLOYMENT_TARGET = 12.3; + MARKETING_VERSION = 0.1.1; PRODUCT_BUNDLE_IDENTIFIER = com.lihaoyun6.Topit; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -356,6 +362,14 @@ minimumVersion = 2.6.4; }; }; + 18D6C25A2CEF5FA00026BFB3 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.2.2; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -364,6 +378,11 @@ package = 18BC29892CEC21B100FF22A8 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 18D6C25B2CEF5FA00026BFB3 /* KeyboardShortcuts */ = { + isa = XCSwiftPackageProductDependency; + package = 18D6C25A2CEF5FA00026BFB3 /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */; + productName = KeyboardShortcuts; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 18221D6B2CEA07BA009F773F /* Project object */; diff --git a/Topit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Topit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5ace476..896210c 100644 --- a/Topit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Topit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "originHash" : "800750e91580ed3ddb5287ec0027c0ce18c477120948b5d890bec3d8a880ccf9", "pins" : [ + { + "identity" : "keyboardshortcuts", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sindresorhus/KeyboardShortcuts", + "state" : { + "revision" : "c3c361f409b8dbe1eab186078b41c330a6a82c9a", + "version" : "2.2.2" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", diff --git a/Topit/Accessibility.swift b/Topit/Accessibility.swift deleted file mode 100644 index 2b6612e..0000000 --- a/Topit/Accessibility.swift +++ /dev/null @@ -1,142 +0,0 @@ -// -// Accessibility.swift -// Topit -// -// Created by apple on 2024/11/18. -// - -import Foundation -import ScreenCaptureKit -import Cocoa - -func cg2ns(cgRect: CGRect, display: SCDisplay) -> NSRect { - let x = cgRect.origin.x - let y = cgRect.origin.y - let w = cgRect.width - let h = cgRect.height - if let main = NSScreen.screens.first(where: { $0.isMainScreen }) { - return NSRect(x: x, y: main.frame.height - y - h, width: w, height: h) - } - return NSRect(x: x, y: display.frame.height - y - h, width: w, height: h) -} - -func bringAppToFront(bundleIdentifier: String) { - if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first { - app.activate(options: [.activateIgnoringOtherApps]) - } else { - print("Application not found.") - } -} - -func isWindowOnTop(windowID: CGWindowID) -> Bool { - guard let windowList = CGWindowListCopyWindowInfo(.optionOnScreenAboveWindow, windowID) as? [[String: Any]] else { - return false - } - return windowList.isEmpty -} - -func getCGWindowFrame(windowID: CGWindowID) -> CGRect? { - guard let cgWindows = CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) as? [[String: Any]] else { - return nil - } - if let cgWindow = cgWindows.first { - if let boundsDict = cgWindow["kCGWindowBounds"] as? [String: CGFloat], - let x = boundsDict["X"], let y = boundsDict["Y"], - let width = boundsDict["Width"], let height = boundsDict["Height"] { - return CGRect(x: x, y: y, width: width, height: height) - } - } - return nil -} - -func getAXWindow(windowID: CGWindowID) -> AXUIElement? { - // 获取窗口的基本信息 - guard let cgWindows = CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) as? [[String: Any]] else { - return nil - } - guard let cgWindow = cgWindows.first else { - print("No matching CGWindow found!") - return nil - } - - // 获取窗口的进程 ID - guard let pid = cgWindow["kCGWindowOwnerPID"] as? pid_t else { - print("Failed to retrieve PID for CGWindow.") - return nil - } - - // 创建应用的 AXUIElement - let appElement = AXUIElementCreateApplication(pid) - var appWindows: CFTypeRef? - let result = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &appWindows) - guard result == .success, let windows = appWindows as? [AXUIElement] else { - print("Failed to retrieve AXUIElement windows for application.") - return nil - } - - // 提取 CGWindow 的标题、位置和尺寸 - let cgWindowTitle = cgWindow["kCGWindowName"] as? String - let cgWindowBounds = cgWindow["kCGWindowBounds"] as? [String: CGFloat] - let cgWindowX = cgWindowBounds?["X"] ?? 0 - let cgWindowY = cgWindowBounds?["Y"] ?? 0 - let cgWindowWidth = cgWindowBounds?["Width"] ?? 0 - let cgWindowHeight = cgWindowBounds?["Height"] ?? 0 - let cgWindowFrame = CGRect(x: cgWindowX, y: cgWindowY, width: cgWindowWidth, height: cgWindowHeight) - - // 匹配窗口的 AXUIElement - for axWindow in windows { - // 检查标题 - var title: CFTypeRef? - AXUIElementCopyAttributeValue(axWindow, kAXTitleAttribute as CFString, &title) - let axTitle = title as? String - - // 检查位置和尺寸 - var positionValue: CFTypeRef? - var sizeValue: CFTypeRef? - var axPosition = CGPoint.zero - var axSize = CGSize.zero - - if AXUIElementCopyAttributeValue(axWindow, kAXPositionAttribute as CFString, &positionValue) == .success { - let position = positionValue as! AXValue - AXValueGetValue(position, .cgPoint, &axPosition) - } - - if AXUIElementCopyAttributeValue(axWindow, kAXSizeAttribute as CFString, &sizeValue) == .success { - let size = sizeValue as! AXValue - AXValueGetValue(size, .cgSize, &axSize) - } - - let axFrame = CGRect(origin: axPosition, size: axSize) - - // 同时匹配标题、位置和尺寸 - if axTitle == cgWindowTitle, axFrame.equalTo(cgWindowFrame) { - return axWindow - } - } - - print("No matching AXUIElement found!") - return nil -} - -func activateWindow(axWindow: AXUIElement?, frame: CGRect) { - if let axWindow = axWindow { - var position = CGPoint(x: frame.origin.x, y: frame.origin.y) - AXUIElementSetAttributeValue(axWindow, kAXPositionAttribute as CFString, AXValue.from(point: &position)) - - var size = CGSize(width: frame.width, height: frame.height) - AXUIElementSetAttributeValue(axWindow, kAXSizeAttribute as CFString, AXValue.from(size: &size)) - - AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString) - } -} - -// AXValue 扩展,便于设置值 -extension AXValue { - static func from(point: inout CGPoint) -> AXValue { - return AXValueCreate(.cgPoint, &point)! - } - - static func from(size: inout CGSize) -> AXValue { - return AXValueCreate(.cgSize, &size)! - } -} diff --git a/Topit/Assets.xcassets/blackWhite.colorset/Contents.json b/Topit/Assets.xcassets/blackWhite.colorset/Contents.json new file mode 100644 index 0000000..8bdc8eb --- /dev/null +++ b/Topit/Assets.xcassets/blackWhite.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xFC", + "green" : "0xFC", + "red" : "0xFC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x38", + "green" : "0x38", + "red" : "0x38" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/block.imageset/Contents.json b/Topit/Assets.xcassets/block.imageset/Contents.json new file mode 100644 index 0000000..5559c99 --- /dev/null +++ b/Topit/Assets.xcassets/block.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "blacklist@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "blacklist.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/block.imageset/blacklist.png b/Topit/Assets.xcassets/block.imageset/blacklist.png new file mode 100644 index 0000000..e0dd424 Binary files /dev/null and b/Topit/Assets.xcassets/block.imageset/blacklist.png differ diff --git a/Topit/Assets.xcassets/block.imageset/blacklist@1x.png b/Topit/Assets.xcassets/block.imageset/blacklist@1x.png new file mode 100644 index 0000000..05a8dbf Binary files /dev/null and b/Topit/Assets.xcassets/block.imageset/blacklist@1x.png differ diff --git a/Topit/Assets.xcassets/buttonBlue.colorset/Contents.json b/Topit/Assets.xcassets/buttonBlue.colorset/Contents.json new file mode 100644 index 0000000..9a769d9 --- /dev/null +++ b/Topit/Assets.xcassets/buttonBlue.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0xEF", + "green" : "0xB6", + "red" : "0x69" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/buttonGreen.colorset/Contents.json b/Topit/Assets.xcassets/buttonGreen.colorset/Contents.json new file mode 100644 index 0000000..d8569ef --- /dev/null +++ b/Topit/Assets.xcassets/buttonGreen.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x55", + "green" : "0xC5", + "red" : "0x61" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/buttonGreenDark.colorset/Contents.json b/Topit/Assets.xcassets/buttonGreenDark.colorset/Contents.json new file mode 100644 index 0000000..f6d1d57 --- /dev/null +++ b/Topit/Assets.xcassets/buttonGreenDark.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x18", + "green" : "0x60", + "red" : "0x29" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/buttonRedDark.colorset/Contents.json b/Topit/Assets.xcassets/buttonRedDark.colorset/Contents.json new file mode 100644 index 0000000..9e2e873 --- /dev/null +++ b/Topit/Assets.xcassets/buttonRedDark.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x10", + "green" : "0x1A", + "red" : "0x8D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/buttonYellow.colorset/Contents.json b/Topit/Assets.xcassets/buttonYellow.colorset/Contents.json new file mode 100644 index 0000000..5e44678 --- /dev/null +++ b/Topit/Assets.xcassets/buttonYellow.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x4F", + "green" : "0xBF", + "red" : "0xF4" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/buttonYellowDark.colorset/Contents.json b/Topit/Assets.xcassets/buttonYellowDark.colorset/Contents.json new file mode 100644 index 0000000..94e6f20 --- /dev/null +++ b/Topit/Assets.xcassets/buttonYellowDark.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0x1D", + "green" : "0x59", + "red" : "0x8F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/gear.imageset/Contents.json b/Topit/Assets.xcassets/gear.imageset/Contents.json new file mode 100644 index 0000000..2e1dd22 --- /dev/null +++ b/Topit/Assets.xcassets/gear.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "gear@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "gear.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/gear.imageset/gear.png b/Topit/Assets.xcassets/gear.imageset/gear.png new file mode 100644 index 0000000..c3419f3 Binary files /dev/null and b/Topit/Assets.xcassets/gear.imageset/gear.png differ diff --git a/Topit/Assets.xcassets/gear.imageset/gear@1x.png b/Topit/Assets.xcassets/gear.imageset/gear@1x.png new file mode 100644 index 0000000..de8ac73 Binary files /dev/null and b/Topit/Assets.xcassets/gear.imageset/gear@1x.png differ diff --git a/Topit/Assets.xcassets/hotkey.imageset/Contents.json b/Topit/Assets.xcassets/hotkey.imageset/Contents.json new file mode 100644 index 0000000..5bef75a --- /dev/null +++ b/Topit/Assets.xcassets/hotkey.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "hotkey@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "hotkey.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/hotkey.imageset/hotkey.png b/Topit/Assets.xcassets/hotkey.imageset/hotkey.png new file mode 100644 index 0000000..12ed813 Binary files /dev/null and b/Topit/Assets.xcassets/hotkey.imageset/hotkey.png differ diff --git a/Topit/Assets.xcassets/hotkey.imageset/hotkey@1x.png b/Topit/Assets.xcassets/hotkey.imageset/hotkey@1x.png new file mode 100644 index 0000000..91482ae Binary files /dev/null and b/Topit/Assets.xcassets/hotkey.imageset/hotkey@1x.png differ diff --git a/Topit/Assets.xcassets/statusIcon.imageset/Contents.json b/Topit/Assets.xcassets/statusIcon.imageset/Contents.json new file mode 100644 index 0000000..6bc3203 --- /dev/null +++ b/Topit/Assets.xcassets/statusIcon.imageset/Contents.json @@ -0,0 +1,57 @@ +{ + "images" : [ + { + "filename" : "topitMenubar@1x2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "topitMenubar@1x3.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "topitMenubar@2x2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "topitMenubar@2x3.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@1x2.png b/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@1x2.png new file mode 100644 index 0000000..e6d6f50 Binary files /dev/null and b/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@1x2.png differ diff --git a/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@1x3.png b/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@1x3.png new file mode 100644 index 0000000..d9c8c10 Binary files /dev/null and b/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@1x3.png differ diff --git a/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@2x2.png b/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@2x2.png new file mode 100644 index 0000000..6c301c3 Binary files /dev/null and b/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@2x2.png differ diff --git a/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@2x3.png b/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@2x3.png new file mode 100644 index 0000000..28fde4e Binary files /dev/null and b/Topit/Assets.xcassets/statusIcon.imageset/topitMenubar@2x3.png differ diff --git a/Topit/Assets.xcassets/window.imageset/Contents.json b/Topit/Assets.xcassets/window.imageset/Contents.json new file mode 100644 index 0000000..2411c37 --- /dev/null +++ b/Topit/Assets.xcassets/window.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "window@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "window.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Topit/Assets.xcassets/window.imageset/window.png b/Topit/Assets.xcassets/window.imageset/window.png new file mode 100644 index 0000000..2784175 Binary files /dev/null and b/Topit/Assets.xcassets/window.imageset/window.png differ diff --git a/Topit/Assets.xcassets/window.imageset/window@1x.png b/Topit/Assets.xcassets/window.imageset/window@1x.png new file mode 100644 index 0000000..9942127 Binary files /dev/null and b/Topit/Assets.xcassets/window.imageset/window@1x.png differ diff --git a/Topit/ContentView.swift b/Topit/ContentView.swift deleted file mode 100644 index 170417c..0000000 --- a/Topit/ContentView.swift +++ /dev/null @@ -1,332 +0,0 @@ -// -// ContentView.swift -// Topit -// -// Created by apple on 2024/11/17. -// - -import SwiftUI -import Foundation -import AVFoundation -import ScreenCaptureKit - -struct ScreenCaptureView: NSViewRepresentable { - @ObservedObject var manager: ScreenCaptureManager - - func makeNSView(context: Context) -> NSView { - let view = NSView() - let videoLayer = manager.videoLayer - videoLayer.videoGravity = .resizeAspectFill - videoLayer.frame = view.bounds - videoLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] - view.layer = CALayer() - view.layer?.addSublayer(videoLayer) - return view - } - - func updateNSView(_ nsView: NSView, context: Context) {} -} - -struct TopView: View { - var display: SCDisplay! - var window: SCWindow! - @State private var timer: Timer? - @StateObject private var captureManager = ScreenCaptureManager() - @State private var opacity: Double = 1 - @State private var overClose: Bool = false - @State private var overView: Bool = false - @State private var resizing: Bool = false - @State private var nsWindow: NSWindow? - @State private var nsScreen: NSScreen? - @State private var axWindow: AXUIElement? - @State private var windowSize: CGSize = .zero - - var body: some View { - ZStack(alignment: Alignment(horizontal: .leading, vertical: .top)) { - Group { - ScreenCaptureView(manager: captureManager) - .frame(width: windowSize.width, height: windowSize.height) - .background( - WindowAccessor( - onWindowOpen: { w in - nsWindow = w - nsWindow?.makeKeyAndOrderFront(self) - checkMouseLocation() - }, - onWindowClose: { - timer?.invalidate() - nsWindow = nil - captureManager.stopCapture() - } - ) - ) - }.opacity(opacity) - if !resizing { - Button(action: { - nsWindow?.close() - }, label: { - Image(systemName: "pin.slash.fill") - .font(.subheadline) - .frame(width: 20, height: 20) - .foregroundStyle(.white) - .background(Circle().fill(overClose ? .buttonRed : .red)) - }) - .padding(4) - .buttonStyle(.plain) - .onHover { hovering in - overClose = hovering - nsWindow?.makeKeyAndOrderFront(self) - } - } - } - .onAppear { - windowSize = window.frame.size - timer = Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { _ in - if let frame = getCGWindowFrame(windowID: window.windowID) { - let newFrame = cg2ns(cgRect: frame, display: display) - if newFrame != nsWindow?.frame { - opacity = 0 - resizing = true - let newDisplay = nsWindow?.screen - if newFrame.size != nsWindow?.frame.size || nsScreen != newDisplay { - nsScreen = newDisplay - captureManager.updateStreamSize(newWidth: frame.width, newHeight: frame.height, screen: newDisplay) - } - nsWindow?.setFrame(cg2ns(cgRect: frame, display: display), display: true) - windowSize = frame.size - } else { - if !overView { opacity = 1 } - resizing = false - } - } - } - axWindow = getAXWindow(windowID: window.windowID) - Task { await captureManager.startCapture(display: display, window: window) } - } - .onChange(of: captureManager.capturing) { newValue in if !newValue { nsWindow?.close() }} - .onChange(of: opacity) { newValue in - if newValue == 1 { nsWindow?.hasShadow = true } else { nsWindow?.hasShadow = false } - } - .onHover { hovering in - overView = hovering - if resizing { return } - if hovering { - nsWindow?.makeKeyAndOrderFront(self) - if let id = window.owningApplication?.bundleIdentifier, let win = nsWindow { - activateWindow(axWindow: axWindow, frame: cg2ns(cgRect: win.frame, display: display)) - NSApp.activate(ignoringOtherApps: true) - bringAppToFront(bundleIdentifier: id) - withAnimation(.easeOut(duration: 0.1)) { opacity = 0 } - } - } else { - opacity = 1 - } - } - } - - private func checkMouseLocation() { - let mouseLocation = NSEvent.mouseLocation - let windowFrame = window.frame - let mouseInWindow = windowFrame.contains(NSPoint(x: mouseLocation.x, y: mouseLocation.y)) - if mouseInWindow { opacity = 0 } - } -} - -struct WinSelector: View { - @Environment(\.colorScheme) var colorScheme - @StateObject var viewModel = WindowSelectorViewModel() - @State private var selected = [SCWindow]() - @State private var display: SCDisplay! - @State private var selectedTab = 0 - @State private var sheeting: Bool = false - @State private var needRefesh: Bool = true - @State private var panel: NSWindow? - //var appDelegate = AppDelegate.shared - - var body: some View { - TabView(selection: $selectedTab) { - let allApps = viewModel.windowThumbnails.sorted(by: { $0.key.displayID < $1.key.displayID }) - ForEach(allApps, id: \.key) { element in - let (screen, thumbnails) = element - let index = allApps.firstIndex(where: { $0.key == screen }) ?? 0 - ScrollView(showsIndicators:false) { - VStack(spacing: 10) { - ForEach(0.. NSImage? { - if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: app.bundleIdentifier) { - let icon = NSWorkspace.shared.icon(forFile: appURL.path) - icon.size = NSSize(width: 69, height: 69) - return icon - } - let icon = NSImage(systemSymbolName: "questionmark.app.dashed", accessibilityDescription: "blank icon") - icon!.size = NSSize(width: 69, height: 69) - return icon - } - - func createNewWindow(display: SCDisplay, window: SCWindow) { - let panel = NNSPanel(contentRect: cg2ns(cgRect: window.frame, display: display), styleMask: [.closable, .nonactivatingPanel, .fullSizeContentView], backing: .buffered, defer: false) - let contentView = NSHostingView(rootView: TopView(display: display, window: window)) - panel.contentView = contentView - panel.title = window.title ?? "Topit Layer" - panel.level = .floating - //panel.hasShadow = false - panel.backgroundColor = .clear - panel.titleVisibility = .hidden - panel.titlebarAppearsTransparent = true - panel.isMovableByWindowBackground = true - panel.isReleasedWhenClosed = false - panel.collectionBehavior = [.canJoinAllSpaces] - panel.makeKeyAndOrderFront(nil) - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - panel.setFrame(cg2ns(cgRect: window.frame, display: display), display: true) - if let id = window.owningApplication?.bundleIdentifier { - NSApp.activate(ignoringOtherApps: true) - bringAppToFront(bundleIdentifier: id) - } - } - } -} diff --git a/Topit/SettingsView.swift b/Topit/SettingsView.swift deleted file mode 100644 index a024365..0000000 --- a/Topit/SettingsView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// SettingsView.swift -// Topit -// -// Created by apple on 2024/11/19. -// - -import SwiftUI - -struct SettingsView: View { - var fromPanel: Bool = false - @Environment(\.presentationMode) var presentationMode - @AppStorage("noTitle") var noTitle = true - - var body: some View { - VStack(spacing: -10) { - Form { - Section { - Toggle("Show Windows with No Title", isOn: $noTitle) - } - Section { - UpdaterSettingsView(updater: updaterController.updater) - } - }.formStyle(.grouped) - if fromPanel { - HStack { - CheckForUpdatesView(updater: updaterController.updater) - if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { - Text("Topit v\(appVersion)") - .font(.subheadline) - .foregroundColor(.secondary) - } - Spacer() - Button("Close") { presentationMode.wrappedValue.dismiss() } - .keyboardShortcut(.defaultAction) - } - .padding(.horizontal, 20) - .padding(.bottom, 17) - } - } - } -} diff --git a/Topit/Supports/Accessibility.swift b/Topit/Supports/Accessibility.swift new file mode 100644 index 0000000..37a0c68 --- /dev/null +++ b/Topit/Supports/Accessibility.swift @@ -0,0 +1,282 @@ +// +// Accessibility.swift +// Topit +// +// Created by apple on 2024/11/18. +// + +import SwiftUI +import ScreenCaptureKit + +func getScreenWithMouse() -> NSScreen? { + let mouseLocation = NSEvent.mouseLocation + let screenWithMouse = NSScreen.screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }) + return screenWithMouse +} + +func getSCDisplayWithMouse() -> SCDisplay? { + if let displays = SCManager.availableContent?.displays { + for display in displays { + if let currentDisplayID = getScreenWithMouse()?.displayID { + if display.displayID == currentDisplayID { + return display + } + } + } + } + return nil +} + +func getAppIcon(_ app: SCRunningApplication) -> NSImage? { + if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: app.bundleIdentifier) { + let icon = NSWorkspace.shared.icon(forFile: appURL.path) + icon.size = NSSize(width: 69, height: 69) + return icon + } + let icon = NSImage(systemSymbolName: "questionmark.app.dashed", accessibilityDescription: "blank icon") + icon!.size = NSSize(width: 69, height: 69) + return icon +} + +func createNewWindow(display: SCDisplay, window: SCWindow) { + @AppStorage("hasShadow") var hasShadow: Bool = true + @AppStorage("fullScreenFloating") var fullScreenFloating: Bool = true + + let panel = NNSPanel(contentRect: CGRectTransform(cgRect: window.frame, display: display), styleMask: [.closable, .nonactivatingPanel, .fullSizeContentView], backing: .buffered, defer: false) + var contentView: NSView! + if #unavailable(macOS 13) { + contentView = NSHostingView(rootView: OverlayView12(display: display, window: window)) + } else { + contentView = NSHostingView(rootView: OverlayView(display: display, window: window)) + } + panel.contentView = contentView + panel.title = "Topit Layer\(window.windowID)" + panel.level = .floating + panel.hasShadow = hasShadow + panel.backgroundColor = .clear + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.isMovableByWindowBackground = true + panel.isReleasedWhenClosed = false + if fullScreenFloating { panel.collectionBehavior = [.canJoinAllSpaces] } + panel.makeKeyAndOrderFront(nil) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + panel.setFrame(CGRectTransform(cgRect: window.frame, display: display), display: true) + if let id = window.owningApplication?.bundleIdentifier { + NSApp.activate(ignoringOtherApps: true) + bringAppToFront(bundleIdentifier: id) + } + } +} + +func getWindowUnderMouse() -> [String: Any]? { + let mousePosition = NSEvent.mouseLocation + + guard var windowList = CGWindowListCopyWindowInfo([.excludeDesktopElements,.optionOnScreenOnly], kCGNullWindowID) as? [[String: Any]] else { + print("Get window list failed!") + return nil + } + + var appBlackList = [String]() + if let savedData = ud.data(forKey: "hiddenApps"), + let decodedApps = try? JSONDecoder().decode([AppInfo].self, from: savedData) { + appBlackList = (decodedApps as [AppInfo]).map({ $0.displayName }) + } + appBlackList.append("Topit") + + windowList = windowList.filter({ + !["SystemUIServer", "Window Server"].contains($0["kCGWindowOwnerName"] as? String) + && $0["kCGWindowAlpha"] as? NSNumber != 0 + && $0["kCGWindowLayer"] as? NSNumber == 0 + }) + for window in windowList { + guard let boundsDict = window["kCGWindowBounds"] as? [String: CGFloat] else { continue } + + let bounds = CGRect( + x: boundsDict["X"] ?? 0, + y: boundsDict["Y"] ?? 0, + width: boundsDict["Width"] ?? 0, + height: boundsDict["Height"] ?? 0 + ) + + if CGRectTransform(cgRect: bounds).contains(mousePosition) { + if appBlackList.contains(window["kCGWindowOwnerName"] as? String ?? "-") { return nil } + return window + } + } + + print("No window under mouse.") + return nil +} + +func CGRectTransform(cgRect: CGRect, display: SCDisplay? = nil) -> NSRect { + let x = cgRect.origin.x + let y = cgRect.origin.y + let w = cgRect.width + let h = cgRect.height + if let main = NSScreen.screens.first(where: { $0.isMainScreen }) { + return NSRect(x: x, y: main.frame.height - y - h, width: w, height: h) + } + if let display = display { + return NSRect(x: x, y: display.frame.height - y - h, width: w, height: h) + } + return cgRect +} + +func CGPointTransform(cgPoint: CGPoint, mainHeight: CGFloat) -> NSPoint { + let x = cgPoint.x + let y = cgPoint.y + return NSPoint(x: x, y: mainHeight - y) +} + +func bringAppToFront(bundleIdentifier: String) { + if let app = NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier).first { + app.activate(options: [.activateIgnoringOtherApps]) + } else { + print("Application not found.") + } +} + +func isWindowOnTop(windowID: CGWindowID) -> Bool { + guard let windowList = CGWindowListCopyWindowInfo(.optionOnScreenAboveWindow, windowID) as? [[String: Any]] else { + return false + } + return windowList.isEmpty +} + +func getCGWindowFrame(windowID: CGWindowID) -> CGRect? { + guard let cgWindows = CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) as? [[String: Any]] else { + return nil + } + if let cgWindow = cgWindows.first { + if let boundsDict = cgWindow["kCGWindowBounds"] as? [String: CGFloat], + let x = boundsDict["X"], let y = boundsDict["Y"], + let width = boundsDict["Width"], let height = boundsDict["Height"] { + return CGRect(x: x, y: y, width: width, height: height) + } + } + return nil +} + +func getApplication(of windowNumber: Int) -> Int? { + // 获取指定窗口的信息 + guard let windowList = CGWindowListCopyWindowInfo([.optionIncludingWindow], CGWindowID(windowNumber)) as? [[String: Any]] else { + return nil + } + + // 获取第一个匹配的窗口信息 + if let windowInfo = windowList.first { + // 从窗口信息中提取层级 + if let windowLayer = windowInfo[kCGWindowLayer as String] as? Int { + return windowLayer + } + } + + return nil +} + +func getAXWindow(windowID: CGWindowID) -> AXUIElement? { + // 获取窗口的基本信息 + guard let cgWindows = CGWindowListCopyWindowInfo([.optionIncludingWindow], windowID) as? [[String: Any]] else { + return nil + } + guard let cgWindow = cgWindows.first else { + print("No matching CGWindow found!") + return nil + } + + // 获取窗口的进程 ID + guard let pid = cgWindow["kCGWindowOwnerPID"] as? pid_t else { + print("Failed to retrieve PID for CGWindow.") + return nil + } + + // 创建应用的 AXUIElement + let appElement = AXUIElementCreateApplication(pid) + var appWindows: CFTypeRef? + let result = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &appWindows) + guard result == .success, let windows = appWindows as? [AXUIElement] else { + print("Failed to retrieve AXUIElement windows for application.") + return nil + } + + // 提取 CGWindow 的标题、位置和尺寸 + let cgWindowTitle = cgWindow["kCGWindowName"] as? String + let cgWindowBounds = cgWindow["kCGWindowBounds"] as? [String: CGFloat] + let cgWindowX = cgWindowBounds?["X"] ?? 0 + let cgWindowY = cgWindowBounds?["Y"] ?? 0 + let cgWindowWidth = cgWindowBounds?["Width"] ?? 0 + let cgWindowHeight = cgWindowBounds?["Height"] ?? 0 + let cgWindowFrame = CGRect(x: cgWindowX, y: cgWindowY, width: cgWindowWidth, height: cgWindowHeight) + + // 匹配窗口的 AXUIElement + for axWindow in windows { + // 检查标题 + var title: CFTypeRef? + AXUIElementCopyAttributeValue(axWindow, kAXTitleAttribute as CFString, &title) + let axTitle = title as? String + + // 检查位置和尺寸 + var positionValue: CFTypeRef? + var sizeValue: CFTypeRef? + var axPosition = CGPoint.zero + var axSize = CGSize.zero + + if AXUIElementCopyAttributeValue(axWindow, kAXPositionAttribute as CFString, &positionValue) == .success { + let position = positionValue as! AXValue + AXValueGetValue(position, .cgPoint, &axPosition) + } + + if AXUIElementCopyAttributeValue(axWindow, kAXSizeAttribute as CFString, &sizeValue) == .success { + let size = sizeValue as! AXValue + AXValueGetValue(size, .cgSize, &axSize) + } + + let axFrame = CGRect(origin: axPosition, size: axSize) + + // 同时匹配标题、位置和尺寸 + if axTitle == cgWindowTitle, axFrame.equalTo(cgWindowFrame) { + return axWindow + } + } + + print("No matching AXUIElement found!") + return nil +} + +func activateWindow(axWindow: AXUIElement?, frame: CGRect) { + if let axWindow = axWindow { + var position = CGPoint(x: frame.origin.x, y: frame.origin.y) + AXUIElementSetAttributeValue(axWindow, kAXPositionAttribute as CFString, AXValue.from(point: &position)) + + var size = CGSize(width: frame.width, height: frame.height) + AXUIElementSetAttributeValue(axWindow, kAXSizeAttribute as CFString, AXValue.from(size: &size)) + + AXUIElementPerformAction(axWindow, kAXRaiseAction as CFString) + } +} + + +func closeAXWindow(_ axWindow: AXUIElement?) -> Bool { + guard let axWindow = axWindow else { return false } + + var closeButtonRef: CFTypeRef? + let closeButtonResult = AXUIElementCopyAttributeValue(axWindow, kAXCloseButtonAttribute as CFString, &closeButtonRef) + guard closeButtonResult == .success, let closeButton = closeButtonRef else { return false } + let closeActionResult = AXUIElementPerformAction(closeButton as! AXUIElement, kAXPressAction as CFString) + if closeActionResult == .success { return true } + print("Failed to close the window!") + return false +} + +// AXValue 扩展,便于设置值 +extension AXValue { + static func from(point: inout CGPoint) -> AXValue { + return AXValueCreate(.cgPoint, &point)! + } + + static func from(size: inout CGSize) -> AXValue { + return AXValueCreate(.cgSize, &size)! + } +} diff --git a/Topit/Supports/GroupForm.swift b/Topit/Supports/GroupForm.swift new file mode 100644 index 0000000..ae2550f --- /dev/null +++ b/Topit/Supports/GroupForm.swift @@ -0,0 +1,265 @@ +// +// SInfoButton.swift +// AirBattery +// +// Created by apple on 2024/10/28. +// + +import SwiftUI + +struct HoverButton: View { + var color: Color = .primary + var secondaryColor: Color = .blue + var action: () -> Void + @ViewBuilder let label: () -> Content + @State private var isHovered: Bool = false + + var body: some View { + Button(action: { + action() + }, label: { + label().foregroundStyle(isHovered ? secondaryColor : color) + }) + .buttonStyle(.plain) + .onHover(perform: { isHovered = $0 }) + } +} + +struct SForm: View { + var spacing: CGFloat = 30 + var noSpacer: Bool = false + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(spacing: spacing) { + content() + if !noSpacer { + Spacer().frame(minHeight: 0) + } + } + .padding(.bottom, noSpacer ? 0 : -spacing) + .padding() + .frame(maxWidth: .infinity) + + } +} + +struct SGroupBox: View { + var label: LocalizedStringKey? = nil + @ViewBuilder let content: () -> Content + + var body: some View { + GroupBox(label: label != nil ? Text(label!).font(.headline) : nil) { + VStack(spacing: 10) { content() }.padding(5) + } + } +} + +struct SItem: View { + var label: LocalizedStringKey? = nil + var spacing: CGFloat = 8 + @ViewBuilder let content: () -> Content + + var body: some View { + HStack(spacing: spacing) { + if let label = label { Text(label) } + Spacer() + content() + }.frame(height: 16) + } +} + +struct SDivider: View { + var body: some View { + Divider().opacity(0.5) + } +} + +struct SSlider: View { + var label: LocalizedStringKey? = nil + @Binding var value: Int + var range: ClosedRange = 0...100 + var width: CGFloat = .infinity + + var body: some View { + HStack { + if let label = label { + Text(label) + } + Spacer() + Slider(value: + Binding(get: { Double(value) }, + set: { newValue in + let base: Int = Int(newValue.rounded()) + let modulo: Int = base % 1 + value = base - modulo + }), in: range).frame(maxWidth: width) + }.frame(height: 16) + } +} + +struct SInfoButton: View { + var tips: LocalizedStringKey + @State private var isPresented: Bool = false + + var body: some View { + Button(action: { + isPresented = true + }, label: { + Image(systemName: "info.circle") + .font(.system(size: 15, weight: .light)) + .opacity(0.5) + }) + .buttonStyle(.plain) + .sheet(isPresented: $isPresented) { + VStack(alignment: .trailing) { + GroupBox { Text(tips).padding() } + Button(action: { + isPresented = false + }, label: { + Text("OK").frame(width: 30) + }).keyboardShortcut(.defaultAction) + }.padding() + } + } +} + +struct SButton: View { + var title: LocalizedStringKey + var buttonTitle: LocalizedStringKey + var tips: LocalizedStringKey? + var action: () -> Void + + init(_ title: LocalizedStringKey, buttonTitle: LocalizedStringKey, tips: LocalizedStringKey? = nil, action: @escaping () -> Void) { + self.title = title + self.buttonTitle = buttonTitle + self.tips = tips + self.action = action + } + + var body: some View { + HStack(spacing: 4) { + Text(title) + Spacer() + if let tips = tips { SInfoButton(tips: tips) } + Button(buttonTitle, + action: { action() }) + }.frame(height: 16) + } +} + +struct SField: View { + var title: LocalizedStringKey + var placeholder: LocalizedStringKey + var tips: LocalizedStringKey? + @Binding var text: String + var width: Double + + init(_ title: LocalizedStringKey, placeholder:LocalizedStringKey = "", tips: LocalizedStringKey? = nil, text: Binding, width: Double = .infinity) { + self.title = title + self.placeholder = placeholder + self.tips = tips + self._text = text + self.width = width + } + + var body: some View { + HStack(spacing: 4) { + Text(title) + Spacer() + if let tips = tips { SInfoButton(tips: tips) } + TextField(placeholder, text: $text) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + .frame(maxWidth: width) + } + } +} + +struct SPicker: View { + var title: LocalizedStringKey + @Binding var selection: T + var style: Style + var tips: LocalizedStringKey? + @ViewBuilder let content: () -> Content + + init(_ title: LocalizedStringKey, selection: Binding, style: Style = .menu, tips: LocalizedStringKey? = nil, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self._selection = selection + self.style = style + self.tips = tips + self.content = content + } + + var body: some View { + HStack { + Text(title) + Spacer() + if let tips = tips { SInfoButton(tips: tips) } + Picker(selection: $selection, content: { content() }, label: {}) + .fixedSize() + .pickerStyle(style) + .buttonStyle(.borderless) + }.frame(height: 16) + } +} + +struct SToggle: View { + var title: LocalizedStringKey + @Binding var isOn: Bool + var tips: LocalizedStringKey? + + init(_ title: LocalizedStringKey, isOn: Binding, tips: LocalizedStringKey? = nil) { + self.title = title + self._isOn = isOn + self.tips = tips + } + + var body: some View { + HStack(spacing: 4) { + Text(title) + Spacer() + if let tips = tips { SInfoButton(tips: tips) } + Toggle("", isOn: $isOn) + .toggleStyle(.switch) + .scaleEffect(0.7) + .frame(width: 32) + }.frame(height: 16) + } +} + +struct SSteper: View { + var title: LocalizedStringKey + @Binding var value: Int + var min: Int + var max: Int + var width: CGFloat + var tips: LocalizedStringKey? + + init(_ title: LocalizedStringKey, value: Binding, min: Int = 0, max: Int = 100, width: CGFloat = 45, tips: LocalizedStringKey? = nil) { + self.title = title + self._value = value + self.tips = tips + self.width = width + self.min = min + self.max = max + } + + var body: some View { + HStack(spacing: 0) { + Text(title) + Spacer() + if let tips = tips { SInfoButton(tips: tips) } + TextField("", value: $value, formatter: NumberFormatter()) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + .frame(width: width) + .onChange(of: value) { newValue in + if newValue > max { value = max } + if newValue < min { value = min } + } + Stepper("", value: $value) + .padding(.leading, -6) + }.frame(height: 16) + } +} diff --git a/Topit/SCManager.swift b/Topit/Supports/SCManager.swift similarity index 83% rename from Topit/SCManager.swift rename to Topit/Supports/SCManager.swift index 0ba4c54..2d093dc 100644 --- a/Topit/SCManager.swift +++ b/Topit/Supports/SCManager.swift @@ -11,6 +11,7 @@ import ScreenCaptureKit class ScreenCaptureManager: NSObject, ObservableObject, SCStreamDelegate, SCStreamOutput { @Published var videoLayer: AVSampleBufferDisplayLayer = AVSampleBufferDisplayLayer() @Published var capturing: Bool = false + @AppStorage("maxFps") private var maxFps: Int = 65535 private var stream: SCStream? private var configuration: SCStreamConfiguration! private var filter: SCContentFilter! @@ -38,11 +39,11 @@ class ScreenCaptureManager: NSObject, ObservableObject, SCStreamDelegate, SCStre configuration = SCStreamConfiguration() configuration.pixelFormat = kCVPixelFormatType_32BGRA configuration.colorSpaceName = CGColorSpace.sRGB - let frameRate = display.nsScreen?.maximumFramesPerSecond ?? 60 + let frameRate = min(maxFps, display.nsScreen?.maximumFramesPerSecond ?? 60) configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(frameRate)) configuration.showsCursor = false - configuration.capturesAudio = false - + if #available (macOS 13, *) { configuration.capturesAudio = false } + filter = SCContentFilter(desktopIndependentWindow: window) if #available(macOS 14, *) { configuration.width = Int(filter.contentRect.width) * Int(filter.pointPixelScale) @@ -68,6 +69,9 @@ class ScreenCaptureManager: NSObject, ObservableObject, SCStreamDelegate, SCStre let pointPixelScaleOld = screen?.backingScaleFactor ?? 2 configuration.width = Int(newWidth * pointPixelScaleOld) configuration.height = Int(newHeight * pointPixelScaleOld) + + let frameRate = min(maxFps, screen?.maximumFramesPerSecond ?? 60) + configuration.minimumFrameInterval = CMTime(value: 1, timescale: CMTimeScale(frameRate)) stream?.updateConfiguration(configuration) { error in if let error = error { @@ -91,22 +95,24 @@ class ScreenCaptureManager: NSObject, ObservableObject, SCStreamDelegate, SCStre } } -class WindowSelectorViewModel: NSObject, ObservableObject, SCStreamDelegate, SCStreamOutput { - @Published var windowThumbnails = [SCDisplay:[WindowThumbnail]]() - @Published var isReady = false - private var allWindows = [SCWindow]() - private var streams = [SCStream]() - private var availableContent: SCShareableContent? - private let excludedApps = ["", "com.apple.dock", "com.apple.screencaptureui", "com.apple.controlcenter", "com.apple.notificationcenterui", "com.apple.systemuiserver", "com.apple.WindowManager", "dev.mnpn.Azayaka", "com.gaosun.eul", "com.pointum.hazeover", "net.matthewpalmer.Vanilla", "com.dwarvesv.minimalbar", "com.bjango.istatmenus.status"] +class SCManager { + static var availableContent: SCShareableContent? + static private let excludedApps = ["", "com.apple.dock", "com.apple.screencaptureui", "com.apple.controlcenter", "com.apple.notificationcenterui", "com.apple.systemuiserver", "com.apple.WindowManager", "dev.mnpn.Azayaka", "com.gaosun.eul", "com.pointum.hazeover", "net.matthewpalmer.Vanilla", "com.dwarvesv.minimalbar", "com.bjango.istatmenus.status", "com.macpaw.CleanMyMac4"] - override init() { - super.init() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.setupStreams() + static func updateAvailableContentSync() -> SCShareableContent? { + let semaphore = DispatchSemaphore(value: 0) + var result: SCShareableContent? = nil + + updateAvailableContent { content in + result = content + semaphore.signal() } + + semaphore.wait() + return result } - private func updateAvailableContent(completion: @escaping (SCShareableContent?) -> Void) { + static func updateAvailableContent(completion: @escaping (SCShareableContent?) -> Void) { SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: true) { [self] content, error in if let error = error { switch error { @@ -131,8 +137,13 @@ class WindowSelectorViewModel: NSObject, ObservableObject, SCStreamDelegate, SCS } } - private func getWindows() -> [SCWindow] { + static func getWindows() -> [SCWindow] { guard let content = availableContent else { return [] } + var appBlackList = [String]() + if let savedData = ud.data(forKey: "hiddenApps"), + let decodedApps = try? JSONDecoder().decode([AppInfo].self, from: savedData) { + appBlackList = (decodedApps as [AppInfo]).map({ $0.bundleID }) + } var windows = [SCWindow]() windows = content.windows.filter { guard let app = $0.owningApplication, @@ -140,13 +151,28 @@ class WindowSelectorViewModel: NSObject, ObservableObject, SCStreamDelegate, SCS return false } return !excludedApps.contains(app.bundleIdentifier) + && !appBlackList.contains(app.bundleIdentifier) && !title.contains("Item-0") - && title != "Window" + //&& title != "Window" && $0.frame.width > 40 && $0.frame.height > 40 } return windows } +} + +class WindowSelectorViewModel: NSObject, ObservableObject, SCStreamDelegate, SCStreamOutput { + @Published var windowThumbnails = [SCDisplay:[WindowThumbnail]]() + @Published var isReady = false + private var allWindows = [SCWindow]() + private var streams = [SCStream]() + + override init() { + super.init() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.setupStreams() + } + } func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } @@ -162,7 +188,7 @@ class WindowSelectorViewModel: NSObject, ObservableObject, SCStreamDelegate, SCS if let index = streams.firstIndex(of: stream), index + 1 <= allWindows.count { let currentWindow = allWindows[index] let thumbnail = WindowThumbnail(image: nsImage, window: currentWindow) - guard let displays = availableContent?.displays.filter({ NSIntersectsRect(currentWindow.frame, $0.frame) }) else { + guard let displays = SCManager.availableContent?.displays.filter({ NSIntersectsRect(currentWindow.frame, $0.frame) }) else { self.streams[index].stopCapture() return } @@ -175,18 +201,18 @@ class WindowSelectorViewModel: NSObject, ObservableObject, SCStreamDelegate, SCS } } } - streams[index].stopCapture() + DispatchQueue.global().asyncAfter(deadline: .now() + 0.1) { self.streams[index].stopCapture() } if index + 1 == streams.count { DispatchQueue.main.async { self.isReady = true }} } } func setupStreams(filter: Bool = false, capture: Bool = true) { - updateAvailableContent {[self] availableContent in + SCManager.updateAvailableContent {[self] availableContent in Task { do { streams.removeAll() DispatchQueue.main.async { self.windowThumbnails.removeAll() } - allWindows = getWindows().filter({ + allWindows = SCManager.getWindows().filter({ !($0.title == "" && $0.owningApplication?.bundleIdentifier == "com.apple.finder") && $0.owningApplication?.bundleIdentifier != Bundle.main.bundleIdentifier && $0.owningApplication?.applicationName != "" diff --git a/Topit/Sparkle.swift b/Topit/Supports/Sparkle.swift similarity index 91% rename from Topit/Sparkle.swift rename to Topit/Supports/Sparkle.swift index f80c3b2..c6a3f4a 100644 --- a/Topit/Sparkle.swift +++ b/Topit/Supports/Sparkle.swift @@ -53,11 +53,12 @@ struct UpdaterSettingsView: View { } var body: some View { - Toggle("Automatically check for updates", isOn: $automaticallyChecksForUpdates) + SToggle("Automatically check for updates", isOn: $automaticallyChecksForUpdates) .onChange(of: automaticallyChecksForUpdates) { newValue in updater.automaticallyChecksForUpdates = newValue } - Toggle("Automatically download updates", isOn: $automaticallyDownloadsUpdates) + SDivider() + SToggle("Automatically download updates", isOn: $automaticallyDownloadsUpdates) .disabled(!automaticallyChecksForUpdates) .onChange(of: automaticallyDownloadsUpdates) { newValue in updater.automaticallyDownloadsUpdates = newValue diff --git a/Topit/WindowAccessor.swift b/Topit/Supports/WindowAccessor.swift similarity index 100% rename from Topit/WindowAccessor.swift rename to Topit/Supports/WindowAccessor.swift diff --git a/Topit/TopitApp.swift b/Topit/TopitApp.swift index 416a47f..9022baf 100644 --- a/Topit/TopitApp.swift +++ b/Topit/TopitApp.swift @@ -9,52 +9,163 @@ import SwiftUI import Cocoa import Foundation import ScreenCaptureKit +import KeyboardShortcuts let ud = UserDefaults.standard -let timer = Timer.publish(every: 0.2, on: .main, in: .common).autoconnect() +let statusBarItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + +var isMacOS13 = false +var isMacOS12 = false +var singleLayer = false +var axPerm = false +var scPerm = false @main struct TopitApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { - WindowGroup("Topit") { - WinSelector() + Settings { + SettingsView() .background( WindowAccessor( onWindowOpen: { w in - w?.level = .floating - w?.titlebarSeparatorStyle = .none - w?.titlebarAppearsTransparent = true + if let w = w { + w.level = .floating + w.titlebarSeparatorStyle = .none + guard let nsSplitView = findNSSplitVIew(view: w.contentView), + let controller = nsSplitView.delegate as? NSSplitViewController else { return } + controller.splitViewItems.first?.canCollapse = false + controller.splitViewItems.first?.minimumThickness = 140 + controller.splitViewItems.first?.maximumThickness = 140 + w.orderFront(nil) + } }) ) - } - .myWindowIsContentResizable() - .windowToolbarStyle(.unifiedCompact) - .commands { + }.commands { CommandGroup(after: .appInfo) { CheckForUpdatesView(updater: updaterController.updater) } } - - Settings { - SettingsView() - .fixedSize() - .navigationTitle("Topit Settings") - } } } class AppDelegate: NSObject, NSApplicationDelegate { + @AppStorage("showOnDock") private var showOnDock: Bool = true + @AppStorage("showMenubar") private var showMenubar: Bool = true + func applicationWillFinishLaunching(_ notification: Notification) { + if #unavailable(macOS 14) { isMacOS13 = true } + if #unavailable(macOS 13) { isMacOS12 = true } + if showOnDock { NSApp.setActivationPolicy(.regular) } + if let button = statusBarItem.button { + button.target = self + button.image = NSImage(named: "statusIcon") + } + let menu = NSMenu() + menu.addItem(NSMenuItem(title: "Pin Window to Top".local, action: #selector(openFromMenuBar), keyEquivalent: "p")) + menu.addItem(NSMenuItem(title: "Unpin All Windows".local, action: #selector(unPinAll), keyEquivalent: "u")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Settings…".local, action: #selector(settings), keyEquivalent: ",")) + menu.addItem(NSMenuItem(title: "Check for Updates…".local, action: #selector(checkForUpdates), keyEquivalent: "")) + //menu.addItem(NSMenuItem(title: "About Topit".local, action: #selector(about), keyEquivalent: "")) + menu.addItem(NSMenuItem.separator()) + menu.addItem(NSMenuItem(title: "Quit Topit".local, action: #selector(NSApplication.terminate(_:)), keyEquivalent: "")) + statusBarItem.menu = menu + statusBarItem.isVisible = showMenubar + + KeyboardShortcuts.onKeyDown(for: .unpinAll) { self.unPinAll() } + KeyboardShortcuts.onKeyDown(for: .pinUnpin) { + if let window = getWindowUnderMouse(), + let windowID = window["kCGWindowNumber"] as? UInt32 { + SCManager.updateAvailableContent { content in + if let scWindow = SCManager.getWindows().first(where: { $0.windowID == windowID }), + let scDisplay = getSCDisplayWithMouse(){ + DispatchQueue.main.async { + let allLayerWindows = NSApp.windows.filter({ $0.title.hasPrefix("Topit Layer") && $0.isVisible}) + let frameNow = CGRectTransform(cgRect: scWindow.frame) + if allLayerWindows.map(\.frame).contains(CGRectTransform(cgRect: scWindow.frame)) { + NSApp.windows.first(where: { + $0.frame == frameNow && $0.title == "Topit Layer\(scWindow.windowID)" + })?.close() + } else { + closeMainWindow() + createNewWindow(display: scDisplay, window: scWindow) + } + } + } + } + } + } + tips("Topit uses the accessibility permissions\nand screen recording permissions\nto control and capture your windows.".local, id: "topit.how-to-use.note") - _ = AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] as NSDictionary) + tips("macOS will prevent any notifications from appearing while Topit is running\nIt's not a bug or Topit's fault!".local, id: "topit.no-notifications.note") + + scPerm = SCManager.updateAvailableContentSync() != nil + axPerm = AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeRetainedValue(): true] as NSDictionary) + } + + func applicationDidFinishLaunching(_ notification: Notification) { + if showOnDock { _ = applicationShouldHandleReopen(NSApp, hasVisibleWindows: false) } } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows: Bool) -> Bool { - if #unavailable(macOS 14) { if let w = NSApp.windows.first(where: { $0.title == "Topit" }) { w.makeKeyAndOrderFront(self) }} + if NSApp.windows.first(where: { $0.title == "Topit".local })?.isVisible != true { + axPerm = AXIsProcessTrusted() + let mainPanel = NSWindow(contentRect: .zero, styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false) + if axPerm && scPerm { mainPanel.level = .floating } + mainPanel.title = "Topit".local + mainPanel.titlebarSeparatorStyle = .none + mainPanel.titlebarAppearsTransparent = true + mainPanel.isMovableByWindowBackground = true + mainPanel.toolbarStyle = .unifiedCompact + let contentView = NSHostingView(rootView: ContentView()) + mainPanel.contentView = contentView + mainPanel.makeKeyAndOrderFront(self) + mainPanel.center() + } return true } + + @objc func checkForUpdates() { + updaterController.checkForUpdates(nil) + } + + @objc func unPinAll() { + DispatchQueue.main.async { + for layer in NSApp.windows.filter({$0.title.hasPrefix("Topit Layer")}) { layer.close() } + } + } + + @objc func about() { + openAboutPanel() + } + + @objc func settings() { + openSettingPanel() + } + + @objc func openFromMenuBar() { + _ = applicationShouldHandleReopen(NSApp, hasVisibleWindows: false) + } +} + +func closeMainWindow() { + NSApp.windows.first(where: { $0.title == "Topit".local })?.close() +} + +func openAboutPanel() { + NSApp.activate(ignoringOtherApps: true) + NSApp.orderFrontStandardAboutPanel() +} + +func getMenuBarHeight() -> CGFloat { + let mouseLocation = NSEvent.mouseLocation + let screen = NSScreen.screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) }) + if let screen = screen { + return screen.frame.height - screen.visibleFrame.height - (screen.visibleFrame.origin.y - screen.frame.origin.y) - 1 + } + return 0.0 } func tips(_ message: String, title: String? = nil, id: String, switchButton: Bool = false, width: Int? = nil, action: (() -> Void)? = nil) { @@ -94,13 +205,18 @@ func openSettingPanel() { } else { NSApp.sendAction(Selector(("showPreferencesWindow:")), to: nil, from: nil) } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let w = NSApp.windows.first(where: { $0.title == "Topit Settings".local }) { - w.level = .floating - w.makeKeyAndOrderFront(nil) - w.center() - } +} + +func findNSSplitVIew(view: NSView?) -> NSSplitView? { + var queue = [NSView]() + if let root = view { queue.append(root) } + + while !queue.isEmpty { + let current = queue.removeFirst() + if current is NSSplitView { return current as? NSSplitView } + for subview in current.subviews { queue.append(subview) } } + return nil } extension NSMenuItem { diff --git a/Topit/ViewModel/AppBlockSelector.swift b/Topit/ViewModel/AppBlockSelector.swift new file mode 100644 index 0000000..9c7a528 --- /dev/null +++ b/Topit/ViewModel/AppBlockSelector.swift @@ -0,0 +1,73 @@ +// +// BundleSelector.swift +// QuickRecorder +// +// Created by apple on 2024/4/28. +// + +import SwiftUI +import Foundation + +struct BundleSelector: View { + @State private var Bundles = [AppInfo]() + @State private var isShowingFilePicker = false + + var body: some View { + ZStack(alignment: Alignment(horizontal: .trailing, vertical: .bottom)) { + List(Bundles, id: \.self) { item in + HStack{ + Image(systemName: "minus.circle.fill") + .font(.system(size: 12)) + .foregroundStyle(.red) + .onTapGesture { + if let index = Bundles.firstIndex(of: item) { + _ = withAnimation { Bundles.remove(at: index) } + } + } + Text(item.displayName).font(.system(size: 12)) + } + } + Button(action: { + self.isShowingFilePicker = true + }) { + Image(systemName: "plus.square.fill") + .font(.system(size: 20)) + .foregroundStyle(.secondary) + }.buttonStyle(.plain).offset(y: -1) + } + .onAppear { + if let savedData = ud.data(forKey: "hiddenApps"), + let decodedApps = try? JSONDecoder().decode([AppInfo].self, from: savedData) { + Bundles = decodedApps + } + } + .onChange(of: Bundles) { bundles in + if let encodedData = try? JSONEncoder().encode(bundles) { + ud.set(encodedData, forKey: "hiddenApps") + } + } + .fileImporter(isPresented: $isShowingFilePicker, allowedContentTypes: [.application]) { result in + do { + guard let appID = try Bundle(url: result.get())?.bundleIdentifier else { return } + guard let displayName = try Bundle(url: result.get())?.fileName else { return } + let app = AppInfo(bundleID: appID, displayName: displayName) + if !self.Bundles.contains(app) { + withAnimation { Bundles.append(app) } + } + } catch { + print("File selection failed: \(error.localizedDescription)") + } + } + } +} + +struct AppInfo: Hashable, Codable { + let bundleID: String + let displayName: String + +} + +extension Bundle { + var bundleName: String? { return object(forInfoDictionaryKey: "CFBundleName") as? String } + var fileName: String { return self.bundleURL.lastPathComponent } +} diff --git a/Topit/ViewModel/ContentView.swift b/Topit/ViewModel/ContentView.swift new file mode 100644 index 0000000..03ee9e3 --- /dev/null +++ b/Topit/ViewModel/ContentView.swift @@ -0,0 +1,217 @@ +// +// ContentView.swift +// Topit +// +// Created by apple on 2024/11/17. +// + +import SwiftUI +import ScreenCaptureKit + +struct ContentView: View { + @Environment(\.colorScheme) var colorScheme + @StateObject var viewModel = WindowSelectorViewModel() + @State private var selected = [SCWindow]() + @State private var display: SCDisplay! + @State private var selectedTab = 0 + @State private var sheeting: Bool = false + @State private var overQuit: Bool = true + @State private var panel: NSWindow? + + var body: some View { + VStack(spacing: 0) { + if isMacOS12 || isMacOS13 { + HStack { + Spacer() + HoverButton(action: { + openSettingPanel() + }, label: { + Image(systemName: "gear").font(.system(size: 14, weight: .medium)) + }) + HoverButton(action: { + selected.removeAll() + viewModel.setupStreams() + }, label: { + Image(systemName: "arrow.triangle.2.circlepath").font(.system(size: 14, weight: .medium)) + }) + Button(action: { + if let window = selected.first, let panel = panel { + if singleLayer && isMacOS12 { + let alert = createAlert(title: "Sorry", message: "You can only pin one window on macOS Monterey.", button1: "OK") + alert.beginSheetModal(for: panel) + } else { + _ = SCManager.updateAvailableContentSync() + if SCManager.getWindows().contains(window) { + createNewWindow(display: display, window: window) + panel.close() + } else { + let alert = createAlert(level: .critical, title: "Error", message: "This window is not available!", button1: "OK") + alert.beginSheetModal(for: panel) { _ in + selected.removeAll() + viewModel.setupStreams() + } + } + } + } + }, label: { + Text(" Topit! ") + .padding(.horizontal, 6) + .padding(.vertical, 3) + .foregroundStyle(.white) + .background(selected.isEmpty ? Color.secondary.opacity(0.5) : .blue) + .cornerRadius(5) + }) + .buttonStyle(.plain) + .disabled(selected.isEmpty) + .padding(.leading, 2) + } + } + TabView(selection: $selectedTab) { + let allApps = viewModel.windowThumbnails.sorted(by: { $0.key.displayID < $1.key.displayID }) + ForEach(allApps, id: \.key) { element in + let (screen, thumbnails) = element + let index = allApps.firstIndex(where: { $0.key == screen }) ?? 0 + ScrollView(showsIndicators:false) { + VStack(spacing: 10) { + ForEach(0.. NSView { + let view = NSView() + let videoLayer = manager.videoLayer + videoLayer.videoGravity = .resizeAspectFill + videoLayer.frame = view.bounds + videoLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + view.layer = CALayer() + view.layer?.addSublayer(videoLayer) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/Topit/ViewModel/OverlayView12.swift b/Topit/ViewModel/OverlayView12.swift new file mode 100644 index 0000000..05566c3 --- /dev/null +++ b/Topit/ViewModel/OverlayView12.swift @@ -0,0 +1,154 @@ +// +// ScreenCaptureView.swift +// Topit +// +// Created by apple on 2024/11/23. +// + + +import SwiftUI +import Foundation +import AVFoundation +import ScreenCaptureKit + +struct OverlayView12: View { + var display: SCDisplay! + var window: SCWindow! + @State private var timer: Timer? + @StateObject private var captureManager = ScreenCaptureManager() + @State private var opacity: Double = 1 + @State private var overButtons: Bool = false + @State private var overView: Bool = false + @State private var resizing: Bool = false + @State private var nsWindow: NSWindow? + @State private var nsScreen: NSScreen? + @State private var axWindow: AXUIElement? + @State private var windowSize: CGSize = .zero + + @AppStorage("showCloseButton") private var showCloseButton: Bool = true + @AppStorage("showUnpinButton") private var showUnpinButton: Bool = true + + var body: some View { + ZStack(alignment: Alignment(horizontal: .leading, vertical: .top)) { + Group { + ScreenCaptureView(manager: captureManager) + .frame(width: windowSize.width, height: windowSize.height) + .background( + WindowAccessor( + onWindowOpen: { w in + nsWindow = w + if nsWindow != nil { + singleLayer = true + nsWindow?.makeKeyAndOrderFront(self) + checkMouseLocation() + } + }, + onWindowClose: { + timer?.invalidate() + nsWindow = nil + singleLayer = false + captureManager.stopCapture() + } + ) + ) + }.opacity(opacity) + if !resizing { + HStack { + if axWindow != nil && showCloseButton { + Button(action: { + nsWindow?.close() + _ = closeAXWindow(axWindow) + }, label: { + Image(systemName: "xmark") + .font(.system(size: 7, weight: .bold)) + .frame(width: 12, height: 12) + .foregroundStyle(overButtons ? .buttonRedDark : .clear) + .background(Circle().fill(.buttonRed)) + }) + .buttonStyle(.plain) + .help("Close") + } + if showUnpinButton { + Button(action: { + nsWindow?.close() + }, label: { + Image(systemName: "pin.slash.fill") + .font(.system(size: 7, weight: .black)) + .frame(width: 12, height: 12) + .rotationEffect(.degrees(45)) + .foregroundStyle(overButtons ? .buttonYellowDark : .clear) + .background(Circle().fill(.buttonYellow)) + }) + .buttonStyle(.plain) + .help("Unpin") + } + } + .focusable(false) + .onHover { hovering in + overButtons = hovering + nsWindow?.makeKeyAndOrderFront(self) + } + .padding(4) + .background( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .fill(.blackWhite) + ) + .overlay { + RoundedRectangle(cornerRadius: 11, style: .continuous) + .stroke(.secondary.opacity(0.5), lineWidth: 1) + .padding(0.5) + } + .padding(4) + } + } + .onAppear { + windowSize = window.frame.size + timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in + if let frame = getCGWindowFrame(windowID: window.windowID) { + let newFrame = CGRectTransform(cgRect: frame, display: display) + if newFrame != nsWindow?.frame { + opacity = 0 + resizing = true + let newDisplay = nsWindow?.screen + if newFrame.size != nsWindow?.frame.size || nsScreen != newDisplay { + nsScreen = newDisplay + captureManager.updateStreamSize(newWidth: frame.width, newHeight: frame.height, screen: newDisplay) + } + nsWindow?.setFrame(CGRectTransform(cgRect: frame, display: display), display: true) + windowSize = frame.size + } else { + if !overView { opacity = 1 } + resizing = false + } + } + checkMouseLocation() + } + axWindow = getAXWindow(windowID: window.windowID) + Task { await captureManager.startCapture(display: display, window: window) } + } + .onChange(of: captureManager.capturing) { newValue in if !newValue { nsWindow?.close() }} + .onChange(of: opacity) { newValue in + if newValue == 1 { nsWindow?.hasShadow = true } else { nsWindow?.hasShadow = false } + } + } + + private func checkMouseLocation() { + let mouseLocation = NSEvent.mouseLocation + let windowFrame = nsWindow?.frame ?? CGRectTransform(cgRect: window.frame) + let mouseInWindow = windowFrame.contains(NSPoint(x: mouseLocation.x, y: mouseLocation.y)) + if resizing { return } + if mouseInWindow { + if overView == mouseInWindow { return } + nsWindow?.makeKeyAndOrderFront(self) + if let id = window.owningApplication?.bundleIdentifier, let win = nsWindow { + activateWindow(axWindow: axWindow, frame: CGRectTransform(cgRect: win.frame, display: display)) + NSApp.activate(ignoringOtherApps: true) + bringAppToFront(bundleIdentifier: id) + withAnimation(.easeOut(duration: 0.1)) { opacity = 0 } + } + } else { + opacity = 1 + } + overView = mouseInWindow + } +} diff --git a/Topit/ViewModel/SettingsView.swift b/Topit/ViewModel/SettingsView.swift new file mode 100644 index 0000000..d9401bc --- /dev/null +++ b/Topit/ViewModel/SettingsView.swift @@ -0,0 +1,155 @@ +// +// SettingsView.swift +// Topit +// +// Created by apple on 2024/11/19. +// + +import SwiftUI +import KeyboardShortcuts +import ServiceManagement + +struct SettingsView: View { + @State private var selectedItem: String? = "General" + + var body: some View { + NavigationView { + List(selection: $selectedItem) { + NavigationLink(destination: GeneralView(), tag: "General", selection: $selectedItem) { + Label("General", image: "gear") + } + NavigationLink(destination: WindowView(), tag: "Window", selection: $selectedItem) { + Label("Windows", image: "window") + } + NavigationLink(destination: HotkeyView(), tag: "Hotkey", selection: $selectedItem) { + Label("Hotkey", image: "hotkey") + } + NavigationLink(destination: FilterView(), tag: "Filter", selection: $selectedItem) { + Label("App Filter", image: "block") + } + } + .listStyle(.sidebar) + .padding(.top, 9) + } + .frame(width: 600, height: 400) + .navigationTitle("Topit Settings") + } +} + +struct GeneralView: View { + @AppStorage("showOnDock") private var showOnDock: Bool = true + @AppStorage("showMenubar") private var showMenubar: Bool = true + + @State private var launchAtLogin = false + + var body: some View { + SForm { + SGroupBox(label: "General") { + if #available(macOS 13, *) { + SToggle("Launch at Login", isOn: $launchAtLogin) + SDivider() + } + SToggle("Show Topit on Dock", isOn: $showOnDock) + SDivider() + SToggle("Show Topit on Menu Bar", isOn: $showMenubar) + } + SGroupBox(label: "Update") { + UpdaterSettingsView(updater: updaterController.updater) + } + VStack(spacing: 8) { + CheckForUpdatesView(updater: updaterController.updater) + if let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String { + Text("Topit v\(appVersion)") + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + .onAppear{ if #available(macOS 13, *) { launchAtLogin = (SMAppService.mainApp.status == .enabled) }} + .onChange(of: showMenubar) { newValue in statusBarItem.isVisible = newValue } + .onChange(of: showOnDock) { newValue in + if !newValue { + NSApp.setActivationPolicy(.accessory) + closeMainWindow() + } else { NSApp.setActivationPolicy(.regular) } + } + .onChange(of: launchAtLogin) { newValue in + if #available(macOS 13, *) { + do { + if newValue { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + }catch{ + print("Failed to \(newValue ? "enable" : "disable") launch at login: \(error.localizedDescription)") + } + } + } + } +} + +struct WindowView: View { + @AppStorage("showCloseButton") private var showCloseButton: Bool = true + @AppStorage("showUnpinButton") private var showUnpinButton: Bool = true + @AppStorage("hasShadow") private var hasShadow: Bool = true + @AppStorage("fullScreenFloating") private var fullScreenFloating: Bool = true + @AppStorage("maxFps") private var maxFps: Int = 65535 + + var body: some View { + SForm { + SGroupBox(label: "Windows") { + SToggle("Floating on Top of Full-screen Apps", isOn: $fullScreenFloating) + SDivider() + SToggle("Show Close Button", isOn: $showCloseButton) + SDivider() + SToggle("Show Unpin Button", isOn: $showUnpinButton) + SDivider() + SToggle("Show Window Shadow", isOn: $hasShadow) + SDivider() + SPicker("Maximum Refresh Rate", selection: $maxFps) { + Text("30 Hz").tag(30) + Text("60 Hz").tag(60) + Text("120 Hz").tag(120) + Text("No Limit").tag(65535) + } + } + } + } +} + +struct HotkeyView: View { + var body: some View { + SForm { + SGroupBox(label: "Hotkey") { + SItem(label: "Pin / Unpin Under-mouse Window") { + KeyboardShortcuts.Recorder("", name: .pinUnpin) + } + SDivider() + SItem(label: "Unpin All Pinned Windows"){ + KeyboardShortcuts.Recorder("", name: .unpinAll) + } + } + } + } +} + +struct FilterView: View { + @AppStorage("noTitle") var noTitle = true + + var body: some View { + SForm(spacing: 10, noSpacer: true) { + SGroupBox(label: "App Filter") { + SToggle("Show Windows with No Title", isOn: $noTitle) + } + SGroupBox { + BundleSelector() + } + } + } +} + +extension KeyboardShortcuts.Name { + static let pinUnpin = Self("pinUnpin") + static let unpinAll = Self("unpinAll") +} diff --git a/Topit/zh-Hans.lproj/Localizable.strings b/Topit/zh-Hans.lproj/Localizable.strings index 5295388..5090973 100644 --- a/Topit/zh-Hans.lproj/Localizable.strings +++ b/Topit/zh-Hans.lproj/Localizable.strings @@ -6,6 +6,7 @@ */ +"Update" = "更新设置"; "Check for Updates…" = "检查更新…"; "Automatically check for updates" = "自动检查程序更新"; "Automatically download updates" = "自动下载程序更新"; @@ -13,7 +14,41 @@ "Refresh" = "刷新"; " Topit! " = "立即置顶"; "OK" = "好"; -"Close" = "关闭"; +"Close" = "关闭窗口"; +"Unpin" = "解除置顶"; +"Quit" = "退出"; +"Topit Settings" = "Topit 设置"; +"General" = "一般设置"; +"Launch at Login" = "登录时启动"; +"Show Topit on Dock" = "在程序坞中显示 Topit"; +"Show Topit on Menu Bar" = "在菜单栏上显示 Topit"; +"Windows" = "窗口设置"; +"Floating on Top of Full-screen Apps" = "允许 Topit 显示在全屏应用之上"; +"Show Close Button" = "显示 \"关闭窗口\" 按钮"; +"Show Unpin Button" = "显示 \"解除置顶\" 按钮"; +"Show Window Shadow" = "显示窗口阴影"; +"Maximum Refresh Rate" = "最高刷新率限制"; +"No Limit" = "不限制"; +"Hotkey" = "快捷键设置"; +"Pin / Unpin Under-mouse Window" = "将鼠标所指的窗口置顶 / 解除置顶"; +"Unpin All Pinned Windows" = "解除所有置顶窗口"; +"App Filter" = "App 过滤器"; " Tips" = " 小贴士"; "Don't remind me again" = "不再提醒"; "Topit uses the accessibility permissions\nand screen recording permissions\nto control and capture your windows." = "Topit 利用屏幕录制权限和辅助功能权限\n来捕获并控制窗口, 请务必同意授权."; +"macOS will prevent any notifications from appearing while Topit is running\nIt's not a bug or Topit's fault!" = "当使用 Topit 对任意窗口进行置顶时\nmacOS 会暂停弹出来自任何应用的通知\n此限制来自 macOS 本身, 不是 Topit 的错"; +"Sorry" = "很抱歉"; +"You can only pin one window on macOS Monterey." = "在 macOS 12 系统上只支持置顶一个窗口."; +"OK" = "好"; +"screen recording permissions" = "屏幕录制权限"; +"accessibility permissions" = "辅助功能权限"; +"accessibility and screen recording permissions" = "辅助功能和屏幕录制权限"; +"You haven't enabled %@ for Topit!\nPlease restart Topit after enabling it." = "尚未为 Topit 启用%@\n请在授权后重新启动 Topit"; +"Permission Error" = "权限错误"; +"Pin Window to Top" = "置顶一个窗口"; +"Unpin All Windows" = "解除所有置顶窗口"; +"About Topit" = "关于 Topit"; +"Settings…" = "设置…"; +"Quit Topit" = "退出 Topit"; +"Error" = "错误"; +"This window is not available!" = "此窗口目前不可用!";