mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-10 11:56:33 +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
|
name: Milestone Update
|
||||||
steps:
|
steps:
|
||||||
- name: Set Milestone for PR
|
- 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
|
if: github.event.pull_request.merged == true
|
||||||
with:
|
with:
|
||||||
action: bind-pr # `bind-pr` is the default action
|
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
|
# Bind milestone to closed issue that has a merged PR fix
|
||||||
- name: Set Milestone for Issue
|
- 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'
|
if: github.event.issue.state == 'closed'
|
||||||
with:
|
with:
|
||||||
action: bind-issue
|
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 }}"
|
echo "Version is valid: ${{ github.event.inputs.version }}"
|
||||||
|
|
||||||
- name: Exract the Version
|
- name: Extract the Version
|
||||||
id: extract_version
|
id: extract_version
|
||||||
run: |
|
run: |
|
||||||
VERSION=${{ github.event.inputs.version }}
|
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
|
nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password
|
||||||
|
|
||||||
- name: Update Release
|
- name: Update Release
|
||||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
||||||
with:
|
with:
|
||||||
name: 'Ghostty Tip ("Nightly")'
|
name: 'Ghostty Tip ("Nightly")'
|
||||||
prerelease: true
|
prerelease: true
|
||||||
@@ -359,7 +359,7 @@ jobs:
|
|||||||
|
|
||||||
# Update Release
|
# Update Release
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
||||||
with:
|
with:
|
||||||
name: 'Ghostty Tip ("Nightly")'
|
name: 'Ghostty Tip ("Nightly")'
|
||||||
prerelease: true
|
prerelease: true
|
||||||
@@ -590,7 +590,7 @@ jobs:
|
|||||||
|
|
||||||
# Update Release
|
# Update Release
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
||||||
with:
|
with:
|
||||||
name: 'Ghostty Tip ("Nightly")'
|
name: 'Ghostty Tip ("Nightly")'
|
||||||
prerelease: true
|
prerelease: true
|
||||||
@@ -775,7 +775,7 @@ jobs:
|
|||||||
|
|
||||||
# Update Release
|
# Update Release
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
||||||
with:
|
with:
|
||||||
name: 'Ghostty Tip ("Nightly")'
|
name: 'Ghostty Tip ("Nightly")'
|
||||||
prerelease: true
|
prerelease: true
|
||||||
|
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@@ -508,9 +508,9 @@ jobs:
|
|||||||
- name: Install zig
|
- name: Install zig
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
# Get the zig version from build.zig so that it only needs to be updated
|
# Get the zig version from build.zig.zon so that it only needs to be updated
|
||||||
$fileContent = Get-Content -Path "build.zig" -Raw
|
$fileContent = Get-Content -Path "build.zig.zon" -Raw
|
||||||
$pattern = 'buildpkg\.requireZig\("(.*?)"\);'
|
$pattern = 'minimum_zig_version\s*=\s*"([^"]+)"'
|
||||||
$zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value
|
$zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value
|
||||||
$version = "zig-x86_64-windows-$zigVersion"
|
$version = "zig-x86_64-windows-$zigVersion"
|
||||||
Write-Output $version
|
Write-Output $version
|
||||||
@@ -575,7 +575,7 @@ jobs:
|
|||||||
- name: Get required Zig version
|
- name: Get required Zig version
|
||||||
id: zig
|
id: zig
|
||||||
run: |
|
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
|
- name: Setup Cache
|
||||||
uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18
|
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
|
## Xcode Version and SDKs
|
||||||
|
|
||||||
Building the Ghostty macOS app requires that Xcode, the macOS SDK,
|
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
|
A common issue is that the incorrect version of Xcode is either
|
||||||
installed or selected. Use the `xcode-select` command to
|
installed or selected. Use the `xcode-select` command to
|
||||||
ensure that the correct version of Xcode is selected:
|
ensure that the correct version of Xcode is selected:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
sudo xcode-select --switch /Applications/Xcode-beta.app
|
sudo xcode-select --switch /Applications/Xcode.app
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
> Main branch development of Ghostty is preparing for the next major
|
> Main branch development of Ghostty requires **Xcode 26 and the macOS 26 SDK**.
|
||||||
> macOS release, Tahoe (macOS 26). Therefore, the main branch requires
|
|
||||||
> **Xcode 26 and the macOS 26 SDK**.
|
|
||||||
>
|
>
|
||||||
> You do not need to be running on macOS 26 to build Ghostty, you can
|
> 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
|
## AI and Agents
|
||||||
|
|
||||||
|
@@ -2,9 +2,11 @@ const std = @import("std");
|
|||||||
const assert = std.debug.assert;
|
const assert = std.debug.assert;
|
||||||
const builtin = @import("builtin");
|
const builtin = @import("builtin");
|
||||||
const buildpkg = @import("src/build/main.zig");
|
const buildpkg = @import("src/build/main.zig");
|
||||||
|
const appVersion = @import("build.zig.zon").version;
|
||||||
|
const minimumZigVersion = @import("build.zig.zon").minimum_zig_version;
|
||||||
|
|
||||||
comptime {
|
comptime {
|
||||||
buildpkg.requireZig("0.15.1");
|
buildpkg.requireZig(minimumZigVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(b: *std.Build) !void {
|
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
|
// This defines all the available build options (e.g. `-D`). If you
|
||||||
// want to know what options are available, you can run `--help` or
|
// want to know what options are available, you can run `--help` or
|
||||||
// you can read `src/build/Config.zig`.
|
// 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 test_filters = b.option(
|
||||||
[][]const u8,
|
[][]const u8,
|
||||||
"test-filter",
|
"test-filter",
|
||||||
|
@@ -125,14 +125,7 @@
|
|||||||
"Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift",
|
"Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift",
|
||||||
"Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift",
|
"Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift",
|
||||||
"Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift",
|
"Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift",
|
||||||
Features/Update/UpdateBadge.swift,
|
|
||||||
Features/Update/UpdateController.swift,
|
|
||||||
Features/Update/UpdateDelegate.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/FullscreenMode+Extension.swift",
|
||||||
Ghostty/Ghostty.Command.swift,
|
Ghostty/Ghostty.Command.swift,
|
||||||
Ghostty/Ghostty.Error.swift,
|
Ghostty/Ghostty.Error.swift,
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import SwiftUI
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import OSLog
|
import OSLog
|
||||||
import Sparkle
|
import Sparkle
|
||||||
@@ -99,10 +98,8 @@ class AppDelegate: NSObject,
|
|||||||
)
|
)
|
||||||
|
|
||||||
/// Manages updates
|
/// Manages updates
|
||||||
let updateController = UpdateController()
|
let updaterController: SPUStandardUpdaterController
|
||||||
var updateViewModel: UpdateViewModel {
|
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
|
||||||
updateController.viewModel
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The elapsed time since the process was started
|
/// The elapsed time since the process was started
|
||||||
var timeSinceLaunch: TimeInterval {
|
var timeSinceLaunch: TimeInterval {
|
||||||
@@ -129,6 +126,15 @@ class AppDelegate: NSObject,
|
|||||||
}
|
}
|
||||||
|
|
||||||
override init() {
|
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()
|
super.init()
|
||||||
|
|
||||||
ghostty.delegate = self
|
ghostty.delegate = self
|
||||||
@@ -173,7 +179,7 @@ class AppDelegate: NSObject,
|
|||||||
ghosttyConfigDidChange(config: ghostty.config)
|
ghosttyConfigDidChange(config: ghostty.config)
|
||||||
|
|
||||||
// Start our update checker.
|
// Start our update checker.
|
||||||
updateController.startUpdater()
|
updaterController.startUpdater()
|
||||||
|
|
||||||
// Register our service provider. This must happen after everything is initialized.
|
// Register our service provider. This must happen after everything is initialized.
|
||||||
NSApp.servicesProvider = ServiceProvider()
|
NSApp.servicesProvider = ServiceProvider()
|
||||||
@@ -800,12 +806,12 @@ class AppDelegate: NSObject,
|
|||||||
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
|
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
|
||||||
// user-based defaults.
|
// user-based defaults.
|
||||||
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
|
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
|
||||||
updateController.updater.automaticallyChecksForUpdates = false
|
updaterController.updater.automaticallyChecksForUpdates = false
|
||||||
updateController.updater.automaticallyDownloadsUpdates = false
|
updaterController.updater.automaticallyDownloadsUpdates = false
|
||||||
} else if let autoUpdate = config.autoUpdate {
|
} else if let autoUpdate = config.autoUpdate {
|
||||||
updateController.updater.automaticallyChecksForUpdates =
|
updaterController.updater.automaticallyChecksForUpdates =
|
||||||
autoUpdate == .check || autoUpdate == .download
|
autoUpdate == .check || autoUpdate == .download
|
||||||
updateController.updater.automaticallyDownloadsUpdates =
|
updaterController.updater.automaticallyDownloadsUpdates =
|
||||||
autoUpdate == .download
|
autoUpdate == .download
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,11 +1004,9 @@ class AppDelegate: NSObject,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func checkForUpdates(_ sender: Any?) {
|
@IBAction func checkForUpdates(_ sender: Any?) {
|
||||||
updateController.checkForUpdates()
|
updaterController.checkForUpdates(sender)
|
||||||
//UpdateSimulator.permissionRequest.simulate(with: updateViewModel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@IBAction func newWindow(_ sender: Any?) {
|
@IBAction func newWindow(_ sender: Any?) {
|
||||||
_ = TerminalController.newWindow(ghostty)
|
_ = TerminalController.newWindow(ghostty)
|
||||||
}
|
}
|
||||||
|
@@ -49,9 +49,6 @@ class BaseTerminalController: NSWindowController,
|
|||||||
/// This can be set to show/hide the command palette.
|
/// This can be set to show/hide the command palette.
|
||||||
@Published var commandPaletteIsShowing: Bool = false
|
@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.
|
/// Whether the terminal surface should focus when the mouse is over it.
|
||||||
var focusFollowsMouse: Bool {
|
var focusFollowsMouse: Bool {
|
||||||
self.derivedConfig.focusFollowsMouse
|
self.derivedConfig.focusFollowsMouse
|
||||||
@@ -821,18 +818,7 @@ class BaseTerminalController: NSWindowController,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fullscreenDidChange() {
|
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Clipboard Confirmation
|
// MARK: Clipboard Confirmation
|
||||||
|
|
||||||
@@ -914,28 +900,6 @@ class BaseTerminalController: NSWindowController,
|
|||||||
fullscreenStyle = NativeFullscreen(window)
|
fullscreenStyle = NativeFullscreen(window)
|
||||||
fullscreenStyle?.delegate = self
|
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
|
// MARK: NSWindowDelegate
|
||||||
|
@@ -31,9 +31,6 @@ protocol TerminalViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// The command palette state.
|
/// The command palette state.
|
||||||
var commandPaletteIsShowing: Bool { get set }
|
var commandPaletteIsShowing: Bool { get set }
|
||||||
|
|
||||||
/// The update overlay should be visible.
|
|
||||||
var updateOverlayIsVisible: Bool { get }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main terminal view. This terminal view supports splits.
|
/// The main terminal view. This terminal view supports splits.
|
||||||
@@ -112,28 +109,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
|||||||
self.delegate?.performAction(action, on: surfaceView)
|
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
|
import AppKit
|
||||||
|
|
||||||
class HiddenTitlebarTerminalWindow: TerminalWindow {
|
class HiddenTitlebarTerminalWindow: TerminalWindow {
|
||||||
// No titlebar, we don't support accessories.
|
|
||||||
override var supportsUpdateAccessory: Bool { false }
|
|
||||||
|
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
super.awakeFromNib()
|
super.awakeFromNib()
|
||||||
|
|
||||||
|
@@ -5,12 +5,6 @@ import GhosttyKit
|
|||||||
/// The base class for all standalone, "normal" terminal windows. This sets the basic
|
/// The base class for all standalone, "normal" terminal windows. This sets the basic
|
||||||
/// style and configuration of the window based on the app configuration.
|
/// style and configuration of the window based on the app configuration.
|
||||||
class TerminalWindow: NSWindow {
|
class TerminalWindow: NSWindow {
|
||||||
/// Posted when a terminal window awakes from nib.
|
|
||||||
static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake")
|
|
||||||
|
|
||||||
/// Posted when a terminal window will close
|
|
||||||
static let terminalWillCloseNotification = Notification.Name("TerminalWindowWillClose")
|
|
||||||
|
|
||||||
/// This is the key in UserDefaults to use for the default `level` value. This is
|
/// This is the key in UserDefaults to use for the default `level` value. This is
|
||||||
/// used by the manual float on top menu item feature.
|
/// used by the manual float on top menu item feature.
|
||||||
static let defaultLevelKey: String = "TerminalDefaultLevel"
|
static let defaultLevelKey: String = "TerminalDefaultLevel"
|
||||||
@@ -21,19 +15,9 @@ class TerminalWindow: NSWindow {
|
|||||||
/// Reset split zoom button in titlebar
|
/// Reset split zoom button in titlebar
|
||||||
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
|
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.
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||||
private(set) var derivedConfig: DerivedConfig = .init()
|
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.
|
/// Gets the terminal controller from the window controller.
|
||||||
var terminalController: TerminalController? {
|
var terminalController: TerminalController? {
|
||||||
windowController as? TerminalController
|
windowController as? TerminalController
|
||||||
@@ -51,9 +35,6 @@ class TerminalWindow: NSWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override func awakeFromNib() {
|
override func awakeFromNib() {
|
||||||
// Notify that this terminal window has loaded
|
|
||||||
NotificationCenter.default.post(name: Self.terminalDidAwake, object: self)
|
|
||||||
|
|
||||||
// This is required so that window restoration properly creates our tabs
|
// This is required so that window restoration properly creates our tabs
|
||||||
// again. I'm not sure why this is required. If you don't do this, then
|
// again. I'm not sure why this is required. If you don't do this, then
|
||||||
// tabs restore as separate windows.
|
// tabs restore as separate windows.
|
||||||
@@ -104,17 +85,6 @@ class TerminalWindow: NSWindow {
|
|||||||
}))
|
}))
|
||||||
addTitlebarAccessoryViewController(resetZoomAccessory)
|
addTitlebarAccessoryViewController(resetZoomAccessory)
|
||||||
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
|
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,
|
// Setup the accessory view for tabs that shows our keyboard shortcuts,
|
||||||
@@ -134,11 +104,6 @@ class TerminalWindow: NSWindow {
|
|||||||
override var canBecomeKey: Bool { return true }
|
override var canBecomeKey: Bool { return true }
|
||||||
override var canBecomeMain: Bool { return true }
|
override var canBecomeMain: Bool { return true }
|
||||||
|
|
||||||
override func close() {
|
|
||||||
NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
|
|
||||||
super.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
override func becomeKey() {
|
override func becomeKey() {
|
||||||
super.becomeKey()
|
super.becomeKey()
|
||||||
resetZoomTabButton.contentTintColor = .controlAccentColor
|
resetZoomTabButton.contentTintColor = .controlAccentColor
|
||||||
@@ -233,9 +198,6 @@ class TerminalWindow: NSWindow {
|
|||||||
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
|
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
|
||||||
removeTitlebarAccessoryViewController(at: idx)
|
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() {
|
private func tabBarDidDisappear() {
|
||||||
@@ -505,21 +467,22 @@ extension TerminalWindow {
|
|||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var isSurfaceZoomed: Bool = false
|
@Published var isSurfaceZoomed: Bool = false
|
||||||
@Published var hasToolbar: 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 {
|
struct ResetZoomAccessoryView: View {
|
||||||
@ObservedObject var viewModel: ViewModel
|
@ObservedObject var viewModel: ViewModel
|
||||||
let action: () -> Void
|
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 {
|
var body: some View {
|
||||||
if viewModel.isSurfaceZoomed {
|
if viewModel.isSurfaceZoomed {
|
||||||
VStack {
|
VStack {
|
||||||
@@ -534,23 +497,10 @@ extension TerminalWindow {
|
|||||||
}
|
}
|
||||||
// With a toolbar, the window title is taller, so we need more padding
|
// With a toolbar, the window title is taller, so we need more padding
|
||||||
// to properly align.
|
// to properly align.
|
||||||
.padding(.top, viewModel.accessoryTopPadding)
|
.padding(.top, topPadding)
|
||||||
// We always need space at the end of the titlebar
|
// We always need space at the end of the titlebar
|
||||||
.padding(.trailing, 10)
|
.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -9,10 +9,6 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
|||||||
/// The view model for SwiftUI views
|
/// The view model for SwiftUI views
|
||||||
private var viewModel = ViewModel()
|
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 {
|
deinit {
|
||||||
tabBarObserver = nil
|
tabBarObserver = nil
|
||||||
}
|
}
|
||||||
|
@@ -2,10 +2,6 @@ import Cocoa
|
|||||||
|
|
||||||
/// Titlebar tabs for macOS 13 to 15.
|
/// Titlebar tabs for macOS 13 to 15.
|
||||||
class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
|
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
|
/// 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.
|
/// be updated whenever the window background color or surrounding elements changes.
|
||||||
fileprivate var isLightTheme: Bool = false
|
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,57 +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(),
|
|
||||||
hostBundle: hostBundle)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,205 +0,0 @@
|
|||||||
import Cocoa
|
|
||||||
import Sparkle
|
|
||||||
|
|
||||||
/// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation.
|
|
||||||
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()
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self,
|
|
||||||
selector: #selector(handleTerminalWindowWillClose),
|
|
||||||
name: TerminalWindow.terminalWillCloseNotification,
|
|
||||||
object: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func handleTerminalWindowWillClose() {
|
|
||||||
// If we lost the ability to show unobtrusive states, cancel whatever
|
|
||||||
// update state we're in. This will allow the manual `check for updates`
|
|
||||||
// call to initialize the standard driver.
|
|
||||||
//
|
|
||||||
// We have to do this after a short delay so that the window can fully
|
|
||||||
// close.
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
|
|
||||||
guard let self else { return }
|
|
||||||
guard !hasUnobtrusiveTarget else { return }
|
|
||||||
viewModel.state.cancel()
|
|
||||||
viewModel.state = .idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func show(_ request: SPUUpdatePermissionRequest,
|
|
||||||
reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
|
|
||||||
viewModel.state = .permissionRequest(.init(request: request, reply: reply))
|
|
||||||
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) {
|
|
||||||
// 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
|
|
||||||
|
|
||||||
if !hasUnobtrusiveTarget {
|
|
||||||
standard.showUpdateNotFoundWithError(error, acknowledgement: acknowledgement)
|
|
||||||
} else {
|
|
||||||
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(self)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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) {
|
|
||||||
viewModel.state = .readyToInstall(.init(reply: reply))
|
|
||||||
|
|
||||||
if !hasUnobtrusiveTarget {
|
|
||||||
standard.showReady(toInstallAndRelaunch: reply)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
|
|
||||||
viewModel.state = .installing
|
|
||||||
|
|
||||||
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 { window in
|
|
||||||
window is TerminalWindow &&
|
|
||||||
window.isVisible
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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,284 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
func cancel() {
|
|
||||||
switch self {
|
|
||||||
case .checking(let checking):
|
|
||||||
checking.cancel()
|
|
||||||
case .updateAvailable(let available):
|
|
||||||
available.reply(.dismiss)
|
|
||||||
case .downloading(let downloading):
|
|
||||||
downloading.cancel()
|
|
||||||
case .readyToInstall(let ready):
|
|
||||||
ready.reply(.dismiss)
|
|
||||||
case .error(let err):
|
|
||||||
err.dismiss()
|
|
||||||
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 (.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 gtk = @import("gtk.zig");
|
||||||
const GitVersion = @import("GitVersion.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.
|
/// Standard build configuration options.
|
||||||
optimize: std.builtin.OptimizeMode,
|
optimize: std.builtin.OptimizeMode,
|
||||||
target: std.Build.ResolvedTarget,
|
target: std.Build.ResolvedTarget,
|
||||||
@@ -69,7 +62,7 @@ emit_unicode_table_gen: bool = false,
|
|||||||
/// Environmental properties
|
/// Environmental properties
|
||||||
env: std.process.EnvMap,
|
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.
|
// Setup our standard Zig target and optimize options, i.e.
|
||||||
// `-Doptimize` and `-Dtarget`.
|
// `-Doptimize` and `-Dtarget`.
|
||||||
const optimize = b.standardOptimizeOption(.{});
|
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.
|
// If an explicit version is given, we always use it.
|
||||||
try std.SemanticVersion.parse(v)
|
try std.SemanticVersion.parse(v)
|
||||||
else version: {
|
else version: {
|
||||||
|
const app_version = try std.SemanticVersion.parse(appVersion);
|
||||||
// If no explicit version is given, we try to detect it from git.
|
// If no explicit version is given, we try to detect it from git.
|
||||||
const vsn = GitVersion.detect(b) catch |err| switch (err) {
|
const vsn = GitVersion.detect(b) catch |err| switch (err) {
|
||||||
// If Git isn't available we just make an unknown dev version.
|
// 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
|
WORKDIR /src
|
||||||
|
|
||||||
COPY ./build.zig /src
|
COPY ./build.zig ./build.zig.zon /src/
|
||||||
|
|
||||||
# Install zig
|
# Install zig
|
||||||
# https://ziglang.org/download/
|
# 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 && \
|
tar -xf /tmp/zig.tar.xz -C /opt && \
|
||||||
rm /tmp/zig.tar.xz && \
|
rm /tmp/zig.tar.xz && \
|
||||||
ln -s "/opt/zig-$(uname -m)-linux-$ZIG_VERSION/zig" /usr/local/bin/zig
|
ln -s "/opt/zig-$(uname -m)-linux-$ZIG_VERSION/zig" /usr/local/bin/zig
|
||||||
@@ -41,4 +41,3 @@ RUN zig build \
|
|||||||
-Dcpu=baseline
|
-Dcpu=baseline
|
||||||
|
|
||||||
RUN ./zig-out/bin/ghostty +version
|
RUN ./zig-out/bin/ghostty +version
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user