12 Commits

Author SHA1 Message Date
Mitchell Hashimoto
f2c7f4ec0f Get app_version from build.zig.zon .version (#9101)
Reads .version from build.zig.zon and passes it to Config.
2025-10-09 11:11:18 -07:00
Mitchell Hashimoto
d0f800c5fb docs: Update build requirements for macOS (#9095)
Adds the Metal Toolchain as a required Xcode component for building
Ghostty. Also updates the notes about Xcode 26 now that it and Tahoe are
out of Beta.
2025-10-09 11:08:19 -07:00
Mitchell Hashimoto
6e5e726bc2 ci: fix typo (#9097) 2025-10-09 11:08:01 -07:00
Ēriks Remess
402c492d94 set minimum required zig version from build.zig.zon in tests and dockerfile 2025-10-09 17:07:58 +03:00
Ēriks Remess
ea5ea5f98e set minimum required zig version from build.zig.zon 2025-10-09 16:47:27 +03:00
Ēriks Remess
f4b051a84c use app_version from build.zig.zon 2025-10-09 16:02:40 +03:00
Mitchell Hashimoto
3b2ef4c216 build(deps): bump softprops/action-gh-release from 2.3.4 to 2.4.0 (#9079)
Bumps
[softprops/action-gh-release](https://github.com/softprops/action-gh-release)
from 2.3.4 to 2.4.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/softprops/action-gh-release/releases">softprops/action-gh-release's
releases</a>.</em></p>
<blockquote>
<h2>v2.4.0</h2>
<!-- raw HTML omitted -->
<h2>What's Changed</h2>
<h3>Exciting New Features 🎉</h3>
<ul>
<li>feat(action): respect working_directory for files globs by <a
href="https://github.com/stephenway"><code>@​stephenway</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/667">softprops/action-gh-release#667</a></li>
</ul>
<h3>Other Changes 🔄</h3>
<ul>
<li>chore(deps): bump the npm group with 2 updates by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/668">softprops/action-gh-release#668</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/softprops/action-gh-release/compare/v2.3.4...v2.4.0">https://github.com/softprops/action-gh-release/compare/v2.3.4...v2.4.0</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md">softprops/action-gh-release's
changelog</a>.</em></p>
<blockquote>
<h2>2.4.0</h2>
<h2>What's Changed</h2>
<h3>Exciting New Features 🎉</h3>
<ul>
<li>feat(action): respect working_directory for files globs by <a
href="https://github.com/stephenway"><code>@​stephenway</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/667">softprops/action-gh-release#667</a></li>
</ul>
<h2>2.3.4</h2>
<h2>What's Changed</h2>
<h3>Bug fixes 🐛</h3>
<ul>
<li>fix(action): handle 422 already_exists race condition by <a
href="https://github.com/stephenway"><code>@​stephenway</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/665">softprops/action-gh-release#665</a></li>
</ul>
<h3>Other Changes 🔄</h3>
<ul>
<li>dependency updates</li>
</ul>
<h2>2.3.3</h2>
<h2>What's Changed</h2>
<h3>Exciting New Features 🎉</h3>
<ul>
<li>feat: add input option <code>overwrite_files</code> by <a
href="https://github.com/asfernandes"><code>@​asfernandes</code></a> in
<a
href="https://redirect.github.com/softprops/action-gh-release/pull/343">softprops/action-gh-release#343</a></li>
</ul>
<h3>Other Changes 🔄</h3>
<ul>
<li>dependency updates</li>
</ul>
<h2>2.3.2</h2>
<ul>
<li>fix: revert fs <code>readableWebStream</code> change</li>
</ul>
<h2>2.3.1</h2>
<h3>Bug fixes 🐛</h3>
<ul>
<li>fix: fix file closing issue by <a
href="https://github.com/WailGree"><code>@​WailGree</code></a> in <a
href="https://redirect.github.com/softprops/action-gh-release/pull/629">softprops/action-gh-release#629</a></li>
</ul>
<h2>2.3.0</h2>
<ul>
<li>Migrate from jest to vitest</li>
<li>Replace <code>mime</code> with <code>mime-types</code></li>
<li>Bump to use node 24</li>
<li>Dependency updates</li>
</ul>
<h2>2.2.2</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="aec2ec56f9"><code>aec2ec5</code></a>
release 2.4.0</li>
<li><a
href="4db716b167"><code>4db716b</code></a>
feat: respect working_directory for files globs; add input and tests (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/667">#667</a>)</li>
<li><a
href="14820f2cee"><code>14820f2</code></a>
chore(deps): bump the npm group with 2 updates (<a
href="https://redirect.github.com/softprops/action-gh-release/issues/668">#668</a>)</li>
<li>See full diff in <a
href="62c96d0c4e...aec2ec56f9">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=softprops/action-gh-release&package-manager=github_actions&previous-version=2.3.4&new-version=2.4.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
2025-10-08 20:17:50 -07:00
Mitchell Hashimoto
beaac8db8b build(deps): bump hustcer/milestone-action from 2.9 to 2.11 (#9094)
Bumps
[hustcer/milestone-action](https://github.com/hustcer/milestone-action)
from 2.9 to 2.11.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/hustcer/milestone-action/releases">hustcer/milestone-action's
releases</a>.</em></p>
<blockquote>
<h2>v2.11</h2>
<h2>[2.11] - 2025-10-08</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Fall back to the earliest-created milestone if no due_on set for
milestones (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/145">#145</a>)</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/hustcer/milestone-action/compare/v2.10...v2.11">https://github.com/hustcer/milestone-action/compare/v2.10...v2.11</a></p>
<h2>v2.10</h2>
<h2>[2.10] - 2025-10-07</h2>
<h3>Features</h3>
<ul>
<li>Try to inherit milestone from closing issues (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/129">#129</a>)</li>
</ul>
<h3>Miscellaneous Tasks</h3>
<ul>
<li>Export guess-milestone-for-pr custom command (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/124">#124</a>)</li>
</ul>
<h3>Deps</h3>
<ul>
<li>Upgrade Nu to v0.107 (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/120">#120</a>)</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/hustcer/milestone-action/compare/v2.9...v2.10">https://github.com/hustcer/milestone-action/compare/v2.9...v2.10</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/hustcer/milestone-action/blob/main/CHANGELOG.md">hustcer/milestone-action's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<p>All notable changes to this project will be documented in this
file.</p>
<h2>[2.11] - 2025-10-08</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Fall back to the earliest-created milestone if no due_on set for
milestones (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/145">#145</a>)</li>
</ul>
<h2>[2.10] - 2025-10-07</h2>
<h3>Features</h3>
<ul>
<li>Try to inherit milestone from closing issues (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/129">#129</a>)</li>
</ul>
<h3>Miscellaneous Tasks</h3>
<ul>
<li>Export <code>guess-milestone-for-pr</code> custom command (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/124">#124</a>)</li>
</ul>
<h3>Deps</h3>
<ul>
<li>Upgrade Nu to v0.107 (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/120">#120</a>)</li>
</ul>
<h2>[2.9] - 2025-07-26</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Fix getting Nu binary path for Nushell 0.106</li>
</ul>
<h3>Deps</h3>
<ul>
<li>Upgrade Nu to 0.106 and pin <code>hustcer/setup-nu</code> to v3.20
(<a
href="https://redirect.github.com/hustcer/milestone-action/issues/118">#118</a>)</li>
</ul>
<h2>[2.8] - 2025-06-11</h2>
<h3>Miscellaneous Tasks</h3>
<ul>
<li>Upgrade <code>Nu</code> to 0.105 and pin
<code>hustcer/setup-nu</code> to v3.19 (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/117">#117</a>)</li>
</ul>
<h2>[2.7] - 2025-03-22</h2>
<h3>Features</h3>
<ul>
<li>Add DeepSeek Code review support by
<code>hustcer/deepseek-review</code></li>
</ul>
<h3>Deps</h3>
<ul>
<li>Upgrade <code>Nu</code> to <strong>v0.103</strong> (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/114">#114</a>)</li>
<li>Upgrade <code>Nu</code> to v0.102 (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/113">#113</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="bff2091b54"><code>bff2091</code></a>
ci skip</li>
<li><a
href="2c7baeca62"><code>2c7baec</code></a>
fix: Improve int number checking</li>
<li><a
href="efffe97cbe"><code>efffe97</code></a>
Bump to 2.11</li>
<li><a
href="837250188c"><code>8372501</code></a>
fix: Fall back to the earliest-created milestone if no due_on set for
milesto...</li>
<li><a
href="dc568606da"><code>dc56860</code></a>
chore: Code formatting by prettier</li>
<li><a
href="92e0e50802"><code>92e0e50</code></a>
ci skip</li>
<li><a
href="69cb97509e"><code>69cb975</code></a>
Bump to v2.10 (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/142">#142</a>)</li>
<li><a
href="ddb9b7cb59"><code>ddb9b7c</code></a>
fix: Fix query-pr-closing-issues command execution error</li>
<li><a
href="76d2b550e8"><code>76d2b55</code></a>
feat: Try to inherit milestone from closing issues (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/128">#128</a>)
(<a
href="https://redirect.github.com/hustcer/milestone-action/issues/129">#129</a>)</li>
<li><a
href="78368de40a"><code>78368de</code></a>
chore: Export guess-milestone-for-pr custom command (<a
href="https://redirect.github.com/hustcer/milestone-action/issues/124">#124</a>)</li>
<li>Additional commits viewable in <a
href="b57a7e52e9...bff2091b54">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=hustcer/milestone-action&package-manager=github_actions&previous-version=2.9&new-version=2.11)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
2025-10-08 20:17:35 -07:00
Zhizhen He
dfb32022d4 ci: fix typo 2025-10-09 10:52:52 +08:00
Mike Akers
e8ebc6f405 docs: Update build requirements for macOS
Adds the Metal Toolchain as a required Xcode component for building
Ghostty. Also updates the notes about Xcode 26 now that it and Tahoe are
out of Beta.
2025-10-08 21:05:04 -04:00
dependabot[bot]
5bebd10b7f build(deps): bump hustcer/milestone-action from 2.9 to 2.11
Bumps [hustcer/milestone-action](https://github.com/hustcer/milestone-action) from 2.9 to 2.11.
- [Release notes](https://github.com/hustcer/milestone-action/releases)
- [Changelog](https://github.com/hustcer/milestone-action/blob/main/CHANGELOG.md)
- [Commits](b57a7e52e9...bff2091b54)

---
updated-dependencies:
- dependency-name: hustcer/milestone-action
  dependency-version: '2.11'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-09 00:06:42 +00:00
dependabot[bot]
b56808f138 build(deps): bump softprops/action-gh-release from 2.3.4 to 2.4.0
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.3.4 to 2.4.0.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](62c96d0c4e...aec2ec56f9)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: 2.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-08 00:07:23 +00:00
26 changed files with 57 additions and 1550 deletions

View File

@@ -15,7 +15,7 @@ jobs:
name: Milestone Update
steps:
- name: Set Milestone for PR
uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9
uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v2.11
if: github.event.pull_request.merged == true
with:
action: bind-pr # `bind-pr` is the default action
@@ -24,7 +24,7 @@ jobs:
# Bind milestone to closed issue that has a merged PR fix
- name: Set Milestone for Issue
uses: hustcer/milestone-action@b57a7e52e9913b6b0cdefb10add762af0398659d # v2.9
uses: hustcer/milestone-action@bff2091b54a91cf1491564659c554742b285442f # v2.11
if: github.event.issue.state == 'closed'
with:
action: bind-issue

View File

@@ -28,7 +28,7 @@ jobs:
echo "Version is valid: ${{ github.event.inputs.version }}"
- name: Exract the Version
- name: Extract the Version
id: extract_version
run: |
VERSION=${{ github.event.inputs.version }}

View File

@@ -188,7 +188,7 @@ jobs:
nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password
- name: Update Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -359,7 +359,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -590,7 +590,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@@ -775,7 +775,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@62c96d0c4e8a889135c1f3a25910db8dbe0e85f7 # v2.3.4
uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # v2.4.0
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true

View File

@@ -508,9 +508,9 @@ jobs:
- name: Install zig
shell: pwsh
run: |
# Get the zig version from build.zig so that it only needs to be updated
$fileContent = Get-Content -Path "build.zig" -Raw
$pattern = 'buildpkg\.requireZig\("(.*?)"\);'
# Get the zig version from build.zig.zon so that it only needs to be updated
$fileContent = Get-Content -Path "build.zig.zon" -Raw
$pattern = 'minimum_zig_version\s*=\s*"([^"]+)"'
$zigVersion = [regex]::Match($fileContent, $pattern).Groups[1].Value
$version = "zig-x86_64-windows-$zigVersion"
Write-Output $version
@@ -575,7 +575,7 @@ jobs:
- name: Get required Zig version
id: zig
run: |
echo "version=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig)" >> $GITHUB_OUTPUT
echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@7baedde84bbf5063413d621f282834bc2654d0c1 # v1.2.18

View File

@@ -50,24 +50,22 @@ macOS users don't require any additional dependencies.
## Xcode Version and SDKs
Building the Ghostty macOS app requires that Xcode, the macOS SDK,
and the iOS SDK are all installed.
the iOS SDK, and Metal Toolchain are all installed.
A common issue is that the incorrect version of Xcode is either
installed or selected. Use the `xcode-select` command to
ensure that the correct version of Xcode is selected:
```shell-session
sudo xcode-select --switch /Applications/Xcode-beta.app
sudo xcode-select --switch /Applications/Xcode.app
```
> [!IMPORTANT]
>
> Main branch development of Ghostty is preparing for the next major
> macOS release, Tahoe (macOS 26). Therefore, the main branch requires
> **Xcode 26 and the macOS 26 SDK**.
> Main branch development of Ghostty requires **Xcode 26 and the macOS 26 SDK**.
>
> You do not need to be running on macOS 26 to build Ghostty, you can
> still use Xcode 26 beta on macOS 15 stable.
> still use Xcode 26 on macOS 15 stable.
## AI and Agents

View File

@@ -2,9 +2,11 @@ const std = @import("std");
const assert = std.debug.assert;
const builtin = @import("builtin");
const buildpkg = @import("src/build/main.zig");
const appVersion = @import("build.zig.zon").version;
const minimumZigVersion = @import("build.zig.zon").minimum_zig_version;
comptime {
buildpkg.requireZig("0.15.1");
buildpkg.requireZig(minimumZigVersion);
}
pub fn build(b: *std.Build) !void {
@@ -15,7 +17,8 @@ pub fn build(b: *std.Build) !void {
// This defines all the available build options (e.g. `-D`). If you
// want to know what options are available, you can run `--help` or
// you can read `src/build/Config.zig`.
const config = try buildpkg.Config.init(b);
const config = try buildpkg.Config.init(b, appVersion);
const test_filters = b.option(
[][]const u8,
"test-filter",

View File

@@ -125,14 +125,7 @@
"Features/Terminal/Window Styles/TitlebarTabsTahoeTerminalWindow.swift",
"Features/Terminal/Window Styles/TitlebarTabsVenturaTerminalWindow.swift",
"Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift",
Features/Update/UpdateBadge.swift,
Features/Update/UpdateController.swift,
Features/Update/UpdateDelegate.swift,
Features/Update/UpdateDriver.swift,
Features/Update/UpdatePill.swift,
Features/Update/UpdatePopoverView.swift,
Features/Update/UpdateSimulator.swift,
Features/Update/UpdateViewModel.swift,
"Ghostty/FullscreenMode+Extension.swift",
Ghostty/Ghostty.Command.swift,
Ghostty/Ghostty.Error.swift,

View File

@@ -1,5 +1,4 @@
import AppKit
import SwiftUI
import UserNotifications
import OSLog
import Sparkle
@@ -99,10 +98,8 @@ class AppDelegate: NSObject,
)
/// Manages updates
let updateController = UpdateController()
var updateViewModel: UpdateViewModel {
updateController.viewModel
}
let updaterController: SPUStandardUpdaterController
let updaterDelegate: UpdaterDelegate = UpdaterDelegate()
/// The elapsed time since the process was started
var timeSinceLaunch: TimeInterval {
@@ -129,6 +126,15 @@ class AppDelegate: NSObject,
}
override init() {
updaterController = SPUStandardUpdaterController(
// Important: we must not start the updater here because we need to read our configuration
// first to determine whether we're automatically checking, downloading, etc. The updater
// is started later in applicationDidFinishLaunching
startingUpdater: false,
updaterDelegate: updaterDelegate,
userDriverDelegate: nil
)
super.init()
ghostty.delegate = self
@@ -173,7 +179,7 @@ class AppDelegate: NSObject,
ghosttyConfigDidChange(config: ghostty.config)
// Start our update checker.
updateController.startUpdater()
updaterController.startUpdater()
// Register our service provider. This must happen after everything is initialized.
NSApp.servicesProvider = ServiceProvider()
@@ -800,12 +806,12 @@ class AppDelegate: NSObject,
// defined by our "auto-update" configuration (if set) or fall back to Sparkle
// user-based defaults.
if Bundle.main.infoDictionary?["SUEnableAutomaticChecks"] as? Bool == false {
updateController.updater.automaticallyChecksForUpdates = false
updateController.updater.automaticallyDownloadsUpdates = false
updaterController.updater.automaticallyChecksForUpdates = false
updaterController.updater.automaticallyDownloadsUpdates = false
} else if let autoUpdate = config.autoUpdate {
updateController.updater.automaticallyChecksForUpdates =
updaterController.updater.automaticallyChecksForUpdates =
autoUpdate == .check || autoUpdate == .download
updateController.updater.automaticallyDownloadsUpdates =
updaterController.updater.automaticallyDownloadsUpdates =
autoUpdate == .download
}
@@ -998,11 +1004,9 @@ class AppDelegate: NSObject,
}
@IBAction func checkForUpdates(_ sender: Any?) {
updateController.checkForUpdates()
//UpdateSimulator.permissionRequest.simulate(with: updateViewModel)
updaterController.checkForUpdates(sender)
}
@IBAction func newWindow(_ sender: Any?) {
_ = TerminalController.newWindow(ghostty)
}

View File

@@ -37,7 +37,7 @@ class QuickTerminalController: BaseTerminalController {
/// Tracks if we're currently handling a manual resize to prevent recursion
private var isHandlingResize: Bool = false
init(_ ghostty: Ghostty.App,
position: QuickTerminalPosition = .top,
baseConfig base: Ghostty.SurfaceConfiguration? = nil,

View File

@@ -48,9 +48,6 @@ class BaseTerminalController: NSWindowController,
/// This can be set to show/hide the command palette.
@Published var commandPaletteIsShowing: Bool = false
/// Set if the terminal view should show the update overlay.
@Published var updateOverlayIsVisible: Bool = false
/// Whether the terminal surface should focus when the mouse is over it.
var focusFollowsMouse: Bool {
@@ -821,18 +818,7 @@ class BaseTerminalController: NSWindowController,
}
}
func fullscreenDidChange() {
guard let fullscreenStyle else { return }
// When we enter fullscreen, we want to show the update overlay so that it
// is easily visible. For native fullscreen this is visible by showing the
// menubar but we don't want to rely on that.
if fullscreenStyle.isFullscreen {
updateOverlayIsVisible = true
} else {
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
}
}
func fullscreenDidChange() {}
// MARK: Clipboard Confirmation
@@ -914,28 +900,6 @@ class BaseTerminalController: NSWindowController,
fullscreenStyle = NativeFullscreen(window)
fullscreenStyle?.delegate = self
}
// Set our update overlay state
updateOverlayIsVisible = defaultUpdateOverlayVisibility()
}
func defaultUpdateOverlayVisibility() -> Bool {
guard let window else { return true }
// No titlebar we always show the update overlay because it can't support
// updates in the titlebar
guard window.styleMask.contains(.titled) else {
return true
}
// If it's a non terminal window we can't trust it has an update accessory,
// so we always want to show the overlay.
guard let window = window as? TerminalWindow else {
return true
}
// Show the overlay if the window isn't.
return !window.supportsUpdateAccessory
}
// MARK: NSWindowDelegate

View File

@@ -31,9 +31,6 @@ protocol TerminalViewModel: ObservableObject {
/// The command palette state.
var commandPaletteIsShowing: Bool { get set }
/// The update overlay should be visible.
var updateOverlayIsVisible: Bool { get }
}
/// The main terminal view. This terminal view supports splits.
@@ -112,28 +109,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
self.delegate?.performAction(action, on: surfaceView)
}
}
// Show update information above all else.
if viewModel.updateOverlayIsVisible {
UpdateOverlay()
}
}
}
}
}
fileprivate struct UpdateOverlay: View {
var body: some View {
if let appDelegate = NSApp.delegate as? AppDelegate {
VStack {
Spacer()
HStack {
Spacer()
UpdatePill(model: appDelegate.updateViewModel)
.padding(.bottom, 12)
.padding(.trailing, 12)
}
}
}
}

View File

@@ -1,9 +1,6 @@
import AppKit
class HiddenTitlebarTerminalWindow: TerminalWindow {
// No titlebar, we don't support accessories.
override var supportsUpdateAccessory: Bool { false }
override func awakeFromNib() {
super.awakeFromNib()

View File

@@ -14,25 +14,15 @@ class TerminalWindow: NSWindow {
/// Reset split zoom button in titlebar
private let resetZoomAccessory = NSTitlebarAccessoryViewController()
/// Update notification UI in titlebar
private let updateAccessory = NSTitlebarAccessoryViewController()
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private(set) var derivedConfig: DerivedConfig = .init()
/// Whether this window supports the update accessory. If this is false, then views within this
/// window should determine how to show update notifications.
var supportsUpdateAccessory: Bool {
// Native window supports it.
true
}
/// Gets the terminal controller from the window controller.
var terminalController: TerminalController? {
windowController as? TerminalController
}
// MARK: NSWindow Overrides
override var toolbar: NSToolbar? {
@@ -95,17 +85,6 @@ class TerminalWindow: NSWindow {
}))
addTitlebarAccessoryViewController(resetZoomAccessory)
resetZoomAccessory.view.translatesAutoresizingMaskIntoConstraints = false
// Create update notification accessory
if supportsUpdateAccessory {
updateAccessory.layoutAttribute = .right
updateAccessory.view = NSHostingView(rootView: UpdateAccessoryView(
viewModel: viewModel,
model: appDelegate.updateViewModel
))
addTitlebarAccessoryViewController(updateAccessory)
updateAccessory.view.translatesAutoresizingMaskIntoConstraints = false
}
}
// Setup the accessory view for tabs that shows our keyboard shortcuts,
@@ -219,9 +198,6 @@ class TerminalWindow: NSWindow {
if let idx = titlebarAccessoryViewControllers.firstIndex(of: resetZoomAccessory) {
removeTitlebarAccessoryViewController(at: idx)
}
// We don't need to do this with the update accessory. I don't know why but
// everything works fine.
}
private func tabBarDidDisappear() {
@@ -460,7 +436,7 @@ class TerminalWindow: NSWindow {
standardWindowButton(.miniaturizeButton)?.isHidden = true
standardWindowButton(.zoomButton)?.isHidden = true
}
// MARK: Config
struct DerivedConfig {
@@ -491,20 +467,21 @@ extension TerminalWindow {
class ViewModel: ObservableObject {
@Published var isSurfaceZoomed: Bool = false
@Published var hasToolbar: Bool = false
/// Calculates the top padding based on toolbar visibility and macOS version
fileprivate var accessoryTopPadding: CGFloat {
if #available(macOS 26.0, *) {
return hasToolbar ? 10 : 5
} else {
return hasToolbar ? 9 : 4
}
}
}
struct ResetZoomAccessoryView: View {
@ObservedObject var viewModel: ViewModel
let action: () -> Void
// The padding from the top that the view appears. This was all just manually
// measured based on the OS.
var topPadding: CGFloat {
if #available(macOS 26.0, *) {
return viewModel.hasToolbar ? 10 : 5
} else {
return viewModel.hasToolbar ? 9 : 4
}
}
var body: some View {
if viewModel.isSurfaceZoomed {
@@ -520,23 +497,10 @@ extension TerminalWindow {
}
// With a toolbar, the window title is taller, so we need more padding
// to properly align.
.padding(.top, viewModel.accessoryTopPadding)
.padding(.top, topPadding)
// We always need space at the end of the titlebar
.padding(.trailing, 10)
}
}
}
/// A pill-shaped button that displays update status and provides access to update actions.
struct UpdateAccessoryView: View {
@ObservedObject var viewModel: ViewModel
@ObservedObject var model: UpdateViewModel
var body: some View {
UpdatePill(model: model)
.padding(.top, viewModel.accessoryTopPadding)
.padding(.trailing, 10)
}
}
}

View File

@@ -8,10 +8,6 @@ import SwiftUI
class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSToolbarDelegate {
/// The view model for SwiftUI views
private var viewModel = ViewModel()
/// Titlebar tabs can't support the update accessory because of the way we layout
/// the native tabs back into the menu bar.
override var supportsUpdateAccessory: Bool { false }
deinit {
tabBarObserver = nil

View File

@@ -2,10 +2,6 @@ import Cocoa
/// Titlebar tabs for macOS 13 to 15.
class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
/// Titlebar tabs can't support the update accessory because of the way we layout
/// the native tabs back into the menu bar.
override var supportsUpdateAccessory: Bool { false }
/// This is used to determine if certain elements should be drawn light or dark and should
/// be updated whenever the window background color or surrounding elements changes.
fileprivate var isLightTheme: Bool = false

View File

@@ -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)
}
}
}

View File

@@ -1,55 +0,0 @@
import Sparkle
import Cocoa
/// Standard controller for managing Sparkle updates in Ghostty.
///
/// This controller wraps SPUStandardUpdaterController to provide a simpler interface
/// for managing updates with Ghostty's custom driver and delegate. It handles
/// initialization, starting the updater, and provides the check for updates action.
class UpdateController {
private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver
private let updaterDelegate = UpdaterDelegate()
var viewModel: UpdateViewModel {
userDriver.viewModel
}
/// Initialize a new update controller.
init() {
let hostBundle = Bundle.main
self.userDriver = UpdateDriver(viewModel: .init())
self.updater = SPUUpdater(
hostBundle: hostBundle,
applicationBundle: hostBundle,
userDriver: userDriver,
delegate: updaterDelegate
)
}
/// Start the updater.
///
/// This must be called before the updater can check for updates. If starting fails,
/// an error alert will be shown after a short delay.
func startUpdater() {
try? updater.start()
}
/// Check for updates.
///
/// This is typically connected to a menu item action.
@objc func checkForUpdates() {
updater.checkForUpdates()
}
/// Validate the check for updates menu item.
///
/// - Parameter item: The menu item to validate
/// - Returns: Whether the menu item should be enabled
func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(checkForUpdates) {
return updater.canCheckForUpdates
}
return true
}
}

View File

@@ -6,7 +6,7 @@ class UpdaterDelegate: NSObject, SPUUpdaterDelegate {
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else {
return nil
}
// Sparkle supports a native concept of "channels" but it requires that
// you share a single appcast file. We don't want to do that so we
// do this instead.

View File

@@ -1,113 +0,0 @@
import Cocoa
import Sparkle
/// Implement the SPUUserDriver to modify our UpdateViewModel for custom presentation.
class UpdateDriver: NSObject, SPUUserDriver {
let viewModel: UpdateViewModel
init(viewModel: UpdateViewModel) {
self.viewModel = viewModel
super.init()
}
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping @Sendable (SUUpdatePermissionResponse) -> Void) {
viewModel.state = .permissionRequest(.init(request: request, reply: reply))
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
viewModel.state = .checking(.init(cancel: cancellation))
}
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .updateAvailable(.init(appcastItem: appcastItem, reply: reply))
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
// We don't do anything with the release notes here because Ghostty
// doesn't use the release notes feature of Sparkle currently.
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: any Error) {
// We don't do anything with release notes. See `showUpdateReleaseNotes`
}
func showUpdateNotFoundWithError(_ error: any Error, acknowledgement: @escaping () -> Void) {
viewModel.state = .notFound
// TODO: Do we need to acknowledge?
}
func showUpdaterError(_ error: any Error, acknowledgement: @escaping () -> Void) {
viewModel.state = .error(.init(
error: error,
retry: {
guard let delegate = NSApp.delegate as? AppDelegate else {
return
}
// TODO fill this in
},
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}))
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
viewModel.state = .downloading(.init(
cancel: cancellation,
expectedLength: nil,
progress: 0))
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
cancel: downloading.cancel,
expectedLength: expectedContentLength,
progress: 0))
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
guard case let .downloading(downloading) = viewModel.state else {
return
}
viewModel.state = .downloading(.init(
cancel: downloading.cancel,
expectedLength: downloading.expectedLength,
progress: downloading.progress + length))
}
func showDownloadDidStartExtractingUpdate() {
viewModel.state = .extracting(.init(progress: 0))
}
func showExtractionReceivedProgress(_ progress: Double) {
viewModel.state = .extracting(.init(progress: progress))
}
func showReady(toInstallAndRelaunch reply: @escaping @Sendable (SPUUserUpdateChoice) -> Void) {
viewModel.state = .readyToInstall(.init(reply: reply))
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
// We don't do anything here.
viewModel.state = .idle
}
func showUpdateInFocus() {
// We don't currently implement this because our update state is
// shown in a terminal window. We may want to implement this at some
// point to handle the case that no windows are open, though.
}
func dismissUpdateInstallation() {
viewModel.state = .idle
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
))
}
}
}
}
}
}

View File

@@ -1,267 +0,0 @@
import Foundation
import SwiftUI
import Sparkle
class UpdateViewModel: ObservableObject {
@Published var state: UpdateState = .idle
/// The text to display for the current update state.
/// Returns an empty string for idle state, progress percentages for downloading/extracting,
/// or descriptive text for other states.
var text: String {
switch state {
case .idle:
return ""
case .permissionRequest:
return "Enable Automatic Updates?"
case .checking:
return "Checking for Updates…"
case .updateAvailable(let update):
return "Update Available: \(update.appcastItem.displayVersionString)"
case .downloading(let download):
if let expectedLength = download.expectedLength, expectedLength > 0 {
let progress = Double(download.progress) / Double(expectedLength)
return String(format: "Downloading: %.0f%%", progress * 100)
}
return "Downloading…"
case .extracting(let extracting):
return String(format: "Preparing: %.0f%%", extracting.progress * 100)
case .readyToInstall:
return "Install Update"
case .installing:
return "Installing…"
case .notFound:
return "No Updates Available"
case .error(let err):
return err.error.localizedDescription
}
}
/// The SF Symbol icon name for the current update state.
/// Returns nil for idle, downloading, and extracting states.
var iconName: String? {
switch state {
case .idle:
return nil
case .permissionRequest:
return "questionmark.circle"
case .checking:
return "arrow.triangle.2.circlepath"
case .updateAvailable:
return "arrow.down.circle.fill"
case .downloading, .extracting:
return nil
case .readyToInstall:
return "checkmark.circle.fill"
case .installing:
return "gear"
case .notFound:
return "info.circle"
case .error:
return "exclamationmark.triangle.fill"
}
}
/// The color to apply to the icon for the current update state.
var iconColor: Color {
switch state {
case .idle:
return .secondary
case .permissionRequest:
return .white
case .checking:
return .secondary
case .updateAvailable, .readyToInstall:
return .accentColor
case .downloading, .extracting, .installing:
return .secondary
case .notFound:
return .secondary
case .error:
return .orange
}
}
/// The background color for the update pill.
var backgroundColor: Color {
switch state {
case .permissionRequest:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.3, of: .black) ?? .systemBlue)
case .updateAvailable:
return .accentColor
case .readyToInstall:
return Color(nsColor: NSColor.systemGreen.blended(withFraction: 0.3, of: .black) ?? .systemGreen)
case .notFound:
return Color(nsColor: NSColor.systemBlue.blended(withFraction: 0.5, of: .black) ?? .systemBlue)
case .error:
return .orange.opacity(0.2)
default:
return Color(nsColor: .controlBackgroundColor)
}
}
/// The foreground (text) color for the update pill.
var foregroundColor: Color {
switch state {
case .permissionRequest:
return .white
case .updateAvailable, .readyToInstall:
return .white
case .notFound:
return .white
case .error:
return .orange
default:
return .primary
}
}
}
enum UpdateState: Equatable {
case idle
case permissionRequest(PermissionRequest)
case checking(Checking)
case updateAvailable(UpdateAvailable)
case notFound
case error(Error)
case downloading(Downloading)
case extracting(Extracting)
case readyToInstall(ReadyToInstall)
case installing
var isIdle: Bool {
if case .idle = self { return true }
return false
}
static func == (lhs: UpdateState, rhs: UpdateState) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle):
return true
case (.permissionRequest, .permissionRequest):
return true
case (.checking, .checking):
return true
case (.updateAvailable(let lUpdate), .updateAvailable(let rUpdate)):
return lUpdate.appcastItem.displayVersionString == rUpdate.appcastItem.displayVersionString
case (.notFound, .notFound):
return true
case (.error(let lErr), .error(let rErr)):
return lErr.error.localizedDescription == rErr.error.localizedDescription
case (.downloading(let lDown), .downloading(let rDown)):
return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength
case (.extracting(let lExt), .extracting(let rExt)):
return lExt.progress == rExt.progress
case (.readyToInstall, .readyToInstall):
return true
case (.installing, .installing):
return true
default:
return false
}
}
struct PermissionRequest {
let request: SPUUpdatePermissionRequest
let reply: @Sendable (SUUpdatePermissionResponse) -> Void
}
struct Checking {
let cancel: () -> Void
}
struct UpdateAvailable {
let appcastItem: SUAppcastItem
let reply: @Sendable (SPUUserUpdateChoice) -> Void
var releaseNotes: ReleaseNotes? {
let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String
return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit)
}
}
enum ReleaseNotes {
case commit(URL)
case compareTip(URL)
case tagged(URL)
init?(displayVersionString: String, currentCommit: String?) {
let version = displayVersionString
// Check for semantic version (x.y.z)
if let semver = Self.extractSemanticVersion(from: version) {
let slug = semver.replacingOccurrences(of: ".", with: "-")
if let url = URL(string: "https://ghostty.org/docs/install/release-notes/\(slug)") {
self = .tagged(url)
return
}
}
// Fall back to git hash detection
guard let newHash = Self.extractGitHash(from: version) else {
return nil
}
if let currentHash = currentCommit, !currentHash.isEmpty,
let url = URL(string: "https://github.com/ghostty-org/ghostty/compare/\(currentHash)...\(newHash)") {
self = .compareTip(url)
} else if let url = URL(string: "https://github.com/ghostty-org/ghostty/commit/\(newHash)") {
self = .commit(url)
} else {
return nil
}
}
private static func extractSemanticVersion(from version: String) -> String? {
let pattern = #"^\d+\.\d+\.\d+$"#
if version.range(of: pattern, options: .regularExpression) != nil {
return version
}
return nil
}
private static func extractGitHash(from version: String) -> String? {
let pattern = #"[0-9a-f]{7,40}"#
if let range = version.range(of: pattern, options: .regularExpression) {
return String(version[range])
}
return nil
}
var url: URL {
switch self {
case .commit(let url): return url
case .compareTip(let url): return url
case .tagged(let url): return url
}
}
var label: String {
switch (self) {
case .commit: return "View GitHub Commit"
case .compareTip: return "Changes Since This Tip Release"
case .tagged: return "View Release Notes"
}
}
}
struct Error {
let error: any Swift.Error
let retry: () -> Void
let dismiss: () -> Void
}
struct Downloading {
let cancel: () -> Void
let expectedLength: UInt64?
let progress: UInt64
}
struct Extracting {
let progress: Double
}
struct ReadyToInstall {
let reply: @Sendable (SPUUserUpdateChoice) -> Void
}
}

View File

@@ -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")
}
}
}

View File

@@ -16,13 +16,6 @@ const expandPath = @import("../os/path.zig").expand;
const gtk = @import("gtk.zig");
const GitVersion = @import("GitVersion.zig");
/// The version of the next release.
///
/// TODO: When Zig 0.14 is released, derive this from build.zig.zon directly.
/// Until then this MUST match build.zig.zon and should always be the
/// _next_ version to release.
const app_version: std.SemanticVersion = .{ .major = 1, .minor = 2, .patch = 1 };
/// Standard build configuration options.
optimize: std.builtin.OptimizeMode,
target: std.Build.ResolvedTarget,
@@ -69,7 +62,7 @@ emit_unicode_table_gen: bool = false,
/// Environmental properties
env: std.process.EnvMap,
pub fn init(b: *std.Build) !Config {
pub fn init(b: *std.Build, appVersion: []const u8) !Config {
// Setup our standard Zig target and optimize options, i.e.
// `-Doptimize` and `-Dtarget`.
const optimize = b.standardOptimizeOption(.{});
@@ -217,6 +210,7 @@ pub fn init(b: *std.Build) !Config {
// If an explicit version is given, we always use it.
try std.SemanticVersion.parse(v)
else version: {
const app_version = try std.SemanticVersion.parse(appVersion);
// If no explicit version is given, we try to detect it from git.
const vsn = GitVersion.detect(b) catch |err| switch (err) {
// If Git isn't available we just make an unknown dev version.

View File

@@ -24,12 +24,12 @@ RUN DEBIAN_FRONTEND="noninteractive" apt-get -qq update && \
WORKDIR /src
COPY ./build.zig /src
COPY ./build.zig ./build.zig.zon /src/
# Install zig
# https://ziglang.org/download/
RUN export ZIG_VERSION=$(sed -n -e 's/^.*requireZig("\(.*\)").*$/\1/p' build.zig) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \
RUN export ZIG_VERSION=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon) && curl -L -o /tmp/zig.tar.xz "https://ziglang.org/download/$ZIG_VERSION/zig-$(uname -m)-linux-$ZIG_VERSION.tar.xz" && \
tar -xf /tmp/zig.tar.xz -C /opt && \
rm /tmp/zig.tar.xz && \
ln -s "/opt/zig-$(uname -m)-linux-$ZIG_VERSION/zig" /usr/local/bin/zig
@@ -41,4 +41,3 @@ RUN zig build \
-Dcpu=baseline
RUN ./zig-out/bin/ghostty +version