Files
shotscreen/ShotScreen/Sources/FirstLaunchWizard.swift
Nick Roodenrijs 0dabed11d2 🎉 ShotScreen v1.0 - Initial Release
🚀 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.
2025-06-28 16:15:15 +02:00

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