🚀 First official release of ShotScreen with complete feature set: ✨ Core Features: - Advanced screenshot capture system - Multi-monitor support - Professional UI/UX design - Automated update system with Sparkle - Apple notarized & code signed 🛠 Technical Excellence: - Native Swift macOS application - Professional build & deployment pipeline - Comprehensive error handling - Memory optimized performance 📦 Distribution Ready: - Professional DMG packaging - Apple notarization complete - No security warnings for users - Ready for public distribution This is the foundation release that establishes ShotScreen as a premium screenshot tool for macOS.
606 lines
22 KiB
Swift
606 lines
22 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import ServiceManagement
|
|
|
|
// MARK: - Environment Key for Window Access
|
|
private struct WindowKey: EnvironmentKey {
|
|
static let defaultValue: FirstLaunchWizard? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var window: FirstLaunchWizard? {
|
|
get { self[WindowKey.self] }
|
|
set { self[WindowKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// MARK: - PreferenceKey for Button Width Sync
|
|
struct ButtonWidthPreferenceKey: PreferenceKey {
|
|
static var defaultValue: CGFloat = 0
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
value = max(value, nextValue())
|
|
}
|
|
}
|
|
|
|
// MARK: - First Launch Wizard Window
|
|
class FirstLaunchWizard: NSWindow {
|
|
private var hostingView: NSHostingView<AnyView>?
|
|
private var isClosing = false
|
|
|
|
// Perfect fit window size - made smaller
|
|
static let windowSize = NSSize(width: 420, height: 520)
|
|
|
|
init() {
|
|
let windowRect = NSRect(origin: .zero, size: FirstLaunchWizard.windowSize)
|
|
|
|
super.init(
|
|
contentRect: windowRect,
|
|
styleMask: [.titled, .fullSizeContentView],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
|
|
setupWindow()
|
|
setupContent()
|
|
}
|
|
|
|
private func setupWindow() {
|
|
title = "Welcome to ShotScreen"
|
|
isOpaque = false
|
|
backgroundColor = .clear
|
|
hasShadow = true
|
|
titleVisibility = .hidden
|
|
titlebarAppearsTransparent = true
|
|
isMovable = true
|
|
isMovableByWindowBackground = true
|
|
level = .normal
|
|
isReleasedWhenClosed = true
|
|
|
|
// Center the window
|
|
center()
|
|
}
|
|
|
|
private func setupContent() {
|
|
let wizardView = SinglePageWizardView { [unowned self] in
|
|
// Completion handler - mark first launch as completed
|
|
SettingsManager.shared.hasCompletedFirstLaunch = true
|
|
print("✅ First launch wizard completed - closing window")
|
|
print("🔍 DEBUG: About to call safeClose(), self exists: \(self)")
|
|
self.safeClose()
|
|
}
|
|
|
|
hostingView = NSHostingView(rootView: AnyView(wizardView.environment(\.window, self)))
|
|
contentView = hostingView
|
|
}
|
|
|
|
private func safeClose() {
|
|
guard Thread.isMainThread else {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.safeClose()
|
|
}
|
|
return
|
|
}
|
|
|
|
guard !isClosing else {
|
|
print("🔍 DEBUG: Already closing, skipping")
|
|
return
|
|
}
|
|
isClosing = true
|
|
|
|
print("🎯 Safely closing wizard...")
|
|
|
|
// Force close immediately without async
|
|
orderOut(nil)
|
|
close()
|
|
}
|
|
|
|
override func close() {
|
|
print("🎉 FirstLaunchWizard: Window closing")
|
|
|
|
guard !isClosing else {
|
|
print("🔍 DEBUG: Close() called but already closing")
|
|
return
|
|
}
|
|
isClosing = true
|
|
|
|
// First hide the window immediately
|
|
orderOut(nil)
|
|
|
|
// Clean up hosting view
|
|
if let hosting = hostingView {
|
|
hosting.rootView = AnyView(EmptyView())
|
|
hosting.removeFromSuperview()
|
|
hostingView = nil
|
|
}
|
|
|
|
contentView = nil
|
|
|
|
// Force the superclass close
|
|
super.close()
|
|
|
|
print("🎉 FirstLaunchWizard: Window actually closed")
|
|
}
|
|
|
|
deinit {
|
|
print("🎉 FirstLaunchWizard: Deallocated")
|
|
}
|
|
}
|
|
|
|
// MARK: - Single Page Wizard View
|
|
struct SinglePageWizardView: View {
|
|
let onComplete: () -> Void
|
|
@Environment(\.window) private var window
|
|
|
|
// State for permission checks
|
|
@State private var hasScreenRecordingPermission = false
|
|
@State private var isLaunchAtStartupEnabled = false
|
|
@State private var hasCompletedScreenshotSettings = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Header with logo and title
|
|
CompactHeaderView()
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 20)
|
|
|
|
// All content perfectly fitted without scrolling
|
|
VStack(spacing: 16) {
|
|
// Setup section
|
|
SetupSection(
|
|
hasScreenRecordingPermission: hasScreenRecordingPermission,
|
|
hasCompletedScreenshotSettings: hasCompletedScreenshotSettings,
|
|
onScreenshotSettingsCompleted: {
|
|
hasCompletedScreenshotSettings = true
|
|
}
|
|
)
|
|
|
|
// Configuration section
|
|
ConfigurationSection(
|
|
isLaunchAtStartupEnabled: isLaunchAtStartupEnabled
|
|
)
|
|
}
|
|
.padding(.horizontal, 20)
|
|
|
|
Spacer()
|
|
|
|
// Bottom action buttons
|
|
HStack(spacing: 10) {
|
|
Button("Skip Setup") {
|
|
print("🔴 Skip Setup button pressed")
|
|
onComplete()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.foregroundColor(.secondary)
|
|
.controlSize(.small)
|
|
|
|
Spacer()
|
|
|
|
Button("Finished!") {
|
|
print("🟢 Finished! button pressed")
|
|
onComplete()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
.padding(.top, 20)
|
|
.padding(.bottom, 20)
|
|
.padding(.horizontal, 20)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(
|
|
VisualEffectBackground(material: .hudWindow, blending: .behindWindow)
|
|
.cornerRadius(16)
|
|
)
|
|
.padding(14)
|
|
.onAppear(perform: checkPermissions)
|
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
|
print("App became active, re-checking permissions...")
|
|
checkPermissions()
|
|
}
|
|
}
|
|
|
|
private func checkPermissions() {
|
|
// Check Screen Recording Permission
|
|
let hasPermission = CGPreflightScreenCaptureAccess()
|
|
if !hasPermission {
|
|
CGRequestScreenCaptureAccess()
|
|
}
|
|
self.hasScreenRecordingPermission = hasPermission
|
|
|
|
// Check Launch at Startup Status
|
|
self.isLaunchAtStartupEnabled = SMAppService.mainApp.status == .enabled
|
|
|
|
print("✅ Permissions check: ScreenRecording=\(self.hasScreenRecordingPermission), LaunchAtStartup=\(self.isLaunchAtStartupEnabled)")
|
|
}
|
|
}
|
|
|
|
// MARK: - Compact Header View
|
|
struct CompactHeaderView: View {
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
// App logo - now using AppIcon instead of Slothhead
|
|
Group {
|
|
if let nsImage = NSImage(named: "AppIcon") {
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 40, height: 40)
|
|
} else if let bundle = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"),
|
|
let nsImage = NSImage(contentsOf: bundle) {
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 40, height: 40)
|
|
} else {
|
|
Image(systemName: "camera.viewfinder")
|
|
.font(.system(size: 40))
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 2) {
|
|
Text("Welcome to ShotScreen")
|
|
.font(.title3)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.primary)
|
|
|
|
Text("Set up your screenshot tool")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Setup Section
|
|
struct SetupSection: View {
|
|
var hasScreenRecordingPermission: Bool
|
|
var hasCompletedScreenshotSettings: Bool
|
|
var onScreenshotSettingsCompleted: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(spacing: 16) {
|
|
// macOS Screenshot Settings with Instructions
|
|
SimpleScreenshotCardWithInstructions(
|
|
action: openMacOSScreenshotSettings,
|
|
isCompleted: hasCompletedScreenshotSettings,
|
|
onCompleted: onScreenshotSettingsCompleted
|
|
)
|
|
|
|
// Privacy Permissions
|
|
SinglePageCard(
|
|
icon: "shield.checkerboard",
|
|
title: "Grant Screen Recording Permission",
|
|
subtitle: "Required for screenshot capture",
|
|
actionTitle: "Open Privacy",
|
|
action: openSystemPreferences,
|
|
isCompleted: hasScreenRecordingPermission
|
|
)
|
|
}
|
|
}
|
|
|
|
private func openMacOSScreenshotSettings() {
|
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.keyboard?Shortcuts") {
|
|
NSWorkspace.shared.open(url)
|
|
} else {
|
|
print("❌ Could not open macOS Screenshot Settings")
|
|
}
|
|
}
|
|
|
|
private func openSystemPreferences() {
|
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") {
|
|
NSWorkspace.shared.open(url)
|
|
} else {
|
|
print("❌ Could not open System Preferences")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Simple Screenshot Card with Instructions
|
|
struct SimpleScreenshotCardWithInstructions: View {
|
|
let action: () -> Void
|
|
var isCompleted: Bool
|
|
var onCompleted: () -> Void
|
|
@State private var showingInstructions = false
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Image(systemName: "gear")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(.blue)
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text("Disable macOS Screenshot Shortcut")
|
|
.fontWeight(.medium)
|
|
|
|
if isCompleted {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
|
|
Text("Click Instructions for step-by-step guide")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button("Instructions") {
|
|
print("📖 Instructions button pressed")
|
|
showingInstructions = true
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color(NSColor.controlBackgroundColor))
|
|
.shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1)
|
|
)
|
|
.sheet(isPresented: $showingInstructions) {
|
|
InstructionsPopupView(openSettingsAction: action, isCompleted: isCompleted, onCompleted: onCompleted)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Instructions Popup View
|
|
struct InstructionsPopupView: View {
|
|
@Environment(\.presentationMode) var presentationMode
|
|
let openSettingsAction: () -> Void
|
|
var isCompleted: Bool
|
|
var onCompleted: () -> Void
|
|
@State private var imageSize: CGSize = CGSize(width: 600, height: 400)
|
|
|
|
var body: some View {
|
|
VStack(spacing: 20) {
|
|
// Header
|
|
HStack {
|
|
Text("How to Disable macOS Screenshot Shortcut")
|
|
.font(.title2)
|
|
.fontWeight(.bold)
|
|
|
|
Spacer()
|
|
|
|
Button("Close") {
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
}
|
|
.padding(.top, 20)
|
|
.padding(.horizontal, 20)
|
|
|
|
// Instruction image
|
|
Group {
|
|
if let bundle = Bundle.main.url(forResource: "images/Wizard_TurnOffSceenShot", withExtension: "png"),
|
|
let nsImage = NSImage(contentsOf: bundle) {
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.onAppear {
|
|
// Get actual image size and scale it appropriately
|
|
let maxWidth: CGFloat = 800
|
|
let maxHeight: CGFloat = 600
|
|
let aspectRatio = nsImage.size.width / nsImage.size.height
|
|
|
|
if nsImage.size.width > maxWidth {
|
|
imageSize = CGSize(width: maxWidth, height: maxWidth / aspectRatio)
|
|
} else if nsImage.size.height > maxHeight {
|
|
imageSize = CGSize(width: maxHeight * aspectRatio, height: maxHeight)
|
|
} else {
|
|
imageSize = nsImage.size
|
|
}
|
|
}
|
|
.frame(width: imageSize.width, height: imageSize.height)
|
|
.background(Color.white)
|
|
.cornerRadius(8)
|
|
.shadow(radius: 2)
|
|
} else if let nsImage = NSImage(named: "Wizard_TurnOffSceenShot") {
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.onAppear {
|
|
// Get actual image size and scale it appropriately
|
|
let maxWidth: CGFloat = 800
|
|
let maxHeight: CGFloat = 600
|
|
let aspectRatio = nsImage.size.width / nsImage.size.height
|
|
|
|
if nsImage.size.width > maxWidth {
|
|
imageSize = CGSize(width: maxWidth, height: maxWidth / aspectRatio)
|
|
} else if nsImage.size.height > maxHeight {
|
|
imageSize = CGSize(width: maxHeight * aspectRatio, height: maxHeight)
|
|
} else {
|
|
imageSize = nsImage.size
|
|
}
|
|
}
|
|
.frame(width: imageSize.width, height: imageSize.height)
|
|
.background(Color.white)
|
|
.cornerRadius(8)
|
|
.shadow(radius: 2)
|
|
} else {
|
|
// Development fallback: try to load from source directory
|
|
let developmentImagePaths = [
|
|
"ScreenShot/Sources/images/Wizard_TurnOffSceenShot.png",
|
|
"./ScreenShot/Sources/images/Wizard_TurnOffSceenShot.png",
|
|
"../ScreenShot/Sources/images/Wizard_TurnOffSceenShot.png"
|
|
]
|
|
|
|
if let workingImagePath = developmentImagePaths.first(where: { FileManager.default.fileExists(atPath: $0) }),
|
|
let nsImage = NSImage(contentsOfFile: workingImagePath) {
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.onAppear {
|
|
// Get actual image size and scale it appropriately
|
|
let maxWidth: CGFloat = 800
|
|
let maxHeight: CGFloat = 600
|
|
let aspectRatio = nsImage.size.width / nsImage.size.height
|
|
|
|
if nsImage.size.width > maxWidth {
|
|
imageSize = CGSize(width: maxWidth, height: maxWidth / aspectRatio)
|
|
} else if nsImage.size.height > maxHeight {
|
|
imageSize = CGSize(width: maxHeight * aspectRatio, height: maxHeight)
|
|
} else {
|
|
imageSize = nsImage.size
|
|
}
|
|
}
|
|
.frame(width: imageSize.width, height: imageSize.height)
|
|
.background(Color.white)
|
|
.cornerRadius(8)
|
|
.shadow(radius: 2)
|
|
} else {
|
|
// Final fallback if image is not found anywhere
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "photo")
|
|
.font(.system(size: 50))
|
|
.foregroundColor(.gray)
|
|
|
|
Text("Instructions Image")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Text("Image will be available in release build")
|
|
.font(.caption2)
|
|
.foregroundColor(.orange)
|
|
}
|
|
.frame(width: imageSize.width, height: imageSize.height)
|
|
.background(Color.gray.opacity(0.1))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
|
|
// Instructions text
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Follow these steps:")
|
|
.font(.headline)
|
|
|
|
Text("1. Click 'Open Settings' below to open macOS System Preferences")
|
|
Text("2. Navigate to Keyboard > Shortcuts > Screenshots")
|
|
Text("3. Uncheck 'Save picture of selected area as a file' (⌘+⇧+4)")
|
|
Text("4. This prevents conflicts with ShotScreen")
|
|
}
|
|
.font(.body)
|
|
.padding(.horizontal, 20)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Spacer()
|
|
|
|
// Bottom buttons
|
|
HStack(spacing: 12) {
|
|
Button("Open Settings") {
|
|
print("🔧 Opening macOS Screenshot Settings from popup...")
|
|
openSettingsAction()
|
|
// Don't dismiss popup - let user read instructions while configuring
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button("Got it!") {
|
|
print("✅ User completed screenshot settings configuration")
|
|
onCompleted()
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding(.bottom, 20)
|
|
}
|
|
.frame(width: max(imageSize.width + 40, 400), height: imageSize.height + 260)
|
|
.background(Color(NSColor.windowBackgroundColor))
|
|
}
|
|
}
|
|
|
|
// MARK: - Configuration Section
|
|
struct ConfigurationSection: View {
|
|
var isLaunchAtStartupEnabled: Bool
|
|
|
|
var body: some View {
|
|
// Launch at Startup - now as a button
|
|
SinglePageCard(
|
|
icon: "power",
|
|
title: "Launch at Startup",
|
|
subtitle: "Start automatically when you log in",
|
|
actionTitle: "Open Login Items",
|
|
action: openLoginItemsSettings,
|
|
isCompleted: isLaunchAtStartupEnabled
|
|
)
|
|
}
|
|
|
|
private func openLoginItemsSettings() {
|
|
print("🔧 Opening Login Items Settings...")
|
|
|
|
// Voor macOS 13+ - gebruik eenvoudige URL
|
|
if #available(macOS 13.0, *) {
|
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users") {
|
|
NSWorkspace.shared.open(url)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Voor macOS 12 en ouder
|
|
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.users") {
|
|
NSWorkspace.shared.open(url)
|
|
} else {
|
|
print("❌ Could not open Login Items Settings")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Single Page Card with Action
|
|
struct SinglePageCard: View {
|
|
let icon: String
|
|
let title: String
|
|
let subtitle: String
|
|
let actionTitle: String
|
|
let action: () -> Void
|
|
var isCompleted: Bool
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 20))
|
|
.foregroundColor(.blue)
|
|
.frame(width: 24)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text(title)
|
|
.fontWeight(.medium)
|
|
|
|
if isCompleted {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(actionTitle) {
|
|
print("🔧 Action button pressed: \(actionTitle)")
|
|
action()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.fill(Color(NSColor.controlBackgroundColor))
|
|
.shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Visual Effect Background Helper
|
|
// Note: VisualEffectBackground is defined in IntegratedGalleryView.swift |