🚀 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.
473 lines
19 KiB
Swift
473 lines
19 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
struct LicenseEntryView: View {
|
|
@ObservedObject private var licenseManager = LicenseManager.shared
|
|
@Environment(\.presentationMode) var presentationMode
|
|
|
|
@State private var licenseKey = ""
|
|
@State private var userName = ""
|
|
@State private var isVerifying = false
|
|
@State private var errorMessage = ""
|
|
@State private var showError = false
|
|
|
|
// Add closure for window dismissal
|
|
var onDismiss: (() -> Void)?
|
|
|
|
init(onDismiss: (() -> Void)? = nil) {
|
|
self.onDismiss = onDismiss
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
// Add minimal top padding since traffic lights are hidden
|
|
Spacer()
|
|
.frame(height: 20)
|
|
|
|
VStack(spacing: 24) {
|
|
// Header Section - similar to SettingsTabView
|
|
VStack(spacing: 16) {
|
|
// Replace key icon with ShotScreen icon at native 200x200 resolution
|
|
Group {
|
|
if let shotScreenIcon = NSImage(named: "ShotScreenIcon_200x200") {
|
|
Image(nsImage: shotScreenIcon)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 200, height: 200)
|
|
.cornerRadius(20)
|
|
} else if let bundle = Bundle.main.url(forResource: "images/ShotScreenIcon_200x200", withExtension: "png"),
|
|
let nsImage = NSImage(contentsOf: bundle) {
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 200, height: 200)
|
|
.cornerRadius(20)
|
|
} else {
|
|
// Fallback to system icon if ShotScreenIcon_200x200 not found
|
|
Image(systemName: "key.fill")
|
|
.font(.system(size: 48))
|
|
.foregroundColor(.blue)
|
|
}
|
|
}
|
|
|
|
VStack(spacing: 8) {
|
|
Text(headerTitle)
|
|
.font(.largeTitle)
|
|
.fontWeight(.bold)
|
|
.foregroundColor(.primary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
Text(headerSubtitle)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
.padding(.top, 10)
|
|
.padding(.bottom, 10)
|
|
|
|
// Trial Status Section
|
|
switch licenseManager.licenseStatus {
|
|
case .trial(let daysLeft):
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Trial Status")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
Divider()
|
|
}
|
|
|
|
HStack {
|
|
Image(systemName: "clock.fill")
|
|
.foregroundColor(.orange)
|
|
Text("\(daysLeft) days remaining in your free trial")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Text("Already purchased? Enter your license key below to activate the full version.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(20)
|
|
.background(Color.primary.opacity(0.03))
|
|
.cornerRadius(12)
|
|
|
|
case .expired:
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Trial Expired")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.red)
|
|
Divider()
|
|
}
|
|
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.red)
|
|
Text("Your 7-day trial has expired")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Text("Purchase a license or enter your license key to continue using ShotScreen.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(20)
|
|
.background(Color.red.opacity(0.05))
|
|
.cornerRadius(12)
|
|
|
|
default:
|
|
EmptyView()
|
|
}
|
|
|
|
// License Entry Form Section
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("License Information")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(.primary)
|
|
Divider()
|
|
}
|
|
|
|
VStack(spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Your Name")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
TextField("Enter your full name", text: $userName)
|
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
|
.disabled(isVerifying)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("License Key")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
TextField("XXXX-XXXX-XXXX-XXXX", text: $licenseKey)
|
|
.textFieldStyle(RoundedBorderTextFieldStyle())
|
|
.font(.system(.body, design: .monospaced))
|
|
.disabled(isVerifying)
|
|
}
|
|
|
|
if showError {
|
|
HStack {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.red)
|
|
Text(errorMessage)
|
|
.foregroundColor(.red)
|
|
.font(.caption)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(Color.red.opacity(0.1))
|
|
.cornerRadius(8)
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(Color.primary.opacity(0.03))
|
|
.cornerRadius(12)
|
|
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Bottom Button Section - matching SettingsTabView style
|
|
HStack {
|
|
Button(action: activateLicense) {
|
|
HStack {
|
|
if isVerifying {
|
|
ProgressView()
|
|
.scaleEffect(0.7)
|
|
}
|
|
Text(isVerifying ? "Verifying..." : "Activate License")
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(!canActivate || isVerifying)
|
|
.keyboardShortcut(.defaultAction) // Return key = Activate
|
|
|
|
Spacer()
|
|
|
|
Button("Buy License") {
|
|
openPurchaseURL()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button("Cancel") {
|
|
dismissWindow()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
.padding()
|
|
}
|
|
.frame(minWidth: 600, idealWidth: 650, minHeight: 700, idealHeight: 750)
|
|
.background(Color.clear)
|
|
}
|
|
|
|
private var canActivate: Bool {
|
|
!licenseKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
|
|
!userName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
|
|
private var headerTitle: String {
|
|
switch licenseManager.licenseStatus {
|
|
case .trial(_):
|
|
return "Activate ShotScreen"
|
|
case .expired:
|
|
return "Unlock ShotScreen"
|
|
default:
|
|
return "Activate ShotScreen"
|
|
}
|
|
}
|
|
|
|
private var headerSubtitle: String {
|
|
switch licenseManager.licenseStatus {
|
|
case .trial(_):
|
|
return "Already have a license? Enter your license key below."
|
|
case .expired:
|
|
return "Your free trial has ended. Enter your license key to continue using ShotScreen."
|
|
default:
|
|
return "Enter your license key to unlock the full version"
|
|
}
|
|
}
|
|
|
|
private func activateLicense() {
|
|
guard canActivate else { return }
|
|
|
|
isVerifying = true
|
|
showError = false
|
|
errorMessage = ""
|
|
|
|
Task {
|
|
let success = await licenseManager.enterLicenseKey(
|
|
licenseKey.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
userName: userName.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
)
|
|
|
|
await MainActor.run {
|
|
isVerifying = false
|
|
|
|
if success {
|
|
dismissWindow()
|
|
} else {
|
|
showError = true
|
|
errorMessage = "Invalid license key or verification failed. Please check your license key and internet connection."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func openPurchaseURL() {
|
|
// ShotScreen Gumroad license product
|
|
if let url = URL(string: "https://roodenrijs.gumroad.com/l/uxexr") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
|
|
private func dismissWindow() {
|
|
onDismiss?()
|
|
presentationMode.wrappedValue.dismiss()
|
|
}
|
|
}
|
|
|
|
// MARK: - License Entry Window
|
|
class LicenseEntryWindow: NSWindow {
|
|
private var visualEffectViewContainer: NSView?
|
|
|
|
override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
|
|
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
|
setupWindow()
|
|
}
|
|
|
|
convenience init() {
|
|
self.init(
|
|
contentRect: NSRect(x: 0, y: 0, width: 650, height: 750),
|
|
styleMask: [.titled, .closable, .resizable, .fullSizeContentView],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
}
|
|
|
|
private func setupWindow() {
|
|
title = "ShotScreen License"
|
|
isReleasedWhenClosed = false
|
|
center()
|
|
|
|
// Ensure the window appears in front and can be brought to front
|
|
level = .normal
|
|
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
|
|
|
|
// Apply same blur style as SettingsWindow
|
|
isOpaque = false
|
|
backgroundColor = .clear
|
|
titlebarAppearsTransparent = true
|
|
titleVisibility = .hidden
|
|
isMovableByWindowBackground = true
|
|
|
|
// Hide traffic lights (red, yellow, green buttons) to prevent content overlap
|
|
standardWindowButton(.closeButton)?.isHidden = true
|
|
standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
standardWindowButton(.zoomButton)?.isHidden = true
|
|
|
|
// Create content view with dismissal callback
|
|
let licenseView = LicenseEntryView(onDismiss: { [weak self] in
|
|
self?.close()
|
|
})
|
|
let hostingView = NSHostingView(rootView: licenseView)
|
|
|
|
// Apply same visual effect as SettingsWindow
|
|
let visualEffectView = NSVisualEffectView()
|
|
visualEffectView.blendingMode = .behindWindow
|
|
visualEffectView.material = .hudWindow // Same strong system blur
|
|
visualEffectView.state = .active
|
|
visualEffectView.alphaValue = 1.0
|
|
visualEffectView.autoresizingMask = [.width, .height]
|
|
|
|
// Extra blur layer for enhanced effect
|
|
let extraBlurView = NSVisualEffectView()
|
|
extraBlurView.blendingMode = .behindWindow
|
|
extraBlurView.material = .hudWindow
|
|
extraBlurView.state = .active
|
|
extraBlurView.alphaValue = 0.6 // Half transparent for optical blur enhancement
|
|
extraBlurView.autoresizingMask = [.width, .height]
|
|
|
|
let newRootContentView = NSView(frame: contentRect(forFrameRect: frame))
|
|
|
|
// Layer order: strongest blur at bottom, half blur above, SwiftUI content on top
|
|
visualEffectView.frame = newRootContentView.bounds
|
|
extraBlurView.frame = newRootContentView.bounds
|
|
|
|
newRootContentView.addSubview(visualEffectView) // Layer 0
|
|
newRootContentView.addSubview(extraBlurView) // Layer 1 (enhancement)
|
|
newRootContentView.addSubview(hostingView) // Layer 2 (UI)
|
|
|
|
contentView = newRootContentView
|
|
visualEffectViewContainer = newRootContentView
|
|
|
|
hostingView.wantsLayer = true
|
|
hostingView.layer?.backgroundColor = NSColor.clear.cgColor
|
|
|
|
hostingView.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
hostingView.topAnchor.constraint(equalTo: newRootContentView.topAnchor),
|
|
hostingView.bottomAnchor.constraint(equalTo: newRootContentView.bottomAnchor),
|
|
hostingView.leadingAnchor.constraint(equalTo: newRootContentView.leadingAnchor),
|
|
hostingView.trailingAnchor.constraint(equalTo: newRootContentView.trailingAnchor)
|
|
])
|
|
|
|
// Force a layout pass after setting up the hierarchy
|
|
contentView?.layoutSubtreeIfNeeded()
|
|
|
|
// Window appearance
|
|
delegate = self
|
|
|
|
// Better window management - bring to front
|
|
orderFrontRegardless()
|
|
makeKeyAndOrderFront(nil)
|
|
}
|
|
|
|
deinit {
|
|
print("🗑️ LicenseEntryWindow deinitialized safely")
|
|
}
|
|
}
|
|
|
|
// MARK: - Window Delegate
|
|
extension LicenseEntryWindow: NSWindowDelegate {
|
|
func windowShouldClose(_ sender: NSWindow) -> Bool {
|
|
// Allow closing
|
|
return true
|
|
}
|
|
|
|
func windowWillClose(_ notification: Notification) {
|
|
// Clean up when window is about to close
|
|
contentView = nil
|
|
delegate = nil
|
|
print("🪟 License window closing gracefully")
|
|
}
|
|
}
|
|
|
|
// MARK: - Trial Expired View
|
|
struct TrialExpiredView: View {
|
|
@ObservedObject private var licenseManager = LicenseManager.shared
|
|
|
|
var body: some View {
|
|
VStack(spacing: 24) {
|
|
// Replace with ShotScreen icon using 200x200 source at smaller display size
|
|
Group {
|
|
if let shotScreenIcon = NSImage(named: "ShotScreenIcon_200x200") {
|
|
Image(nsImage: shotScreenIcon)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 100, height: 100)
|
|
.cornerRadius(16)
|
|
} else if let bundle = Bundle.main.url(forResource: "images/ShotScreenIcon_200x200", withExtension: "png"),
|
|
let nsImage = NSImage(contentsOf: bundle) {
|
|
Image(nsImage: nsImage)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 100, height: 100)
|
|
.cornerRadius(16)
|
|
} else {
|
|
// Fallback icon
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 64))
|
|
.foregroundColor(.orange)
|
|
}
|
|
}
|
|
|
|
// Title
|
|
Text("Trial Expired")
|
|
.font(.largeTitle)
|
|
.fontWeight(.bold)
|
|
|
|
// Message
|
|
Text("Your 7-day trial of ShotScreen has expired.\nPlease purchase a license to continue using the app.")
|
|
.font(.body)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundColor(.secondary)
|
|
|
|
// Buttons - matching new style
|
|
VStack(spacing: 12) {
|
|
Button("Purchase License") {
|
|
openPurchaseURL()
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button("Enter License Key") {
|
|
licenseManager.showLicenseEntryDialog()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
Button("Quit ShotScreen") {
|
|
NSApplication.shared.terminate(nil)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
.padding(40)
|
|
.frame(width: 400, height: 350)
|
|
.background(Color(NSColor.windowBackgroundColor))
|
|
}
|
|
|
|
private func openPurchaseURL() {
|
|
// ShotScreen Gumroad license product
|
|
if let url = URL(string: "https://roodenrijs.gumroad.com/l/uxexr") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
LicenseEntryView()
|
|
} |