diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/128.png b/Assets.xcassets/AppIcon-Debug.appiconset/128.png index f470aeff..38a667a1 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/128.png and b/Assets.xcassets/AppIcon-Debug.appiconset/128.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png index abcbc24f..d58bd7ed 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/128@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/16.png b/Assets.xcassets/AppIcon-Debug.appiconset/16.png index d4941f6d..cff0d96c 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/16.png and b/Assets.xcassets/AppIcon-Debug.appiconset/16.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png index e298fe39..0514b3ce 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/16@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/256.png b/Assets.xcassets/AppIcon-Debug.appiconset/256.png index 67457fa1..d58bd7ed 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/256.png and b/Assets.xcassets/AppIcon-Debug.appiconset/256.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png index be740aae..8b5bb49e 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/256@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/32.png b/Assets.xcassets/AppIcon-Debug.appiconset/32.png index e298fe39..0514b3ce 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/32.png and b/Assets.xcassets/AppIcon-Debug.appiconset/32.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png index 9be0ae7a..dfeae3ae 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/32@2x.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/512.png b/Assets.xcassets/AppIcon-Debug.appiconset/512.png index be740aae..8b5bb49e 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/512.png and b/Assets.xcassets/AppIcon-Debug.appiconset/512.png differ diff --git a/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png b/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png index 8804e1d4..2188fe54 100644 Binary files a/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png and b/Assets.xcassets/AppIcon-Debug.appiconset/512@2x.png differ diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 24d987a6..a92eaaa3 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -26,6 +26,10 @@ A5001204 /* UpdateViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001214 /* UpdateViewModel.swift */; }; A5001205 /* UpdatePill.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001215 /* UpdatePill.swift */; }; A5001206 /* UpdateBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001216 /* UpdateBadge.swift */; }; + A500120A /* UpdateTiming.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001220 /* UpdateTiming.swift */; }; + A500120B /* UpdateTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001221 /* UpdateTestSupport.swift */; }; + A500120C /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001222 /* WindowAccessor.swift */; }; + A500120D /* UpdateLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001223 /* UpdateLogStore.swift */; }; A5001207 /* UpdatePopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001217 /* UpdatePopoverView.swift */; }; A5001208 /* UpdateTitlebarAccessory.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001218 /* UpdateTitlebarAccessory.swift */; }; A5001209 /* WindowToolbarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001219 /* WindowToolbarController.swift */; }; @@ -33,6 +37,7 @@ A5001230 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A5001231 /* Sparkle */; }; 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */ = {isa = PBXBuildFile; fileRef = B2E7294509CC42FE9191870E /* xterm-ghostty */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; + C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -82,10 +87,15 @@ A5001214 /* UpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateViewModel.swift; sourceTree = ""; }; A5001215 /* UpdatePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdatePill.swift; sourceTree = ""; }; A5001216 /* UpdateBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateBadge.swift; sourceTree = ""; }; + A5001220 /* UpdateTiming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTiming.swift; sourceTree = ""; }; + A5001221 /* UpdateTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTestSupport.swift; sourceTree = ""; }; A5001217 /* UpdatePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdatePopoverView.swift; sourceTree = ""; }; A5001218 /* UpdateTitlebarAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTitlebarAccessory.swift; sourceTree = ""; }; A5001219 /* WindowToolbarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowToolbarController.swift; sourceTree = ""; }; + A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; + A5001223 /* UpdateLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateLogStore.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; + C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillUITests.swift; sourceTree = ""; }; A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "terminfo/78/xterm-ghostty"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -164,9 +174,13 @@ A5001214 /* UpdateViewModel.swift */, A5001215 /* UpdatePill.swift */, A5001216 /* UpdateBadge.swift */, + A5001220 /* UpdateTiming.swift */, + A5001221 /* UpdateTestSupport.swift */, + A5001223 /* UpdateLogStore.swift */, A5001217 /* UpdatePopoverView.swift */, A5001218 /* UpdateTitlebarAccessory.swift */, A5001219 /* WindowToolbarController.swift */, + A5001222 /* WindowAccessor.swift */, ); path = Sources; sourceTree = ""; @@ -192,6 +206,7 @@ isa = PBXGroup; children = ( 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */, + C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */, ); path = GhosttyTabsUITests; sourceTree = ""; @@ -293,9 +308,13 @@ A5001204 /* UpdateViewModel.swift in Sources */, A5001205 /* UpdatePill.swift in Sources */, A5001206 /* UpdateBadge.swift in Sources */, + A500120A /* UpdateTiming.swift in Sources */, + A500120B /* UpdateTestSupport.swift in Sources */, + A500120D /* UpdateLogStore.swift in Sources */, A5001207 /* UpdatePopoverView.swift in Sources */, A5001208 /* UpdateTitlebarAccessory.swift in Sources */, A5001209 /* WindowToolbarController.swift in Sources */, + A500120C /* WindowAccessor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -304,6 +323,7 @@ buildActionMask = 2147483647; files = ( B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */, + C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -390,8 +410,8 @@ DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = NO; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_CFBundleDisplayName = cmux; - INFOPLIST_KEY_CFBundleName = cmux; + INFOPLIST_KEY_CFBundleDisplayName = "cmux DEV"; + INFOPLIST_KEY_CFBundleName = "cmux DEV"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSMainStoryboardFile = ""; @@ -416,8 +436,8 @@ "-framework", Carbon, ); - PRODUCT_BUNDLE_IDENTIFIER = com.cmux.app; - PRODUCT_NAME = cmux; + PRODUCT_BUNDLE_IDENTIFIER = com.cmux.app.debug; + PRODUCT_NAME = "cmux DEV"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = "cmux-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..f55129a2 --- /dev/null +++ b/GhosttyTabs.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "e721da7f9826abdffcb6185e886155efa2514bd6234475f1afa893e29eb258d6", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "5581748cef2bae787496fe6d61139aebe0a451f6", + "version" : "2.8.1" + } + } + ], + "version" : 3 +} diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index 4e1f4920..8b5e0201 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -6,48 +6,146 @@ final class NonDraggableHostingView: NSHostingView { override var mouseDownCanMoveWindow: Bool { false } } +#if DEBUG +private struct DevTitlebarAccessoryView: View { + var body: some View { + Text("THIS IS A DEV BUILD") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.red) + .padding(.horizontal, 8) + .padding(.vertical, 2) + } +} + +final class DevBuildAccessoryViewController: NSTitlebarAccessoryViewController { + private let hostingView: NonDraggableHostingView + private let containerView = NSView() + private var pendingSizeUpdate = false + + init() { + hostingView = NonDraggableHostingView(rootView: DevTitlebarAccessoryView()) + + super.init(nibName: nil, bundle: nil) + + view = containerView + containerView.translatesAutoresizingMaskIntoConstraints = true + hostingView.translatesAutoresizingMaskIntoConstraints = true + hostingView.autoresizingMask = [.width, .height] + containerView.addSubview(hostingView) + + scheduleSizeUpdate() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidAppear() { + super.viewDidAppear() + scheduleSizeUpdate() + } + + override func viewDidLayout() { + super.viewDidLayout() + scheduleSizeUpdate() + } + + private func scheduleSizeUpdate() { + guard !pendingSizeUpdate else { return } + pendingSizeUpdate = true + DispatchQueue.main.async { [weak self] in + self?.pendingSizeUpdate = false + self?.updateSize() + } + } + + private func updateSize() { + hostingView.invalidateIntrinsicContentSize() + hostingView.layoutSubtreeIfNeeded() + let labelSize = hostingView.fittingSize + let titlebarHeight = view.window.map { window in + window.frame.height - window.contentLayoutRect.height + } ?? labelSize.height + let containerHeight = max(labelSize.height, titlebarHeight) + let yOffset = max(0, (containerHeight - labelSize.height) / 2.0) + preferredContentSize = NSSize(width: labelSize.width, height: containerHeight) + containerView.frame = NSRect(x: 0, y: 0, width: labelSize.width, height: containerHeight) + hostingView.frame = NSRect(x: 0, y: yOffset, width: labelSize.width, height: labelSize.height) + } +} +#endif + private struct TitlebarAccessoryView: View { @ObservedObject var model: UpdateViewModel - let onIdleTap: () -> Void var body: some View { - UpdatePill( - model: model, - showWhenIdle: false, - onIdleTap: onIdleTap - ) - .fixedSize() - .padding(.top, 4) + UpdatePill(model: model) .padding(.trailing, 8) } } final class UpdateAccessoryViewController: NSTitlebarAccessoryViewController { - private var sizeCancellable: AnyCancellable? + private let hostingView: NonDraggableHostingView + private let containerView = NSView() + private var stateCancellable: AnyCancellable? + private var pendingSizeUpdate = false + + init(model: UpdateViewModel) { + hostingView = NonDraggableHostingView(rootView: TitlebarAccessoryView(model: model)) - init(model: UpdateViewModel, onIdleTap: @escaping () -> Void) { super.init(nibName: nil, bundle: nil) - let hostingView = NonDraggableHostingView(rootView: TitlebarAccessoryView( - model: model, - onIdleTap: onIdleTap - )) - hostingView.setFrameSize(hostingView.fittingSize) - view = hostingView + view = containerView + containerView.translatesAutoresizingMaskIntoConstraints = true + hostingView.translatesAutoresizingMaskIntoConstraints = true + hostingView.autoresizingMask = [.width, .height] + containerView.addSubview(hostingView) - sizeCancellable = model.$state + stateCancellable = model.$state .receive(on: DispatchQueue.main) - .sink { [weak hostingView] _ in - guard let hostingView else { return } - hostingView.invalidateIntrinsicContentSize() - hostingView.layoutSubtreeIfNeeded() - hostingView.setFrameSize(hostingView.fittingSize) + .sink { [weak self] _ in + self?.scheduleSizeUpdate() } + + scheduleSizeUpdate() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override func viewDidAppear() { + super.viewDidAppear() + scheduleSizeUpdate() + } + + override func viewDidLayout() { + super.viewDidLayout() + scheduleSizeUpdate() + } + + private func scheduleSizeUpdate() { + guard !pendingSizeUpdate else { return } + pendingSizeUpdate = true + DispatchQueue.main.async { [weak self] in + self?.pendingSizeUpdate = false + self?.updateSize() + } + } + + private func updateSize() { + hostingView.invalidateIntrinsicContentSize() + hostingView.layoutSubtreeIfNeeded() + let pillSize = hostingView.fittingSize + let titlebarHeight = view.window.map { window in + window.frame.height - window.contentLayoutRect.height + } ?? pillSize.height + let containerHeight = max(pillSize.height, titlebarHeight) + let yOffset = max(0, (containerHeight - pillSize.height) / 2.0) + preferredContentSize = NSSize(width: pillSize.width, height: containerHeight) + containerView.frame = NSRect(x: 0, y: 0, width: pillSize.width, height: containerHeight) + hostingView.frame = NSRect(x: 0, y: yOffset, width: pillSize.width, height: pillSize.height) + } } final class UpdateTitlebarAccessoryController { @@ -55,6 +153,12 @@ final class UpdateTitlebarAccessoryController { private var didStart = false private let attachedWindows = NSHashTable.weakObjects() private var observers: [NSObjectProtocol] = [] + private var stateCancellable: AnyCancellable? + private var lastIsIdle: Bool? + private let updateIdentifier = NSUserInterfaceItemIdentifier("cmux.updateAccessory") +#if DEBUG + private let devIdentifier = NSUserInterfaceItemIdentifier("cmux.devAccessory") +#endif init(viewModel: UpdateViewModel) { self.updateViewModel = viewModel @@ -71,6 +175,11 @@ final class UpdateTitlebarAccessoryController { didStart = true attachToExistingWindows() installObservers() + installStateObserver() + } + + func attach(to window: NSWindow) { + attachIfNeeded(to: window) } private func installObservers() { @@ -105,24 +214,58 @@ final class UpdateTitlebarAccessoryController { guard !attachedWindows.contains(window) else { return } guard window.styleMask.contains(.titled) else { return } - let identifier = NSUserInterfaceItemIdentifier("cmux.updateAccessory") - if window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == identifier }) { - attachedWindows.add(window) - return +#if DEBUG + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == devIdentifier }) { + let devAccessory = DevBuildAccessoryViewController() + devAccessory.layoutAttribute = .left + devAccessory.view.identifier = devIdentifier + window.addTitlebarAccessoryViewController(devAccessory) + } +#endif + + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == updateIdentifier }) { + let accessory = UpdateAccessoryViewController(model: updateViewModel) + accessory.layoutAttribute = .right + accessory.view.identifier = updateIdentifier + window.addTitlebarAccessoryViewController(accessory) } - let accessory = UpdateAccessoryViewController( - model: updateViewModel, - onIdleTap: { - guard let delegate = NSApp.delegate as? AppDelegate else { return } - delegate.checkForUpdates(nil) - } - ) - accessory.layoutAttribute = .right - - accessory.view.identifier = identifier - - window.addTitlebarAccessoryViewController(accessory) attachedWindows.add(window) } + + private func installStateObserver() { + guard let updateViewModel else { return } + stateCancellable = Publishers.CombineLatest(updateViewModel.$state, updateViewModel.$overrideState) + .map { state, override in + override ?? state + } + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self else { return } + let isIdle = state.isIdle + if let lastIsIdle, lastIsIdle == isIdle { + return + } + self.lastIsIdle = isIdle + self.refreshAccessories(isIdle: isIdle) + } + } + + private func refreshAccessories(isIdle: Bool) { + guard let updateViewModel else { return } + + for window in attachedWindows.allObjects { + if let index = window.titlebarAccessoryViewControllers.firstIndex(where: { $0.view.identifier == updateIdentifier }) { + window.removeTitlebarAccessoryViewController(at: index) + } + + guard !isIdle else { continue } + + let accessory = UpdateAccessoryViewController(model: updateViewModel) + accessory.layoutAttribute = .right + accessory.view.identifier = updateIdentifier + window.addTitlebarAccessoryViewController(accessory) + } + } } diff --git a/scripts/reload-prod.sh b/scripts/reload-prod.sh new file mode 100755 index 00000000..cea1a42e --- /dev/null +++ b/scripts/reload-prod.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +pkill -x cmux || true +sleep 0.2 +open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Release/cmux.app diff --git a/scripts/reload.sh b/scripts/reload.sh new file mode 100755 index 00000000..68078c89 --- /dev/null +++ b/scripts/reload.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +pkill -x "cmux DEV" || true +sleep 0.2 +open /Users/lawrencechen/Library/Developer/Xcode/DerivedData/GhosttyTabs-cbjivvtpirygxbbgqlpdpiiyjnwh/Build/Products/Debug/cmux\ DEV.app