Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions platforms/macos/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ extension Defaults.Keys {
// Kubernetes-related keys
static let customNamespaces = Key<[String]>("customNamespaces", default: [])

// Onboarding
static let hasCompletedOnboarding = Key<Bool>("hasCompletedOnboarding", default: false)

// Sponsor-related keys
static let sponsorCache = Key<SponsorCache?>("sponsorCache", default: nil)
static let lastSponsorWindowShown = Key<Date?>("lastSponsorWindowShown", default: nil)
Expand Down
4 changes: 4 additions & 0 deletions platforms/macos/Sources/PortKillerApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ struct PortKillerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@State private var state = AppState()
@State private var sponsorManager = SponsorManager()
@State private var showOnboarding = !Defaults[.hasCompletedOnboarding]
@Environment(\.openWindow) private var openWindow

init() {
Expand All @@ -77,6 +78,9 @@ struct PortKillerApp: App {
MainWindowView()
.environment(state)
.environment(sponsorManager)
.sheet(isPresented: $showOnboarding) {
OnboardingView()
}
.task {
// Pass state to AppDelegate for termination handling
appDelegate.appState = state
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import SwiftUI

struct OnboardingFeaturesStep: View {
private let features: [(icon: String, title: String, description: String, color: Color)] = [
("magnifyingglass", "Port Scanning", "See all listening ports in one place", .blue),
("xmark.circle.fill", "Quick Kill", "Terminate processes with one click", .red),
("star.fill", "Favorites", "Pin frequently used ports for quick access", .yellow),
("eye.fill", "Watched Ports", "Get notifications when ports become active", .purple),
("globe", "Cloudflare Tunnels", "Share local ports publicly", .orange),
]

var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("What you can do")
.font(.title2)
.fontWeight(.bold)
.padding(.bottom, 4)

ForEach(features, id: \.title) { feature in
HStack(spacing: 14) {
Image(systemName: feature.icon)
.font(.title3)
.foregroundStyle(feature.color)
.frame(width: 28, alignment: .center)

VStack(alignment: .leading, spacing: 2) {
Text(feature.title)
.fontWeight(.medium)
Text(feature.description)
.font(.callout)
.foregroundStyle(.secondary)
}
}
}
}
.padding(32)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
45 changes: 45 additions & 0 deletions platforms/macos/Sources/Views/Onboarding/OnboardingReadyStep.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import SwiftUI

struct OnboardingReadyStep: View {
var body: some View {
VStack(spacing: 20) {
Spacer()

Image(systemName: "checkmark.circle.fill")
.font(.system(size: 56))
.foregroundColor(.green)

Text("You're All Set!")
.font(.largeTitle)
.fontWeight(.bold)

Text("PortKiller is ready to use.\nLook for the icon in your menu bar.")
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineSpacing(4)

HStack(spacing: 24) {
tipView(icon: "menubar.arrow.up.rectangle", text: "Click the menu bar icon\nfor quick access")
tipView(icon: "gearshape.fill", text: "Visit Settings to\ncustomize further")
}
.padding(.top, 8)

Spacer()
}
.padding(32)
}

private func tipView(icon: String, text: String) -> some View {
VStack(spacing: 8) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(.secondary)
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(width: 140)
}
}
115 changes: 115 additions & 0 deletions platforms/macos/Sources/Views/Onboarding/OnboardingSetupStep.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import SwiftUI
import LaunchAtLogin
import KeyboardShortcuts
@preconcurrency import UserNotifications

struct OnboardingSetupStep: View {
@State private var notificationStatus: UNAuthorizationStatus = .notDetermined

var body: some View {
VStack(alignment: .leading, spacing: 20) {
Text("Quick Setup")
.font(.title2)
.fontWeight(.bold)
.padding(.bottom, 4)

// Launch at Login
VStack(alignment: .leading, spacing: 8) {
LaunchAtLogin.Toggle {
VStack(alignment: .leading, spacing: 2) {
Text("Launch at Login")
.fontWeight(.medium)
Text("Start PortKiller when you log in")
.font(.callout)
.foregroundStyle(.secondary)
}
}
.toggleStyle(.switch)
}
.padding(12)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))

// Notifications
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Notifications")
.fontWeight(.medium)
Text("Get notified when watched ports change state")
.font(.callout)
.foregroundStyle(.secondary)
}

Spacer()

if notificationStatus == .authorized {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Enabled")
.font(.callout)
.foregroundStyle(.green)
}
} else if notificationStatus == .denied {
Button("Open Settings") {
if let bundleId = Bundle.main.bundleIdentifier {
let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension?id=\(bundleId)")!
NSWorkspace.shared.open(url)
}
}
.controlSize(.small)
} else {
Button("Enable") {
requestPermission()
}
.controlSize(.small)
}
}
}
.padding(12)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))

// Keyboard Shortcut
VStack(alignment: .leading, spacing: 8) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Global Shortcut")
.fontWeight(.medium)
Text("Open PortKiller from anywhere")
.font(.callout)
.foregroundStyle(.secondary)
}

Spacer()

KeyboardShortcuts.Recorder(for: .toggleMainWindow)
.frame(width: 150)
}
}
.padding(12)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 8))

Spacer()
}
.padding(32)
.task {
await checkNotificationStatus()
}
}

private func requestPermission() {
Task {
_ = await NotificationService.shared.requestPermission()
await checkNotificationStatus()
}
}

private func checkNotificationStatus() async {
guard Bundle.main.bundlePath.hasSuffix(".app") else { return }
let settings = await UNUserNotificationCenter.current().notificationSettings()
notificationStatus = settings.authorizationStatus
}
}
81 changes: 81 additions & 0 deletions platforms/macos/Sources/Views/Onboarding/OnboardingView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import SwiftUI
import Defaults

struct OnboardingView: View {
@Environment(\.dismiss) private var dismiss
@State private var currentStep = 0

private let totalSteps = 4

var body: some View {
VStack(spacing: 0) {
// Content
Group {
switch currentStep {
case 0: OnboardingWelcomeStep()
case 1: OnboardingFeaturesStep()
case 2: OnboardingSetupStep()
case 3: OnboardingReadyStep()
default: EmptyView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)

Divider()

// Navigation
HStack {
// Step indicators
HStack(spacing: 6) {
ForEach(0..<totalSteps, id: \.self) { step in
Circle()
.fill(step == currentStep ? Color.accentColor : Color.secondary.opacity(0.3))
.frame(width: 7, height: 7)
}
}

Spacer()

HStack(spacing: 12) {
if currentStep > 0 {
Button("Back") {
withAnimation(.easeInOut(duration: 0.2)) {
currentStep -= 1
}
}
.controlSize(.large)
}

if currentStep < totalSteps - 1 {
Button("Skip") {
completeOnboarding()
}
.controlSize(.large)
.foregroundStyle(.secondary)

Button("Next") {
withAnimation(.easeInOut(duration: 0.2)) {
currentStep += 1
}
}
.controlSize(.large)
.buttonStyle(.borderedProminent)
} else {
Button("Get Started") {
completeOnboarding()
}
.controlSize(.large)
.buttonStyle(.borderedProminent)
}
}
}
.padding(20)
}
.frame(width: 520, height: 420)
}

private func completeOnboarding() {
Defaults[.hasCompletedOnboarding] = true
dismiss()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import SwiftUI

struct OnboardingWelcomeStep: View {
var body: some View {
VStack(spacing: 20) {
Spacer()

Image(systemName: "network")
.font(.system(size: 56))
.foregroundColor(.accentColor)

Text("Welcome to PortKiller")
.font(.largeTitle)
.fontWeight(.bold)

Text("Find and kill processes on any port.\nManage your development servers with ease.")
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.lineSpacing(4)

Spacer()
}
.padding(32)
}
}
9 changes: 9 additions & 0 deletions platforms/macos/Sources/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,15 @@ struct SettingsView: View {
SettingsLinkRow(title: "Report Issue", subtitle: "Found a bug?", icon: "ladybug.fill", url: AppInfo.githubIssues)
SettingsDivider()
SettingsLinkRow(title: "Twitter/X", subtitle: "@productdevbook", icon: "at", url: AppInfo.twitterURL)
SettingsDivider()
SettingsButtonRow(
title: "Show Welcome Screen",
subtitle: "Replay the onboarding wizard",
icon: "hand.wave.fill",
action: {
Defaults[.hasCompletedOnboarding] = false
}
)
}
}
}
Expand Down
Loading