mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-10-10 03:46:34 +00:00
Compare commits
16 Commits
tip
...
unobtrusiv
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f124bb4975 | ||
![]() |
f2e5b8fb2d | ||
![]() |
bbf875216f | ||
![]() |
a2fbaec613 | ||
![]() |
49eb65df77 | ||
![]() |
abab6899f9 | ||
![]() |
bce49a0843 | ||
![]() |
b4ab1cc1ed | ||
![]() |
9e17255ca9 | ||
![]() |
95a9e63401 | ||
![]() |
a55de09944 | ||
![]() |
59829f5359 | ||
![]() |
f975ac8019 | ||
![]() |
81e3ff90a3 | ||
![]() |
fc347a6040 | ||
![]() |
09ba5a27a2 |
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@bff2091b54a91cf1491564659c554742b285442f # v2.11
|
uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9
|
||||||
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@bff2091b54a91cf1491564659c554742b285442f # v2.11
|
uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9
|
||||||
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: Extract the Version
|
- name: Exract 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@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||||
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@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||||
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@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||||
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@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
|
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
|
||||||
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.zon so that it only needs to be updated
|
# Get the zig version from build.zig so that it only needs to be updated
|
||||||
$fileContent = Get-Content -Path "build.zig.zon" -Raw
|
$fileContent = Get-Content -Path "build.zig" -Raw
|
||||||
$pattern = 'minimum_zig_version\s*=\s*"([^"]+)"'
|
$pattern = 'buildpkg\.requireZig\("(.*?)"\);'
|
||||||
$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/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT
|
echo "version=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig)" >> $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,22 +50,24 @@ 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,
|
||||||
the iOS SDK, and Metal Toolchain are all installed.
|
and the iOS SDK 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.app
|
sudo xcode-select --switch /Applications/Xcode-beta.app
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
>
|
>
|
||||||
> Main branch development of Ghostty requires **Xcode 26 and the macOS 26 SDK**.
|
> 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**.
|
||||||
>
|
>
|
||||||
> 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 on macOS 15 stable.
|
> still use Xcode 26 beta on macOS 15 stable.
|
||||||
|
|
||||||
## AI and Agents
|
## AI and Agents
|
||||||
|
|
||||||
|
@@ -2,11 +2,9 @@ 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(minimumZigVersion);
|
buildpkg.requireZig("0.15.1");
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build(b: *std.Build) !void {
|
pub fn build(b: *std.Build) !void {
|
||||||
@@ -17,8 +15,7 @@ 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,7 +125,14 @@
|
|||||||
"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,4 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
import OSLog
|
import OSLog
|
||||||
import Sparkle
|
import Sparkle
|
||||||
@@ -98,8 +99,10 @@ class AppDelegate: NSObject,
|
|||||||
)
|
)
|
||||||
|
|
||||||
/// Manages updates
|
/// Manages updates
|
||||||
let updaterController: SPUStandardUpdaterController
|
let updateController = UpdateController()
|
||||||
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
|
var updateViewModel: UpdateViewModel {
|
||||||
|
updateController.viewModel
|
||||||
|
}
|
||||||
|
|
||||||
/// The elapsed time since the process was started
|
/// The elapsed time since the process was started
|
||||||
var timeSinceLaunch: TimeInterval {
|
var timeSinceLaunch: TimeInterval {
|
||||||
@@ -126,15 +129,6 @@ 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
|
||||||
@@ -179,7 +173,7 @@ class AppDelegate: NSObject,
|
|||||||
ghosttyConfigDidChange(config: ghostty.config)
|
ghosttyConfigDidChange(config: ghostty.config)
|
||||||
|
|
||||||
// Start our update checker.
|
// Start our update checker.
|
||||||
updaterController.startUpdater()
|
updateController.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()
|
||||||
@@ -806,12 +800,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 {
|
||||||
updaterController.updater.automaticallyChecksForUpdates = false
|
updateController.updater.automaticallyChecksForUpdates = false
|
||||||
updaterController.updater.automaticallyDownloadsUpdates = false
|
updateController.updater.automaticallyDownloadsUpdates = false
|
||||||
} else if let autoUpdate = config.autoUpdate {
|
} else if let autoUpdate = config.autoUpdate {
|
||||||
updaterController.updater.automaticallyChecksForUpdates =
|
updateController.updater.automaticallyChecksForUpdates =
|
||||||
autoUpdate == .check || autoUpdate == .download
|
autoUpdate == .check || autoUpdate == .download
|
||||||
updaterController.updater.automaticallyDownloadsUpdates =
|
updateController.updater.automaticallyDownloadsUpdates =
|
||||||
autoUpdate == .download
|
autoUpdate == .download
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1004,9 +998,11 @@ class AppDelegate: NSObject,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func checkForUpdates(_ sender: Any?) {
|
@IBAction func checkForUpdates(_ sender: Any?) {
|
||||||
updaterController.checkForUpdates(sender)
|
updateController.checkForUpdates()
|
||||||
|
//UpdateSimulator.permissionRequest.simulate(with: updateViewModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@IBAction func newWindow(_ sender: Any?) {
|
@IBAction func newWindow(_ sender: Any?) {
|
||||||
_ = TerminalController.newWindow(ghostty)
|
_ = TerminalController.newWindow(ghostty)
|
||||||
}
|
}
|
||||||
|
@@ -37,7 +37,7 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
|
|
||||||
/// Tracks if we're currently handling a manual resize to prevent recursion
|
/// Tracks if we're currently handling a manual resize to prevent recursion
|
||||||
private var isHandlingResize: Bool = false
|
private var isHandlingResize: Bool = false
|
||||||
|
|
||||||
init(_ ghostty: Ghostty.App,
|
init(_ ghostty: Ghostty.App,
|
||||||
position: QuickTerminalPosition = .top,
|
position: QuickTerminalPosition = .top,
|
||||||
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||||
|
@@ -48,6 +48,9 @@ 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 {
|
||||||
@@ -818,7 +821,18 @@ 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
|
||||||
|
|
||||||
@@ -900,6 +914,28 @@ 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,6 +31,9 @@ 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.
|
||||||
@@ -109,6 +112,28 @@ 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,6 +1,9 @@
|
|||||||
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,6 +5,12 @@ 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"
|
||||||
@@ -14,15 +20,25 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: NSWindow Overrides
|
// MARK: NSWindow Overrides
|
||||||
|
|
||||||
override var toolbar: NSToolbar? {
|
override var toolbar: NSToolbar? {
|
||||||
@@ -35,6 +51,9 @@ 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.
|
||||||
@@ -85,6 +104,17 @@ 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,
|
||||||
@@ -103,6 +133,11 @@ class TerminalWindow: NSWindow {
|
|||||||
// still become key/main and receive events.
|
// still become key/main and receive events.
|
||||||
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()
|
||||||
@@ -198,6 +233,9 @@ 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() {
|
||||||
@@ -436,7 +474,7 @@ class TerminalWindow: NSWindow {
|
|||||||
standardWindowButton(.miniaturizeButton)?.isHidden = true
|
standardWindowButton(.miniaturizeButton)?.isHidden = true
|
||||||
standardWindowButton(.zoomButton)?.isHidden = true
|
standardWindowButton(.zoomButton)?.isHidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Config
|
// MARK: Config
|
||||||
|
|
||||||
struct DerivedConfig {
|
struct DerivedConfig {
|
||||||
@@ -467,21 +505,20 @@ 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 {
|
||||||
@@ -497,10 +534,23 @@ 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, topPadding)
|
.padding(.top, viewModel.accessoryTopPadding)
|
||||||
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -8,6 +8,10 @@ import SwiftUI
|
|||||||
class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate {
|
class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate {
|
||||||
/// 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,6 +2,10 @@ 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
|
||||||
|
73
macos/Sources/Features/Update/UpdateBadge.swift
Normal file
73
macos/Sources/Features/Update/UpdateBadge.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
57
macos/Sources/Features/Update/UpdateController.swift
Normal file
57
macos/Sources/Features/Update/UpdateController.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
@@ -6,7 +6,7 @@ class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
|
|||||||
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else {
|
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sparkle supports a native concept of "channels" but it requires that
|
// 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
|
// you share a single appcast file. We don't want to do that so we
|
||||||
// do this instead.
|
// do this instead.
|
||||||
|
205
macos/Sources/Features/Update/UpdateDriver.swift
Normal file
205
macos/Sources/Features/Update/UpdateDriver.swift
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
61
macos/Sources/Features/Update/UpdatePill.swift
Normal file
61
macos/Sources/Features/Update/UpdatePill.swift
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
402
macos/Sources/Features/Update/UpdatePopoverView.swift
Normal file
402
macos/Sources/Features/Update/UpdatePopoverView.swift
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
275
macos/Sources/Features/Update/UpdateSimulator.swift
Normal file
275
macos/Sources/Features/Update/UpdateSimulator.swift
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
284
macos/Sources/Features/Update/UpdateViewModel.swift
Normal file
284
macos/Sources/Features/Update/UpdateViewModel.swift
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
130
macos/Tests/Update/ReleaseNotesTests.swift
Normal file
130
macos/Tests/Update/ReleaseNotesTests.swift
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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,6 +16,13 @@ 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,
|
||||||
@@ -62,7 +69,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, appVersion: []const u8) !Config {
|
pub fn init(b: *std.Build) !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(.{});
|
||||||
@@ -210,7 +217,6 @@ pub fn init(b: *std.Build, appVersion: []const u8) !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 ./build.zig.zon /src/
|
COPY ./build.zig /src
|
||||||
|
|
||||||
# Install zig
|
# Install zig
|
||||||
# https://ziglang.org/download/
|
# https://ziglang.org/download/
|
||||||
|
|
||||||
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" && \
|
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" && \
|
||||||
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,3 +41,4 @@ 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