Skip to content
Open
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
16 changes: 16 additions & 0 deletions Sources/Kaset/KasetApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ extension EnvironmentValues {
@Entry var showCommandBar: Binding<Bool> = .constant(false)
}

extension EnvironmentValues {
@Entry var showWhatsNew: Binding<Bool> = .constant(false)
}

// MARK: - KasetApp

/// Main entry point for the Kaset macOS application.
Expand Down Expand Up @@ -42,6 +46,9 @@ struct KasetApp: App {
/// Whether the command bar is visible.
@State private var showCommandBar = false

/// Whether the "What's New" sheet should be shown.
@State private var showWhatsNew = false

init() {
let auth = AuthService()
let webkit = WebKitManager.shared
Expand Down Expand Up @@ -114,6 +121,7 @@ struct KasetApp: App {
.environment(\.searchFocusTrigger, self.$searchFocusTrigger)
.environment(\.navigationSelection, self.$navigationSelection)
.environment(\.showCommandBar, self.$showCommandBar)
.environment(\.showWhatsNew, self.$showWhatsNew)
.onAppear {
// Wire up PlayerService to AppDelegate for dock menu and AppleScript actions
// This runs synchronously so AppleScript commands can access playerService immediately
Expand Down Expand Up @@ -279,6 +287,14 @@ struct KasetApp: App {
}
.keyboardShortcut("0", modifiers: .command)
}

// Help menu - What's New
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says this is a “Help menu” item, but CommandGroup(after: .appInfo) inserts into the app menu (near About/Updates), not the Help menu. Update the comment to match the actual placement (or change the placement to .help if it’s meant to live under Help).

Suggested change
// Help menu - What's New
// App menu - What's New

Copilot uses AI. Check for mistakes.
CommandGroup(after: .appInfo) {
Divider()
Button("What's New in Kaset") {
self.showWhatsNew = true
}
}
}
}

Expand Down
112 changes: 112 additions & 0 deletions Sources/Kaset/Models/WhatsNew.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import Foundation

// MARK: - WhatsNew

/// Represents a "What's New" entry for a specific app version.
struct WhatsNew: Identifiable {
/// The app version this entry corresponds to.
let version: Version

/// The headline title displayed at the top of the sheet.
let title: String

/// The list of features to showcase (used for static/fallback entries).
let features: [Feature]

/// Markdown body from release notes (used for dynamic entries from GitHub).
let releaseNotes: String?

/// Optional URL to open when the user taps "Learn more".
let learnMoreURL: URL?

var id: Version {
self.version
}

init(
version: Version,
title: String,
features: [Feature] = [],
releaseNotes: String? = nil,
learnMoreURL: URL? = nil
) {
self.version = version
self.title = title
self.features = features
self.releaseNotes = releaseNotes
self.learnMoreURL = learnMoreURL
}
}

// MARK: WhatsNew.Version

extension WhatsNew {
/// A semantic version with major, minor, and patch components.
struct Version: Hashable, Comparable, Codable, Sendable {
let major: Int
let minor: Int
let patch: Int

init(major: Int, minor: Int, patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}

/// Returns the minor-release version (patch set to 0).
var minorRelease: Version {
Version(major: self.major, minor: self.minor, patch: 0)
}

// MARK: Comparable

static func < (lhs: Version, rhs: Version) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
return lhs.patch < rhs.patch
}

// MARK: CustomStringConvertible

var description: String {
"\(self.major).\(self.minor).\(self.patch)"
}
}
}

// MARK: - WhatsNew.Version + ExpressibleByStringLiteral

extension WhatsNew.Version: ExpressibleByStringLiteral {
init(stringLiteral value: String) {
let parts = value.components(separatedBy: ".").compactMap(Int.init)
self.major = parts.indices.contains(0) ? parts[0] : 0
self.minor = parts.indices.contains(1) ? parts[1] : 0
self.patch = parts.indices.contains(2) ? parts[2] : 0
}
}

// MARK: - Version+current

extension WhatsNew.Version {
/// Retrieves the current app version from the main bundle's `CFBundleShortVersionString`.
static func current(in bundle: Bundle = .main) -> WhatsNew.Version {
let versionString = bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
return .init(stringLiteral: versionString)
}
}

// MARK: - WhatsNew.Feature

extension WhatsNew {
/// A single feature to display in the "What's New" sheet.
struct Feature: Hashable, Sendable {
/// SF Symbol name for the feature icon.
let icon: String

/// Short feature title.
let title: String

/// Longer description of the feature.
let subtitle: String
}
}
46 changes: 46 additions & 0 deletions Sources/Kaset/Services/WhatsNewVersionStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

// MARK: - WhatsNewVersionStore

/// Persists which app versions have had their "What's New" sheet presented.
struct WhatsNewVersionStore: @unchecked Sendable {
/// UserDefaults key prefix for presented versions.
private static let keyPrefix = "com.kaset.whatsNew.presented."

private let defaults: UserDefaults

init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

// MARK: - Read

/// Whether the given version has already been presented.
func hasPresented(_ version: WhatsNew.Version) -> Bool {
self.defaults.bool(forKey: Self.key(for: version))
}

/// All versions that have been marked as presented.
var presentedVersions: [WhatsNew.Version] {
self.defaults.dictionaryRepresentation()
.keys
.filter { $0.hasPrefix(Self.keyPrefix) }
.map { key in
let versionString = String(key.dropFirst(Self.keyPrefix.count))
return WhatsNew.Version(stringLiteral: versionString)
}
}

// MARK: - Write

/// Marks a version as presented so it won't be shown again.
func markPresented(_ version: WhatsNew.Version) {
self.defaults.set(true, forKey: Self.key(for: version))
}

// MARK: - Private

private static func key(for version: WhatsNew.Version) -> String {
"\(self.keyPrefix)\(version.description)"
}
}
27 changes: 27 additions & 0 deletions Sources/Kaset/Views/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ struct MainWindow: View {
@Environment(WebKitManager.self) private var webKitManager
@Environment(AccountService.self) private var accountService
@Environment(\.showCommandBar) private var showCommandBar
@Environment(\.showWhatsNew) private var showWhatsNew

/// Binding to navigation selection for keyboard shortcut control from parent.
@Binding var navigationSelection: NavigationItem?
Expand All @@ -19,6 +20,7 @@ struct MainWindow: View {

@State private var showLoginSheet = false
@State private var showCommandBarSheet = false
@State private var whatsNewToPresent: WhatsNew?

// MARK: - Cached ViewModels (persist across tab switches)

Expand Down Expand Up @@ -106,6 +108,12 @@ struct MainWindow: View {
.sheet(isPresented: self.$showLoginSheet) {
LoginSheet()
}
.sheet(item: self.$whatsNewToPresent) { whatsNew in
WhatsNewView(whatsNew: whatsNew) {
WhatsNewVersionStore().markPresented(whatsNew.version)
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On dismiss, this marks whatsNew.version as presented. Since the provider can return a minor-release fallback (e.g., showing 1.0.0 notes for app 1.0.2) or even the latest release, the current app version may remain unmarked and the sheet can reappear on every launch. Mark the current app version as presented (or track a separate presentedForVersion) instead of the fetched release’s version.

Suggested change
WhatsNewVersionStore().markPresented(whatsNew.version)
let currentAppVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? whatsNew.version
WhatsNewVersionStore().markPresented(currentAppVersion)

Copilot uses AI. Check for mistakes.
self.whatsNewToPresent = nil
}
}
.overlay {
// Command bar overlay - dismisses when clicking outside
if self.showCommandBarSheet {
Expand Down Expand Up @@ -135,6 +143,19 @@ struct MainWindow: View {
self.showCommandBar.wrappedValue = false
}
}
.onChange(of: self.showWhatsNew.wrappedValue) { _, newValue in
if newValue {
// Manual trigger from Help menu — fetch release notes, bypass version store
Task {
let version = WhatsNew.Version.current()
// Try fetching dynamic release notes first, then fall back to static
let whatsNew = await WhatsNewProvider.fetchWhatsNew(for: version, store: WhatsNewVersionStore(defaults: .init()))
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WhatsNewVersionStore(defaults: .init()) is using UserDefaults via a default initializer that doesn’t exist in Foundation (only .standard or init?(suiteName:)). This should be replaced with a valid defaults instance (or, better, add an explicit provider API to bypass version-gating for the manual menu action).

Suggested change
let whatsNew = await WhatsNewProvider.fetchWhatsNew(for: version, store: WhatsNewVersionStore(defaults: .init()))
let whatsNew = await WhatsNewProvider.fetchWhatsNew(for: version, store: WhatsNewVersionStore(defaults: .standard))

Copilot uses AI. Check for mistakes.
?? WhatsNewProvider.fallbackCollection.first
self.whatsNewToPresent = whatsNew
}
self.showWhatsNew.wrappedValue = false
}
}
.onChange(of: self.authService.state) { oldState, newState in
self.handleAuthStateChange(oldState: oldState, newState: newState)
}
Expand Down Expand Up @@ -311,6 +332,12 @@ struct MainWindow: View {
self.showLoginSheet = true
case .loggedIn:
self.showLoginSheet = false
// Auto-present "What's New" — fetch from GitHub release notes
if self.whatsNewToPresent == nil {
Task {
self.whatsNewToPresent = await WhatsNewProvider.fetchWhatsNew()
}
}
Task {
await self.accountService.fetchAccounts()
}
Expand Down
Loading
Loading