mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-09 19:36:45 +00:00
Compare commits
12 Commits
unobtrusiv
...
tip
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f2c7f4ec0f | ||
![]() |
d0f800c5fb | ||
![]() |
6e5e726bc2 | ||
![]() |
402c492d94 | ||
![]() |
ea5ea5f98e | ||
![]() |
f4b051a84c | ||
![]() |
3b2ef4c216 | ||
![]() |
beaac8db8b | ||
![]() |
dfb32022d4 | ||
![]() |
e8ebc6f405 | ||
![]() |
5bebd10b7f | ||
![]() |
b56808f138 |
4
.github/workflows/milestone.yml
vendored
4
.github/workflows/milestone.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Milestone Update
|
||||
steps:
|
||||
- name: Set Milestone for PR
|
||||
uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9
|
||||
uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v2.11
|
||||
if: github.event.pull_request.merged == true
|
||||
with:
|
||||
action: bind-pr # `bind-pr` is the default action
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
# Bind milestone to closed issue that has a merged PR fix
|
||||
- name: Set Milestone for Issue
|
||||
uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9
|
||||
uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v2.11
|
||||
if: github.event.issue.state == 'closed'
|
||||
with:
|
||||
action: bind-issue
|
||||
|
2
.github/workflows/publish-tag.yml
vendored
2
.github/workflows/publish-tag.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
echo "Version is valid: ${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Exract the Version
|
||||
- name: Extract the Version
|
||||
id: extract_version
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
|
8
.github/workflows/release-tip.yml
vendored
8
.github/workflows/release-tip.yml
vendored
@@ -188,7 +188,7 @@ jobs:
|
||||
nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password
|
||||
|
||||
- name: Update Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
||||
with:
|
||||
name: 'Ghostty Tip ("Nightly")'
|
||||
prerelease: true
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
# Update Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
||||
with:
|
||||
name: 'Ghostty Tip ("Nightly")'
|
||||
prerelease: true
|
||||
@@ -590,7 +590,7 @@ jobs:
|
||||
|
||||
# Update Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
||||
with:
|
||||
name: 'Ghostty Tip ("Nightly")'
|
||||
prerelease: true
|
||||
@@ -775,7 +775,7 @@ jobs:
|
||||
|
||||
# Update Release
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
||||
with:
|
||||
name: 'Ghostty Tip ("Nightly")'
|
||||
prerelease: true
|
||||
|
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -508,9 +508,9 @@ jobs:
|
||||
- name: Install zig
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Get the zig version from build.zig so that it only needs to be updated
|
||||
$fileContent = Get-Content -Path "build.zig" -Raw
|
||||
$pattern = 'buildpkg\.requireZig\("(.*?)"\);'
|
||||
# Get the zig version from build.zig.zon so that it only needs to be updated
|
||||
$fileContent = Get-Content -Path "build.zig.zon" -Raw
|
||||
$pattern = 'minimum_zig_version\s*=\s*"([^"]+)"'
|
||||
$zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value
|
||||
$version = "zig-x86_64-windows-$zigVersion"
|
||||
Write-Output $version
|
||||
@@ -575,7 +575,7 @@ jobs:
|
||||
- name: Get required Zig version
|
||||
id: zig
|
||||
run: |
|
||||
echo "version=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig)" >> $GITHUB_OUTPUT
|
||||
echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Cache
|
||||
uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18
|
||||
|
10
HACKING.md
10
HACKING.md
@@ -50,24 +50,22 @@ macOS users don't require any additional dependencies.
|
||||
## Xcode Version and SDKs
|
||||
|
||||
Building the Ghostty macOS app requires that Xcode, the macOS SDK,
|
||||
and the iOS SDK are all installed.
|
||||
the iOS SDK, and Metal Toolchain are all installed.
|
||||
|
||||
A common issue is that the incorrect version of Xcode is either
|
||||
installed or selected. Use the `xcode-select` command to
|
||||
ensure that the correct version of Xcode is selected:
|
||||
|
||||
```shell-session
|
||||
sudo xcode-select --switch /Applications/Xcode-beta.app
|
||||
sudo xcode-select --switch /Applications/Xcode.app
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> Main branch development of Ghostty is preparing for the next major
|
||||
> macOS release, Tahoe (macOS 26). Therefore, the main branch requires
|
||||
> **Xcode 26 and the macOS 26 SDK**.
|
||||
> Main branch development of Ghostty requires **Xcode 26 and the macOS 26 SDK**.
|
||||
>
|
||||
> You do not need to be running on macOS 26 to build Ghostty, you can
|
||||
> still use Xcode 26 beta on macOS 15 stable.
|
||||
> still use Xcode 26 on macOS 15 stable.
|
||||
|
||||
## AI and Agents
|
||||
|
||||
|
@@ -2,9 +2,11 @@ const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const builtin = @import("builtin");
|
||||
const buildpkg = @import("src/build/main.zig");
|
||||
const appVersion = @import("build.zig.zon").version;
|
||||
const minimumZigVersion = @import("build.zig.zon").minimum_zig_version;
|
||||
|
||||
comptime {
|
||||
buildpkg.requireZig("0.15.1");
|
||||
buildpkg.requireZig(minimumZigVersion);
|
||||
}
|
||||
|
||||
pub fn build(b: *std.Build) !void {
|
||||
@@ -15,7 +17,8 @@ pub fn build(b: *std.Build) !void {
|
||||
// This defines all the available build options (e.g. `-D`). If you
|
||||
// want to know what options are available, you can run `--help` or
|
||||
// you can read `src/build/Config.zig`.
|
||||
const config = try buildpkg.Config.init(b);
|
||||
|
||||
const config = try buildpkg.Config.init(b, appVersion);
|
||||
const test_filters = b.option(
|
||||
[][]const u8,
|
||||
"test-filter",
|
||||
|
@@ -125,14 +125,7 @@
|
||||
"Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift",
|
||||
"Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift",
|
||||
"Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift",
|
||||
Features/Update/UpdateBadge.swift,
|
||||
Features/Update/UpdateController.swift,
|
||||
Features/Update/UpdateDelegate.swift,
|
||||
Features/Update/UpdateDriver.swift,
|
||||
Features/Update/UpdatePill.swift,
|
||||
Features/Update/UpdatePopoverView.swift,
|
||||
Features/Update/UpdateSimulator.swift,
|
||||
Features/Update/UpdateViewModel.swift,
|
||||
"Ghostty/FullscreenMode+Extension.swift",
|
||||
Ghostty/Ghostty.Command.swift,
|
||||
Ghostty/Ghostty.Error.swift,
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import OSLog
|
||||
import Sparkle
|
||||
@@ -99,10 +98,8 @@ class AppDelegate: NSObject,
|
||||
)
|
||||
|
||||
/// Manages updates
|
||||
let updateController = UpdateController()
|
||||
var updateViewModel: UpdateViewModel {
|
||||
updateController.viewModel
|
||||
}
|
||||
let updaterController: SPUStandardUpdaterController
|
||||
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
|
||||
|
||||
/// The elapsed time since the process was started
|
||||
var timeSinceLaunch: TimeInterval {
|
||||
@@ -129,6 +126,15 @@ class AppDelegate: NSObject,
|
||||
}
|
||||
|
||||
override init() {
|
||||
updaterController = SPUStandardUpdaterController(
|
||||
// Important: we must not start the updater here because we need to read our configuration
|
||||
// first to determine whether we're automatically checking, downloading, etc. The updater
|
||||
// is started later in applicationDidFinishLaunching
|
||||
startingUpdater: false,
|
||||
updaterDelegate: updaterDelegate,
|
||||
userDriverDelegate: nil
|
||||
)
|
||||
|
||||
super.init()
|
||||
|
||||
ghostty.delegate = self
|
||||
@@ -173,7 +179,7 @@ class AppDelegate: NSObject,
|
||||
ghosttyConfigDidChange(config: ghostty.config)
|
||||
|
||||
// Start our update checker.
|
||||
updateController.startUpdater()
|
||||
updaterController.startUpdater()
|
||||
|
||||
// Register our service provider. This must happen after everything is initialized.
|
||||
NSApp.servicesProvider = ServiceProvider()
|
||||
@@ -800,12 +806,12 @@ class AppDelegate: NSObject,
|
||||
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
|
||||
// user-based defaults.
|
||||
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
|
||||
updateController.updater.automaticallyChecksForUpdates = false
|
||||
updateController.updater.automaticallyDownloadsUpdates = false
|
||||
updaterController.updater.automaticallyChecksForUpdates = false
|
||||
updaterController.updater.automaticallyDownloadsUpdates = false
|
||||
} else if let autoUpdate = config.autoUpdate {
|
||||
updateController.updater.automaticallyChecksForUpdates =
|
||||
updaterController.updater.automaticallyChecksForUpdates =
|
||||
autoUpdate == .check || autoUpdate == .download
|
||||
updateController.updater.automaticallyDownloadsUpdates =
|
||||
updaterController.updater.automaticallyDownloadsUpdates =
|
||||
autoUpdate == .download
|
||||
}
|
||||
|
||||
@@ -998,11 +1004,9 @@ class AppDelegate: NSObject,
|
||||
}
|
||||
|
||||
@IBAction func checkForUpdates(_ sender: Any?) {
|
||||
updateController.checkForUpdates()
|
||||
//UpdateSimulator.permissionRequest.simulate(with: updateViewModel)
|
||||
updaterController.checkForUpdates(sender)
|
||||
}
|
||||
|
||||
|
||||
@IBAction func newWindow(_ sender: Any?) {
|
||||
_ = TerminalController.newWindow(ghostty)
|
||||
}
|
||||
|
@@ -37,7 +37,7 @@ class QuickTerminalController: BaseTerminalController {
|
||||
|
||||
/// Tracks if we're currently handling a manual resize to prevent recursion
|
||||
private var isHandlingResize: Bool = false
|
||||
|
||||
|
||||
init(_ ghostty: Ghostty.App,
|
||||
position: QuickTerminalPosition = .top,
|
||||
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
|
@@ -48,9 +48,6 @@ class BaseTerminalController: NSWindowController,
|
||||
|
||||
/// This can be set to show/hide the command palette.
|
||||
@Published var commandPaletteIsShowing: Bool = false
|
||||
|
||||
/// Set if the terminal view should show the update overlay.
|
||||
@Published var updateOverlayIsVisible: Bool = false
|
||||
|
||||
/// Whether the terminal surface should focus when the mouse is over it.
|
||||
var focusFollowsMouse: Bool {
|
||||
@@ -821,18 +818,7 @@ class BaseTerminalController: NSWindowController,
|
||||
}
|
||||
}
|
||||
|
||||
func fullscreenDidChange() {
|
||||
guard let fullscreenStyle else { return }
|
||||
|
||||
// When we enter fullscreen, we want to show the update overlay so that it
|
||||
// is easily visible. For native fullscreen this is visible by showing the
|
||||
// menubar but we don't want to rely on that.
|
||||
if fullscreenStyle.isFullscreen {
|
||||
updateOverlayIsVisible = true
|
||||
} else {
|
||||
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
|
||||
}
|
||||
}
|
||||
func fullscreenDidChange() {}
|
||||
|
||||
// MARK: Clipboard Confirmation
|
||||
|
||||
@@ -914,28 +900,6 @@ class BaseTerminalController: NSWindowController,
|
||||
fullscreenStyle = NativeFullscreen(window)
|
||||
fullscreenStyle?.delegate = self
|
||||
}
|
||||
|
||||
// Set our update overlay state
|
||||
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
|
||||
}
|
||||
|
||||
func defaultUpdateOverlayVisibility() -> Bool {
|
||||
guard let window else { return true }
|
||||
|
||||
// No titlebar we always show the update overlay because it can't support
|
||||
// updates in the titlebar
|
||||
guard window.styleMask.contains(.titled) else {
|
||||
return true
|
||||
}
|
||||
|
||||
// If it's a non terminal window we can't trust it has an update accessory,
|
||||
// so we always want to show the overlay.
|
||||
guard let window = window as? TerminalWindow else {
|
||||
return true
|
||||
}
|
||||
|
||||
// Show the overlay if the window isn't.
|
||||
return !window.supportsUpdateAccessory
|
||||
}
|
||||
|
||||
// MARK: NSWindowDelegate
|
||||
|
@@ -31,9 +31,6 @@ protocol TerminalViewModel: ObservableObject {
|
||||
|
||||
/// The command palette state.
|
||||
var commandPaletteIsShowing: Bool { get set }
|
||||
|
||||
/// The update overlay should be visible.
|
||||
var updateOverlayIsVisible: Bool { get }
|
||||
}
|
||||
|
||||
/// The main terminal view. This terminal view supports splits.
|
||||
@@ -112,28 +109,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
self.delegate?.performAction(action, on: surfaceView)
|
||||
}
|
||||
}
|
||||
|
||||
// Show update information above all else.
|
||||
if viewModel.updateOverlayIsVisible {
|
||||
UpdateOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct UpdateOverlay: View {
|
||||
var body: some View {
|
||||
if let appDelegate = NSApp.delegate as? AppDelegate {
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
UpdatePill(model: appDelegate.updateViewModel)
|
||||
.padding(.bottom, 12)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,6 @@
|
||||
import AppKit
|
||||
|
||||
class HiddenTitlebarTerminalWindow: TerminalWindow {
|
||||
// No titlebar, we don't support accessories.
|
||||
override var supportsUpdateAccessory: Bool { false }
|
||||
|
||||
override func awakeFromNib() {
|
||||
super.awakeFromNib()
|
||||
|
||||
|
@@ -14,25 +14,15 @@ class TerminalWindow: NSWindow {
|
||||
|
||||
/// Reset split zoom button in titlebar
|
||||
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
|
||||
|
||||
/// Update notification UI in titlebar
|
||||
private let updateAccessory = NSTitlebarAccessoryViewController()
|
||||
|
||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||
private(set) var derivedConfig: DerivedConfig = .init()
|
||||
|
||||
/// Whether this window supports the update accessory. If this is false, then views within this
|
||||
/// window should determine how to show update notifications.
|
||||
var supportsUpdateAccessory: Bool {
|
||||
// Native window supports it.
|
||||
true
|
||||
}
|
||||
|
||||
/// Gets the terminal controller from the window controller.
|
||||
var terminalController: TerminalController? {
|
||||
windowController as? TerminalController
|
||||
}
|
||||
|
||||
|
||||
// MARK: NSWindow Overrides
|
||||
|
||||
override var toolbar: NSToolbar? {
|
||||
@@ -95,17 +85,6 @@ class TerminalWindow: NSWindow {
|
||||
}))
|
||||
addTitlebarAccessoryViewController(resetZoomAccessory)
|
||||
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Create update notification accessory
|
||||
if supportsUpdateAccessory {
|
||||
updateAccessory.layoutAttribute = .right
|
||||
updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView(
|
||||
viewModel: viewModel,
|
||||
model: appDelegate.updateViewModel
|
||||
))
|
||||
addTitlebarAccessoryViewController(updateAccessory)
|
||||
updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
}
|
||||
}
|
||||
|
||||
// Setup the accessory view for tabs that shows our keyboard shortcuts,
|
||||
@@ -219,9 +198,6 @@ class TerminalWindow: NSWindow {
|
||||
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
|
||||
removeTitlebarAccessoryViewController(at: idx)
|
||||
}
|
||||
|
||||
// We don't need to do this with the update accessory. I don't know why but
|
||||
// everything works fine.
|
||||
}
|
||||
|
||||
private func tabBarDidDisappear() {
|
||||
@@ -460,7 +436,7 @@ class TerminalWindow: NSWindow {
|
||||
standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||
standardWindowButton(.zoomButton)?.isHidden = true
|
||||
}
|
||||
|
||||
|
||||
// MARK: Config
|
||||
|
||||
struct DerivedConfig {
|
||||
@@ -491,20 +467,21 @@ extension TerminalWindow {
|
||||
class ViewModel: ObservableObject {
|
||||
@Published var isSurfaceZoomed: Bool = false
|
||||
@Published var hasToolbar: Bool = false
|
||||
|
||||
/// Calculates the top padding based on toolbar visibility and macOS version
|
||||
fileprivate var accessoryTopPadding: CGFloat {
|
||||
if #available(macOS 26.0, *) {
|
||||
return hasToolbar ? 10 : 5
|
||||
} else {
|
||||
return hasToolbar ? 9 : 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ResetZoomAccessoryView: View {
|
||||
@ObservedObject var viewModel: ViewModel
|
||||
let action: () -> Void
|
||||
|
||||
// The padding from the top that the view appears. This was all just manually
|
||||
// measured based on the OS.
|
||||
var topPadding: CGFloat {
|
||||
if #available(macOS 26.0, *) {
|
||||
return viewModel.hasToolbar ? 10 : 5
|
||||
} else {
|
||||
return viewModel.hasToolbar ? 9 : 4
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viewModel.isSurfaceZoomed {
|
||||
@@ -520,23 +497,10 @@ extension TerminalWindow {
|
||||
}
|
||||
// With a toolbar, the window title is taller, so we need more padding
|
||||
// to properly align.
|
||||
.padding(.top, viewModel.accessoryTopPadding)
|
||||
.padding(.top, topPadding)
|
||||
// We always need space at the end of the titlebar
|
||||
.padding(.trailing, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pill-shaped button that displays update status and provides access to update actions.
|
||||
struct UpdateAccessoryView: View {
|
||||
@ObservedObject var viewModel: ViewModel
|
||||
@ObservedObject var model: UpdateViewModel
|
||||
|
||||
var body: some View {
|
||||
UpdatePill(model: model)
|
||||
.padding(.top, viewModel.accessoryTopPadding)
|
||||
.padding(.trailing, 10)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@@ -8,10 +8,6 @@ import SwiftUI
|
||||
class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate {
|
||||
/// The view model for SwiftUI views
|
||||
private var viewModel = ViewModel()
|
||||
|
||||
/// Titlebar tabs can't support the update accessory because of the way we layout
|
||||
/// the native tabs back into the menu bar.
|
||||
override var supportsUpdateAccessory: Bool { false }
|
||||
|
||||
deinit {
|
||||
tabBarObserver = nil
|
||||
|
@@ -2,10 +2,6 @@ import Cocoa
|
||||
|
||||
/// Titlebar tabs for macOS 13 to 15.
|
||||
class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
||||
/// Titlebar tabs can't support the update accessory because of the way we layout
|
||||
/// the native tabs back into the menu bar.
|
||||
override var supportsUpdateAccessory: Bool { false }
|
||||
|
||||
/// This is used to determine if certain elements should be drawn light or dark and should
|
||||
/// be updated whenever the window background color or surrounding elements changes.
|
||||
fileprivate var isLightTheme: Bool = false
|
||||
|
@@ -1,73 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A badge view that displays the current state of an update operation.
|
||||
///
|
||||
/// Shows different visual indicators based on the update state:
|
||||
/// - Progress ring for downloading/extracting with progress
|
||||
/// - Animated rotating icon for checking/installing
|
||||
/// - Static icon for other states
|
||||
struct UpdateBadge: View {
|
||||
/// The update view model that provides the current state and progress
|
||||
@ObservedObject var model: UpdateViewModel
|
||||
|
||||
/// Current rotation angle for animated icon states
|
||||
@State private var rotationAngle: Double = 0
|
||||
|
||||
var body: some View {
|
||||
switch model.state {
|
||||
case .downloading(let download):
|
||||
if let expectedLength = download.expectedLength, expectedLength > 0 {
|
||||
let progress = Double(download.progress) / Double(expectedLength)
|
||||
ProgressRingView(progress: progress)
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
|
||||
case .extracting(let extracting):
|
||||
ProgressRingView(progress: extracting.progress)
|
||||
|
||||
case .checking, .installing:
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A circular progress indicator with a stroke-based ring design.
|
||||
///
|
||||
/// Displays a partially filled circle that represents progress from 0.0 to 1.0.
|
||||
fileprivate struct ProgressRingView: View {
|
||||
/// The current progress value, ranging from 0.0 (empty) to 1.0 (complete)
|
||||
let progress: Double
|
||||
|
||||
/// The width of the progress ring stroke
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,55 +0,0 @@
|
||||
import Sparkle
|
||||
import Cocoa
|
||||
|
||||
/// Standard controller for managing Sparkle updates in Ghostty.
|
||||
///
|
||||
/// This controller wraps SPUStandardUpdaterController to provide a simpler interface
|
||||
/// for managing updates with Ghostty's custom driver and delegate. It handles
|
||||
/// initialization, starting the updater, and provides the check for updates action.
|
||||
class UpdateController {
|
||||
private(set) var updater: SPUUpdater
|
||||
private let userDriver: UpdateDriver
|
||||
private let updaterDelegate = UpdaterDelegate()
|
||||
|
||||
var viewModel: UpdateViewModel {
|
||||
userDriver.viewModel
|
||||
}
|
||||
|
||||
/// Initialize a new update controller.
|
||||
init() {
|
||||
let hostBundle = Bundle.main
|
||||
self.userDriver = UpdateDriver(viewModel: .init())
|
||||
self.updater = SPUUpdater(
|
||||
hostBundle: hostBundle,
|
||||
applicationBundle: hostBundle,
|
||||
userDriver: userDriver,
|
||||
delegate: updaterDelegate
|
||||
)
|
||||
}
|
||||
|
||||
/// Start the updater.
|
||||
///
|
||||
/// This must be called before the updater can check for updates. If starting fails,
|
||||
/// an error alert will be shown after a short delay.
|
||||
func startUpdater() {
|
||||
try? updater.start()
|
||||
}
|
||||
|
||||
/// Check for updates.
|
||||
///
|
||||
/// This is typically connected to a menu item action.
|
||||
@objc func checkForUpdates() {
|
||||
updater.checkForUpdates()
|
||||
}
|
||||
|
||||
/// Validate the check for updates menu item.
|
||||
///
|
||||
/// - Parameter item: The menu item to validate
|
||||
/// - Returns: Whether the menu item should be enabled
|
||||
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
||||
if item.action == #selector(checkForUpdates) {
|
||||
return updater.canCheckForUpdates
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
|
||||
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// Sparkle supports a native concept of "channels" but it requires that
|
||||
// you share a single appcast file. We don't want to do that so we
|
||||
// do this instead.
|
||||
|
@@ -1,113 +0,0 @@
|
||||
import Cocoa
|
||||
import Sparkle
|
||||
|
||||
/// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation.
|
||||
class UpdateDriver: NSObject, SPUUserDriver {
|
||||
let viewModel: UpdateViewModel
|
||||
|
||||
init(viewModel: UpdateViewModel) {
|
||||
self.viewModel = viewModel
|
||||
super.init()
|
||||
}
|
||||
|
||||
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
|
||||
viewModel.state = .permissionRequest(.init(request: request, reply: reply))
|
||||
}
|
||||
|
||||
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
|
||||
viewModel.state = .checking(.init(cancel: cancellation))
|
||||
}
|
||||
|
||||
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
|
||||
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
|
||||
}
|
||||
|
||||
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
|
||||
// We don't do anything with the release notes here because Ghostty
|
||||
// doesn't use the release notes feature of Sparkle currently.
|
||||
}
|
||||
|
||||
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {
|
||||
// We don't do anything with release notes. See `showUpdateReleaseNotes`
|
||||
}
|
||||
|
||||
func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) {
|
||||
viewModel.state = .notFound
|
||||
// TODO: Do we need to acknowledge?
|
||||
}
|
||||
|
||||
func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
|
||||
viewModel.state = .error(.init(
|
||||
error: error,
|
||||
retry: {
|
||||
guard let delegate = NSApp.delegate as? AppDelegate else {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO fill this in
|
||||
},
|
||||
dismiss: { [weak viewModel] in
|
||||
viewModel?.state = .idle
|
||||
}))
|
||||
}
|
||||
|
||||
func showDownloadInitiated(cancellation: @escaping () -> Void) {
|
||||
viewModel.state = .downloading(.init(
|
||||
cancel: cancellation,
|
||||
expectedLength: nil,
|
||||
progress: 0))
|
||||
}
|
||||
|
||||
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
|
||||
guard case let .downloading(downloading) = viewModel.state else {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.state = .downloading(.init(
|
||||
cancel: downloading.cancel,
|
||||
expectedLength: expectedContentLength,
|
||||
progress: 0))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
func showDownloadDidStartExtractingUpdate() {
|
||||
viewModel.state = .extracting(.init(progress: 0))
|
||||
}
|
||||
|
||||
func showExtractionReceivedProgress(_ progress: Double) {
|
||||
viewModel.state = .extracting(.init(progress: progress))
|
||||
}
|
||||
|
||||
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
|
||||
viewModel.state = .readyToInstall(.init(reply: reply))
|
||||
}
|
||||
|
||||
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
|
||||
viewModel.state = .installing
|
||||
}
|
||||
|
||||
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
|
||||
// We don't do anything here.
|
||||
viewModel.state = .idle
|
||||
}
|
||||
|
||||
func showUpdateInFocus() {
|
||||
// We don't currently implement this because our update state is
|
||||
// shown in a terminal window. We may want to implement this at some
|
||||
// point to handle the case that no windows are open, though.
|
||||
}
|
||||
|
||||
func dismissUpdateInstallation() {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
@@ -1,61 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
/// A pill-shaped button that displays update status and provides access to update actions.
|
||||
struct UpdatePill: View {
|
||||
/// The update view model that provides the current state and information
|
||||
@ObservedObject var model: UpdateViewModel
|
||||
|
||||
/// Whether the update popover is currently visible
|
||||
@State private var showPopover = false
|
||||
|
||||
var body: some View {
|
||||
if !model.state.isIdle {
|
||||
pillButton
|
||||
.popover(isPresented: $showPopover, arrowEdge: .bottom) {
|
||||
UpdatePopoverView(model: model)
|
||||
}
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.95)))
|
||||
.onChange(of: model.state) { newState in
|
||||
if case .notFound = newState {
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
if case .notFound = model.state {
|
||||
model.state = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pill-shaped button view that displays the update badge and text
|
||||
@ViewBuilder
|
||||
private var pillButton: some View {
|
||||
Button(action: {
|
||||
if case .notFound = model.state {
|
||||
model.state = .idle
|
||||
} else {
|
||||
showPopover.toggle()
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
UpdateBadge(model: model)
|
||||
.frame(width: 14, height: 14)
|
||||
|
||||
Text(model.text)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(model.backgroundColor)
|
||||
)
|
||||
.foregroundColor(model.foregroundColor)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(model.text)
|
||||
}
|
||||
}
|
@@ -1,402 +0,0 @@
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
/// A popover view that displays detailed update information and action buttons.
|
||||
///
|
||||
/// The view adapts its content based on the current update state, showing appropriate
|
||||
/// UI for checking, downloading, installing, or handling errors.
|
||||
struct UpdatePopoverView: View {
|
||||
/// The update view model that provides the current state and information
|
||||
@ObservedObject var model: UpdateViewModel
|
||||
|
||||
/// Environment value for dismissing the popover
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
switch model.state {
|
||||
case .idle:
|
||||
// Shouldn't happen in a well-formed view stack. Higher levels
|
||||
// should not call the popover for idles.
|
||||
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 .readyToInstall(let ready):
|
||||
ReadyToInstallView(ready: ready, dismiss: dismiss)
|
||||
|
||||
case .installing:
|
||||
InstallingView()
|
||||
|
||||
case .notFound:
|
||||
NotFoundView(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("Ghostty 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") {
|
||||
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 = 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: extracting.progress, total: 1.0)
|
||||
Text(String(format: "%.0f%%", extracting.progress * 100))
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct ReadyToInstallView: View {
|
||||
let ready: UpdateState.ReadyToInstall
|
||||
let dismiss: DismissAction
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Ready to Install")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
|
||||
Text("The update is ready to install.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Later") {
|
||||
ready.reply(.dismiss)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.controlSize(.small)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button("Install and Relaunch") {
|
||||
ready.reply(.install)
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct InstallingView: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
Text("Installing…")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
}
|
||||
|
||||
Text("The application will relaunch shortly.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct NotFoundView: View {
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
}
|
@@ -1,275 +0,0 @@
|
||||
import Foundation
|
||||
import Sparkle
|
||||
|
||||
/// Simulates various update scenarios for testing the update UI.
|
||||
///
|
||||
/// The expected usage is by overriding the `checkForUpdates` function in AppDelegate and
|
||||
/// calling one of these instead. This will allow us to test the update flows without having to use
|
||||
/// real updates.
|
||||
enum UpdateSimulator {
|
||||
/// Complete successful update flow: checking → available → download → extract → ready → install → idle
|
||||
case happyPath
|
||||
|
||||
/// No updates available: checking (2s) → "No Updates Available" (3s) → idle
|
||||
case notFound
|
||||
|
||||
/// Error during check: checking (2s) → error with retry callback
|
||||
case error
|
||||
|
||||
/// Slower download for testing progress UI: checking → available → download (20 steps, ~10s) → extract → install
|
||||
case slowDownload
|
||||
|
||||
/// Initial permission request flow: shows permission dialog → proceeds with happy path if accepted
|
||||
case permissionRequest
|
||||
|
||||
/// User cancels during download: checking → available → download (5 steps) → cancels → idle
|
||||
case cancelDuringDownload
|
||||
|
||||
/// User cancels while checking: checking (1s) → cancels → idle
|
||||
case cancelDuringChecking
|
||||
|
||||
func simulate(with viewModel: UpdateViewModel) {
|
||||
switch self {
|
||||
case .happyPath:
|
||||
simulateHappyPath(viewModel)
|
||||
case .notFound:
|
||||
simulateNotFound(viewModel)
|
||||
case .error:
|
||||
simulateError(viewModel)
|
||||
case .slowDownload:
|
||||
simulateSlowDownload(viewModel)
|
||||
case .permissionRequest:
|
||||
simulatePermissionRequest(viewModel)
|
||||
case .cancelDuringDownload:
|
||||
simulateCancelDuringDownload(viewModel)
|
||||
case .cancelDuringChecking:
|
||||
simulateCancelDuringChecking(viewModel)
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateHappyPath(_ viewModel: UpdateViewModel) {
|
||||
viewModel.state = .checking(.init(cancel: {
|
||||
viewModel.state = .idle
|
||||
}))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
viewModel.state = .updateAvailable(.init(
|
||||
appcastItem: SUAppcastItem.empty(),
|
||||
reply: { choice in
|
||||
if choice == .install {
|
||||
simulateDownload(viewModel)
|
||||
} else {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateNotFound(_ viewModel: UpdateViewModel) {
|
||||
viewModel.state = .checking(.init(cancel: {
|
||||
viewModel.state = .idle
|
||||
}))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
viewModel.state = .notFound
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateError(_ viewModel: UpdateViewModel) {
|
||||
viewModel.state = .checking(.init(cancel: {
|
||||
viewModel.state = .idle
|
||||
}))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
viewModel.state = .error(.init(
|
||||
error: NSError(domain: "UpdateError", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Failed to check for updates"
|
||||
]),
|
||||
retry: {
|
||||
simulateHappyPath(viewModel)
|
||||
},
|
||||
dismiss: {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateSlowDownload(_ viewModel: UpdateViewModel) {
|
||||
viewModel.state = .checking(.init(cancel: {
|
||||
viewModel.state = .idle
|
||||
}))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
viewModel.state = .updateAvailable(.init(
|
||||
appcastItem: SUAppcastItem.empty(),
|
||||
reply: { choice in
|
||||
if choice == .install {
|
||||
simulateSlowDownloadProgress(viewModel)
|
||||
} else {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateSlowDownloadProgress(_ viewModel: UpdateViewModel) {
|
||||
let download = UpdateState.Downloading(
|
||||
cancel: {
|
||||
viewModel.state = .idle
|
||||
},
|
||||
expectedLength: nil,
|
||||
progress: 0
|
||||
)
|
||||
viewModel.state = .downloading(download)
|
||||
|
||||
for i in 1...20 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.5) {
|
||||
let updatedDownload = UpdateState.Downloading(
|
||||
cancel: download.cancel,
|
||||
expectedLength: 2000,
|
||||
progress: UInt64(i * 100)
|
||||
)
|
||||
viewModel.state = .downloading(updatedDownload)
|
||||
|
||||
if i == 20 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
simulateExtract(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func simulatePermissionRequest(_ viewModel: UpdateViewModel) {
|
||||
let request = SPUUpdatePermissionRequest(systemProfile: [])
|
||||
viewModel.state = .permissionRequest(.init(
|
||||
request: request,
|
||||
reply: { response in
|
||||
if response.automaticUpdateChecks {
|
||||
simulateHappyPath(viewModel)
|
||||
} else {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
private func simulateCancelDuringDownload(_ viewModel: UpdateViewModel) {
|
||||
viewModel.state = .checking(.init(cancel: {
|
||||
viewModel.state = .idle
|
||||
}))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
viewModel.state = .updateAvailable(.init(
|
||||
appcastItem: SUAppcastItem.empty(),
|
||||
reply: { choice in
|
||||
if choice == .install {
|
||||
simulateDownloadThenCancel(viewModel)
|
||||
} else {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateDownloadThenCancel(_ viewModel: UpdateViewModel) {
|
||||
let download = UpdateState.Downloading(
|
||||
cancel: {
|
||||
viewModel.state = .idle
|
||||
},
|
||||
expectedLength: nil,
|
||||
progress: 0
|
||||
)
|
||||
viewModel.state = .downloading(download)
|
||||
|
||||
for i in 1...5 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
|
||||
let updatedDownload = UpdateState.Downloading(
|
||||
cancel: download.cancel,
|
||||
expectedLength: 1000,
|
||||
progress: UInt64(i * 100)
|
||||
)
|
||||
viewModel.state = .downloading(updatedDownload)
|
||||
|
||||
if i == 5 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateCancelDuringChecking(_ viewModel: UpdateViewModel) {
|
||||
viewModel.state = .checking(.init(cancel: {
|
||||
viewModel.state = .idle
|
||||
}))
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateDownload(_ viewModel: UpdateViewModel) {
|
||||
let download = UpdateState.Downloading(
|
||||
cancel: {
|
||||
viewModel.state = .idle
|
||||
},
|
||||
expectedLength: nil,
|
||||
progress: 0
|
||||
)
|
||||
viewModel.state = .downloading(download)
|
||||
|
||||
for i in 1...10 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(i) * 0.3) {
|
||||
let updatedDownload = UpdateState.Downloading(
|
||||
cancel: download.cancel,
|
||||
expectedLength: 1000,
|
||||
progress: UInt64(i * 100)
|
||||
)
|
||||
viewModel.state = .downloading(updatedDownload)
|
||||
|
||||
if i == 10 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
simulateExtract(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func simulateExtract(_ viewModel: UpdateViewModel) {
|
||||
viewModel.state = .extracting(.init(progress: 0.0))
|
||||
|
||||
for j in 1...5 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(j) * 0.3) {
|
||||
viewModel.state = .extracting(.init(progress: Double(j) / 5.0))
|
||||
|
||||
if j == 5 {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
viewModel.state = .readyToInstall(.init(
|
||||
reply: { choice in
|
||||
if choice == .install {
|
||||
viewModel.state = .installing
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
} else {
|
||||
viewModel.state = .idle
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,267 +0,0 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
class UpdateViewModel: ObservableObject {
|
||||
@Published var state: UpdateState = .idle
|
||||
|
||||
/// The text to display for the current update state.
|
||||
/// Returns an empty string for idle state, progress percentages for downloading/extracting,
|
||||
/// or descriptive text for other states.
|
||||
var text: String {
|
||||
switch state {
|
||||
case .idle:
|
||||
return ""
|
||||
case .permissionRequest:
|
||||
return "Enable Automatic Updates?"
|
||||
case .checking:
|
||||
return "Checking for Updates…"
|
||||
case .updateAvailable(let update):
|
||||
return "Update Available: \(update.appcastItem.displayVersionString)"
|
||||
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 .readyToInstall:
|
||||
return "Install Update"
|
||||
case .installing:
|
||||
return "Installing…"
|
||||
case .notFound:
|
||||
return "No Updates Available"
|
||||
case .error(let err):
|
||||
return err.error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// The SF Symbol icon name for the current update state.
|
||||
/// Returns nil for idle, downloading, and extracting states.
|
||||
var iconName: String? {
|
||||
switch state {
|
||||
case .idle:
|
||||
return nil
|
||||
case .permissionRequest:
|
||||
return "questionmark.circle"
|
||||
case .checking:
|
||||
return "arrow.triangle.2.circlepath"
|
||||
case .updateAvailable:
|
||||
return "arrow.down.circle.fill"
|
||||
case .downloading, .extracting:
|
||||
return nil
|
||||
case .readyToInstall:
|
||||
return "checkmark.circle.fill"
|
||||
case .installing:
|
||||
return "gear"
|
||||
case .notFound:
|
||||
return "info.circle"
|
||||
case .error:
|
||||
return "exclamationmark.triangle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// The color to apply to the icon for the current update state.
|
||||
var iconColor: Color {
|
||||
switch state {
|
||||
case .idle:
|
||||
return .secondary
|
||||
case .permissionRequest:
|
||||
return .white
|
||||
case .checking:
|
||||
return .secondary
|
||||
case .updateAvailable, .readyToInstall:
|
||||
return .accentColor
|
||||
case .downloading, .extracting, .installing:
|
||||
return .secondary
|
||||
case .notFound:
|
||||
return .secondary
|
||||
case .error:
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
/// The background color for the update pill.
|
||||
var backgroundColor: Color {
|
||||
switch state {
|
||||
case .permissionRequest:
|
||||
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
|
||||
case .updateAvailable:
|
||||
return .accentColor
|
||||
case .readyToInstall:
|
||||
return Color(nsColor: NSColor.systemGreen.blended(withFraction: 0.3, of: .black) ?? .systemGreen)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/// The foreground (text) color for the update pill.
|
||||
var foregroundColor: Color {
|
||||
switch state {
|
||||
case .permissionRequest:
|
||||
return .white
|
||||
case .updateAvailable, .readyToInstall:
|
||||
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
|
||||
case error(Error)
|
||||
case downloading(Downloading)
|
||||
case extracting(Extracting)
|
||||
case readyToInstall(ReadyToInstall)
|
||||
case installing
|
||||
|
||||
var isIdle: Bool {
|
||||
if case .idle = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
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 (.readyToInstall, .readyToInstall):
|
||||
return true
|
||||
case (.installing, .installing):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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? {
|
||||
let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String
|
||||
return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit)
|
||||
}
|
||||
}
|
||||
|
||||
enum ReleaseNotes {
|
||||
case commit(URL)
|
||||
case compareTip(URL)
|
||||
case tagged(URL)
|
||||
|
||||
init?(displayVersionString: String, currentCommit: String?) {
|
||||
let version = displayVersionString
|
||||
|
||||
// Check for semantic version (x.y.z)
|
||||
if let semver = Self.extractSemanticVersion(from: version) {
|
||||
let slug = semver.replacingOccurrences(of: ".", with: "-")
|
||||
if let url = URL(string: "https://ghostty.org/docs/install/release-notes/\(slug)") {
|
||||
self = .tagged(url)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to git hash detection
|
||||
guard let newHash = Self.extractGitHash(from: version) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let currentHash = currentCommit, !currentHash.isEmpty,
|
||||
let url = URL(string: "https://github.com/ghostty-org/ghostty/compare/\(currentHash)...\(newHash)") {
|
||||
self = .compareTip(url)
|
||||
} else if let url = URL(string: "https://github.com/ghostty-org/ghostty/commit/\(newHash)") {
|
||||
self = .commit(url)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func extractSemanticVersion(from version: String) -> String? {
|
||||
let pattern = #"^\d+\.\d+\.\d+$"#
|
||||
if version.range(of: pattern, options: .regularExpression) != nil {
|
||||
return version
|
||||
}
|
||||
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 .compareTip(let url): return url
|
||||
case .tagged(let url): return url
|
||||
}
|
||||
}
|
||||
|
||||
var label: String {
|
||||
switch (self) {
|
||||
case .commit: return "View GitHub Commit"
|
||||
case .compareTip: return "Changes Since This Tip Release"
|
||||
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 ReadyToInstall {
|
||||
let reply: @Sendable (SPUUserUpdateChoice) -> Void
|
||||
}
|
||||
}
|
@@ -1,130 +0,0 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import Ghostty
|
||||
|
||||
struct ReleaseNotesTests {
|
||||
/// Test tagged release (semantic version)
|
||||
@Test func testTaggedRelease() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "1.2.3",
|
||||
currentCommit: nil
|
||||
)
|
||||
|
||||
#expect(notes != nil)
|
||||
if case .tagged(let url) = notes {
|
||||
#expect(url.absoluteString == "https://ghostty.org/docs/install/release-notes/1-2-3")
|
||||
#expect(notes?.label == "View Release Notes")
|
||||
} else {
|
||||
Issue.record("Expected tagged case")
|
||||
}
|
||||
}
|
||||
|
||||
/// Test tip release comparison with current commit
|
||||
@Test func testTipReleaseComparison() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "tip-abc1234",
|
||||
currentCommit: "def5678"
|
||||
)
|
||||
|
||||
#expect(notes != nil)
|
||||
if case .compareTip(let url) = notes {
|
||||
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234")
|
||||
#expect(notes?.label == "Changes Since This Tip Release")
|
||||
} else {
|
||||
Issue.record("Expected compareTip case")
|
||||
}
|
||||
}
|
||||
|
||||
/// Test tip release without current commit
|
||||
@Test func testTipReleaseWithoutCurrentCommit() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "tip-abc1234",
|
||||
currentCommit: nil
|
||||
)
|
||||
|
||||
#expect(notes != nil)
|
||||
if case .commit(let url) = notes {
|
||||
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234")
|
||||
#expect(notes?.label == "View GitHub Commit")
|
||||
} else {
|
||||
Issue.record("Expected commit case")
|
||||
}
|
||||
}
|
||||
|
||||
/// Test tip release with empty current commit
|
||||
@Test func testTipReleaseWithEmptyCurrentCommit() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "tip-abc1234",
|
||||
currentCommit: ""
|
||||
)
|
||||
|
||||
#expect(notes != nil)
|
||||
if case .commit(let url) = notes {
|
||||
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/abc1234")
|
||||
} else {
|
||||
Issue.record("Expected commit case")
|
||||
}
|
||||
}
|
||||
|
||||
/// Test version with full 40-character hash
|
||||
@Test func testFullGitHash() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "tip-1234567890abcdef1234567890abcdef12345678",
|
||||
currentCommit: nil
|
||||
)
|
||||
|
||||
#expect(notes != nil)
|
||||
if case .commit(let url) = notes {
|
||||
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/commit/1234567890abcdef1234567890abcdef12345678")
|
||||
} else {
|
||||
Issue.record("Expected commit case")
|
||||
}
|
||||
}
|
||||
|
||||
/// Test version with no recognizable pattern
|
||||
@Test func testInvalidVersion() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "unknown-version",
|
||||
currentCommit: nil
|
||||
)
|
||||
|
||||
#expect(notes == nil)
|
||||
}
|
||||
|
||||
/// Test semantic version with prerelease suffix should not match
|
||||
@Test func testSemanticVersionWithSuffix() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "1.2.3-beta",
|
||||
currentCommit: nil
|
||||
)
|
||||
|
||||
// Should not match semantic version pattern, falls back to hash detection
|
||||
#expect(notes == nil)
|
||||
}
|
||||
|
||||
/// Test semantic version with 4 components should not match
|
||||
@Test func testSemanticVersionFourComponents() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "1.2.3.4",
|
||||
currentCommit: nil
|
||||
)
|
||||
|
||||
// Should not match pattern
|
||||
#expect(notes == nil)
|
||||
}
|
||||
|
||||
/// Test version string with git hash embedded
|
||||
@Test func testVersionWithEmbeddedHash() async throws {
|
||||
let notes = UpdateState.ReleaseNotes(
|
||||
displayVersionString: "v2024.01.15-abc1234",
|
||||
currentCommit: "def5678"
|
||||
)
|
||||
|
||||
#expect(notes != nil)
|
||||
if case .compareTip(let url) = notes {
|
||||
#expect(url.absoluteString == "https://github.com/ghostty-org/ghostty/compare/def5678...abc1234")
|
||||
} else {
|
||||
Issue.record("Expected compareTip case")
|
||||
}
|
||||
}
|
||||
}
|
@@ -16,13 +16,6 @@ const expandPath = @import("../os/path.zig").expand;
|
||||
const gtk = @import("gtk.zig");
|
||||
const GitVersion = @import("GitVersion.zig");
|
||||
|
||||
/// The version of the next release.
|
||||
///
|
||||
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
|
||||
/// Until then this MUST match build.zig.zon and should always be the
|
||||
/// _next_ version to release.
|
||||
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 1 };
|
||||
|
||||
/// Standard build configuration options.
|
||||
optimize: std.builtin.OptimizeMode,
|
||||
target: std.Build.ResolvedTarget,
|
||||
@@ -69,7 +62,7 @@ emit_unicode_table_gen: bool = false,
|
||||
/// Environmental properties
|
||||
env: std.process.EnvMap,
|
||||
|
||||
pub fn init(b: *std.Build) !Config {
|
||||
pub fn init(b: *std.Build, appVersion: []const u8) !Config {
|
||||
// Setup our standard Zig target and optimize options, i.e.
|
||||
// `-Doptimize` and `-Dtarget`.
|
||||
const optimize = b.standardOptimizeOption(.{});
|
||||
@@ -217,6 +210,7 @@ pub fn init(b: *std.Build) !Config {
|
||||
// If an explicit version is given, we always use it.
|
||||
try std.SemanticVersion.parse(v)
|
||||
else version: {
|
||||
const app_version = try std.SemanticVersion.parse(appVersion);
|
||||
// If no explicit version is given, we try to detect it from git.
|
||||
const vsn = GitVersion.detect(b) catch |err| switch (err) {
|
||||
// If Git isn't available we just make an unknown dev version.
|
||||
|
@@ -24,12 +24,12 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY ./build.zig /src
|
||||
COPY ./build.zig ./build.zig.zon /src/
|
||||
|
||||
# Install zig
|
||||
# https://ziglang.org/download/
|
||||
|
||||
RUN export ZIG_VERSION=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \
|
||||
RUN export ZIG_VERSION=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \
|
||||
tar -xf /tmp/zig.tar.xz -C /opt && \
|
||||
rm /tmp/zig.tar.xz && \
|
||||
ln -s "/opt/zig-$(uname -m)-linux-$ZIG_VERSION/zig" /usr/local/bin/zig
|
||||
@@ -41,4 +41,3 @@ RUN zig build \
|
||||
-Dcpu=baseline
|
||||
|
||||
RUN ./zig-out/bin/ghostty +version
|
||||
|
||||
|
Reference in New Issue
Block a user