Add Sparkle auto-update flow and titlebar update UI
This commit is contained in:
parent
a158c744ea
commit
e3ee246930
19 changed files with 1813 additions and 80 deletions
16
.github/workflows/release.yml
vendored
16
.github/workflows/release.yml
vendored
|
|
@ -51,6 +51,8 @@ jobs:
|
|||
cp -R ghostty/macos/GhosttyKit.xcframework GhosttyKit.xcframework
|
||||
|
||||
- name: Build app (Release)
|
||||
env:
|
||||
SPARKLE_PUBLIC_KEY: ${{ secrets.SPARKLE_PUBLIC_KEY }}
|
||||
run: |
|
||||
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build
|
||||
|
||||
|
|
@ -117,10 +119,22 @@ jobs:
|
|||
xcrun stapler staple "$DMG_RELEASE"
|
||||
xcrun stapler validate "$DMG_RELEASE"
|
||||
|
||||
- name: Generate Sparkle appcast
|
||||
env:
|
||||
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
|
||||
run: |
|
||||
if [ -z "$SPARKLE_PRIVATE_KEY" ]; then
|
||||
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
|
||||
exit 1
|
||||
fi
|
||||
./scripts/sparkle_generate_appcast.sh cmux-macos.dmg "$GITHUB_REF_NAME" appcast.xml
|
||||
|
||||
- name: Upload release asset
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: cmux-macos.dmg
|
||||
files: |
|
||||
cmux-macos.dmg
|
||||
appcast.xml
|
||||
generate_release_notes: true
|
||||
|
||||
- name: Cleanup keychain
|
||||
|
|
|
|||
|
|
@ -25,3 +25,4 @@ Notes:
|
|||
`APPLE_SIGNING_IDENTITY`, `APPLE_ID`, `APPLE_APP_SPECIFIC_PASSWORD`, `APPLE_TEAM_ID`.
|
||||
- The release asset is `cmux-macos.dmg` attached to the tag.
|
||||
- README download button points to `releases/latest/download/cmux-macos.dmg`.
|
||||
- Versioning: bump the minor version for updates unless explicitly asked otherwise.
|
||||
|
|
|
|||
|
|
@ -20,7 +20,17 @@
|
|||
A50010A4 /* SplitTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A0 /* SplitTree.swift */; };
|
||||
A50010A5 /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A1 /* SplitView.swift */; };
|
||||
A50010A6 /* TerminalSplitTreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50010A2 /* TerminalSplitTreeView.swift */; };
|
||||
A5001201 /* UpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001211 /* UpdateController.swift */; };
|
||||
A5001202 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001212 /* UpdateDelegate.swift */; };
|
||||
A5001203 /* UpdateDriver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001213 /* UpdateDriver.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
A5001100 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5001101 /* Assets.xcassets */; };
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
|
@ -66,6 +76,15 @@
|
|||
A50010A0 /* SplitTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/SplitTree.swift; sourceTree = "<group>"; };
|
||||
A50010A1 /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/SplitView.swift; sourceTree = "<group>"; };
|
||||
A50010A2 /* TerminalSplitTreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Splits/TerminalSplitTreeView.swift; sourceTree = "<group>"; };
|
||||
A5001211 /* UpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateController.swift; sourceTree = "<group>"; };
|
||||
A5001212 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDelegate.swift; sourceTree = "<group>"; };
|
||||
A5001213 /* UpdateDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateDriver.swift; sourceTree = "<group>"; };
|
||||
A5001214 /* UpdateViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateViewModel.swift; sourceTree = "<group>"; };
|
||||
A5001215 /* UpdatePill.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdatePill.swift; sourceTree = "<group>"; };
|
||||
A5001216 /* UpdateBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateBadge.swift; sourceTree = "<group>"; };
|
||||
A5001217 /* UpdatePopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdatePopoverView.swift; sourceTree = "<group>"; };
|
||||
A5001218 /* UpdateTitlebarAccessory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/UpdateTitlebarAccessory.swift; sourceTree = "<group>"; };
|
||||
A5001219 /* WindowToolbarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowToolbarController.swift; sourceTree = "<group>"; };
|
||||
818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = "<group>"; };
|
||||
A5001101 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
B2E7294509CC42FE9191870E /* xterm-ghostty */ = {isa = PBXFileReference; lastKnownFileType = file; path = "terminfo/78/xterm-ghostty"; sourceTree = "<group>"; };
|
||||
|
|
@ -77,6 +96,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A5001006 /* GhosttyKit.xcframework in Frameworks */,
|
||||
A5001230 /* Sparkle in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -138,6 +158,15 @@
|
|||
A50010A0 /* SplitTree.swift */,
|
||||
A50010A1 /* SplitView.swift */,
|
||||
A50010A2 /* TerminalSplitTreeView.swift */,
|
||||
A5001211 /* UpdateController.swift */,
|
||||
A5001212 /* UpdateDelegate.swift */,
|
||||
A5001213 /* UpdateDriver.swift */,
|
||||
A5001214 /* UpdateViewModel.swift */,
|
||||
A5001215 /* UpdatePill.swift */,
|
||||
A5001216 /* UpdateBadge.swift */,
|
||||
A5001217 /* UpdatePopoverView.swift */,
|
||||
A5001218 /* UpdateTitlebarAccessory.swift */,
|
||||
A5001219 /* WindowToolbarController.swift */,
|
||||
);
|
||||
path = Sources;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -183,6 +212,9 @@
|
|||
);
|
||||
dependencies = (
|
||||
);
|
||||
packageProductDependencies = (
|
||||
A5001231 /* Sparkle */,
|
||||
);
|
||||
name = GhosttyTabs;
|
||||
productName = GhosttyTabs;
|
||||
productReference = A5001000 /* cmux.app */;
|
||||
|
|
@ -225,6 +257,9 @@
|
|||
Base,
|
||||
);
|
||||
mainGroup = A5001040;
|
||||
packageReferences = (
|
||||
A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
);
|
||||
productRefGroup = A5001042 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
|
|
@ -252,6 +287,15 @@
|
|||
A50010A4 /* SplitTree.swift in Sources */,
|
||||
A50010A5 /* SplitView.swift in Sources */,
|
||||
A50010A6 /* TerminalSplitTreeView.swift in Sources */,
|
||||
A5001201 /* UpdateController.swift in Sources */,
|
||||
A5001202 /* UpdateDelegate.swift in Sources */,
|
||||
A5001203 /* UpdateDriver.swift in Sources */,
|
||||
A5001204 /* UpdateViewModel.swift in Sources */,
|
||||
A5001205 /* UpdatePill.swift in Sources */,
|
||||
A5001206 /* UpdateBadge.swift in Sources */,
|
||||
A5001207 /* UpdatePopoverView.swift in Sources */,
|
||||
A5001208 /* UpdateTitlebarAccessory.swift in Sources */,
|
||||
A5001209 /* WindowToolbarController.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -342,7 +386,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
|
@ -352,11 +396,13 @@
|
|||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMainStoryboardFile = "";
|
||||
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||
INFOPLIST_KEY_SUFeedURL = "https://github.com/manaflow-ai/GhosttyTabs/releases/latest/download/appcast.xml";
|
||||
INFOPLIST_KEY_SUPublicEDKey = "$(SPARKLE_PUBLIC_KEY)";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.4;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -385,7 +431,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
ENABLE_HARDENED_RUNTIME = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
|
|
@ -395,11 +441,13 @@
|
|||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSMainStoryboardFile = "";
|
||||
INFOPLIST_KEY_NSPrincipalClass = NSApplication;
|
||||
INFOPLIST_KEY_SUFeedURL = "https://github.com/manaflow-ai/GhosttyTabs/releases/latest/download/appcast.xml";
|
||||
INFOPLIST_KEY_SUPublicEDKey = "$(SPARKLE_PUBLIC_KEY)";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.4;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"-lc++",
|
||||
"-framework",
|
||||
|
|
@ -426,10 +474,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0.4;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -443,10 +491,10 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
CURRENT_PROJECT_VERSION = 6;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0.4;
|
||||
MARKETING_VERSION = 1.1.0;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.cmux.appuitests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -457,6 +505,25 @@
|
|||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.5.1;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
A5001231 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = A5001232 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
A5001060 /* Build configuration list for PBXNativeTarget "GhosttyTabs" */ = {
|
||||
isa = XCConfigurationList;
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -10,6 +10,35 @@ Tag a version like `v0.1.0` and push it to trigger the GitHub Actions release wo
|
|||
The workflow builds `GhosttyKit.xcframework`, builds the Release app, signs, notarizes,
|
||||
staples, and uploads `cmux-macos.dmg` to the release.
|
||||
|
||||
## Auto updates
|
||||
|
||||
cmux uses Sparkle with the same update UI flow as upstream Ghostty. The app looks for
|
||||
an appcast at:
|
||||
|
||||
```
|
||||
https://github.com/manaflow-ai/GhosttyTabs/releases/latest/download/appcast.xml
|
||||
```
|
||||
|
||||
To sign updates, set these secrets for release builds:
|
||||
|
||||
- `SPARKLE_PUBLIC_KEY`: Sparkle EdDSA public key (embedded in the app).
|
||||
- `SPARKLE_PRIVATE_KEY`: Sparkle EdDSA private key (used when generating appcasts).
|
||||
|
||||
You still need to generate and upload `appcast.xml` alongside each release asset.
|
||||
|
||||
To generate keys locally (stores the private key in your Keychain and appends values
|
||||
to `.env`), run:
|
||||
|
||||
```bash
|
||||
./scripts/sparkle_generate_keys.sh
|
||||
```
|
||||
|
||||
For manual appcast generation (uses `SPARKLE_PRIVATE_KEY`):
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY=... ./scripts/sparkle_generate_appcast.sh cmux-macos.dmg vX.Y.Z appcast.xml
|
||||
```
|
||||
|
||||
### Required GitHub secrets
|
||||
|
||||
- `APPLE_CERTIFICATE_BASE64`: Base64-encoded Developer ID Application .p12
|
||||
|
|
@ -18,3 +47,5 @@ staples, and uploads `cmux-macos.dmg` to the release.
|
|||
- `APPLE_ID`: Apple ID used for notarization
|
||||
- `APPLE_APP_SPECIFIC_PASSWORD`: App-specific password for the Apple ID
|
||||
- `APPLE_TEAM_ID`: Apple Developer Team ID
|
||||
- `SPARKLE_PUBLIC_KEY`: Sparkle EdDSA public key for update verification
|
||||
- `SPARKLE_PRIVATE_KEY`: Sparkle EdDSA private key for appcast signing
|
||||
|
|
|
|||
|
|
@ -2,12 +2,18 @@ import AppKit
|
|||
import CoreServices
|
||||
import UserNotifications
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
|
||||
static var shared: AppDelegate?
|
||||
|
||||
weak var tabManager: TabManager?
|
||||
weak var notificationStore: TerminalNotificationStore?
|
||||
private var workspaceObserver: NSObjectProtocol?
|
||||
private let updateController = UpdateController()
|
||||
private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel)
|
||||
|
||||
var updateViewModel: UpdateViewModel {
|
||||
updateController.viewModel
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
|
|
@ -22,6 +28,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
ensureApplicationIcon()
|
||||
observeDuplicateLaunches()
|
||||
configureUserNotifications()
|
||||
updateController.startUpdater()
|
||||
titlebarAccessoryController.start()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
|
|
@ -33,6 +41,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
|||
self.notificationStore = notificationStore
|
||||
}
|
||||
|
||||
@objc func checkForUpdates(_ sender: Any?) {
|
||||
updateController.checkForUpdates()
|
||||
}
|
||||
|
||||
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
updateController.validateMenuItem(item)
|
||||
}
|
||||
|
||||
private func configureUserNotifications() {
|
||||
let actions = [
|
||||
UNNotificationAction(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import AppKit
|
|||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var updateViewModel: UpdateViewModel
|
||||
@EnvironmentObject var tabManager: TabManager
|
||||
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
||||
@State private var sidebarWidth: CGFloat = 200
|
||||
|
|
@ -16,85 +17,85 @@ struct ContentView: View {
|
|||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Vertical Tabs Sidebar
|
||||
VerticalTabsSidebar(
|
||||
sidebarWidth: sidebarWidth,
|
||||
selection: $sidebarSelection,
|
||||
selectedTabIds: $selectedTabIds,
|
||||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
|
||||
)
|
||||
.frame(width: sidebarWidth)
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global))
|
||||
})
|
||||
.overlay(alignment: .trailing) {
|
||||
Color.clear
|
||||
.frame(width: sidebarHandleWidth)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityIdentifier("SidebarResizer")
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
if !isResizerHovering {
|
||||
NSCursor.resizeLeftRight.push()
|
||||
isResizerHovering = true
|
||||
// Vertical Tabs Sidebar
|
||||
VerticalTabsSidebar(
|
||||
sidebarWidth: sidebarWidth,
|
||||
selection: $sidebarSelection,
|
||||
selectedTabIds: $selectedTabIds,
|
||||
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
|
||||
)
|
||||
.frame(width: sidebarWidth)
|
||||
.background(GeometryReader { proxy in
|
||||
Color.clear
|
||||
.preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global))
|
||||
})
|
||||
.overlay(alignment: .trailing) {
|
||||
Color.clear
|
||||
.frame(width: sidebarHandleWidth)
|
||||
.contentShape(Rectangle())
|
||||
.accessibilityIdentifier("SidebarResizer")
|
||||
.onHover { hovering in
|
||||
if hovering {
|
||||
if !isResizerHovering {
|
||||
NSCursor.resizeLeftRight.push()
|
||||
isResizerHovering = true
|
||||
}
|
||||
} else if isResizerHovering {
|
||||
if !isResizerDragging {
|
||||
NSCursor.pop()
|
||||
isResizerHovering = false
|
||||
}
|
||||
}
|
||||
} else if isResizerHovering {
|
||||
if !isResizerDragging {
|
||||
}
|
||||
.onDisappear {
|
||||
if isResizerHovering || isResizerDragging {
|
||||
NSCursor.pop()
|
||||
isResizerHovering = false
|
||||
isResizerDragging = false
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
if isResizerHovering || isResizerDragging {
|
||||
NSCursor.pop()
|
||||
isResizerHovering = false
|
||||
isResizerDragging = false
|
||||
}
|
||||
}
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
if !isResizerDragging {
|
||||
isResizerDragging = true
|
||||
if !isResizerHovering {
|
||||
NSCursor.resizeLeftRight.push()
|
||||
isResizerHovering = true
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
if !isResizerDragging {
|
||||
isResizerDragging = true
|
||||
if !isResizerHovering {
|
||||
NSCursor.resizeLeftRight.push()
|
||||
isResizerHovering = true
|
||||
}
|
||||
}
|
||||
let nextWidth = max(140, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
|
||||
withTransaction(Transaction(animation: nil)) {
|
||||
sidebarWidth = nextWidth
|
||||
}
|
||||
}
|
||||
let nextWidth = max(140, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
|
||||
withTransaction(Transaction(animation: nil)) {
|
||||
sidebarWidth = nextWidth
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if isResizerDragging {
|
||||
isResizerDragging = false
|
||||
if !isResizerHovering {
|
||||
NSCursor.pop()
|
||||
.onEnded { _ in
|
||||
if isResizerDragging {
|
||||
isResizerDragging = false
|
||||
if !isResizerHovering {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Terminal Content - use ZStack to keep all surfaces alive
|
||||
ZStack {
|
||||
ZStack {
|
||||
ForEach(tabManager.tabs) { tab in
|
||||
let isActive = tabManager.selectedTabId == tab.id
|
||||
TerminalSplitTreeView(tab: tab, isTabActive: isActive)
|
||||
.opacity(isActive ? 1 : 0)
|
||||
.allowsHitTesting(isActive)
|
||||
.focusable()
|
||||
.focused($focusedTabId, equals: tab.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
.opacity(sidebarSelection == .tabs ? 1 : 0)
|
||||
.allowsHitTesting(sidebarSelection == .tabs)
|
||||
|
||||
NotificationsPage(selection: $sidebarSelection)
|
||||
// Terminal Content - use ZStack to keep all surfaces alive
|
||||
ZStack {
|
||||
ZStack {
|
||||
ForEach(tabManager.tabs) { tab in
|
||||
let isActive = tabManager.selectedTabId == tab.id
|
||||
TerminalSplitTreeView(tab: tab, isTabActive: isActive)
|
||||
.opacity(isActive ? 1 : 0)
|
||||
.allowsHitTesting(isActive)
|
||||
.focusable()
|
||||
.focused($focusedTabId, equals: tab.id)
|
||||
}
|
||||
}
|
||||
.opacity(sidebarSelection == .tabs ? 1 : 0)
|
||||
.allowsHitTesting(sidebarSelection == .tabs)
|
||||
|
||||
NotificationsPage(selection: $sidebarSelection)
|
||||
.opacity(sidebarSelection == .notifications ? 1 : 0)
|
||||
.allowsHitTesting(sidebarSelection == .notifications)
|
||||
}
|
||||
|
|
@ -139,6 +140,7 @@ struct ContentView: View {
|
|||
sidebarMinX = frame.minX
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct VerticalTabsSidebar: View {
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ class TabManager: ObservableObject {
|
|||
let previousTabId = oldValue
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.focusSelectedTabSurface(previousTabId: previousTabId)
|
||||
self?.updateWindowTitleForSelectedTab()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -399,9 +400,37 @@ class TabManager: ObservableObject {
|
|||
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
|
||||
if tabs[index].title != title {
|
||||
tabs[index].title = title
|
||||
if selectedTabId == tabId {
|
||||
updateWindowTitle(for: tabs[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateWindowTitleForSelectedTab() {
|
||||
guard let selectedTabId,
|
||||
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
|
||||
updateWindowTitle(for: nil)
|
||||
return
|
||||
}
|
||||
updateWindowTitle(for: tab)
|
||||
}
|
||||
|
||||
private func updateWindowTitle(for tab: Tab?) {
|
||||
let title = windowTitle(for: tab)
|
||||
let targetWindow = NSApp.keyWindow ?? NSApp.mainWindow ?? NSApp.windows.first
|
||||
targetWindow?.title = title
|
||||
}
|
||||
|
||||
private func windowTitle(for tab: Tab?) -> String {
|
||||
guard let tab else { return "cmux" }
|
||||
let trimmedTitle = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedTitle.isEmpty {
|
||||
return trimmedTitle
|
||||
}
|
||||
let trimmedDirectory = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedDirectory.isEmpty ? "cmux" : trimmedDirectory
|
||||
}
|
||||
|
||||
func focusTab(_ tabId: UUID, surfaceId: UUID? = nil) {
|
||||
guard tabs.contains(where: { $0.id == tabId }) else { return }
|
||||
selectedTabId = tabId
|
||||
|
|
|
|||
65
Sources/Update/UpdateBadge.swift
Normal file
65
Sources/Update/UpdateBadge.swift
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import SwiftUI
|
||||
|
||||
/// A badge view that displays the current state of an update operation.
|
||||
struct UpdateBadge: View {
|
||||
@ObservedObject var model: UpdateViewModel
|
||||
@State private var rotationAngle: Double = 0
|
||||
|
||||
var body: some View {
|
||||
badgeContent
|
||||
.accessibilityLabel(model.text)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var badgeContent: some View {
|
||||
switch model.state {
|
||||
case .downloading(let download):
|
||||
if let expectedLength = download.expectedLength, expectedLength > 0 {
|
||||
let progress = min(1, max(0, Double(download.progress) / Double(expectedLength)))
|
||||
ProgressRingView(progress: progress)
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
|
||||
case .extracting(let extracting):
|
||||
ProgressRingView(progress: min(1, max(0, extracting.progress)))
|
||||
|
||||
case .checking:
|
||||
if let iconName = model.iconName {
|
||||
Image(systemName: iconName)
|
||||
.rotationEffect(.degrees(rotationAngle))
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 2.5).repeatForever(autoreverses: false)) {
|
||||
rotationAngle = 360
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
rotationAngle = 0
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
if let iconName = model.iconName {
|
||||
Image(systemName: iconName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct ProgressRingView: View {
|
||||
let progress: Double
|
||||
let lineWidth: CGFloat = 2
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.primary.opacity(0.2), lineWidth: lineWidth)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(Color.primary, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.2), value: progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Sources/Update/UpdateController.swift
Normal file
90
Sources/Update/UpdateController.swift
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import Sparkle
|
||||
import Cocoa
|
||||
import Combine
|
||||
|
||||
/// Controller for managing Sparkle updates in cmux.
|
||||
class UpdateController {
|
||||
private(set) var updater: SPUUpdater
|
||||
private let userDriver: UpdateDriver
|
||||
private var installCancellable: AnyCancellable?
|
||||
|
||||
var viewModel: UpdateViewModel {
|
||||
userDriver.viewModel
|
||||
}
|
||||
|
||||
/// True if we're force-installing an update.
|
||||
var isInstalling: Bool {
|
||||
installCancellable != nil
|
||||
}
|
||||
|
||||
init() {
|
||||
let hostBundle = Bundle.main
|
||||
self.userDriver = UpdateDriver(viewModel: .init(), hostBundle: hostBundle)
|
||||
self.updater = SPUUpdater(
|
||||
hostBundle: hostBundle,
|
||||
applicationBundle: hostBundle,
|
||||
userDriver: userDriver,
|
||||
delegate: userDriver
|
||||
)
|
||||
}
|
||||
|
||||
deinit {
|
||||
installCancellable?.cancel()
|
||||
}
|
||||
|
||||
/// Start the updater. If startup fails, the error is shown via the custom UI.
|
||||
func startUpdater() {
|
||||
do {
|
||||
try updater.start()
|
||||
} catch {
|
||||
userDriver.viewModel.state = .error(.init(
|
||||
error: error,
|
||||
retry: { [weak self] in
|
||||
self?.userDriver.viewModel.state = .idle
|
||||
self?.startUpdater()
|
||||
},
|
||||
dismiss: { [weak self] in
|
||||
self?.userDriver.viewModel.state = .idle
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Force install the current update by auto-confirming all installable states.
|
||||
func installUpdate() {
|
||||
guard viewModel.state.isInstallable else { return }
|
||||
guard installCancellable == nil else { return }
|
||||
|
||||
installCancellable = viewModel.$state.sink { [weak self] state in
|
||||
guard let self else { return }
|
||||
guard state.isInstallable else {
|
||||
self.installCancellable = nil
|
||||
return
|
||||
}
|
||||
state.confirm()
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for updates (used by the menu item).
|
||||
@objc func checkForUpdates() {
|
||||
if viewModel.state == .idle {
|
||||
updater.checkForUpdates()
|
||||
return
|
||||
}
|
||||
|
||||
installCancellable?.cancel()
|
||||
viewModel.state.cancel()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in
|
||||
self?.updater.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the check for updates menu item.
|
||||
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
if item.action == #selector(checkForUpdates) {
|
||||
return updater.canCheckForUpdates
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
28
Sources/Update/UpdateDelegate.swift
Normal file
28
Sources/Update/UpdateDelegate.swift
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import Sparkle
|
||||
import Cocoa
|
||||
|
||||
extension UpdateDriver: SPUUpdaterDelegate {
|
||||
func feedURLString(for updater: SPUUpdater) -> String? {
|
||||
Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String
|
||||
}
|
||||
|
||||
/// Called when an update is scheduled to install silently,
|
||||
/// which occurs when automatic download is enabled.
|
||||
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
|
||||
viewModel.state = .installing(.init(
|
||||
isAutoUpdate: true,
|
||||
retryTerminatingApplication: immediateInstallHandler,
|
||||
dismiss: { [weak viewModel] in
|
||||
viewModel?.state = .idle
|
||||
}
|
||||
))
|
||||
return true
|
||||
}
|
||||
|
||||
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
|
||||
NSApp.invalidateRestorableState()
|
||||
for window in NSApp.windows {
|
||||
window.invalidateRestorableState()
|
||||
}
|
||||
}
|
||||
}
|
||||
181
Sources/Update/UpdateDriver.swift
Normal file
181
Sources/Update/UpdateDriver.swift
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import Cocoa
|
||||
import Sparkle
|
||||
|
||||
/// SPUUserDriver that updates the view model for custom update UI.
|
||||
class UpdateDriver: NSObject, SPUUserDriver {
|
||||
let viewModel: UpdateViewModel
|
||||
let standard: SPUStandardUserDriver
|
||||
|
||||
init(viewModel: UpdateViewModel, hostBundle: Bundle) {
|
||||
self.viewModel = viewModel
|
||||
self.standard = SPUStandardUserDriver(hostBundle: hostBundle, delegate: nil)
|
||||
super.init()
|
||||
}
|
||||
|
||||
func show(_ request: SPUUpdatePermissionRequest,
|
||||
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
|
||||
viewModel.state = .permissionRequest(.init(request: request, reply: { [weak viewModel] response in
|
||||
viewModel?.state = .idle
|
||||
reply(response)
|
||||
}))
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.show(request, reply: reply)
|
||||
}
|
||||
}
|
||||
|
||||
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
|
||||
viewModel.state = .checking(.init(cancel: cancellation))
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showUserInitiatedUpdateCheck(cancellation: cancellation)
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdateFound(with appcastItem: SUAppcastItem,
|
||||
state: SPUUserUpdateState,
|
||||
reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
|
||||
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showUpdateFound(with: appcastItem, state: state, reply: reply)
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
|
||||
// cmux uses Sparkle's UI for release notes links instead.
|
||||
}
|
||||
|
||||
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {
|
||||
// Release notes are handled via link buttons.
|
||||
}
|
||||
|
||||
func showUpdateNotFoundWithError(_ error: any Error,
|
||||
acknowledgement: @escaping () -> Void) {
|
||||
viewModel.state = .notFound(.init(acknowledgement: acknowledgement))
|
||||
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdaterError(_ error: any Error,
|
||||
acknowledgement: @escaping () -> Void) {
|
||||
viewModel.state = .error(.init(
|
||||
error: error,
|
||||
retry: { [weak viewModel] in
|
||||
viewModel?.state = .idle
|
||||
DispatchQueue.main.async {
|
||||
guard let delegate = NSApp.delegate as? AppDelegate else { return }
|
||||
delegate.checkForUpdates(nil)
|
||||
}
|
||||
},
|
||||
dismiss: { [weak viewModel] in
|
||||
viewModel?.state = .idle
|
||||
}))
|
||||
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showUpdaterError(error, acknowledgement: acknowledgement)
|
||||
} else {
|
||||
acknowledgement()
|
||||
}
|
||||
}
|
||||
|
||||
func showDownloadInitiated(cancellation: @escaping () -> Void) {
|
||||
viewModel.state = .downloading(.init(
|
||||
cancel: cancellation,
|
||||
expectedLength: nil,
|
||||
progress: 0))
|
||||
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showDownloadInitiated(cancellation: cancellation)
|
||||
}
|
||||
}
|
||||
|
||||
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
|
||||
guard case let .downloading(downloading) = viewModel.state else {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.state = .downloading(.init(
|
||||
cancel: downloading.cancel,
|
||||
expectedLength: expectedContentLength,
|
||||
progress: 0))
|
||||
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showDownloadDidReceiveExpectedContentLength(expectedContentLength)
|
||||
}
|
||||
}
|
||||
|
||||
func showDownloadDidReceiveData(ofLength length: UInt64) {
|
||||
guard case let .downloading(downloading) = viewModel.state else {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.state = .downloading(.init(
|
||||
cancel: downloading.cancel,
|
||||
expectedLength: downloading.expectedLength,
|
||||
progress: downloading.progress + length))
|
||||
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showDownloadDidReceiveData(ofLength: length)
|
||||
}
|
||||
}
|
||||
|
||||
func showDownloadDidStartExtractingUpdate() {
|
||||
viewModel.state = .extracting(.init(progress: 0))
|
||||
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showDownloadDidStartExtractingUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
func showExtractionReceivedProgress(_ progress: Double) {
|
||||
viewModel.state = .extracting(.init(progress: progress))
|
||||
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showExtractionReceivedProgress(progress)
|
||||
}
|
||||
}
|
||||
|
||||
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showReady(toInstallAndRelaunch: reply)
|
||||
} else {
|
||||
reply(.install)
|
||||
}
|
||||
}
|
||||
|
||||
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
|
||||
viewModel.state = .installing(.init(
|
||||
retryTerminatingApplication: retryTerminatingApplication,
|
||||
dismiss: { [weak viewModel] in
|
||||
viewModel?.state = .idle
|
||||
}
|
||||
))
|
||||
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showInstallingUpdate(withApplicationTerminated: applicationTerminated, retryTerminatingApplication: retryTerminatingApplication)
|
||||
}
|
||||
}
|
||||
|
||||
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
|
||||
standard.showUpdateInstalledAndRelaunched(relaunched, acknowledgement: acknowledgement)
|
||||
viewModel.state = .idle
|
||||
}
|
||||
|
||||
func showUpdateInFocus() {
|
||||
if !hasUnobtrusiveTarget {
|
||||
standard.showUpdateInFocus()
|
||||
}
|
||||
}
|
||||
|
||||
func dismissUpdateInstallation() {
|
||||
viewModel.state = .idle
|
||||
standard.dismissUpdateInstallation()
|
||||
}
|
||||
|
||||
// MARK: No-Window Fallback
|
||||
|
||||
/// True if there is a target that can render our unobtrusive update checker.
|
||||
var hasUnobtrusiveTarget: Bool {
|
||||
NSApp.windows.contains { $0.isVisible }
|
||||
}
|
||||
}
|
||||
96
Sources/Update/UpdatePill.swift
Normal file
96
Sources/Update/UpdatePill.swift
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// A pill-shaped button that displays update status and provides access to update actions.
|
||||
struct UpdatePill: View {
|
||||
@ObservedObject var model: UpdateViewModel
|
||||
var showWhenIdle: Bool = false
|
||||
var idleText: String = "Check for Updates"
|
||||
var onIdleTap: (() -> Void)?
|
||||
@State private var showPopover = false
|
||||
@State private var resetTask: Task<Void, Never>?
|
||||
|
||||
private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium)
|
||||
|
||||
var body: some View {
|
||||
if !model.state.isIdle || showWhenIdle {
|
||||
pillButton
|
||||
.popover(isPresented: $showPopover, arrowEdge: .bottom) {
|
||||
UpdatePopoverView(model: model)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
.onChange(of: model.state) { newState in
|
||||
resetTask?.cancel()
|
||||
if case .notFound(let notFound) = newState {
|
||||
resetTask = Task { [weak model] in
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
guard !Task.isCancelled, case .notFound? = model?.state else { return }
|
||||
model?.state = .idle
|
||||
notFound.acknowledgement()
|
||||
}
|
||||
} else {
|
||||
resetTask = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pillButton: some View {
|
||||
Button(action: {
|
||||
if model.state.isIdle && showWhenIdle {
|
||||
if let onIdleTap {
|
||||
onIdleTap()
|
||||
} else {
|
||||
showPopover.toggle()
|
||||
}
|
||||
return
|
||||
}
|
||||
if case .notFound(let notFound) = model.state {
|
||||
model.state = .idle
|
||||
notFound.acknowledgement()
|
||||
} else {
|
||||
showPopover.toggle()
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
if model.state.isIdle && showWhenIdle {
|
||||
Image(systemName: "arrow.triangle.2.circlepath")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 14, height: 14)
|
||||
} else {
|
||||
UpdateBadge(model: model)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
|
||||
Text(displayText)
|
||||
.font(Font(textFont))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.frame(width: textWidth)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(model.backgroundColor)
|
||||
)
|
||||
.foregroundColor(model.foregroundColor)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(displayText)
|
||||
.accessibilityLabel(displayText)
|
||||
}
|
||||
|
||||
private var textWidth: CGFloat? {
|
||||
let attributes: [NSAttributedString.Key: Any] = [.font: textFont]
|
||||
let text = model.state.isIdle && showWhenIdle ? idleText : model.maxWidthText
|
||||
let size = (text as NSString).size(withAttributes: attributes)
|
||||
return size.width
|
||||
}
|
||||
|
||||
private var displayText: String {
|
||||
model.state.isIdle && showWhenIdle ? idleText : model.text
|
||||
}
|
||||
}
|
||||
377
Sources/Update/UpdatePopoverView.swift
Normal file
377
Sources/Update/UpdatePopoverView.swift
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
/// Popover view that displays detailed update information and actions.
|
||||
struct UpdatePopoverView: View {
|
||||
@ObservedObject var model: UpdateViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
switch model.state {
|
||||
case .idle:
|
||||
EmptyView()
|
||||
|
||||
case .permissionRequest(let request):
|
||||
PermissionRequestView(request: request, dismiss: dismiss)
|
||||
|
||||
case .checking(let checking):
|
||||
CheckingView(checking: checking, dismiss: dismiss)
|
||||
|
||||
case .updateAvailable(let update):
|
||||
UpdateAvailableView(update: update, dismiss: dismiss)
|
||||
|
||||
case .downloading(let download):
|
||||
DownloadingView(download: download, dismiss: dismiss)
|
||||
|
||||
case .extracting(let extracting):
|
||||
ExtractingView(extracting: extracting)
|
||||
|
||||
case .installing(let installing):
|
||||
InstallingView(installing: installing, dismiss: dismiss)
|
||||
|
||||
case .notFound(let notFound):
|
||||
NotFoundView(notFound: notFound, dismiss: dismiss)
|
||||
|
||||
case .error(let error):
|
||||
UpdateErrorView(error: error, dismiss: dismiss)
|
||||
}
|
||||
}
|
||||
.frame(width: 300)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct PermissionRequestView: View {
|
||||
let request: UpdateState.PermissionRequest
|
||||
let dismiss: DismissAction
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Enable automatic updates?")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
|
||||
Text("cmux can automatically check for updates in the background.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Not Now") {
|
||||
request.reply(SUUpdatePermissionResponse(
|
||||
automaticUpdateChecks: false,
|
||||
sendSystemProfile: false))
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Allow") {
|
||||
request.reply(SUUpdatePermissionResponse(
|
||||
automaticUpdateChecks: true,
|
||||
sendSystemProfile: false))
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct CheckingView: View {
|
||||
let checking: UpdateState.Checking
|
||||
let dismiss: DismissAction
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Checking for updates…")
|
||||
.font(.system(size: 13))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") {
|
||||
checking.cancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct UpdateAvailableView: View {
|
||||
let update: UpdateState.UpdateAvailable
|
||||
let dismiss: DismissAction
|
||||
|
||||
private let labelWidth: CGFloat = 60
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Update Available")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Version:")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: labelWidth, alignment: .trailing)
|
||||
Text(update.appcastItem.displayVersionString)
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
|
||||
if update.appcastItem.contentLength > 0 {
|
||||
HStack(spacing: 6) {
|
||||
Text("Size:")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: labelWidth, alignment: .trailing)
|
||||
Text(ByteCountFormatter.string(fromByteCount: Int64(update.appcastItem.contentLength), countStyle: .file))
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
|
||||
if let date = update.appcastItem.date {
|
||||
HStack(spacing: 6) {
|
||||
Text("Released:")
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: labelWidth, alignment: .trailing)
|
||||
Text(date.formatted(date: .abbreviated, time: .omitted))
|
||||
}
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Skip") {
|
||||
update.reply(.skip)
|
||||
dismiss()
|
||||
}
|
||||
.controlSize(.small)
|
||||
|
||||
Button("Later") {
|
||||
update.reply(.dismiss)
|
||||
dismiss()
|
||||
}
|
||||
.controlSize(.small)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Install and Relaunch") {
|
||||
update.reply(.install)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
|
||||
if let notes = update.releaseNotes {
|
||||
Divider()
|
||||
|
||||
Link(destination: notes.url) {
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 11))
|
||||
Text(notes.label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
Spacer()
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct DownloadingView: View {
|
||||
let download: UpdateState.Downloading
|
||||
let dismiss: DismissAction
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Downloading Update")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
|
||||
if let expectedLength = download.expectedLength, expectedLength > 0 {
|
||||
let progress = min(1, max(0, Double(download.progress) / Double(expectedLength)))
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: progress)
|
||||
Text(String(format: "%.0f%%", progress * 100))
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("Cancel") {
|
||||
download.cancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct ExtractingView: View {
|
||||
let extracting: UpdateState.Extracting
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Preparing Update")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ProgressView(value: min(1, max(0, extracting.progress)), total: 1.0)
|
||||
Text(String(format: "%.0f%%", min(1, max(0, extracting.progress)) * 100))
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct InstallingView: View {
|
||||
let installing: UpdateState.Installing
|
||||
let dismiss: DismissAction
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Restart Required")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
|
||||
Text("The update is ready. Please restart the application to complete the installation.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Restart Later") {
|
||||
installing.dismiss()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.controlSize(.small)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Restart Now") {
|
||||
installing.retryTerminatingApplication()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct NotFoundView: View {
|
||||
let notFound: UpdateState.NotFound
|
||||
let dismiss: DismissAction
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("No Updates Found")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
|
||||
Text("You're already running the latest version.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
Button("OK") {
|
||||
notFound.acknowledgement()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct UpdateErrorView: View {
|
||||
let error: UpdateState.Error
|
||||
let dismiss: DismissAction
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(.orange)
|
||||
.font(.system(size: 13))
|
||||
Text("Update Failed")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
|
||||
Text(error.error.localizedDescription)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("OK") {
|
||||
error.dismiss()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.controlSize(.small)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Retry") {
|
||||
error.retry()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
128
Sources/Update/UpdateTitlebarAccessory.swift
Normal file
128
Sources/Update/UpdateTitlebarAccessory.swift
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import AppKit
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
final class NonDraggableHostingView<Content: View>: NSHostingView<Content> {
|
||||
override var mouseDownCanMoveWindow: Bool { false }
|
||||
}
|
||||
|
||||
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)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
|
||||
final class UpdateAccessoryViewController: NSTitlebarAccessoryViewController {
|
||||
private var sizeCancellable: AnyCancellable?
|
||||
|
||||
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
|
||||
|
||||
sizeCancellable = model.$state
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak hostingView] _ in
|
||||
guard let hostingView else { return }
|
||||
hostingView.invalidateIntrinsicContentSize()
|
||||
hostingView.layoutSubtreeIfNeeded()
|
||||
hostingView.setFrameSize(hostingView.fittingSize)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
final class UpdateTitlebarAccessoryController {
|
||||
private weak var updateViewModel: UpdateViewModel?
|
||||
private var didStart = false
|
||||
private let attachedWindows = NSHashTable<NSWindow>.weakObjects()
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init(viewModel: UpdateViewModel) {
|
||||
self.updateViewModel = viewModel
|
||||
}
|
||||
|
||||
deinit {
|
||||
for observer in observers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !didStart else { return }
|
||||
didStart = true
|
||||
attachToExistingWindows()
|
||||
installObservers()
|
||||
}
|
||||
|
||||
private func installObservers() {
|
||||
let center = NotificationCenter.default
|
||||
observers.append(center.addObserver(
|
||||
forName: NSWindow.didBecomeMainNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
self?.attachIfNeeded(to: window)
|
||||
})
|
||||
|
||||
observers.append(center.addObserver(
|
||||
forName: NSWindow.didBecomeKeyNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
self?.attachIfNeeded(to: window)
|
||||
})
|
||||
}
|
||||
|
||||
private func attachToExistingWindows() {
|
||||
for window in NSApp.windows {
|
||||
attachIfNeeded(to: window)
|
||||
}
|
||||
}
|
||||
|
||||
private func attachIfNeeded(to window: NSWindow) {
|
||||
guard let updateViewModel else { return }
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
345
Sources/Update/UpdateViewModel.swift
Normal file
345
Sources/Update/UpdateViewModel.swift
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import Foundation
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
class UpdateViewModel: ObservableObject {
|
||||
@Published var state: UpdateState = .idle
|
||||
|
||||
var text: String {
|
||||
switch state {
|
||||
case .idle:
|
||||
return ""
|
||||
case .permissionRequest:
|
||||
return "Enable Automatic Updates?"
|
||||
case .checking:
|
||||
return "Checking for Updates…"
|
||||
case .updateAvailable(let update):
|
||||
let version = update.appcastItem.displayVersionString
|
||||
if !version.isEmpty {
|
||||
return "Update Available: \(version)"
|
||||
}
|
||||
return "Update Available"
|
||||
case .downloading(let download):
|
||||
if let expectedLength = download.expectedLength, expectedLength > 0 {
|
||||
let progress = Double(download.progress) / Double(expectedLength)
|
||||
return String(format: "Downloading: %.0f%%", progress * 100)
|
||||
}
|
||||
return "Downloading…"
|
||||
case .extracting(let extracting):
|
||||
return String(format: "Preparing: %.0f%%", extracting.progress * 100)
|
||||
case .installing(let install):
|
||||
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…"
|
||||
case .notFound:
|
||||
return "No Updates Available"
|
||||
case .error(let err):
|
||||
return err.error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
var maxWidthText: String {
|
||||
switch state {
|
||||
case .downloading:
|
||||
return "Downloading: 100%"
|
||||
case .extracting:
|
||||
return "Preparing: 100%"
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String? {
|
||||
switch state {
|
||||
case .idle:
|
||||
return nil
|
||||
case .permissionRequest:
|
||||
return "questionmark.circle"
|
||||
case .checking:
|
||||
return "arrow.triangle.2.circlepath"
|
||||
case .updateAvailable:
|
||||
return "shippingbox.fill"
|
||||
case .downloading:
|
||||
return "arrow.down.circle"
|
||||
case .extracting:
|
||||
return "shippingbox"
|
||||
case .installing:
|
||||
return "power.circle"
|
||||
case .notFound:
|
||||
return "info.circle"
|
||||
case .error:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch state {
|
||||
case .idle:
|
||||
return ""
|
||||
case .permissionRequest:
|
||||
return "Configure automatic update preferences"
|
||||
case .checking:
|
||||
return "Please wait while we check for available updates"
|
||||
case .updateAvailable(let update):
|
||||
return update.releaseNotes?.label ?? "Download and install the latest version"
|
||||
case .downloading:
|
||||
return "Downloading the update package"
|
||||
case .extracting:
|
||||
return "Extracting and preparing the update"
|
||||
case let .installing(install):
|
||||
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart"
|
||||
case .notFound:
|
||||
return "You are running the latest version"
|
||||
case .error:
|
||||
return "An error occurred during the update process"
|
||||
}
|
||||
}
|
||||
|
||||
var badge: String? {
|
||||
switch state {
|
||||
case .updateAvailable(let update):
|
||||
let version = update.appcastItem.displayVersionString
|
||||
return version.isEmpty ? nil : version
|
||||
case .downloading(let download):
|
||||
if let expectedLength = download.expectedLength, expectedLength > 0 {
|
||||
let percentage = Double(download.progress) / Double(expectedLength) * 100
|
||||
return String(format: "%.0f%%", percentage)
|
||||
}
|
||||
return nil
|
||||
case .extracting(let extracting):
|
||||
return String(format: "%.0f%%", extracting.progress * 100)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var iconColor: Color {
|
||||
switch state {
|
||||
case .idle:
|
||||
return .secondary
|
||||
case .permissionRequest:
|
||||
return .white
|
||||
case .checking:
|
||||
return .secondary
|
||||
case .updateAvailable:
|
||||
return .accentColor
|
||||
case .downloading, .extracting, .installing:
|
||||
return .secondary
|
||||
case .notFound:
|
||||
return .secondary
|
||||
case .error:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor: Color {
|
||||
switch state {
|
||||
case .permissionRequest:
|
||||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
|
||||
case .updateAvailable:
|
||||
return .accentColor
|
||||
case .notFound:
|
||||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue)
|
||||
case .error:
|
||||
return .orange.opacity(0.2)
|
||||
default:
|
||||
return Color(nsColor: .controlBackgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundColor: Color {
|
||||
switch state {
|
||||
case .permissionRequest:
|
||||
return .white
|
||||
case .updateAvailable:
|
||||
return .white
|
||||
case .notFound:
|
||||
return .white
|
||||
case .error:
|
||||
return .orange
|
||||
default:
|
||||
return .primary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum UpdateState: Equatable {
|
||||
case idle
|
||||
case permissionRequest(PermissionRequest)
|
||||
case checking(Checking)
|
||||
case updateAvailable(UpdateAvailable)
|
||||
case notFound(NotFound)
|
||||
case error(Error)
|
||||
case downloading(Downloading)
|
||||
case extracting(Extracting)
|
||||
case installing(Installing)
|
||||
|
||||
var isIdle: Bool {
|
||||
if case .idle = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isInstallable: Bool {
|
||||
switch self {
|
||||
case .checking,
|
||||
.updateAvailable,
|
||||
.downloading,
|
||||
.extracting,
|
||||
.installing:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
switch self {
|
||||
case .checking(let checking):
|
||||
checking.cancel()
|
||||
case .updateAvailable(let available):
|
||||
available.reply(.dismiss)
|
||||
case .downloading(let downloading):
|
||||
downloading.cancel()
|
||||
case .notFound(let notFound):
|
||||
notFound.acknowledgement()
|
||||
case .error(let err):
|
||||
err.dismiss()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func confirm() {
|
||||
switch self {
|
||||
case .updateAvailable(let available):
|
||||
available.reply(.install)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
static func == (lhs: UpdateState, rhs: UpdateState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle):
|
||||
return true
|
||||
case (.permissionRequest, .permissionRequest):
|
||||
return true
|
||||
case (.checking, .checking):
|
||||
return true
|
||||
case (.updateAvailable(let lUpdate), .updateAvailable(let rUpdate)):
|
||||
return lUpdate.appcastItem.displayVersionString == rUpdate.appcastItem.displayVersionString
|
||||
case (.notFound, .notFound):
|
||||
return true
|
||||
case (.error(let lErr), .error(let rErr)):
|
||||
return lErr.error.localizedDescription == rErr.error.localizedDescription
|
||||
case (.downloading(let lDown), .downloading(let rDown)):
|
||||
return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength
|
||||
case (.extracting(let lExt), .extracting(let rExt)):
|
||||
return lExt.progress == rExt.progress
|
||||
case (.installing(let lInstall), .installing(let rInstall)):
|
||||
return lInstall.isAutoUpdate == rInstall.isAutoUpdate
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
struct NotFound {
|
||||
let acknowledgement: () -> Void
|
||||
}
|
||||
|
||||
struct PermissionRequest {
|
||||
let request: SPUUpdatePermissionRequest
|
||||
let reply: @Sendable (SUUpdatePermissionResponse) -> Void
|
||||
}
|
||||
|
||||
struct Checking {
|
||||
let cancel: () -> Void
|
||||
}
|
||||
|
||||
struct UpdateAvailable {
|
||||
let appcastItem: SUAppcastItem
|
||||
let reply: @Sendable (SPUUserUpdateChoice) -> Void
|
||||
|
||||
var releaseNotes: ReleaseNotes? {
|
||||
ReleaseNotes(displayVersionString: appcastItem.displayVersionString)
|
||||
}
|
||||
}
|
||||
|
||||
enum ReleaseNotes {
|
||||
case commit(URL)
|
||||
case tagged(URL)
|
||||
|
||||
init?(displayVersionString: String) {
|
||||
let version = displayVersionString
|
||||
|
||||
if let semver = Self.extractSemanticVersion(from: version) {
|
||||
let tag = semver.hasPrefix("v") ? semver : "v\(semver)"
|
||||
if let url = URL(string: "https://github.com/manaflow-ai/GhosttyTabs/releases/tag/\(tag)") {
|
||||
self = .tagged(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard let newHash = Self.extractGitHash(from: version) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let url = URL(string: "https://github.com/manaflow-ai/GhosttyTabs/commit/\(newHash)") {
|
||||
self = .commit(url)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractSemanticVersion(from version: String) -> String? {
|
||||
let pattern = #"v?\d+\.\d+\.\d+"#
|
||||
if let range = version.range(of: pattern, options: .regularExpression) {
|
||||
return String(version[range])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func extractGitHash(from version: String) -> String? {
|
||||
let pattern = #"[0-9a-f]{7,40}"#
|
||||
if let range = version.range(of: pattern, options: .regularExpression) {
|
||||
return String(version[range])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var url: URL {
|
||||
switch self {
|
||||
case .commit(let url): return url
|
||||
case .tagged(let url): return url
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .commit: return "View GitHub Commit"
|
||||
case .tagged: return "View Release Notes"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Error {
|
||||
let error: any Swift.Error
|
||||
let retry: () -> Void
|
||||
let dismiss: () -> Void
|
||||
}
|
||||
|
||||
struct Downloading {
|
||||
let cancel: () -> Void
|
||||
let expectedLength: UInt64?
|
||||
let progress: UInt64
|
||||
}
|
||||
|
||||
struct Extracting {
|
||||
let progress: Double
|
||||
}
|
||||
|
||||
struct Installing {
|
||||
var isAutoUpdate = false
|
||||
let retryTerminatingApplication: () -> Void
|
||||
let dismiss: () -> Void
|
||||
}
|
||||
}
|
||||
135
Sources/WindowToolbarController.swift
Normal file
135
Sources/WindowToolbarController.swift
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
final class WindowToolbarController: NSObject, NSToolbarDelegate {
|
||||
private let commandItemIdentifier = NSToolbarItem.Identifier("cmux.focusedCommand")
|
||||
private let updateItemIdentifier = NSToolbarItem.Identifier("cmux.updatePill")
|
||||
|
||||
private weak var tabManager: TabManager?
|
||||
private weak var updateViewModel: UpdateViewModel?
|
||||
|
||||
private var commandLabels: [ObjectIdentifier: NSTextField] = [:]
|
||||
private var observers: [NSObjectProtocol] = []
|
||||
|
||||
init(updateViewModel: UpdateViewModel) {
|
||||
self.updateViewModel = updateViewModel
|
||||
super.init()
|
||||
}
|
||||
|
||||
deinit {
|
||||
for observer in observers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
func start(tabManager: TabManager) {
|
||||
self.tabManager = tabManager
|
||||
attachToExistingWindows()
|
||||
installObservers()
|
||||
updateFocusedCommandText()
|
||||
}
|
||||
|
||||
private func installObservers() {
|
||||
let center = NotificationCenter.default
|
||||
observers.append(center.addObserver(
|
||||
forName: .ghosttyDidSetTitle,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.updateFocusedCommandText()
|
||||
})
|
||||
|
||||
observers.append(center.addObserver(
|
||||
forName: .ghosttyDidFocusTab,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.updateFocusedCommandText()
|
||||
})
|
||||
|
||||
observers.append(center.addObserver(
|
||||
forName: NSWindow.didBecomeMainNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
guard let window = notification.object as? NSWindow else { return }
|
||||
self?.attach(to: window)
|
||||
})
|
||||
}
|
||||
|
||||
private func attachToExistingWindows() {
|
||||
for window in NSApp.windows {
|
||||
attach(to: window)
|
||||
}
|
||||
}
|
||||
|
||||
private func attach(to window: NSWindow) {
|
||||
guard window.toolbar == nil else { return }
|
||||
let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.toolbar"))
|
||||
toolbar.delegate = self
|
||||
toolbar.displayMode = .iconOnly
|
||||
toolbar.allowsUserCustomization = false
|
||||
toolbar.autosavesConfiguration = false
|
||||
toolbar.showsBaselineSeparator = false
|
||||
window.toolbar = toolbar
|
||||
window.toolbarStyle = .unified
|
||||
window.titleVisibility = .visible
|
||||
}
|
||||
|
||||
private func updateFocusedCommandText() {
|
||||
guard let tabManager else { return }
|
||||
let text: String
|
||||
if let selectedId = tabManager.selectedTabId,
|
||||
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) {
|
||||
let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
text = title.isEmpty ? "Cmd: —" : "Cmd: \(title)"
|
||||
} else {
|
||||
text = "Cmd: —"
|
||||
}
|
||||
|
||||
for label in commandLabels.values {
|
||||
label.stringValue = text
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSToolbarDelegate
|
||||
|
||||
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
[commandItemIdentifier, .flexibleSpace, updateItemIdentifier]
|
||||
}
|
||||
|
||||
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
|
||||
[commandItemIdentifier, .flexibleSpace, updateItemIdentifier]
|
||||
}
|
||||
|
||||
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
|
||||
if itemIdentifier == commandItemIdentifier {
|
||||
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
|
||||
let label = NSTextField(labelWithString: "Cmd: —")
|
||||
label.font = NSFont.systemFont(ofSize: 12, weight: .medium)
|
||||
label.textColor = .secondaryLabelColor
|
||||
label.lineBreakMode = .byTruncatingMiddle
|
||||
label.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
item.view = label
|
||||
commandLabels[ObjectIdentifier(toolbar)] = label
|
||||
updateFocusedCommandText()
|
||||
return item
|
||||
}
|
||||
|
||||
if itemIdentifier == updateItemIdentifier, let updateViewModel {
|
||||
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
|
||||
let view = NonDraggableHostingView(rootView: UpdatePill(
|
||||
model: updateViewModel,
|
||||
showWhenIdle: true,
|
||||
onIdleTap: {
|
||||
guard let delegate = NSApp.delegate as? AppDelegate else { return }
|
||||
delegate.checkForUpdates(nil)
|
||||
}
|
||||
))
|
||||
item.view = view
|
||||
return item
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ struct cmuxApp: App {
|
|||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
ContentView(updateViewModel: appDelegate.updateViewModel)
|
||||
.environmentObject(tabManager)
|
||||
.environmentObject(notificationStore)
|
||||
.onAppear {
|
||||
|
|
@ -23,7 +23,7 @@ struct cmuxApp: App {
|
|||
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore)
|
||||
}
|
||||
}
|
||||
.windowStyle(.hiddenTitleBar)
|
||||
.windowToolbarStyle(.automatic)
|
||||
.commands {
|
||||
CommandGroup(replacing: .appInfo) {
|
||||
Button("About cmux") {
|
||||
|
|
@ -31,6 +31,12 @@ struct cmuxApp: App {
|
|||
}
|
||||
}
|
||||
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates…") {
|
||||
appDelegate.checkForUpdates(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// New tab commands
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Tab") {
|
||||
|
|
|
|||
62
scripts/sparkle_generate_appcast.sh
Executable file
62
scripts/sparkle_generate_appcast.sh
Executable file
|
|
@ -0,0 +1,62 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <dmg-path> <tag> [output-path]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DMG_PATH="$1"
|
||||
TAG="$2"
|
||||
OUT_PATH="${3:-appcast.xml}"
|
||||
|
||||
if [[ -z "${SPARKLE_PRIVATE_KEY:-}" ]]; then
|
||||
echo "SPARKLE_PRIVATE_KEY is required (exported from Sparkle generate_keys)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SPARKLE_VERSION="${SPARKLE_VERSION:-2.8.1}"
|
||||
DOWNLOAD_URL_PREFIX="${DOWNLOAD_URL_PREFIX:-https://github.com/manaflow-ai/GhosttyTabs/releases/download/$TAG/}"
|
||||
RELEASE_NOTES_URL="${RELEASE_NOTES_URL:-https://github.com/manaflow-ai/GhosttyTabs/releases/tag/$TAG}"
|
||||
|
||||
work_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$work_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Cloning Sparkle ${SPARKLE_VERSION}..."
|
||||
git clone --depth 1 --branch "$SPARKLE_VERSION" https://github.com/sparkle-project/Sparkle "$work_dir/Sparkle"
|
||||
|
||||
echo "Building Sparkle generate_appcast tool..."
|
||||
xcodebuild \
|
||||
-project "$work_dir/Sparkle/Sparkle.xcodeproj" \
|
||||
-scheme generate_appcast \
|
||||
-configuration Release \
|
||||
-derivedDataPath "$work_dir/build" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build >/dev/null
|
||||
|
||||
generate_appcast="$work_dir/build/Build/Products/Release/generate_appcast"
|
||||
if [[ ! -x "$generate_appcast" ]]; then
|
||||
echo "generate_appcast binary not found at $generate_appcast" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archives_dir="$work_dir/archives"
|
||||
mkdir -p "$archives_dir"
|
||||
cp "$DMG_PATH" "$archives_dir/$(basename "$DMG_PATH")"
|
||||
|
||||
printf "%s" "$SPARKLE_PRIVATE_KEY" | "$generate_appcast" \
|
||||
--ed-key-file - \
|
||||
--download-url-prefix "$DOWNLOAD_URL_PREFIX" \
|
||||
--full-release-notes-url "$RELEASE_NOTES_URL" \
|
||||
"$archives_dir"
|
||||
|
||||
if [[ ! -f "$archives_dir/appcast.xml" ]]; then
|
||||
echo "appcast.xml not generated." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "$archives_dir/appcast.xml" "$OUT_PATH"
|
||||
echo "Generated appcast at $OUT_PATH"
|
||||
60
scripts/sparkle_generate_keys.sh
Executable file
60
scripts/sparkle_generate_keys.sh
Executable file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SPARKLE_VERSION="${SPARKLE_VERSION:-2.8.1}"
|
||||
SPARKLE_KEYCHAIN_ACCOUNT="${SPARKLE_KEYCHAIN_ACCOUNT:-cmux}"
|
||||
SPARKLE_ENV_FILE="${SPARKLE_ENV_FILE:-.env}"
|
||||
|
||||
work_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$work_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Cloning Sparkle ${SPARKLE_VERSION}..."
|
||||
git clone --depth 1 --branch "$SPARKLE_VERSION" https://github.com/sparkle-project/Sparkle "$work_dir/Sparkle"
|
||||
|
||||
echo "Building Sparkle generate_keys tool..."
|
||||
xcodebuild \
|
||||
-project "$work_dir/Sparkle/Sparkle.xcodeproj" \
|
||||
-scheme generate_keys \
|
||||
-configuration Release \
|
||||
-derivedDataPath "$work_dir/build" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build >/dev/null
|
||||
|
||||
generate_keys="$work_dir/build/Build/Products/Release/generate_keys"
|
||||
if [[ ! -x "$generate_keys" ]]; then
|
||||
echo "generate_keys binary not found at $generate_keys" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Generating or locating Sparkle keys in keychain (account: $SPARKLE_KEYCHAIN_ACCOUNT)..."
|
||||
"$generate_keys" --account "$SPARKLE_KEYCHAIN_ACCOUNT"
|
||||
|
||||
public_key="$("$generate_keys" --account "$SPARKLE_KEYCHAIN_ACCOUNT" -p)"
|
||||
private_key_file="$work_dir/sparkle_private_key.txt"
|
||||
"$generate_keys" --account "$SPARKLE_KEYCHAIN_ACCOUNT" -x "$private_key_file"
|
||||
private_key="$(cat "$private_key_file")"
|
||||
|
||||
if [[ -z "$public_key" || -z "$private_key" ]]; then
|
||||
echo "Failed to generate Sparkle keys." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -f "$SPARKLE_ENV_FILE" ]]; then
|
||||
tmp_env="$work_dir/env.tmp"
|
||||
awk -F= 'BEGIN {OFS="="}
|
||||
$1 == "SPARKLE_PUBLIC_KEY" {next}
|
||||
$1 == "SPARKLE_PRIVATE_KEY" {next}
|
||||
{print}
|
||||
' "$SPARKLE_ENV_FILE" > "$tmp_env"
|
||||
mv "$tmp_env" "$SPARKLE_ENV_FILE"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "SPARKLE_PUBLIC_KEY=$public_key"
|
||||
echo "SPARKLE_PRIVATE_KEY=$private_key"
|
||||
} >> "$SPARKLE_ENV_FILE"
|
||||
|
||||
echo "Sparkle keys written to $SPARKLE_ENV_FILE"
|
||||
Loading…
Add table
Add a link
Reference in a new issue