Skip to content

Commit f76df9c

Browse files
committed
Merge branch 'release/1.3' for v1.3
2 parents 0c92dfc + 82fc2a8 commit f76df9c

32 files changed

Lines changed: 1125 additions & 245 deletions

ClaudeIsland.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@
283283
CODE_SIGN_ENTITLEMENTS = ClaudeIsland/Resources/ClaudeIsland.entitlements;
284284
CODE_SIGN_STYLE = Automatic;
285285
COMBINE_HIDPI_IMAGES = YES;
286-
CURRENT_PROJECT_VERSION = 3;
286+
CURRENT_PROJECT_VERSION = 4;
287287
DEVELOPMENT_TEAM = 2DKS5U9LV4;
288288
ENABLE_APP_SANDBOX = NO;
289289
ENABLE_HARDENED_RUNTIME = YES;
@@ -297,7 +297,7 @@
297297
"$(inherited)",
298298
"@executable_path/../Frameworks",
299299
);
300-
MARKETING_VERSION = 1.2;
300+
MARKETING_VERSION = 1.3;
301301
PRODUCT_BUNDLE_IDENTIFIER = com.celestial.ClaudeIsland;
302302
PRODUCT_NAME = "Claude Island";
303303
REGISTER_APP_GROUPS = YES;
@@ -318,7 +318,7 @@
318318
CODE_SIGN_ENTITLEMENTS = ClaudeIsland/Resources/ClaudeIsland.entitlements;
319319
CODE_SIGN_STYLE = Automatic;
320320
COMBINE_HIDPI_IMAGES = YES;
321-
CURRENT_PROJECT_VERSION = 3;
321+
CURRENT_PROJECT_VERSION = 4;
322322
DEVELOPMENT_TEAM = 2DKS5U9LV4;
323323
ENABLE_APP_SANDBOX = NO;
324324
ENABLE_HARDENED_RUNTIME = YES;
@@ -332,7 +332,7 @@
332332
"$(inherited)",
333333
"@executable_path/../Frameworks",
334334
);
335-
MARKETING_VERSION = 1.2;
335+
MARKETING_VERSION = 1.3;
336336
PRODUCT_BUNDLE_IDENTIFIER = com.celestial.ClaudeIsland;
337337
PRODUCT_NAME = "Claude Island";
338338
REGISTER_APP_GROUPS = YES;

ClaudeIsland/App/AppDelegate.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
126126
}
127127

128128
private func fetchAndRegisterClaudeVersion() {
129-
let claudeProjectsDir = FileManager.default.homeDirectoryForCurrentUser
130-
.appendingPathComponent(".claude/projects")
129+
let claudeProjectsDir = ClaudePaths.projectsDir
131130

132131
guard let projectDirs = try? FileManager.default.contentsOfDirectory(
133132
at: claudeProjectsDir,

ClaudeIsland/App/ScreenObserver.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import AppKit
1010
class ScreenObserver {
1111
private var observer: Any?
1212
private let onScreenChange: () -> Void
13+
private var pendingWork: DispatchWorkItem?
14+
15+
/// Debounce interval to coalesce rapid screen change notifications
16+
/// (e.g., when waking from sleep, displays reconnect in stages)
17+
private let debounceInterval: TimeInterval = 0.5
1318

1419
init(onScreenChange: @escaping () -> Void) {
1520
self.onScreenChange = onScreenChange
@@ -26,11 +31,26 @@ class ScreenObserver {
2631
object: nil,
2732
queue: .main
2833
) { [weak self] _ in
34+
self?.scheduleScreenChange()
35+
}
36+
}
37+
38+
private func scheduleScreenChange() {
39+
pendingWork?.cancel()
40+
41+
let work = DispatchWorkItem { [weak self] in
2942
self?.onScreenChange()
3043
}
44+
pendingWork = work
45+
46+
DispatchQueue.main.asyncAfter(
47+
deadline: .now() + debounceInterval,
48+
execute: work
49+
)
3150
}
3251

3352
private func stopObserving() {
53+
pendingWork?.cancel()
3454
if let observer = observer {
3555
NotificationCenter.default.removeObserver(observer)
3656
}

ClaudeIsland/App/WindowManager.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ private let logger = Logger(subsystem: "com.claudeisland", category: "Window")
1313

1414
class WindowManager {
1515
private(set) var windowController: NotchWindowController?
16+
private var isInitialLaunch = true
17+
private var currentScreenFrame: NSRect?
1618

1719
/// Set up or recreate the notch window
1820
func setupNotchWindow() -> NotchWindowController? {
@@ -25,13 +27,26 @@ class WindowManager {
2527
return nil
2628
}
2729

30+
// Skip recreation if screen hasn't meaningfully changed
31+
if let existingController = windowController,
32+
let existingFrame = currentScreenFrame,
33+
existingFrame == screen.frame {
34+
logger.debug("Screen unchanged, skipping window recreation")
35+
return existingController
36+
}
37+
38+
// Only animate on initial app launch, not on screen changes
39+
let shouldAnimate = isInitialLaunch
40+
isInitialLaunch = false
41+
2842
if let existingController = windowController {
2943
existingController.window?.orderOut(nil)
3044
existingController.window?.close()
3145
windowController = nil
3246
}
3347

34-
windowController = NotchWindowController(screen: screen)
48+
currentScreenFrame = screen.frame
49+
windowController = NotchWindowController(screen: screen, animateOnLaunch: shouldAnimate)
3550
windowController?.showWindow(nil)
3651

3752
return windowController
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// ClaudeDirSelector.swift
3+
// ClaudeIsland
4+
//
5+
// Manages the expand/collapse state of the Claude directory picker row,
6+
// so NotchViewModel can grow the settings panel when the picker is open
7+
// (matching SoundSelector / ScreenSelector behavior).
8+
//
9+
10+
import Combine
11+
import Foundation
12+
13+
@MainActor
14+
class ClaudeDirSelector: ObservableObject {
15+
static let shared = ClaudeDirSelector()
16+
17+
@Published var isPickerExpanded: Bool = false
18+
19+
/// Height of a single option row (matches SoundOptionRowInline style).
20+
private let rowHeight: CGFloat = 32
21+
22+
/// Number of option rows the picker shows (Auto-detect + Choose folder…).
23+
private let optionCount: Int = 2
24+
25+
private init() {}
26+
27+
/// Extra height needed when the picker is expanded.
28+
var expandedPickerHeight: CGFloat {
29+
guard isPickerExpanded else { return 0 }
30+
return CGFloat(optionCount) * rowHeight + 8 // +8 for padding
31+
}
32+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
//
2+
// ClaudePaths.swift
3+
// ClaudeIsland
4+
//
5+
// Single source of truth for all Claude config directory paths.
6+
// Resolves automatically via CLAUDE_CONFIG_DIR env var or filesystem detection,
7+
// with an optional user override via AppSettings.claudeDirectoryName.
8+
//
9+
10+
import Foundation
11+
12+
enum ClaudePaths {
13+
14+
/// Cached resolved directory to avoid filesystem checks on every access
15+
private static var _cachedDir: URL?
16+
17+
/// Guards reads/writes to _cachedDir — accessed from the main actor
18+
/// (UI settings), the ConversationParser actor, and background watcher
19+
/// queues, so cross-thread access needs synchronization.
20+
private static let cacheLock = NSLock()
21+
22+
/// Root Claude config directory, resolved once and cached.
23+
///
24+
/// Resolution order:
25+
/// 1. CLAUDE_CONFIG_DIR environment variable (if set and exists)
26+
/// 2. AppSettings.claudeDirectoryName override (if changed from default)
27+
/// 3. ~/.config/claude/ (new default since Claude Code v2.1.30+, if projects/ exists)
28+
/// 4. ~/.claude/ (legacy fallback)
29+
static var claudeDir: URL {
30+
cacheLock.lock()
31+
if let cached = _cachedDir {
32+
cacheLock.unlock()
33+
return cached
34+
}
35+
cacheLock.unlock()
36+
37+
// Resolve outside the lock — involves filesystem and settings reads
38+
// that shouldn't block other threads.
39+
let resolved = resolveClaudeDir()
40+
41+
cacheLock.lock()
42+
// Another thread may have populated the cache while we were resolving;
43+
// prefer theirs for consistency, but either value is correct.
44+
if let existing = _cachedDir {
45+
cacheLock.unlock()
46+
return existing
47+
}
48+
_cachedDir = resolved
49+
cacheLock.unlock()
50+
return resolved
51+
}
52+
53+
static var hooksDir: URL {
54+
claudeDir.appendingPathComponent("hooks")
55+
}
56+
57+
static var settingsFile: URL {
58+
claudeDir.appendingPathComponent("settings.json")
59+
}
60+
61+
static var projectsDir: URL {
62+
claudeDir.appendingPathComponent("projects")
63+
}
64+
65+
/// Shell-safe absolute path for hook commands in settings.json.
66+
/// Absolute paths keep custom directories and ~/.config/claude working;
67+
/// quoting keeps paths with spaces from being split by the shell.
68+
static var hookScriptShellPath: String {
69+
shellQuote(claudeDir.appendingPathComponent("hooks/claude-island-state.py").path)
70+
}
71+
72+
/// Invalidate the cached directory so the next access re-resolves.
73+
/// Call this when the user changes AppSettings.claudeDirectoryName.
74+
static func invalidateCache() {
75+
cacheLock.lock()
76+
_cachedDir = nil
77+
cacheLock.unlock()
78+
}
79+
80+
private static func resolveClaudeDir() -> URL {
81+
let fm = FileManager.default
82+
let home = fm.homeDirectoryForCurrentUser
83+
84+
// 1. CLAUDE_CONFIG_DIR env var takes highest priority
85+
if let envDir = Foundation.ProcessInfo.processInfo.environment["CLAUDE_CONFIG_DIR"] {
86+
let expanded = (envDir as NSString).expandingTildeInPath
87+
let url = URL(fileURLWithPath: expanded)
88+
if fm.fileExists(atPath: url.path) {
89+
return url
90+
}
91+
}
92+
93+
// 2. User override via settings — accepts either an absolute path (chosen
94+
// via the folder picker) or a legacy directory name under ~/
95+
let settingsValue = AppSettings.claudeDirectoryName
96+
if !settingsValue.isEmpty && settingsValue != ".claude" {
97+
if settingsValue.hasPrefix("/") {
98+
return URL(fileURLWithPath: settingsValue)
99+
} else {
100+
return home.appendingPathComponent(settingsValue)
101+
}
102+
}
103+
104+
// 3. New default ~/.config/claude/ (if projects/ exists there)
105+
let newDefault = home.appendingPathComponent(".config/claude")
106+
if fm.fileExists(atPath: newDefault.appendingPathComponent("projects").path) {
107+
return newDefault
108+
}
109+
110+
// 4. Legacy fallback
111+
return home.appendingPathComponent(".claude")
112+
}
113+
114+
private static func shellQuote(_ path: String) -> String {
115+
"'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'"
116+
}
117+
}

ClaudeIsland/Core/NotchViewModel.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class NotchViewModel: ObservableObject {
5050

5151
private let screenSelector = ScreenSelector.shared
5252
private let soundSelector = SoundSelector.shared
53+
private let claudeDirSelector = ClaudeDirSelector.shared
5354

5455
// MARK: - Geometry
5556

@@ -71,10 +72,15 @@ class NotchViewModel: ObservableObject {
7172
height: 580
7273
)
7374
case .menu:
74-
// Compact size for settings menu
75+
// Base height covers all static rows (Back, 3 picker rows, 3 toggles,
76+
// Accessibility, Update, GitHub, Quit + 4 dividers + padding).
77+
// Picker expansion deltas added on top when expanded.
7578
return CGSize(
7679
width: min(screenRect.width * 0.4, 480),
77-
height: 420 + screenSelector.expandedPickerHeight + soundSelector.expandedPickerHeight
80+
height: 540
81+
+ screenSelector.expandedPickerHeight
82+
+ soundSelector.expandedPickerHeight
83+
+ claudeDirSelector.expandedPickerHeight
7884
)
7985
case .instances:
8086
return CGSize(
@@ -117,6 +123,10 @@ class NotchViewModel: ObservableObject {
117123
soundSelector.$isPickerExpanded
118124
.sink { [weak self] _ in self?.objectWillChange.send() }
119125
.store(in: &cancellables)
126+
127+
claudeDirSelector.$isPickerExpanded
128+
.sink { [weak self] _ in self?.objectWillChange.send() }
129+
.store(in: &cancellables)
120130
}
121131

122132
// MARK: - Event Handling

ClaudeIsland/Core/Settings.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ enum AppSettings {
3838

3939
private enum Keys {
4040
static let notificationSound = "notificationSound"
41+
static let claudeDirectoryName = "claudeDirectoryName"
4142
}
4243

4344
// MARK: - Notification Sound
@@ -55,4 +56,19 @@ enum AppSettings {
5556
defaults.set(newValue.rawValue, forKey: Keys.notificationSound)
5657
}
5758
}
59+
60+
// MARK: - Claude Directory
61+
62+
/// The name of the Claude config directory under the user's home folder.
63+
/// Defaults to ".claude" (standard Claude Code installation).
64+
/// Change to ".claude-internal" (or similar) for enterprise/custom distributions.
65+
static var claudeDirectoryName: String {
66+
get {
67+
let value = defaults.string(forKey: Keys.claudeDirectoryName) ?? ""
68+
return value.isEmpty ? ".claude" : value
69+
}
70+
set {
71+
defaults.set(newValue.trimmingCharacters(in: .whitespaces), forKey: Keys.claudeDirectoryName)
72+
}
73+
}
5874
}

0 commit comments

Comments
 (0)