Merge branch 'ghostty-org:main' into localization-da

This commit is contained in:
Carl Villads Priisholm
2026-08-13 08:47:16 +02:00
committed by GitHub
296 changed files with 50581 additions and 7565 deletions

View File

@@ -47,7 +47,7 @@ Write commit messages that follow commit style guidelines for the project.
and **how the new behavior works** at a high level.
- Use plain prose, not bullet points. Wrap lines at ~72 characters.
- Focus on the _why_ and _how_ rather than restating the diff.
- Keep the tone direct and technical without no filler phrases.
- Keep the tone direct and technical without filler phrases.
- Don't exceed a handful of paragraphs; less is more.
## Workflow

View File

@@ -156,6 +156,8 @@ body:
options:
- label: I have reviewed the FAQ and confirm that my issue is NOT among them.
required: true
- label: I have searched the Ghostty documentation and confirm there doesn't exist a configuration option that solves my issue.
required: true
- label: I have searched the Ghostty repository (both open and closed Discussions and Issues) and confirm this is not a duplicate of an existing issue or discussion.
required: true
- label: I have checked the "Preview" tab on all text fields to ensure that everything looks right, and have wrapped all configuration and code in code blocks with a group of three backticks (` ``` `) on separate lines.

View File

@@ -1,5 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Features, Bug Reports, Questions
url: https://github.com/ghostty-org/ghostty/discussions/new/choose
about: Our preferred starting point if you have any questions or suggestions about configuration, features or behavior.

View File

@@ -1,9 +0,0 @@
---
name: Pre-Discussed and Approved Topics
about: |-
Only for topics already discussed and approved in the GitHub Discussions section.
---
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**

16
.github/VOUCHED.td vendored
View File

@@ -23,8 +23,10 @@
00jciv00
04cb
0xdvc
12ya
-4rh1t3ct0r7
52dyd
a-lang
aalhendi
aaron-ang
abdurrahmanski
@@ -38,6 +40,7 @@ akimiojr
alaasdk
alanmoyano
alaviss
alex19ep
alexbathome
alexfeijoo44
alexjuca
@@ -88,6 +91,7 @@ damyanbogoev
danneu
danulqua
dariogriffo
dave92082
davidsanchez222
deblasis
dervedro
@@ -116,17 +120,21 @@ exlee
-f1813483-netizen
fallintoplace
faukah
figelwump
filip7
flou
fornwall
francescarpi
fru1tworld
gadgetman6
gagbo
ghokun
gmile
gordonbondon
gotenksin
gpanders
guilhermetk
-guysoft LLM communication (likely automated)
h3nock
hakonhagland
halosatrio
@@ -142,7 +150,7 @@ illiakrauchanka
j0hnm4r5
jacobsandlund
jake-stewart
jamesarch
-jamesarch Bad AI etiquette
jamylak
jarred-sumner
jcollie
@@ -179,6 +187,7 @@ lepips
liby
linustalacko
lonsagisawa
lotheac
louisunlimited
luisnquin
lynicis
@@ -242,6 +251,7 @@ pluiedev
pouwerkerk
poweruser64
prakhar54-byte
prihlop
priyans-hu
puzza007
qappell
@@ -270,9 +280,11 @@ sanjj1
secrus
seruman
seyoungjeong
shorsher
silveirapf
simonbcn
slsrepo
steven-tk
sunshine-syz
svmhdvn
tasselx
@@ -289,6 +301,7 @@ turbolent
tweedbeetle
uhojin
unphased
unsaltedscholar
uzaaft
vancluever
vaughanandrews
@@ -304,6 +317,7 @@ yak3d
yamshta
ydah
-zaviro
zenangst
zenyr
zeshi09
zubb

View File

@@ -66,7 +66,7 @@ jobs:
- name: Detect changes since last tip
id: changes
if: github.event_name == 'workflow_run'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
with:
token: ""
base: tip

View File

@@ -31,7 +31,7 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter_every
with:
token: ""
@@ -40,7 +40,7 @@ jobs:
code:
- '**'
- '!.github/VOUCHED.td'
- uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
- uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3
id: filter_any
with:
token: ""
@@ -86,6 +86,7 @@ jobs:
- build-bench
- build-dist
- build-dist-lib-vt
- build-example-zig-vt-macos-no-sdk
- build-examples-zig
- build-examples-cmake
- build-examples-cmake-windows
@@ -239,6 +240,38 @@ jobs:
cd example/${{ matrix.dir }}
nix develop -c zig build
build-example-zig-vt-macos-no-sdk:
name: Example zig-vt (macOS, no SDK)
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test
env:
ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1
with:
cache: |
xcode
path: |
/Users/runner/zig
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17
with:
name: ghostty
authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}"
- name: Build Example without SDK
working-directory: example/zig-vt
run: nix develop -c env DEVELOPER_DIR=/nonexistent zig build
build-examples-cmake:
strategy:
fail-fast: false
@@ -1131,14 +1164,6 @@ jobs:
COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \
COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES
# Build the iOS target without code signing just to verify it works.
- name: Build Ghostty iOS
run: |
cd macos
xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" \
COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \
COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES
build-macos-xcode-27:
runs-on: namespace-profile-ghostty-macos-tahoe
needs: test

View File

@@ -50,7 +50,7 @@ dependencies:
### macOS
Building the Ghostty macOS app requires that Xcode, the macOS SDK,
the iOS SDK, and Metal Toolchain are all installed.
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

View File

@@ -101,8 +101,10 @@ pub fn build(b: *std.Build) !void {
if (config.emit_webdata) webdata.install();
// Ghostty bench tools
const bench = try buildpkg.GhosttyBench.init(b, &deps);
if (config.emit_bench) bench.install();
if (config.emit_bench) {
const bench = try buildpkg.GhosttyBench.init(b, &deps);
bench.install();
}
// Ghostty dist tarball
const dist = try buildpkg.GhosttyDist.init(b, &config);

View File

@@ -125,8 +125,8 @@
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.android_ndk = .{ .path = "./pkg/android-ndk" },
.iterm2_themes = .{
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz",
.hash = "N-V-__8AAGxKBAAjYkT1jYUiOZDXQuj0u34ndIjAWxiR1KX9",
.url = "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz",
.hash = "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl",
.lazy = true,
},
},

6
build.zig.zon.json generated
View File

@@ -59,10 +59,10 @@
"url": "https://deps.files.ghostty.org/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI.tar.gz",
"hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs="
},
"N-V-__8AAGxKBAAjYkT1jYUiOZDXQuj0u34ndIjAWxiR1KX9": {
"N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl": {
"name": "iterm2_themes",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz",
"hash": "sha256-cynQ4ulY7oQE5RamVQvQczTtxhEzSnP4TVBHfapFnww="
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz",
"hash": "sha256-/qxLa9S8bSg2NAbJzwEtZhGvOxaPTAVFNWnBCs/MFBs="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",

6
build.zig.zon.nix generated
View File

@@ -206,11 +206,11 @@ in
};
}
{
name = "N-V-__8AAGxKBAAjYkT1jYUiOZDXQuj0u34ndIjAWxiR1KX9";
name = "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz";
hash = "sha256-cynQ4ulY7oQE5RamVQvQczTtxhEzSnP4TVBHfapFnww=";
url = "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz";
hash = "sha256-/qxLa9S8bSg2NAbJzwEtZhGvOxaPTAVFNWnBCs/MFBs=";
unpack = false;
};
}

2
build.zig.zon.txt generated
View File

@@ -9,7 +9,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918
https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz
https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz
https://deps.files.ghostty.org/gettext-0.24.tar.gz
https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz
https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz
https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz
https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz
https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz

View File

@@ -70,6 +70,32 @@ GhosttyClipboardWriteResult on_clipboard_write(
}
//! [effects-clipboard-write]
//! [effects-unknown-sequence]
void on_unknown_sequence(
GhosttyTerminal terminal,
void* userdata,
const GhosttyTerminalUnknownSequence* sequence) {
(void)terminal;
(void)userdata;
switch (sequence->tag) {
case GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC: {
const GhosttyTerminalUnknownStringSequence* apc = &sequence->value.apc;
printf(" unknown APC (truncated=%s, content=%zu bytes): ",
apc->truncated ? "yes" : "no",
apc->content.len);
if (apc->content.len > 0) {
fwrite(apc->content.ptr, 1, apc->content.len, stdout);
}
printf("\n");
break;
}
default:
break;
}
}
//! [effects-unknown-sequence]
//! [effects-register]
int main() {
// Create a terminal
@@ -92,6 +118,14 @@ int main() {
(const void *)on_title_changed);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
(const void *)on_clipboard_write);
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE,
(const void *)on_unknown_sequence);
// Unknown sequence capture is independently bounded and disabled by
// default. This limit will apply to every supported unknown sequence type.
size_t unknown_max_bytes = 256;
ghostty_terminal_set(terminal, GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES,
&unknown_max_bytes);
// Feed VT data that triggers effects:
@@ -120,7 +154,13 @@ int main() {
ghostty_terminal_vt_write(terminal, (const uint8_t*)clipboard_seq,
strlen(clipboard_seq));
// 5. Another bell to show the counter increments
// 5. Unsupported APC sequence
printf("Sending unknown APC:\n");
const char* unknown_apc = "\x1B_private-command;payload\x1B\\";
ghostty_terminal_vt_write(terminal, (const uint8_t*)unknown_apc,
strlen(unknown_apc));
// 6. Another bell to show the counter increments
printf("Sending another BEL:\n");
ghostty_terminal_vt_write(terminal, &bel, 1);

View File

@@ -0,0 +1,15 @@
# Example: Terminal Snapshots in C
This example creates a terminal with continuation tracking, encodes its full
state, and restores the snapshot using both the one-shot and incremental C
decoder APIs. The incremental path uses a synchronous `GhosttyReader` callback
and reports each restored history page. The standalone project links the static
libghostty-vt artifact so it can run consistently on every supported host.
## Usage
Run the example:
```shell-session
zig build run
```

View File

@@ -0,0 +1,33 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const run_step = b.step("run", "Run the app");
const exe_mod = b.createModule(.{
.target = target,
.optimize = optimize,
});
exe_mod.addCSourceFiles(.{
.root = b.path("src"),
.files = &.{"main.c"},
});
exe_mod.addCMacro("GHOSTTY_STATIC", "");
if (b.lazyDependency("ghostty", .{})) |dep| {
exe_mod.linkLibrary(dep.artifact("ghostty-vt-static"));
}
const exe = b.addExecutable(.{
.name = "c_vt_snapshot",
.root_module = exe_mod,
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| run_cmd.addArgs(args);
run_step.dependOn(&run_cmd.step);
}

View File

@@ -0,0 +1,14 @@
.{
.name = .c_vt_snapshot,
.version = "0.0.0",
.fingerprint = 0xff13ccc637fd5383,
.minimum_zig_version = "0.15.1",
.dependencies = .{
.ghostty = .{ .path = "../../" },
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
},
}

View File

@@ -0,0 +1,162 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <ghostty/vt.h>
//! [snapshot-buffer-reader]
typedef struct {
const uint8_t *data;
size_t len;
size_t offset;
} BufferReader;
// GhosttyReader callbacks are synchronous. A successful zero-byte read is
// permanent EOF; returning false would report an I/O error.
static bool buffer_read(void *userdata,
uint8_t *buffer,
size_t capacity,
size_t *out_read) {
BufferReader *reader = userdata;
size_t remaining = reader->len - reader->offset;
size_t count = remaining < capacity ? remaining : capacity;
// Deliberately return short reads to demonstrate that the decoder retries.
if (count > 64) count = 64;
memcpy(buffer, reader->data + reader->offset, count);
reader->offset += count;
*out_read = count;
return true;
}
//! [snapshot-buffer-reader]
int main(void) {
GhosttyResult result;
//! [snapshot-encode]
GhosttyTerminal source = NULL;
// A wide, shallow screen fills backing pages quickly enough to leave older
// PAGE records after READY for the incremental decoder to demonstrate.
result = ghostty_terminal_new(NULL, &source, 215, 2);
assert(result == GHOSTTY_SUCCESS);
// Snapshot encoding requires continuation tracking to be enabled before
// feeding input. The limit bounds unfinished VT sequence retention.
const size_t continuation_limit = 1024;
result = ghostty_terminal_set(
source,
GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES,
&continuation_limit);
assert(result == GHOSTTY_SUCCESS);
// Keep enough scrollback to demonstrate incremental history restoration.
result = ghostty_terminal_set(
source, GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_BYTES, NULL);
assert(result == GHOSTTY_SUCCESS);
const char *line = "snapshot history line\r\n";
for (size_t i = 0; i < 1000; i++) {
ghostty_terminal_vt_write(
source, (const uint8_t *)line, strlen(line));
}
// Leave an SGR sequence unfinished so its continuation is snapshotted too.
const char *unfinished = "\x1b[31";
ghostty_terminal_vt_write(
source, (const uint8_t *)unfinished, strlen(unfinished));
uint8_t *snapshot = NULL;
size_t snapshot_len = 0;
result = ghostty_snapshot_encode_alloc(
source, NULL, &snapshot, &snapshot_len);
assert(result == GHOSTTY_SUCCESS);
printf("encoded %zu snapshot bytes\n", snapshot_len);
//! [snapshot-encode]
//! [snapshot-decode]
GhosttySnapshotDecoder full_decoder = NULL;
result = ghostty_snapshot_decoder_new_buf(
NULL, &full_decoder, snapshot, snapshot_len);
assert(result == GHOSTTY_SUCCESS);
GhosttyTerminal full_terminal = NULL;
result = ghostty_snapshot_decoder_decode(full_decoder, &full_terminal);
assert(result == GHOSTTY_SUCCESS);
ghostty_snapshot_decoder_free(full_decoder);
ghostty_terminal_free(full_terminal);
//! [snapshot-decode]
//! [snapshot-incremental]
BufferReader reader_state = {
.data = snapshot,
.len = snapshot_len,
.offset = 0,
};
GhosttyReader reader = {
.read = buffer_read,
.userdata = &reader_state,
};
GhosttySnapshotDecoder incremental_decoder = NULL;
result = ghostty_snapshot_decoder_new(
NULL, &incremental_decoder, reader);
assert(result == GHOSTTY_SUCCESS);
// READY returns a validated, renderable terminal before old history.
GhosttyTerminal incremental_terminal = NULL;
result = ghostty_snapshot_decoder_ready(
incremental_decoder, &incremental_terminal);
assert(result == GHOSTTY_SUCCESS);
uint64_t history_rows = 0;
result = ghostty_snapshot_decoder_get(
incremental_decoder,
GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY,
&history_rows);
assert(result == GHOSTTY_SUCCESS);
printf("snapshot advertises %llu primary history rows\n",
(unsigned long long)history_rows);
size_t page_count = 0;
while ((result = ghostty_snapshot_decoder_next(incremental_decoder)) ==
GHOSTTY_SUCCESS) {
GhosttyTerminalScreen screen;
size_t rows = 0;
uint32_t remaining = 0;
const GhosttySnapshotDecoderData keys[] = {
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN,
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS,
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING,
};
void *values[] = {&screen, &rows, &remaining};
size_t written = 0;
result = ghostty_snapshot_decoder_get_multi(
incremental_decoder,
sizeof(keys) / sizeof(keys[0]),
keys,
values,
&written);
assert(result == GHOSTTY_SUCCESS);
assert(written == sizeof(keys) / sizeof(keys[0]));
printf("restored %zu rows to screen %d (%u pages remain)\n",
rows, (int)screen, remaining);
page_count++;
}
// NO_VALUE means FINISH validated successfully and is idempotent.
assert(result == GHOSTTY_NO_VALUE);
assert(page_count > 0);
assert(ghostty_snapshot_decoder_next(incremental_decoder) ==
GHOSTTY_NO_VALUE);
ghostty_snapshot_decoder_free(incremental_decoder);
ghostty_terminal_free(incremental_terminal);
//! [snapshot-incremental]
ghostty_free(NULL, snapshot, snapshot_len);
ghostty_terminal_free(source);
return 0;
}

View File

@@ -13,12 +13,12 @@ modules:
- chmod a+x /app/zig/zig
sources:
- type: archive
sha256: 02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239
url: https://ziglang.org/download/0.15.2/zig-x86_64-linux-0.15.2.tar.xz
sha256: 70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00
url: https://ziglang.org/download/0.16.0/zig-x86_64-linux-0.16.0.tar.xz
only-arches: [x86_64]
- type: archive
sha256: 958ed7d1e00d0ea76590d27666efbf7a932281b3d7ba0c6b01b0ff26498f667f
url: https://ziglang.org/download/0.15.2/zig-aarch64-linux-0.15.2.tar.xz
sha256: ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17
url: https://ziglang.org/download/0.16.0/zig-aarch64-linux-0.16.0.tar.xz
only-arches: [aarch64]
- name: bzip2-redirect

View File

@@ -73,9 +73,9 @@
},
{
"type": "archive",
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260720-153658-97e244c.tgz",
"dest": "vendor/p/N-V-__8AAGxKBAAjYkT1jYUiOZDXQuj0u34ndIjAWxiR1KX9",
"sha256": "7329d0e2e958ee8404e516a6550bd07334edc611334a73f84d50477daa459f0c"
"url": "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz",
"dest": "vendor/p/N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl",
"sha256": "feac4b6bd4bc6d28363406c9cf012d6611af3b168f4c05453569c10acfcc141b"
},
{
"type": "archive",

View File

@@ -1,10 +1,14 @@
// Ghostty embedding API. The documentation for the embedding API is
// only within the Zig source files that define the implementations. This
// isn't meant to be a general purpose embedding API (yet) so there hasn't
// been documentation or example work beyond that.
// Ghostty's internal embedder API, a.k.a. "libghostty-internal".
//
// The only consumer of this API is the macOS app, but the API is built to
// be more general purpose.
// The only consumer of this API is the macOS app, and while it is fairly
// comprehensive, it is tailored to the needs of the macOS app and not designed
// for external use, hence why most functions are undocumented and some are
// macOS-specific (e.g. ones dealing with the Metal graphics API).
//
// External embedders should instead use `libghostty-vt` or other related
// packages, which are extensively documented and designed from the ground up
// to be used in other software. Header files for which can be found in
// `include/ghostty/`.
#ifndef GHOSTTY_H
#define GHOSTTY_H
@@ -645,6 +649,12 @@ typedef enum {
GHOSTTY_INSPECTOR_HIDE,
} ghostty_action_inspector_e;
// apprt.action.ExportTerminalIO.C
typedef struct {
const char* contents;
size_t len;
} ghostty_action_export_terminal_io_s;
// apprt.action.QuitTimer
typedef enum {
GHOSTTY_QUIT_TIMER_START,
@@ -672,6 +682,7 @@ typedef struct {
typedef enum {
GHOSTTY_PROMPT_TITLE_SURFACE,
GHOSTTY_PROMPT_TITLE_TAB,
GHOSTTY_PROMPT_TITLE_WINDOW,
} ghostty_action_prompt_title_e;
// apprt.action.Pwd.C
@@ -679,6 +690,14 @@ typedef struct {
const char* pwd;
} ghostty_action_pwd_s;
// apprt.action.OpenConfig
typedef enum {
// Open the config in the OS default editor.
GHOSTTY_ACTION_OPEN_CONFIG_OS_OPEN,
// Open the config in a new window using $EDITOR or $VISUAL
GHOSTTY_ACTION_OPEN_CONFIG_NEW_WINDOW,
} ghostty_action_open_config_e;
// terminal.MouseShape
typedef enum {
GHOSTTY_MOUSE_SHAPE_DEFAULT,
@@ -812,6 +831,7 @@ typedef enum {
GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,
GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,
GHOSTTY_ACTION_OPEN_URL_KIND_HTML,
GHOSTTY_ACTION_OPEN_URL_KIND_OSC8,
} ghostty_action_open_url_kind_e;
// apprt.action.OpenUrl.C
@@ -914,9 +934,11 @@ typedef enum {
GHOSTTY_ACTION_INSPECTOR,
GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,
GHOSTTY_ACTION_RENDER_INSPECTOR,
GHOSTTY_ACTION_EXPORT_TERMINAL_IO,
GHOSTTY_ACTION_DESKTOP_NOTIFICATION,
GHOSTTY_ACTION_SET_TITLE,
GHOSTTY_ACTION_SET_TAB_TITLE,
GHOSTTY_ACTION_SET_WINDOW_TITLE,
GHOSTTY_ACTION_PROMPT_TITLE,
GHOSTTY_ACTION_PWD,
GHOSTTY_ACTION_MOUSE_SHAPE,
@@ -949,6 +971,7 @@ typedef enum {
GHOSTTY_ACTION_SEARCH_SELECTED,
GHOSTTY_ACTION_READONLY,
GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD,
GHOSTTY_ACTION_MOVE_TAB_TO_NEW_WINDOW,
} ghostty_action_tag_e;
typedef union {
@@ -964,6 +987,7 @@ typedef union {
ghostty_action_cell_size_s cell_size;
ghostty_action_scrollbar_s scrollbar;
ghostty_action_inspector_e inspector;
ghostty_action_export_terminal_io_s export_terminal_io;
ghostty_action_desktop_notification_s desktop_notification;
ghostty_action_set_title_s set_title;
ghostty_action_set_title_s set_tab_title;
@@ -990,6 +1014,7 @@ typedef union {
ghostty_action_search_total_s search_total;
ghostty_action_search_selected_s search_selected;
ghostty_action_readonly_e readonly;
ghostty_action_open_config_e open_config;
} ghostty_action_u;
typedef struct {
@@ -1055,6 +1080,7 @@ typedef union {
// apprt.ipc.Action.Key
typedef enum {
GHOSTTY_IPC_ACTION_NEW_WINDOW,
GHOSTTY_IPC_ACTION_NEW_TAB,
GHOSTTY_IPC_ACTION_TOGGLE_QUICK_TERMINAL,
} ghostty_ipc_action_tag_e;

View File

@@ -31,12 +31,14 @@
* - @ref terminal "Terminal" - Complete terminal emulator state and rendering
* - @ref render "Render State" - Incremental render state updates for custom renderers
* - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML
* - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state
* - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences
* - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences
* - @ref paste "Paste Utilities" - Validate paste data safety
* - @ref unicode "Unicode Utilities" - Codepoint properties for text layout
* - @ref build_info "Build Info" - Query compile-time build configuration
* - @ref allocator "Memory Management" - Memory management and custom allocators
* - @ref io "Byte-stream I/O" - Reusable synchronous reader and writer callbacks
* - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions
*
* Encoding related APIs:
@@ -140,6 +142,7 @@ extern "C" {
#include <ghostty/vt/terminal.h>
#include <ghostty/vt/grid_ref.h>
#include <ghostty/vt/grid_ref_tracked.h>
#include <ghostty/vt/io.h>
#include <ghostty/vt/osc.h>
#include <ghostty/vt/sgr.h>
#include <ghostty/vt/style.h>
@@ -153,6 +156,7 @@ extern "C" {
#include <ghostty/vt/screen.h>
#include <ghostty/vt/selection.h>
#include <ghostty/vt/size_report.h>
#include <ghostty/vt/snapshot.h>
#include <ghostty/vt/unicode.h>
#include <ghostty/vt/wasm.h>

107
include/ghostty/vt/io.h Normal file
View File

@@ -0,0 +1,107 @@
/**
* @file io.h
*
* Generic IO callbacks for libghostty-vt.
*/
#ifndef GHOSTTY_VT_IO_H
#define GHOSTTY_VT_IO_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
/** @defgroup io I/O
*
* Synchronous callback interfaces used by APIs that consume or produce byte
* streams. The callback and userdata pointers must remain valid for the full
* lifetime documented by the API receiving a GhosttyReader or GhosttyWriter.
*
* @{
*/
#ifdef __cplusplus
extern "C" {
#endif
/**
* Read bytes from a source.
*
* The callback must set @p out_read to a value no greater than @p capacity
* when returning true. A positive value reports progress; it may be less than
* capacity and does not indicate end-of-file. A zero value is definitive
* end-of-file. It must not be used to report temporary input starvation or a
* would-block condition.
*
* Returning false reports a fatal read error and the value of @p out_read is
* ignored. The library does not inspect or modify errno.
*
* All pointer arguments are borrowed and valid only for the duration of the
* callback. The callback is invoked synchronously on the calling thread.
*
* @param userdata Opaque userdata from GhosttyReader
* @param buffer Destination for read bytes; always non-NULL
* @param capacity Writable capacity of @p buffer; always greater than zero
* @param[out] out_read Number of bytes read when returning true; non-NULL
* @return true for a successful read or end-of-file, false for a fatal error
*/
typedef bool (*GhosttyReaderFn)(
void* userdata,
uint8_t* buffer,
size_t capacity,
size_t* out_read);
/**
* Write bytes to a destination.
*
* Returning true means all @p len bytes were accepted. Returning false
* reports a fatal write error. A callback wrapping an interface that permits
* partial writes must retry internally until the full slice is accepted or
* an error occurs.
*
* On failure, the destination may already contain a prefix of the bytes. The
* calling operation fails and must not be resumed from that partial output.
* The library does not inspect or modify errno.
*
* @p data is borrowed and valid only for the duration of the callback. The
* callback is invoked synchronously on the calling thread. Successful return
* means the bytes were handed to the destination; it does not imply that the
* destination was flushed or made durable.
*
* @param userdata Opaque userdata from GhosttyWriter
* @param data Source bytes; always non-NULL
* @param len Number of source bytes; always greater than zero
* @return true if the complete slice was accepted, false on fatal error
*/
typedef bool (*GhosttyWriterFn)(
void* userdata,
const uint8_t* data,
size_t len);
/**
* A byte source callback and its opaque context.
*
* The struct is passed by value. @p read must be non-NULL.
*/
typedef struct {
GhosttyReaderFn read;
void* userdata;
} GhosttyReader;
/**
* A byte destination callback and its opaque context.
*
* The struct is passed by value. @p write must be non-NULL.
*/
typedef struct {
GhosttyWriterFn write;
void* userdata;
} GhosttyWriter;
#ifdef __cplusplus
}
#endif
/** @} */
#endif /* GHOSTTY_VT_IO_H */

View File

@@ -390,7 +390,8 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Borrowed pointer to the raw pixel data. Valid as long as the
* underlying terminal is not mutated.
* underlying terminal is not mutated. Returns GHOSTTY_NO_VALUE when
* the image metadata is resident but its pixel payload is pending.
*
* The data is always fully decoded, uncompressed pixels in the
* format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads
@@ -404,7 +405,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* Length of the raw pixel data in bytes. Always equal to
* width * height * bytes-per-pixel for the reported format.
* width * height * bytes-per-pixel for the reported format. For a
* pending image, this is the expected length reserved against the
* storage limit even though DATA_PTR is not available yet.
*
* Output type: size_t *
*/
@@ -421,7 +424,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Stamps are unique and monotonically increasing process-wide and
* are drawn from the same sequence as
* GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored
* image, so zero can be used as an "empty" sentinel by callers.
* image, so zero can be used as an "empty" sentinel by callers. Pending
* payload completion preserves this value to retain image age; consumers
* detect that completion through GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION
* and retry DATA_PTR.
*
* Output type: uint64_t *
*/

View File

@@ -0,0 +1,525 @@
/**
* @file snapshot.h
*
* Encode and restore complete terminal snapshots.
*/
#ifndef GHOSTTY_VT_SNAPSHOT_H
#define GHOSTTY_VT_SNAPSHOT_H
#include <stddef.h>
#include <stdint.h>
#include <ghostty/vt/allocator.h>
#include <ghostty/vt/io.h>
#include <ghostty/vt/terminal.h>
#include <ghostty/vt/types.h>
#ifdef __cplusplus
extern "C" {
#endif
/** @defgroup snapshot Terminal Snapshot
*
* Encode and restore the complete state of a terminal via a binary format.
*
* A snapshot is an ordered, CRC-protected record stream. Its READY marker
* follows enough state to render and resume the terminal, including any
* unfinished VT parser input. Older scrollback pages follow READY and the
* FINISH marker terminates the complete snapshot.
*
* End-of-file before an operation's required READY or FINISH marker is
* malformed, truncated snapshot data and returns GHOSTTY_INVALID_VALUE.
* GHOSTTY_IO_ERROR is reserved for a reader callback that returns false.
*
* ## Examples
*
* The complete working example is available in `example/c-vt-snapshot`.
*
* ### Encode a terminal and its unfinished VT continuation
* @snippet c-vt-snapshot/src/main.c snapshot-encode
*
* ### Restore a complete snapshot in one call
* @snippet c-vt-snapshot/src/main.c snapshot-decode
*
* ### Adapt a byte source to GhosttyReader
* @snippet c-vt-snapshot/src/main.c snapshot-buffer-reader
*
* ### Restore READY first, then incrementally prepend history
* @snippet c-vt-snapshot/src/main.c snapshot-incremental
*
* ## Format
*
* Every integer is unsigned and little-endian. The stream begins with this
* fixed ten-byte envelope:
*
* @code{.unparsed}
* byte 0 8 10
* +---------------+--------+
* | "GHOSTSNP" | version|
* | 8-byte magic | u16 |
* +---------------+--------+
* @endcode
*
* The envelope is followed by independently checksummed records. A record's
* CRC32C covers its encoded tag and payload length followed by its payload; it
* does not cover the CRC field itself.
*
* @code{.unparsed}
* byte 0 2 6 10 10 + payload_len
* +-------+-------------+-----------+----------------+
* | tag | payload_len | CRC32C | payload |
* | u16 | u32 | u32 | payload_len B |
* +-------+-------------+-----------+----------------+
* \____________________/ \______________/
* CRC prefix CRC suffix
* @endcode
*
* Record groups occur in this strict order. SCREEN and HISTORY groups contain
* one entry for each screen declared by TERMINAL. Each manifest is followed
* by the number of PAGE records it declares. Active SCREEN pages make the
* terminal renderable; HISTORY pages are older scrollback ordered newest to
* oldest so an incremental decoder can prepend them as they arrive.
*
* @code{.unparsed}
* +---------------- TERMINAL ----------------+
* | terminal-wide state and screen count |
* +----------------- SCREEN -----------------+ repeated per screen
* | active-screen manifest |
* +------------------ PAGE ------------------+ repeated per manifest
* | active screen rows |
* +------------- CONTINUATION ---------------+
* | unfinished VT/UTF-8 input, or ground |
* +------------------ READY -----------------+
* | empty renderable-state marker | ready() returns here
* +----------------- HISTORY ----------------+ repeated per screen
* | scrollback manifest |
* +------------------ PAGE ------------------+ next() consumes one page
* | older screen rows |
* +------------------ FINISH ----------------+
* | empty end-of-snapshot marker | next() returns NO_VALUE
* +------------------------------------------+
* | trailing transport bytes (not consumed) |
* +------------------------------------------+
* @endcode
*
* READY separates the renderable prefix through CONTINUATION from history.
* FINISH terminates the record sequence. Both are empty records protected by
* CRC32C, like every other record. Declared record counts, tags, and strict
* decoding enforce the stream's ordering and completeness.
*
* Snapshot format version 1 is a work in progress and does not yet carry a
* binary-compatibility guarantee.
*
* @see <a href="https://github.com/ghostty-org/ghostty/blob/main/src/terminal/snapshot/main.zig">Snapshot format and Zig codec documentation</a>
*
* @{
*/
/**
* Configurable snapshot decoder options.
*
* Options may only be changed before decoding starts. Calling
* ghostty_snapshot_decoder_set() after ghostty_snapshot_decoder_ready() or
* ghostty_snapshot_decoder_decode() returns GHOSTTY_INVALID_VALUE.
*/
typedef enum GHOSTTY_ENUM_TYPED {
/**
* Largest non-ground continuation the decoder will accept.
*
* A value of zero accepts only snapshots whose VT parser is in the ground
* state. The decoder default matches the largest built-in APC protocol
* buffer limit, currently 65 MiB.
*
* This is an input validation limit only. It does not configure continuation
* tracking on a terminal returned by the decoder.
*
* Input type: size_t *
*/
GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES = 0,
GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySnapshotDecoderOption;
/**
* Queryable snapshot decoder data.
*
* Each variant documents the output pointer type expected by
* ghostty_snapshot_decoder_get().
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Invalid data type. Never results in data extraction. */
GHOSTTY_SNAPSHOT_DECODER_DATA_INVALID = 0,
/**
* Current maximum accepted continuation size.
*
* This value is available in every non-failed decoder state.
*
* Output type: size_t *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_CONTINUATION_BYTES = 1,
/**
* Number of snapshot source bytes consumed so far.
*
* At FINISH this identifies the first byte after the snapshot. Trailing
* bytes are not consumed. This value is unavailable after a decoding error,
* because the decoder can no longer guarantee its source position.
*
* Output type: size_t *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET = 2,
/**
* Advisory complete logical history extent for the primary screen.
*
* The value counts rows before the active area, including any resident
* overlap carried before READY. It becomes available after READY validates.
*
* Output type: uint64_t *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY = 3,
/**
* Advisory complete logical history extent for the alternate screen.
*
* The value has the same semantics and lifetime as
* GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_PRIMARY. Querying it returns
* GHOSTTY_NO_VALUE when the snapshot does not declare an alternate screen.
*
* Output type: uint64_t *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_HISTORY_ROWS_ALTERNATE = 4,
/**
* Screen associated with the most recently decoded history page.
*
* This value is available only after ghostty_snapshot_decoder_next()
* returns GHOSTTY_SUCCESS. A later call to next replaces it or clears it
* when FINISH is reached or an error occurs.
*
* Output type: GhosttyTerminalScreen *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_SCREEN = 5,
/**
* Rows prepended by the most recently decoded history page.
*
* Zero means the page was consumed and validated but could not be
* applied to the live terminal.
*
* Output type: size_t *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS = 6,
/**
* Page records remaining in the same screen's HISTORY sequence.
*
* This is not a count of all pages remaining in the snapshot.
*
* Output type: uint32_t *
*/
GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING = 7,
GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttySnapshotDecoderData;
/**
* Encode a complete terminal snapshot to a writer.
*
* The terminal's persistent VT stream supplies the continuation bytes needed
* to reconstruct unfinished parser state. The caller must prevent concurrent
* writes or other terminal mutation for the duration of this call. The writer
* callback must not call terminal APIs with the same terminal handle.
* A terminal can be encoded with tracking disabled when its VT parser and
* UTF-8 decoder are both at ground. If either is unfinished, tracking must
* have been enabled before the input that produced that state was written;
* otherwise this returns GHOSTTY_INVALID_VALUE.
*
* Encoding begins at the writer's current position. If an error occurs, the
* writer may contain a partial snapshot without a valid FINISH marker.
* Calls to the writer are synchronous; this function does not flush or make
* the caller's destination durable.
*
* @param terminal Terminal to encode (must not be NULL)
* @param writer Destination writer whose write callback must not be NULL
* @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects
* output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or
* another error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_encode(GhosttyTerminal terminal,
GhosttyWriter writer);
/**
* Encode a complete terminal snapshot to a caller-provided buffer.
*
* Pass NULL for buf with buf_len zero to query the required size. If the
* buffer is too small, this returns GHOSTTY_OUT_OF_SPACE and stores the
* required capacity in out_written. A non-NULL undersized buffer may contain
* a partial snapshot prefix. On success, out_written receives the number of
* bytes encoded.
*
* A terminal can be encoded with tracking disabled when its VT parser and
* UTF-8 decoder are both at ground. If either is unfinished, tracking must
* have been enabled before the input that produced that state was written;
* otherwise this returns GHOSTTY_INVALID_VALUE.
*
* @param terminal Terminal to encode (must not be NULL)
* @param buf Destination buffer, or NULL when buf_len is zero
* @param buf_len Destination buffer capacity in bytes
* @param[out] out_written Bytes written, or required capacity on
* GHOSTTY_OUT_OF_SPACE (must not be NULL)
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_encode_buf(
GhosttyTerminal terminal,
uint8_t* buf,
size_t buf_len,
size_t* out_written);
/**
* Encode a complete terminal snapshot to an allocated buffer.
*
* The returned buffer is allocated with allocator, or the default allocator
* when allocator is NULL. The caller must release it with ghostty_free(),
* passing the same allocator used here.
*
* A terminal can be encoded with tracking disabled when its VT parser and
* UTF-8 decoder are both at ground. If either is unfinished, tracking must
* have been enabled before the input that produced that state was written;
* otherwise this returns GHOSTTY_INVALID_VALUE.
*
* @param terminal Terminal to encode (must not be NULL)
* @param allocator Allocator for the output, or NULL for the default allocator
* @param[out] out_ptr Allocated snapshot bytes (must not be NULL)
* @param[out] out_len Number of allocated snapshot bytes (must not be NULL)
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_encode_alloc(
GhosttyTerminal terminal,
const GhosttyAllocator* allocator,
uint8_t** out_ptr,
size_t* out_len);
/**
* Create a snapshot decoder that reads from a caller-provided reader.
*
* The decoder stores a copy of reader. Its read callback must not be NULL, and
* both the callback and its caller-owned context must remain valid until
* FINISH is reached or the decoder is freed. Reads are synchronous and occur
* only during ready, next, or decode calls. A zero-byte successful read is
* permanent end-of-file, not temporary starvation; nonblocking sources must
* wait outside the decoder or block in their callback. The read callback must
* not call APIs, including ghostty_snapshot_decoder_free(), on the decoder
* that owns it. Returning false reports GHOSTTY_IO_ERROR; returning true with
* zero bytes before a required marker reports truncated snapshot data as
* GHOSTTY_INVALID_VALUE.
*
* @param allocator Allocator for decoder and decoded terminal state, or NULL
* for the default allocator
* @param decoder Pointer to receive the decoder handle (must not be NULL)
* @param reader Snapshot source reader
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_new(
const GhosttyAllocator* allocator,
GhosttySnapshotDecoder* decoder,
GhosttyReader reader);
/**
* Create a snapshot decoder over a borrowed byte buffer.
*
* The bytes are not copied. ptr must remain valid and immutable until FINISH
* is reached or the decoder is freed. Bytes after FINISH are not consumed;
* query GHOSTTY_SNAPSHOT_DECODER_DATA_SOURCE_OFFSET to locate them.
*
* @param allocator Allocator for decoder and decoded terminal state, or NULL
* for the default allocator
* @param decoder Pointer to receive the decoder handle (must not be NULL)
* @param ptr Snapshot source bytes
* @param len Number of source bytes
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_new_buf(
const GhosttyAllocator* allocator,
GhosttySnapshotDecoder* decoder,
const uint8_t* ptr,
size_t len);
/**
* Free a snapshot decoder.
*
* This does not release the caller's ownership of a terminal returned by
* ready or decode. Abandoning an incremental decode leaves that terminal
* usable with whatever history had already been restored.
*
* @param decoder Decoder to free (may be NULL)
*
* @ingroup snapshot
*/
GHOSTTY_API void ghostty_snapshot_decoder_free(GhosttySnapshotDecoder decoder);
/**
* Set a snapshot decoder option.
*
* The value pointer must have the type documented by option. Options may only
* be changed before decoding starts.
*
* @param decoder Decoder handle (must not be NULL)
* @param option Option to change
* @param value Pointer to the option value (must not be NULL)
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if decoding has
* started or an argument is invalid, or another error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_set(
GhosttySnapshotDecoder decoder,
GhosttySnapshotDecoderOption option,
const void* value);
/**
* Decode and validate the renderable snapshot prefix through READY.
*
* On success, terminal receives a caller-owned terminal with its persistent
* VT stream already restored from the snapshot continuation. The terminal is
* immediately usable for rendering and live input. Older scrollback remains
* to be restored with ghostty_snapshot_decoder_next().
*
* The restored parser state may be unfinished, but terminal continuation
* tracking is disabled; GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns
* zero. The decoder's continuation option is an input limit, not terminal
* runtime policy.
*
* The caller must keep the returned terminal alive until FINISH validates or
* the decoder is freed. The decoder borrows this terminal handle while it
* restores history; ghostty_snapshot_decoder_next() uses it automatically.
*
* This operation may only be called once and only before decoding starts.
* terminal is set to NULL on every error. A decoding, I/O, or allocation
* error after input consumption begins poisons the decoder, after which it
* must be freed. An invalid argument or lifecycle error detected before the
* operation consumes input does not poison it.
*
* @param decoder Decoder handle (must not be NULL)
* @param[out] terminal Pointer to receive the terminal (must not be NULL)
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_ready(
GhosttySnapshotDecoder decoder,
GhosttyTerminal* terminal);
/**
* Decode one history page into the terminal returned by READY.
*
* Each GHOSTTY_SUCCESS consumes and validates one PAGE record. Query the
* GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_* values before calling next again.
* GHOSTTY_NO_VALUE means FINISH was validated; repeated calls after FINISH
* also return GHOSTTY_NO_VALUE.
*
* The terminal may be rendered, resized, and fed live PTY input between calls.
* If a history page can no longer be applied safely, it is still consumed and
* validated and progress reports zero rows. The decoder applies history
* to the caller-owned terminal produced by its READY operation.
*
* A decoding error invalidates the decoder's source position. The terminal
* remains caller-owned and usable with its already-restored history, but only
* ghostty_snapshot_decoder_free() may subsequently be called on the decoder.
*
* @param decoder Decoder handle (must not be NULL)
* @return GHOSTTY_SUCCESS for one page, GHOSTTY_NO_VALUE after FINISH, or an
* error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_next(
GhosttySnapshotDecoder decoder);
/**
* Decode and validate one complete snapshot.
*
* This is the one-shot form of READY followed by all history pages through
* FINISH. It may only be called before decoding starts. Bytes following FINISH
* are left unread. On success terminal receives a caller-owned terminal with
* its persistent VT stream restored. Continuation tracking on the returned
* terminal is disabled and GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES
* returns zero. terminal is set to NULL on every error.
* A decoding, I/O, or allocation error after input consumption begins poisons
* the decoder, after which it must be freed. An invalid argument or
* lifecycle error detected before the operation consumes input does not
* poison it.
*
* @param decoder Decoder handle (must not be NULL)
* @param[out] terminal Pointer to receive the terminal (must not be NULL)
* @return GHOSTTY_SUCCESS on success, or an error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_decode(
GhosttySnapshotDecoder decoder,
GhosttyTerminal* terminal);
/**
* Get typed data from a snapshot decoder.
*
* The output pointer must have the type documented by data. A phase-dependent
* value that is not currently available returns GHOSTTY_NO_VALUE.
*
* @param decoder Decoder handle (must not be NULL)
* @param data Data kind to query
* @param[out] out Pointer to receive the value (must not be NULL)
* @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the requested data
* is unavailable, or another error code on failure
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_get(
GhosttySnapshotDecoder decoder,
GhosttySnapshotDecoderData data,
void* out);
/**
* Get multiple snapshot decoder data fields in a single call.
*
* Each keys element selects a data kind and the corresponding values element
* points to storage of the documented output type. Processing stops at the
* first error. On success out_written is set to count; on error it is set to
* the number of values written before the failing key. Invalid array arguments
* report zero values written.
*
* @param decoder Decoder handle (must not be NULL)
* @param count Number of key/value pairs
* @param keys Array of data kinds to query
* @param values Array of output pointers corresponding to keys
* @param[out] out_written Number of successfully written values (may be NULL)
* @return GHOSTTY_SUCCESS if every query succeeds, or the first error
*
* @ingroup snapshot
*/
GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_get_multi(
GhosttySnapshotDecoder decoder,
size_t count,
const GhosttySnapshotDecoderData* keys,
void** values,
size_t* out_written);
/** @} */
#ifdef __cplusplus
}
#endif
#endif /* GHOSTTY_VT_SNAPSHOT_H */

View File

@@ -16,6 +16,7 @@
#include <ghostty/vt/modes.h>
#include <ghostty/vt/size_report.h>
#include <ghostty/vt/grid_ref.h>
#include <ghostty/vt/io.h>
#include <ghostty/vt/kitty_graphics.h>
#include <ghostty/vt/screen.h>
#include <ghostty/vt/point.h>
@@ -53,17 +54,17 @@ extern "C" {
*
* ## Effects
*
* By default, the terminal sequence processing with ghostty_terminal_vt_write()
* only process sequences that directly affect terminal state and
* By default, terminal sequence processing with the VT write functions only
* processes sequences that directly affect terminal state and
* ignores sequences that have side effect behavior or require responses.
* These sequences include things like bell characters, title changes, device
* attributes queries, and more. To handle these sequences, the embedder
* must configure "effects."
*
* Effects are callbacks that the terminal invokes in response to VT
* sequences processed during ghostty_terminal_vt_write(). They let the
* embedding application react to terminal-initiated events such as bell
* characters, title changes, device status report responses, and more.
* sequences processed during VT writes. They let the embedding application
* react to terminal-initiated events such as bell characters, title changes,
* device status report responses, and more.
*
* Each effect is registered with ghostty_terminal_set() using the
* corresponding `GhosttyTerminalOption` identifier. A `NULL` value
@@ -74,9 +75,10 @@ extern "C" {
* back to their own application state without global variables.
* You cannot specify different userdata for different callbacks.
*
* All callbacks are invoked synchronously during
* ghostty_terminal_vt_write(). Callbacks **must not** call
* ghostty_terminal_vt_write() on the same terminal (no reentrancy).
* All callbacks are invoked synchronously during VT writes. Callbacks
* **must not** call ghostty_terminal_vt_write() or
* ghostty_terminal_vt_write_until_ground() on the same terminal
* (no reentrancy).
* And callbacks must be very careful to not block for too long or perform
* expensive operations, since they are blocking further IO processing.
*
@@ -96,6 +98,7 @@ extern "C" {
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
* | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 |
* | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier |
*
* ### Defining a write_pty callback
* @snippet c-vt-effects/src/main.c effects-write-pty
@@ -109,6 +112,9 @@ extern "C" {
* ### Defining a clipboard_write callback
* @snippet c-vt-effects/src/main.c effects-clipboard-write
*
* ### Defining an unknown_sequence callback
* @snippet c-vt-effects/src/main.c effects-unknown-sequence
*
* ### Registering effects and processing VT data
* @snippet c-vt-effects/src/main.c effects-register
*
@@ -330,6 +336,88 @@ typedef struct {
typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal,
void* userdata);
/**
* Unsupported terminal sequence tags.
*
* Only APC sequences are currently reported. Additional sequence types may
* be added without changing the callback shape.
*
* @ingroup terminal
*/
typedef enum GHOSTTY_ENUM_TYPED {
/** Application Program Command (APC). */
GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_APC = 0,
GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalUnknownSequenceTag;
/**
* An unsupported string terminal sequence.
*
* The content is borrowed and valid only for the callback duration. It
* contains the bytes between the sequence introducer and terminator, may
* contain arbitrary binary data, and is not null-terminated.
*
* @ingroup terminal
*/
typedef struct {
/** Whether content was shortened by the byte limit or allocation failure. */
bool truncated;
/** Retained sequence content. */
GhosttyString content;
} GhosttyTerminalUnknownStringSequence;
/**
* Unsupported terminal sequence value.
*
* @ingroup terminal
*/
typedef union {
/** Application Program Command (APC). */
GhosttyTerminalUnknownStringSequence apc;
/**
* Padding for ABI compatibility. Do not use.
*
* 128 bytes leaves room for future structured sequence payloads, such as
* CSI with borrowed parameter, separator, and intermediate arrays, without
* changing the tagged union's ABI.
*/
uint64_t _padding[16];
} GhosttyTerminalUnknownSequenceValue;
/**
* An unsupported terminal sequence.
*
* @ingroup terminal
*/
typedef struct {
GhosttyTerminalUnknownSequenceTag tag;
GhosttyTerminalUnknownSequenceValue value;
} GhosttyTerminalUnknownSequence;
/**
* Callback function type for unsupported terminal sequences.
*
* Called synchronously for normally terminated sequences whose identifier is
* not supported by the active terminal handler. Aborted sequences, malformed
* recognized commands, and explicitly disabled known protocols are ignored.
*
* Capture must also be enabled with a nonzero
* GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES value. Installing this callback alone
* does not retain sequence content or allocate memory.
*
* @param terminal The terminal handle
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
* @param sequence Borrowed unsupported sequence
*
* @ingroup terminal
*/
typedef void (*GhosttyTerminalUnknownSequenceFn)(
GhosttyTerminal terminal,
void* userdata,
const GhosttyTerminalUnknownSequence* sequence);
/**
* Clipboard destination for a clipboard write.
*
@@ -694,6 +782,24 @@ typedef void (*GhosttyTerminalWritePtyFn)(GhosttyTerminal terminal,
typedef GhosttyString (*GhosttyTerminalXtversionFn)(GhosttyTerminal terminal,
void* userdata);
/**
* A terminal mode and boolean value used for mode configuration and queries.
*
* For GHOSTTY_TERMINAL_DATA_MODE, initialize `mode` before calling
* ghostty_terminal_get(). On success, `value` contains the current mode value.
*
* This struct has a frozen layout and will not gain fields in future versions.
*
* @ingroup terminal
*/
typedef struct {
/** Mode to configure or query. */
GhosttyMode mode;
/** Value to set, or the current value returned by a query. */
bool value;
} GhosttyTerminalModeConfig;
/**
* Terminal option identifiers.
*
@@ -1027,6 +1133,100 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Input type: GhosttyTerminalProgressReportFn
*/
GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT = 30,
/**
* Set the maximum number of replay-safe VT continuation bytes retained.
*
* Continuation bytes reconstruct an escape sequence or UTF-8 codepoint
* which was unfinished at the end of the most recent
* VT write call. They are used automatically by terminal snapshots and may
* also be exported directly with the continuation APIs.
*
* Tracking is disabled by default. A nonzero value enables tracking and
* sets its byte limit. Passing NULL or a pointer to zero disables tracking.
* Lowering the limit below an already-retained
* continuation, or enabling tracking while the parser is already
* unfinished, makes the current continuation unavailable because earlier
* bytes cannot be reconstructed. Tracking recovers automatically after a
* later write reaches the ground state or contains a fresh replay start.
*
* Input type: size_t*
*/
GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES = 31,
/**
* Enable window title reports in response to CSI 21 t.
*
* This is disabled by default because a running program can set a title and
* query it back into the pty input stream, potentially injecting commands
* that execute after user interaction. Passing NULL or a pointer to false
* disables title reporting.
*
* Input type: bool*
*/
GHOSTTY_TERMINAL_OPT_TITLE_REPORT = 32,
/**
* Set the reset default for a terminal mode.
*
* This unconditionally updates both the current value and the value restored
* by a full terminal reset (RIS).
*
* Some recognized modes represent transitions or mirror additional terminal
* state and cannot safely be configured as reset defaults. Those modes return
* GHOSTTY_INVALID_VALUE. A NULL value pointer also returns
* GHOSTTY_INVALID_VALUE.
*
* Input type: GhosttyTerminalModeConfig*
*/
GHOSTTY_TERMINAL_OPT_MODE_DEFAULT = 33,
/**
* Set the current value of a terminal mode.
*
* This does not change the value restored by a full terminal reset (RIS).
* A NULL value pointer or unknown mode returns GHOSTTY_INVALID_VALUE.
*
* Input type: GhosttyTerminalModeConfig*
*/
GHOSTTY_TERMINAL_OPT_MODE = 34,
/**
* Callback invoked for unsupported terminal sequence identifiers. Set to
* NULL to ignore unsupported sequences. Capture must also be enabled with
* GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES.
*
* Input type: GhosttyTerminalUnknownSequenceFn
*/
GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE = 35,
/**
* Set the maximum content bytes retained for each unsupported terminal
* sequence. A NULL value pointer or zero disables capture and prevents
* unknown-sequence callbacks.
*
* When this limit is hit, the unknown sequence callback will still
* be invoked but `truncated` will be set to true.
*
* Input type: size_t*
*/
GHOSTTY_TERMINAL_OPT_UNKNOWN_MAX_BYTES = 36,
/**
* Set the name of the terminfo entry this terminal runs as, reported
* in response to an XTGETTCAP query for "TN" (e.g. "xterm-256color").
*
* The string data is copied into the terminal. A NULL value pointer
* clears the name (equivalent to setting an empty string). A name
* longer than 128 bytes returns GHOSTTY_INVALID_VALUE.
*
* If this is unset then we don't report anything for an XTGETTCAP
* TN query, because we don't know what the embedding terminal around
* libghostty is advertising itself as.
*
* Input type: GhosttyString*
*/
GHOSTTY_TERMINAL_OPT_TERMINFO_NAME = 37,
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalOption;
@@ -1138,9 +1338,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
/**
* The terminal title as set by escape sequences (e.g. OSC 0/2).
*
* Returns a borrowed string. The pointer is valid until the next call
* to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty
* string (len=0) is returned when no title has been set.
* Returns a borrowed string. The pointer is valid until the next mutating
* terminal call. An empty string (len=0) is returned when no title has been
* set.
*
* Output type: GhosttyString *
*/
@@ -1150,9 +1350,9 @@ typedef enum GHOSTTY_ENUM_TYPED {
* The terminal's current working directory as set by escape sequences
* (e.g. OSC 7).
*
* Returns a borrowed string. The pointer is valid until the next call
* to ghostty_terminal_vt_write() or ghostty_terminal_reset(). An empty
* string (len=0) is returned when no pwd has been set.
* Returns a borrowed string. The pointer is valid until the next mutating
* terminal call. An empty string (len=0) is returned when no pwd has been
* set.
*
* Output type: GhosttyString *
*/
@@ -1376,6 +1576,54 @@ typedef enum GHOSTTY_ENUM_TYPED {
* Output type: size_t *
*/
GHOSTTY_TERMINAL_DATA_SCROLLBACK_MAX_LINES = 35,
/**
* The configured maximum retained VT continuation size in bytes.
*
* A value of zero means continuation tracking is disabled. This reports the
* configured limit even when a current unfinished continuation is
* temporarily unavailable.
*
* Output type: size_t *
*/
GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES = 36,
/**
* Get the current value of a terminal mode.
*
* The caller must initialize the `mode` field. On success, the `value` field
* is updated with the current value. A NULL pointer or unknown mode returns
* GHOSTTY_INVALID_VALUE.
*
* Input/output type: GhosttyTerminalModeConfig *
*/
GHOSTTY_TERMINAL_DATA_MODE = 37,
/**
* Whether VT processing is at ground.
*
* Ground is when the stream isn't in the middle of any type of sequence:
* UTF-8, ESC, CSI, OSC, etc. It is the stateless point of the stream.
*
* This is useful to know because it is a point at which you can
* safely insert out-of-band VT sequences. For example, while reading
* from a pty if you want to make your own changes, you can wait until
* the pty input reaches ground, then write yours.
*
* Output type: bool *
*/
GHOSTTY_TERMINAL_DATA_VT_GROUND = 38,
/**
* Whether the cursor is currently at a semantic shell prompt or input area.
*
* This depends on semantic prompt markers such as OSC 133. Returns false
* when semantic prompt information is unavailable or the alternate screen
* is active.
*
* Output type: bool *
*/
GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT = 39,
GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyTerminalData;
@@ -1461,9 +1709,10 @@ GHOSTTY_API GhosttyResult ghostty_terminal_resize(GhosttyTerminal terminal,
* The behavior of a NULL value is specific to each option and is
* documented by the corresponding GhosttyTerminalOption value.
*
* Callbacks are invoked synchronously during ghostty_terminal_vt_write().
* Callbacks must not call ghostty_terminal_vt_write() on the same
* terminal (no reentrancy).
* Callbacks are invoked synchronously during VT writes. Callbacks must not
* call ghostty_terminal_vt_write() or
* ghostty_terminal_vt_write_until_ground() on the same terminal
* (no reentrancy).
*
* @param terminal The terminal handle (may be NULL, in which case this is a no-op)
* @param option The option to set
@@ -1501,6 +1750,129 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal,
const uint8_t* data,
size_t len);
/**
* Write VT-encoded data, but only the shortest prefix needed to reach ground.
*
* Ground is when the stream isn't in the middle of any type of sequence:
* UTF-8, ESC, CSI, OSC, etc. It is the stateless point of the stream.
*
* This is useful to know because it is a point at which you can
* safely insert out-of-band VT sequences. For example, while reading
* from a pty if you want to make your own changes, you can wait until
* the pty input reaches ground, then write yours.
*
* If the stream is already at ground then this consumes nothing and returns
* GHOSTTY_SUCCESS. On success, out_consumed is the number of bytes consumed
* before reaching ground, including the byte that reaches it.
* GHOSTTY_NO_VALUE means the full slice was consumed without reaching ground.
*
* @param terminal The terminal handle (must not be NULL)
* @param data Pointer to the data to write, or NULL when len is zero
* @param len Length of the data in bytes
* @param[out] out_consumed Number of bytes consumed (must not be NULL)
* @return GHOSTTY_SUCCESS if ground was reached, GHOSTTY_NO_VALUE if all input
* was consumed without reaching ground, or GHOSTTY_INVALID_VALUE if
* an argument is invalid
*
* @ingroup terminal
*/
GHOSTTY_API GhosttyResult ghostty_terminal_vt_write_until_ground(
GhosttyTerminal terminal,
const uint8_t* data,
size_t len,
size_t* out_consumed);
/**
* Write the terminal's replay-safe VT continuation to a callback writer.
*
* The continuation is the exact byte suffix needed to reconstruct unfinished
* VT parser or UTF-8 decoder state in an equivalent terminal. It is empty
* when the stream is at ground. The callback is invoked synchronously and
* may be called more than once. It must not call terminal APIs with the same
* terminal handle.
*
* Continuation tracking must have been enabled by setting
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the
* input that produced the continuation was written.
*
* The caller must serialize this operation with both VT write functions and
* all other access to the same terminal.
*
* @param terminal Terminal to read from (must not be NULL)
* @param writer Destination writer whose write callback must not be NULL
* @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the callback rejects
* a write, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or
* GHOSTTY_INVALID_VALUE if an argument is invalid, tracking is
* disabled, or the current continuation is unavailable
*
* @ingroup terminal
*/
GHOSTTY_API GhosttyResult ghostty_terminal_continuation_write(
GhosttyTerminal terminal,
GhosttyWriter writer);
/**
* Copy the terminal's replay-safe VT continuation into a caller buffer.
*
* Pass NULL for buf with buf_len zero to query the required size. A size query
* returns GHOSTTY_OUT_OF_SPACE and stores the required size in out_written,
* including zero when the stream is at ground. If a non-NULL buffer is too
* small, the function has the same result and reports the full required size.
* Continuation tracking must have been enabled by setting
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the
* input that produced the continuation was written.
*
* The caller must serialize this operation with all other access to the same
* terminal.
*
* @param terminal Terminal to read from (must not be NULL)
* @param buf Destination buffer, or NULL when buf_len is zero
* @param buf_len Destination buffer capacity in bytes
* @param[out] out_written Bytes written, or required size on
* GHOSTTY_OUT_OF_SPACE (must not be NULL)
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE for a size query or
* insufficient buffer, or GHOSTTY_INVALID_VALUE if an argument is
* invalid, tracking is disabled, or the current continuation is
* unavailable
*
* @ingroup terminal
*/
GHOSTTY_API GhosttyResult ghostty_terminal_continuation_buf(
GhosttyTerminal terminal,
uint8_t* buf,
size_t buf_len,
size_t* out_written);
/**
* Return an allocated copy of the terminal's replay-safe VT continuation.
*
* The returned bytes are allocated with allocator, or the default allocator
* when allocator is NULL. The caller must release them with ghostty_free(),
* passing the same allocator and returned length. An empty continuation is a
* successful zero-length allocation.
* Continuation tracking must have been enabled by setting
* GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to a nonzero value before the
* input that produced the continuation was written.
*
* The caller must serialize this operation with all other access to the same
* terminal.
*
* @param terminal Terminal to read from (must not be NULL)
* @param allocator Allocator for the output, or NULL for the default allocator
* @param[out] out_ptr Allocated continuation bytes (must not be NULL)
* @param[out] out_len Number of continuation bytes (must not be NULL)
* @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY on allocation
* failure, or GHOSTTY_INVALID_VALUE if an argument is invalid,
* tracking is disabled, or the current continuation is unavailable
*
* @ingroup terminal
*/
GHOSTTY_API GhosttyResult ghostty_terminal_continuation_alloc(
GhosttyTerminal terminal,
const GhosttyAllocator* allocator,
uint8_t** out_ptr,
size_t* out_len);
/**
* Scroll the terminal viewport.
*
@@ -1574,41 +1946,6 @@ GHOSTTY_API GhosttyResult ghostty_terminal_compress(
GhosttyTerminalCompressionMode mode,
GhosttyTerminalCompressionResult* out_result);
/**
* Get the current value of a terminal mode.
*
* Returns the value of the mode identified by the given mode.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param mode The mode identifying the mode to query
* @param[out] out_value On success, set to true if the mode is set, false
* if it is reset
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal
* is NULL or the mode does not correspond to a known mode
*
* @ingroup terminal
*/
GHOSTTY_API GhosttyResult ghostty_terminal_mode_get(GhosttyTerminal terminal,
GhosttyMode mode,
bool* out_value);
/**
* Set the value of a terminal mode.
*
* Sets the mode identified by the given mode to the specified value.
*
* @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)
* @param mode The mode identifying the mode to set
* @param value true to set the mode, false to reset it
* @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if the terminal
* is NULL or the mode does not correspond to a known mode
*
* @ingroup terminal
*/
GHOSTTY_API GhosttyResult ghostty_terminal_mode_set(GhosttyTerminal terminal,
GhosttyMode mode,
bool value);
/**
* Get data from a terminal instance.
*

View File

@@ -82,6 +82,10 @@ typedef enum GHOSTTY_ENUM_TYPED {
GHOSTTY_OUT_OF_SPACE = -3,
/** The requested value has no value */
GHOSTTY_NO_VALUE = -4,
/** Operation failed while reading from or writing to external I/O */
GHOSTTY_IO_ERROR = -5,
/** Operation failed because encoded input exceeded a configured limit */
GHOSTTY_LIMIT_EXCEEDED = -6,
GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
} GhosttyResult;
@@ -94,6 +98,13 @@ typedef enum GHOSTTY_ENUM_TYPED {
*/
typedef struct GhosttyTerminalImpl* GhosttyTerminal;
/**
* Opaque handle to an incremental terminal snapshot decoder.
*
* @ingroup snapshot
*/
typedef struct GhosttySnapshotDecoderImpl* GhosttySnapshotDecoder;
/**
* Opaque handle to a tracked grid reference.
*

View File

@@ -15,10 +15,7 @@
8F3A9B4C2FA6B88000A18D13 /* Ghostty.sdef in Resources */ = {isa = PBXBuildFile; fileRef = 8F3A9B4B2FA6B88000A18D13 /* Ghostty.sdef */; };
9351BE8E3D22937F003B3499 /* nvim in Resources */ = {isa = PBXBuildFile; fileRef = 9351BE8E2D22937F003B3499 /* nvim */; };
A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; };
A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
A546F1142D7B68D7003B11A0 /* locale in Resources */ = {isa = PBXBuildFile; fileRef = A546F1132D7B68D7003B11A0 /* locale */; };
A553F4132E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; };
A553F4142E06EB1600257779 /* Ghostty.icon in Resources */ = {isa = PBXBuildFile; fileRef = A553F4122E06EB1600257779 /* Ghostty.icon */; };
A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; };
A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
@@ -89,7 +86,6 @@
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Ghostty-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = "<group>"; };
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; };
@@ -106,164 +102,16 @@
"Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift",
Ghostty/Ghostty.ConfigTypes.swift,
Ghostty/GhosttyPackageMeta.swift,
Helpers/CrossKit.swift,
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/OSColor+Extension.swift",
"Helpers/Extensions/OSPasteboard+Extension.swift",
);
target = 8193244C2F24E6C000A9ED8F /* DockTilePlugin */;
};
81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/macOS/AppDelegate.swift,
"App/macOS/AppDelegate+Ghostty.swift",
App/macOS/main.swift,
App/macOS/MainMenu.xib,
Features/About/About.xib,
Features/About/AboutController.swift,
Features/About/AboutView.swift,
Features/About/AboutViewModel.swift,
Features/About/CyclingIconView.swift,
"Features/App Intents/CloseTerminalIntent.swift",
"Features/App Intents/CommandPaletteIntent.swift",
"Features/App Intents/Entities/CommandEntity.swift",
"Features/App Intents/Entities/TerminalEntity.swift",
"Features/App Intents/FocusTerminalIntent.swift",
"Features/App Intents/GetTerminalDetailsIntent.swift",
"Features/App Intents/GhosttyIntentError.swift",
"Features/App Intents/InputIntent.swift",
"Features/App Intents/IntentPermission.swift",
"Features/App Intents/KeybindIntent.swift",
"Features/App Intents/NewTerminalIntent.swift",
"Features/App Intents/QuickTerminalIntent.swift",
"Features/AppleScript/AppDelegate+AppleScript.swift",
"Features/AppleScript/Ghostty.Input.Mods+AppleScript.swift",
Features/AppleScript/ScriptInputTextCommand.swift,
Features/AppleScript/ScriptKeyEventCommand.swift,
Features/AppleScript/ScriptMouseButtonCommand.swift,
Features/AppleScript/ScriptMousePosCommand.swift,
Features/AppleScript/ScriptMouseScrollCommand.swift,
Features/AppleScript/ScriptRecord.swift,
Features/AppleScript/ScriptSurfaceConfiguration.swift,
Features/AppleScript/ScriptTab.swift,
Features/AppleScript/ScriptTerminal.swift,
Features/AppleScript/ScriptWindow.swift,
Features/ClipboardConfirmation/ClipboardConfirmation.xib,
Features/ClipboardConfirmation/ClipboardConfirmationController.swift,
Features/ClipboardConfirmation/ClipboardConfirmationView.swift,
"Features/Command Palette/CommandPalette.swift",
"Features/Command Palette/TerminalCommandPalette.swift",
"Features/Custom App Icon/AppIcon.swift",
"Features/Custom App Icon/ColorizedGhosttyIcon.swift",
"Features/Custom App Icon/ColorizedGhosttyIconImage.swift",
"Features/Custom App Icon/ColorizedGhosttyIconView.swift",
"Features/Custom App Icon/DockTilePlugin.swift",
"Features/Custom App Icon/Extensions/Notification+AppIcon.swift",
"Features/Custom App Icon/Extensions/UserDefaults+AppIcon.swift",
"Features/Global Keybinds/GlobalEventTap.swift",
Features/QuickTerminal/QuickTerminal.xib,
Features/QuickTerminal/QuickTerminalController.swift,
Features/QuickTerminal/QuickTerminalPosition.swift,
Features/QuickTerminal/QuickTerminalRestorableState.swift,
Features/QuickTerminal/QuickTerminalScreen.swift,
Features/QuickTerminal/QuickTerminalScreenStateCache.swift,
Features/QuickTerminal/QuickTerminalSize.swift,
Features/QuickTerminal/QuickTerminalSpaceBehavior.swift,
Features/QuickTerminal/QuickTerminalWindow.swift,
"Features/Secure Input/SecureInput.swift",
"Features/Secure Input/SecureInputOverlay.swift",
Features/Services/ServiceProvider.swift,
Features/Settings/ConfigurationErrors.xib,
Features/Settings/ConfigurationErrorsController.swift,
Features/Settings/ConfigurationErrorsView.swift,
Features/Settings/SettingsView.swift,
Features/Splits/SplitTree.swift,
Features/Splits/SplitView.Divider.swift,
Features/Splits/SplitView.swift,
Features/Splits/TerminalSplitTreeView.swift,
Features/Terminal/BaseTerminalController.swift,
Features/Terminal/ErrorView.swift,
Features/Terminal/TerminalController.swift,
Features/Terminal/TerminalRestorable.swift,
"Features/Terminal/TerminalRestorableState+InteralState.swift",
Features/Terminal/TerminalTabColor.swift,
Features/Terminal/TerminalView.swift,
Features/Terminal/TerminalViewContainer.swift,
"Features/Terminal/Window Styles/HiddenTitlebarTerminalWindow.swift",
"Features/Terminal/Window Styles/Terminal.xib",
"Features/Terminal/Window Styles/TerminalHiddenTitlebar.xib",
"Features/Terminal/Window Styles/TerminalTabsTitlebarTahoe.xib",
"Features/Terminal/Window Styles/TerminalTabsTitlebarVentura.xib",
"Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib",
"Features/Terminal/Window Styles/TerminalWindow.swift",
"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.Error.swift,
Ghostty/Ghostty.Event.swift,
Ghostty/Ghostty.Input.swift,
Ghostty/Ghostty.MenuShortcutManager.swift,
Ghostty/Ghostty.Surface.swift,
"Ghostty/NSEvent+Extension.swift",
"Ghostty/Surface View/InspectorView.swift",
"Ghostty/Surface View/SurfaceDragSource.swift",
"Ghostty/Surface View/SurfaceGrabHandle.swift",
"Ghostty/Surface View/SurfaceScrollView.swift",
"Ghostty/Surface View/SurfaceView_AppKit.swift",
Helpers/AppInfo.swift,
Helpers/CodableBridge.swift,
Helpers/Cursor.swift,
Helpers/ExpiringUndoManager.swift,
"Helpers/Extensions/Double+Extension.swift",
"Helpers/Extensions/EventModifiers+Extension.swift",
"Helpers/Extensions/FileHandle+Extension.swift",
"Helpers/Extensions/KeyboardShortcut+Extension.swift",
"Helpers/Extensions/NSAppearance+Extension.swift",
"Helpers/Extensions/NSApplication+Extension.swift",
"Helpers/Extensions/NSColor+Extension.swift",
"Helpers/Extensions/NSImage+Extension.swift",
"Helpers/Extensions/NSMenu+Extension.swift",
"Helpers/Extensions/NSMenuItem+Extension.swift",
"Helpers/Extensions/NSPasteboard+Extension.swift",
"Helpers/Extensions/NSScreen+Extension.swift",
"Helpers/Extensions/NSView+Extension.swift",
"Helpers/Extensions/NSWindow+Extension.swift",
"Helpers/Extensions/NSWorkspace+Extension.swift",
"Helpers/Extensions/Transferable+Extension.swift",
"Helpers/Extensions/UndoManager+Extension.swift",
"Helpers/Extensions/View+Extension.swift",
Helpers/Fullscreen.swift,
Helpers/HostingWindow.swift,
Helpers/KeyboardLayout.swift,
Helpers/LastWindowPosition.swift,
Helpers/MetalView.swift,
Helpers/NonDraggableHostingView.swift,
Helpers/ObjCExceptionCatcher.m,
Helpers/PermissionRequest.swift,
Helpers/Private/CGS.swift,
Helpers/Private/Dock.swift,
Helpers/TabGroupCloseCoordinator.swift,
Helpers/TabTitleEditor.swift,
Helpers/VibrantLayer.m,
);
target = A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */;
};
81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/iOS/iOSApp.swift,
"Features/Custom App Icon/DockTilePlugin.swift",
"Ghostty/Surface View/SurfaceView_UIKit.swift",
);
target = A5B30530299BEAAA0047F10C /* Ghostty */;
};
@@ -271,7 +119,7 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
810ACCA02E9D3302004F8F92 /* GhosttyUITests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = GhosttyUITests; sourceTree = "<group>"; };
81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 81F82CB02E8281F5001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = "<group>"; };
81F82BC72E82815D001EDFA7 /* Sources */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (81F82CB12E8281F9001EDFA7 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 8193245D2F24E80800A9ED8F /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Sources; sourceTree = "<group>"; };
A54F45F42E1F047A0046BD5C /* Tests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Tests; sourceTree = "<group>"; };
/* End PBXFileSystemSynchronizedRootGroup section */
@@ -307,14 +155,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A5D4499A2B53AE7B000F5B83 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -358,7 +198,6 @@
isa = PBXGroup;
children = (
A5B30531299BEAAA0047F10C /* Ghostty.app */,
A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */,
A54F45F32E1F047A0046BD5C /* GhosttyTests.xctest */,
810ACC9F2E9D3301004F8F92 /* GhosttyUITests.xctest */,
8193244D2F24E6C000A9ED8F /* DockTilePlugin.plugin */,
@@ -469,26 +308,6 @@
productReference = A5B30531299BEAAA0047F10C /* Ghostty.app */;
productType = "com.apple.product-type.application";
};
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = A5D449AB2B53AE7B000F5B83 /* Build configuration list for PBXNativeTarget "Ghostty-iOS" */;
buildPhases = (
A5D449992B53AE7B000F5B83 /* Sources */,
A5D4499A2B53AE7B000F5B83 /* Frameworks */,
A5D4499B2B53AE7B000F5B83 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
81F82BC72E82815D001EDFA7 /* Sources */,
);
name = "Ghostty-iOS";
productName = "Ghostty-iOS";
productReference = A5D4499D2B53AE7B000F5B83 /* Ghostty-iOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -514,9 +333,6 @@
CreatedOnToolsVersion = 14.2;
LastSwiftMigration = 1510;
};
A5D4499C2B53AE7B000F5B83 = {
CreatedOnToolsVersion = 15.2;
};
};
};
buildConfigurationList = A5B3052C299BEAAA0047F10C /* Build configuration list for PBXProject "Ghostty" */;
@@ -536,7 +352,6 @@
projectRoot = "";
targets = (
A5B30530299BEAAA0047F10C /* Ghostty */,
A5D4499C2B53AE7B000F5B83 /* Ghostty-iOS */,
8193244C2F24E6C000A9ED8F /* DockTilePlugin */,
A54F45F22E1F047A0046BD5C /* GhosttyTests */,
810ACC9E2E9D3301004F8F92 /* GhosttyUITests */,
@@ -587,15 +402,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A5D4499B2B53AE7B000F5B83 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */,
A553F4132E06EB1600257779 /* Ghostty.icon in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
@@ -650,13 +456,6 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
A5D449992B53AE7B000F5B83 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -783,7 +582,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h";
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/ghostty-bridging-header.h";
SWIFT_VERSION = 5.0;
};
name = ReleaseLocal;
@@ -1180,7 +979,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty.debug;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h";
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/ghostty-bridging-header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
@@ -1235,128 +1034,11 @@
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/macOS/ghostty-bridging-header.h";
SWIFT_OBJC_BRIDGING_HEADER = "Sources/App/ghostty-bridging-header.h";
SWIFT_VERSION = 5.0;
};
name = Release;
};
A5D449A82B53AE7B000F5B83 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-ios";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
A5D449A92B53AE7B000F5B83 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-ios";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
A5D449AA2B53AE7B000F5B83 /* ReleaseLocal */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = Ghostty;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "";
DEVELOPMENT_TEAM = "";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Ghostty;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = "com.mitchellh.ghostty-ios";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = ReleaseLocal;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -1410,16 +1092,6 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = ReleaseLocal;
};
A5D449AB2B53AE7B000F5B83 /* Build configuration list for PBXNativeTarget "Ghostty-iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A5D449A82B53AE7B000F5B83 /* Debug */,
A5D449A92B53AE7B000F5B83 /* Release */,
A5D449AA2B53AE7B000F5B83 /* ReleaseLocal */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = ReleaseLocal;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */

View File

@@ -92,6 +92,15 @@ class AppDelegate: NSObject,
/// seconds since the process was launched.
private var applicationLaunchTime: TimeInterval = 0
/// AppKit treats positional command-line arguments as documents to open. This
/// filter consumes the corresponding open-file events for arguments following
/// `-e`. It is initialized lazily because most launches never open a file.
private lazy var commandLineOpenFileFilter = CommandLineOpenFileFilter(
arguments: CommandLine.arguments,
workingDirectory: FileManager.default.currentDirectoryPath,
fileExists: { FileManager.default.fileExists(atPath: $0) }
)
/// This is the current configuration from the Ghostty configuration that we need.
private var derivedConfig: DerivedConfig = DerivedConfig()
@@ -376,18 +385,7 @@ class AppDelegate: NSObject,
// If we've already accepted to install an update, then we don't need to
// confirm quit. The user is already expecting the update to happen.
if updateController.isInstalling {
return .terminateNow
}
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
// quite work with SwiftUI because windows are retained on close. So instead we check
// if there are any that are visible. I'm guessing this breaks under certain scenarios.
//
// NOTE(mitchellh): I don't think we need this check at all anymore. I'm keeping it
// here because I don't want to remove it in a patch release cycle but we should
// target removing it soon.
if (windows.allSatisfy { !$0.isVisible }) {
if updateController.shouldTerminateWithoutWarning {
return .terminateNow
}
@@ -446,6 +444,14 @@ class AppDelegate: NSObject,
}
func application(_ sender: NSApplication, openFile filename: String) -> Bool {
// `-e` makes existing path arguments part of the child command, but
// AppKit also reports those paths as documents to open. Only consume
// matching command arguments so unrelated Finder or Dock requests work.
if commandLineOpenFileFilter.shouldIgnore(filename) {
Self.logger.debug("ignoring command argument open-file event path=\(filename, privacy: .public)")
return true
}
// Ghostty will validate as well but we can avoid creating an entirely new
// surface by doing our own validation here. We can also show a useful error
// this way.

View File

@@ -0,0 +1,59 @@
import Foundation
/// Filters the open-file events AppKit creates from command arguments following
/// `-e`. Each matching event is consumed once so later requests to open the same
/// file are handled normally.
final class CommandLineOpenFileFilter {
private let workingDirectory: URL
private var filesToIgnore: Set<URL>
init(
arguments: [String],
workingDirectory: String,
fileExists: (String) -> Bool
) {
let workingDirectory = URL(
filePath: workingDirectory,
directoryHint: .isDirectory
).absoluteURL.standardizedFileURL
self.workingDirectory = workingDirectory
guard let commandIndex = arguments.firstIndex(of: "-e") else {
self.filesToIgnore = []
return
}
// Ghostty treats every argument following `-e` as part of the child
// command. Existing paths in that suffix are the arguments AppKit can
// independently turn into open-file events.
self.filesToIgnore = Set(arguments[arguments.index(after: commandIndex)...]
.compactMap { argument in
// Command arguments can be relative, while AppKit normally
// reports absolute paths for the corresponding open event. Use
// file URLs internally so both forms have the same identity.
let url = Self.fileURL(argument, relativeTo: workingDirectory)
// Ignore only paths that exist during launch. A non-path
// argument cannot produce the duplicate event and retaining it
// could suppress a legitimate open if that path appears later.
return fileExists(url.path) ? url : nil
})
}
func shouldIgnore(_ filename: String) -> Bool {
let url = Self.fileURL(filename, relativeTo: workingDirectory)
// Consume each match once. Later requests to open the same file may
// come from Finder, the Dock, or another invocation and must proceed.
return filesToIgnore.remove(url) != nil
}
private static func fileURL(_ path: String, relativeTo workingDirectory: URL) -> URL {
let expanded = (path as NSString).expandingTildeInPath
return URL(
filePath: expanded,
directoryHint: .notDirectory,
relativeTo: workingDirectory
).absoluteURL.standardizedFileURL
}
}

View File

@@ -1,50 +0,0 @@
import SwiftUI
import GhosttyKit
@main
struct Ghostty_iOSApp: App {
@StateObject private var ghostty_app: Ghostty.App
init() {
if ghostty_init(UInt(CommandLine.argc), CommandLine.unsafeArgv) != GHOSTTY_SUCCESS {
preconditionFailure("Initialize ghostty backend failed")
}
_ghostty_app = StateObject(wrappedValue: Ghostty.App())
}
var body: some Scene {
WindowGroup {
iOS_GhosttyTerminal()
.environmentObject(ghostty_app)
}
}
}
struct iOS_GhosttyTerminal: View {
@EnvironmentObject private var ghostty_app: Ghostty.App
var body: some View {
ZStack {
// Make sure that our background color extends to all parts of the screen
Color(ghostty_app.config.backgroundColor).ignoresSafeArea()
Ghostty.Terminal()
}
}
}
struct iOS_GhosttyInitView: View {
@EnvironmentObject private var ghostty_app: Ghostty.App
var body: some View {
VStack {
Image("AppIconImage")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 96)
Text("Ghostty")
Text("State: \(ghostty_app.readiness.rawValue)")
}
.padding()
}
}

View File

@@ -45,7 +45,6 @@ struct AboutView: View {
private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String }
#if os(macOS)
// This creates a background style similar to the Apple "About My Mac" Window
private struct VisualEffectBackground: NSViewRepresentable {
let material: NSVisualEffectView.Material
@@ -72,7 +71,6 @@ struct AboutView: View {
return visualEffect
}
}
#endif
var body: some View {
VStack(alignment: .center) {
@@ -141,9 +139,7 @@ struct AboutView: View {
.padding(.top, 8)
.padding(32)
.frame(minWidth: 256)
#if os(macOS)
.background(VisualEffectBackground(material: .underWindowBackground).ignoresSafeArea())
#endif
}
private struct PropertyRow: View {

View File

@@ -27,7 +27,7 @@ struct CloseTerminalIntent: AppIntent {
throw GhosttyIntentError.surfaceNotFound
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
guard let controller = BaseTerminalController.controller(owning: surfaceView) else {
return .result()
}

View File

@@ -27,7 +27,7 @@ struct FocusTerminalIntent: AppIntent {
throw GhosttyIntentError.surfaceNotFound
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
guard let controller = BaseTerminalController.controller(owning: surfaceView) else {
return .result()
}

View File

@@ -112,7 +112,7 @@ final class ScriptTerminal: NSObject {
baseConfig = nil
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
guard let controller = BaseTerminalController.controller(owning: surfaceView) else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a splittable window."
return nil
@@ -142,7 +142,7 @@ final class ScriptTerminal: NSObject {
return nil
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
guard let controller = BaseTerminalController.controller(owning: surfaceView) else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a window."
return nil
@@ -163,7 +163,7 @@ final class ScriptTerminal: NSObject {
return nil
}
guard let controller = surfaceView.window?.windowController as? BaseTerminalController else {
guard let controller = BaseTerminalController.controller(owning: surfaceView) else {
command.scriptErrorNumber = errAEEventFailed
command.scriptErrorString = "Terminal is not in a window."
return nil

View File

@@ -1,7 +1,6 @@
import Foundation
import Cocoa
import SwiftUI
import GhosttyKit
/// This initializes a clipboard confirmation warning window. The window itself
/// WILL NOT show automatically and the caller must show the window via
@@ -9,17 +8,14 @@ import GhosttyKit
class ClipboardConfirmationController: NSWindowController {
override var windowNibName: NSNib.Name? { "ClipboardConfirmation" }
let surface: ghostty_surface_t
let contents: String
let request: Ghostty.ClipboardRequest
let state: UnsafeMutableRawPointer?
private(set) var confirmation: Ghostty.ClipboardConfirmationRequest
weak private var delegate: ClipboardConfirmationViewDelegate?
init(surface: ghostty_surface_t, contents: String, request: Ghostty.ClipboardRequest, state: UnsafeMutableRawPointer?, delegate: ClipboardConfirmationViewDelegate) {
self.surface = surface
self.contents = contents
self.request = request
self.state = state
init(
confirmation: Ghostty.ClipboardConfirmationRequest,
delegate: ClipboardConfirmationViewDelegate
) {
self.confirmation = confirmation
self.delegate = delegate
super.init(window: nil)
}
@@ -28,12 +24,27 @@ class ClipboardConfirmationController: NSWindowController {
fatalError("init(coder:) is not supported for this view")
}
/// Replace the request represented by the visible sheet without changing
/// the sheet's focus state. The previous request is cancelled by its
/// SurfaceView before this method is called.
func replaceConfirmation(with confirmation: Ghostty.ClipboardConfirmationRequest) {
guard self.confirmation !== confirmation else { return }
self.confirmation = confirmation
guard isWindowLoaded, let window else { return }
configure(window)
}
// MARK: - NSWindowController
override func windowDidLoad() {
guard let window = window else { return }
switch request {
configure(window)
}
private func configure(_ window: NSWindow) {
switch confirmation.kind {
case .paste:
window.title = "Warning: Potentially Unsafe Paste"
case .osc_52_read, .osc_52_write:
@@ -41,8 +52,8 @@ class ClipboardConfirmationController: NSWindowController {
}
window.contentView = NSHostingView(rootView: ClipboardConfirmationView(
contents: contents,
request: request,
contents: confirmation.contents,
request: confirmation.kind,
delegate: delegate
))
}

View File

@@ -2,7 +2,7 @@ import SwiftUI
/// This delegate is notified of the completion result of the clipboard confirmation dialog.
protocol ClipboardConfirmationViewDelegate: AnyObject {
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest)
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action)
}
/// The SwiftUI view for showing a clipboard confirmation dialog.
@@ -87,10 +87,10 @@ struct ClipboardConfirmationView: View {
}
private func onCancel() {
delegate?.clipboardConfirmationComplete(.cancel, request)
delegate?.clipboardConfirmationComplete(.cancel)
}
private func onPaste() {
delegate?.clipboardConfirmationComplete(.confirm, request)
delegate?.clipboardConfirmationComplete(.confirm)
}
}

View File

@@ -70,24 +70,13 @@ struct CommandPaletteView: View {
}
// The options that we should show, taking into account any filtering from
// the query. Options with matching leadingColor are ranked higher.
// the query. Matched options are ranked in the following order:
// leadingColor > title > subtitle > description.
var filteredOptions: [CommandOption] {
if query.isEmpty {
return options
} else {
// Filter by title/subtitle match OR color match
let filtered = options.filter {
$0.title.matchedIndices(for: query) != nil ||
($0.subtitle?.matchedIndices(for: query) != nil) ||
colorMatchScore(for: $0.leadingColor, query: query) > 0
}
// Sort by color match score (higher scores first), then maintain original order
return filtered.sorted { a, b in
let scoreA = colorMatchScore(for: a.leadingColor, query: query)
let scoreB = colorMatchScore(for: b.leadingColor, query: query)
return scoreA > scoreB
}
return options.filteredAndSorted(query: query)
}
}
@@ -101,7 +90,7 @@ struct CommandPaletteView: View {
}
var body: some View {
let scheme: ColorScheme = if OSColor(backgroundColor).isLightColor {
let scheme: ColorScheme = if NSColor(backgroundColor).isLightColor {
.light
} else {
.dark
@@ -160,7 +149,7 @@ struct CommandPaletteView: View {
hoveredOptionID: $hoveredOptionID) { option in
isPresented = false
option.action()
}
}
}
.frame(maxWidth: 500)
.background(
@@ -191,31 +180,6 @@ struct CommandPaletteView: View {
}
}
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
/// Returns 0 if no color name in the query matches, or if the color is nil.
private func colorMatchScore(for color: Color?, query: String) -> Double {
guard let color = color else { return 0 }
let queryLower = query.lowercased()
let nsColor = NSColor(color)
var bestScore: Double = 0
for name in NSColor.colorNames {
guard queryLower.contains(name),
let systemColor = NSColor(named: name) else { continue }
let distance = nsColor.distance(to: systemColor)
// Max distance in weighted RGB space is ~3.0, so normalize and invert
// Use a threshold to determine "close enough" matches
let maxDistance: Double = 1.5
if distance < maxDistance {
let score = 1.0 - (distance / maxDistance)
bestScore = max(bestScore, score)
}
}
return bestScore
}
}
/// The text field for building the query for the command palette.
@@ -401,7 +365,7 @@ private struct CommandRow: View {
VStack(alignment: .leading, spacing: 2) {
highlightedTitle
if let subtitle = option.subtitle {
if let subtitle = option.subtitle ?? option.description {
highlightedSubtitle(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
@@ -492,3 +456,74 @@ extension String {
return queryIndex == query.endIndex ? matched : nil
}
}
// MARK: - Match score
extension Collection where Element == CommandOption {
/// Filters to the options matching `query` and ranks them best-first while
/// maintaining original order: a closer leading color match always wins,
/// then a title match beats a subtitle match beats a description match.
func filteredAndSorted(query: String) -> [Element] {
compactMap { CommandOptionMatch(option: $0, query: query) }
.sorted {
($0.colorScore, $0.textScore) > ($1.colorScore, $1.textScore)
}
.map(\.option)
}
}
/// A scored match of a command option against a palette query.
struct CommandOptionMatch {
let option: CommandOption
/// How closely the option's leading color matches a color name in the
/// query, from 0 (no match) to 1 (exact).
let colorScore: Double
/// Which text field matched, ranked: title (3), subtitle (2),
/// description (1), none (0).
let textScore: Int
/// Returns nil if the option doesn't match the query at all.
init?(option: CommandOption, query: String) {
let colorScore = Self.colorMatchScore(for: option.leadingColor, query: query)
let textScore: Int = if option.title.matchedIndices(for: query) != nil {
3
} else if option.subtitle?.matchedIndices(for: query) != nil {
2
} else if option.description?.matchedIndices(for: query) != nil {
1
} else {
0
}
guard colorScore > 0 || textScore > 0 else { return nil }
self.option = option
self.colorScore = colorScore
self.textScore = textScore
}
/// Returns a score (0.0 to 1.0) indicating how well a color matches a search query color name.
/// Returns 0 if no color name in the query matches, or if the color is nil.
static func colorMatchScore(for color: Color?, query: String) -> Double {
guard let color = color else { return 0 }
let queryLower = query.lowercased()
let nsColor = NSColor(color)
var bestScore: Double = 0
for name in NSColor.colorNames {
guard queryLower.contains(name),
let systemColor = NSColor(named: name) else { continue }
let distance = nsColor.distance(to: systemColor)
// Max distance in weighted RGB space is ~3.0, so normalize and invert
// Use a threshold to determine "close enough" matches
let maxDistance: Double = 1.5
if distance < maxDistance {
let score = 1.0 - (distance / maxDistance)
bestScore = max(bestScore, score)
}
}
return bestScore
}
}

View File

@@ -84,35 +84,39 @@ struct TerminalCommandPaletteView: View {
private var updateOptions: [CommandOption] {
var options: [CommandOption] = []
guard let updateViewModel, updateViewModel.state.isInstallable else {
guard let updateViewModel else {
return options
}
// We override the update available one only because we want to properly
// convey it'll go all the way through.
let title: String
if case .updateAvailable = updateViewModel.state {
title = "Update Ghostty and Restart"
} else {
title = updateViewModel.text
if updateViewModel.state.isInstallable {
// We override the update available one only because we want to properly
// convey it'll go all the way through.
let title: String
if case .updateAvailable = updateViewModel.state {
title = "Update Ghostty and Restart"
} else {
title = updateViewModel.text
}
options.append(CommandOption(
title: title,
description: updateViewModel.description,
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
badge: updateViewModel.badge,
emphasis: true
) {
(NSApp.delegate as? AppDelegate)?.updateController.viewModel.state.confirm()
})
}
options.append(CommandOption(
title: title,
description: updateViewModel.description,
leadingIcon: updateViewModel.iconName ?? "shippingbox.fill",
badge: updateViewModel.badge,
emphasis: true
) {
(NSApp.delegate as? AppDelegate)?.updateController.installUpdate()
})
options.append(CommandOption(
title: "Cancel or Skip Update",
description: "Dismiss the current update process"
) {
updateViewModel.state.cancel()
})
if updateViewModel.state.isCancellable {
options.append(CommandOption(
title: "Cancel or Skip Update",
description: "Dismiss the current update process"
) {
updateViewModel.state.cancel()
})
}
return options
}

View File

@@ -3,7 +3,6 @@ import SwiftUI
extension View {
/// Returns the ghostty icon to use for views.
func ghosttyIconImage() -> Image {
#if os(macOS)
// Grab the icon from the running application. This is the best way
// I've found so far to get the proper icon for our current icon
// tinting and so on with macOS Tahoe
@@ -15,7 +14,6 @@ extension View {
if let nsImage = NSApp.applicationIconImage {
return Image(nsImage: nsImage)
}
#endif
// Fall back to a static representation
return Image("AppIconImage")

View File

@@ -1,4 +1,5 @@
import Cocoa
import ApplicationServices
import CoreGraphics
import Carbon
import OSLog
@@ -18,8 +19,7 @@ class GlobalEventTap {
// created.
fileprivate var eventTap: CFMachPort?
// This is the timer used to retry enabling the global event tap if we
// don't have permissions.
// Polls Accessibility permission before enabling the global event tap.
private var enableTimer: Timer?
// Private init so it can't be constructed outside of our singleton
@@ -29,29 +29,36 @@ class GlobalEventTap {
disable()
}
// Enable the global event tap. This is safe to call if it is already enabled.
// If enabling fails due to permissions, this will start a timer to retry since
// accessibility permissions take affect immediately.
// Enable the global event tap. This is safe to call if it is already enabled or
// waiting for Accessibility permission.
func enable() {
if eventTap != nil {
// Already enabled
// If we already have a tap or we're already checking on a timer, do nothing.
guard eventTap == nil, enableTimer == nil else { return }
// Creating a CGEventTap without Accessibility permission leaks a Mach port
// inside CoreGraphics on each failed attempt. Request permission once and
// poll the non-leaking trust check instead of retrying tap creation.
if AXIsProcessTrusted() {
_ = tryEnable()
return
}
// If we are already trying to enable, then stop the timer and restart it.
if let enableTimer {
enableTimer.invalidate()
}
// Ask macOS to prompt for Accessibility access. Approval happens
// asynchronously, so ignore the current result and poll below.
Self.logger.info("No accessibility permission detected, prompting...")
let options = [
kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true,
] as CFDictionary
_ = AXIsProcessTrustedWithOptions(options)
// Try to enable the event tap immediately. If this succeeds then we're done!
if tryEnable() {
return
}
// Check in a timer
enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
guard let self, AXIsProcessTrusted() else { return }
// Failed, probably due to permissions. The permissions dialog should've
// popped up. We retry on a timer since once the permissions are granted
// then they take affect immediately.
enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in
// Stop polling before attempting creation. If creation fails for a
// reason other than permissions, we must not retry it indefinitely.
self.enableTimer?.invalidate()
self.enableTimer = nil
_ = self.tryEnable()
}
}
@@ -88,10 +95,7 @@ class GlobalEventTap {
callback: cgEventFlagsChangedHandler(proxy:type:cgEvent:userInfo:),
userInfo: nil
) else {
// Return false if creation failed. This is usually because we don't have
// Accessibility permissions but can probably be other reasons I don't
// know about.
Self.logger.debug("creating global event tap failed, missing permissions?")
Self.logger.warning("creating global event tap failed despite Accessibility permission")
return false
}

View File

@@ -639,6 +639,16 @@ class QuickTerminalController: BaseTerminalController {
terminalViewContainer?.ghosttyConfigDidChange(ghostty.config, preferredBackgroundColor: nil)
}
override func confirmCloseAsync(messageText: String, informativeText: String, confirmButtonTitle: String = "Close") async -> NSApplication.ModalResponse? {
let waitTime = visible ? 0 : 0.25
animateIn()
try? await Task.sleep(for: .seconds(waitTime))
return await super.confirmCloseAsync(messageText: messageText, informativeText: informativeText, confirmButtonTitle: confirmButtonTitle)
}
private func showNoNewTabAlert() {
guard let window else { return }
let alert = NSAlert()

View File

@@ -13,7 +13,15 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi
@Published var errors: [String] = [] {
didSet {
if errors.count == 0 {
self.window?.performClose(nil)
// Only close the window if it was ever loaded: accessing
// `window` on an NSWindowController loads the nib (and our
// SwiftUI content view), which takes tens of milliseconds.
// This happens on every app launch via the initial config
// apply, when there are usually no errors and the window
// was never loaded.
if isWindowLoaded {
self.window?.performClose(nil)
}
}
}
}

View File

@@ -32,6 +32,11 @@ class BaseTerminalController: NSWindowController,
TerminalViewModel,
ClipboardConfirmationViewDelegate,
FullscreenDelegate {
/// Weak surface-to-controller ownership independent of AppKit's transient
/// view and window attachment state.
private static let surfaceControllers =
NSMapTable<Ghostty.SurfaceView, BaseTerminalController>.weakToWeakObjects()
/// The app instance that this terminal view will represent.
let ghostty: Ghostty.App
@@ -42,7 +47,10 @@ class BaseTerminalController: NSWindowController,
/// The tree of splits within this terminal window.
@Published var surfaceTree: SplitTree<Ghostty.SurfaceView> = .init() {
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
didSet {
Self.updateSurfaceControllers(self, from: oldValue, to: surfaceTree)
surfaceTreeDidChange(from: oldValue, to: surfaceTree)
}
}
/// This can be set to show/hide the command palette.
@@ -89,6 +97,9 @@ class BaseTerminalController: NSWindowController,
/// Cancellable for aggregating bell state across all surfaces in this controller.
private var bellStateCancellable: AnyCancellable?
/// Cancellable for clipboard confirmation requests from surfaces in this controller.
private var clipboardConfirmationCancellable: AnyCancellable?
/// An override title for the tab/window set by the user via prompt_tab_title.
/// When set, this takes precedence over the computed title from the terminal.
var titleOverride: String? {
@@ -140,17 +151,14 @@ class BaseTerminalController: NSWindowController,
// Initialize our initial surface.
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(ghostty_app, baseConfig: base))
Self.updateSurfaceControllers(self, from: .init(), to: surfaceTree)
// Setup our bell state for the window
setupBellNotificationPublisher()
setupClipboardConfirmationPublisher()
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(onConfirmClipboardRequest),
name: Ghostty.Notification.confirmClipboard,
object: nil)
center.addObserver(
self,
selector: #selector(didChangeScreenParametersNotification),
@@ -231,6 +239,44 @@ class BaseTerminalController: NSWindowController,
// MARK: Methods
/// Finds the controller whose split tree owns the given surface.
///
/// A surface's `window` can briefly be nil or point at its previous window
/// while AppKit is attaching or moving a native tab. Callers performing
/// lifecycle operations must use tree ownership rather than that transient
/// view relationship.
static func controller(owning surface: Ghostty.SurfaceView) -> BaseTerminalController? {
if let controller = surfaceControllers.object(forKey: surface),
controller.surfaceTree.contains(surface) {
return controller
}
if let controller = surface.window?.windowController as? BaseTerminalController,
controller.surfaceTree.contains(surface) {
return controller
}
return NSApp.windows
.compactMap { $0.windowController as? BaseTerminalController }
.first { $0.surfaceTree.contains(surface) }
}
private static func updateSurfaceControllers(
_ controller: BaseTerminalController,
from oldTree: SplitTree<Ghostty.SurfaceView>,
to newTree: SplitTree<Ghostty.SurfaceView>
) {
for surface in oldTree where !newTree.contains(surface) {
if surfaceControllers.object(forKey: surface) === controller {
surfaceControllers.removeObject(forKey: surface)
}
}
for surface in newTree {
surfaceControllers.setObject(controller, forKey: surface)
}
}
/// Create a new split.
@discardableResult
func newSplit(
@@ -288,6 +334,10 @@ class BaseTerminalController: NSWindowController,
///
/// Subclasses should call super first.
func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
for surfaceView in from where !to.contains(surfaceView) {
cancelPendingClipboardConfirmation(for: surfaceView)
}
// If our surface tree becomes empty then we have no focused surface.
if to.isEmpty {
focusedSurface = nil
@@ -298,6 +348,7 @@ class BaseTerminalController: NSWindowController,
/// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about
/// what surface is focused. This must be called whenever a surface OR window changes focus.
func syncFocusToSurfaceTree() {
var newlyFocused: Ghostty.SurfaceView?
for surfaceView in surfaceTree {
// Our focus state requires that this window is key and our currently
// focused surface is the surface in this view.
@@ -305,6 +356,11 @@ class BaseTerminalController: NSWindowController,
surfaceView == focusedSurface &&
surfaceView.isFirstResponder
surfaceView.focusDidChange(focused)
if focused { newlyFocused = surfaceView }
}
if let newlyFocused {
presentPendingClipboardConfirmation(for: newlyFocused)
}
}
@@ -1094,69 +1150,6 @@ class BaseTerminalController: NSWindowController,
syncAppearance()
}
// MARK: Clipboard Confirmation
@objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
guard let surface = target.surface else { return }
// We need a window
guard let window = self.window else { return }
// Check whether we use non-native fullscreen
guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return }
guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return }
guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return }
// If we already have a clipboard confirmation view up, we ignore this request.
// This shouldn't be possible...
guard self.clipboardConfirmation == nil else {
Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true)
return
}
// Show our paste confirmation
self.clipboardConfirmation = ClipboardConfirmationController(
surface: surface,
contents: str,
request: request,
state: state,
delegate: self
)
window.beginSheet(self.clipboardConfirmation!.window!)
}
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) {
// End our clipboard confirmation no matter what
guard let cc = self.clipboardConfirmation else { return }
self.clipboardConfirmation = nil
// Close the sheet
if let ccWindow = cc.window {
window?.endSheet(ccWindow)
}
switch request {
case let .osc_52_write(pasteboard):
guard case .confirm = action else { break }
let pb = pasteboard ?? NSPasteboard.general
pb.declareTypes([.string], owner: nil)
pb.setString(cc.contents, forType: .string)
case .osc_52_read, .paste:
let str: String
switch action {
case .cancel:
str = ""
case .confirm:
str = cc.contents
}
Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true)
}
}
// MARK: NSWindowController
override func windowDidLoad() {
@@ -1203,7 +1196,7 @@ class BaseTerminalController: NSWindowController,
/// Check whether window should be closed without showing an alert
func windowCanBeClosedWithoutConfirmation() -> Bool {
// We must have a window. Is it even possible not to?
guard let window = self.window else { return true }
guard window != nil else { return true }
// If we have no surfaces, close.
if surfaceTree.isEmpty { return true }
@@ -1238,6 +1231,10 @@ class BaseTerminalController: NSWindowController,
func windowWillClose(_ notification: Notification) {
guard let window else { return }
for surfaceView in surfaceTree {
cancelPendingClipboardConfirmation(for: surfaceView)
}
// Emit a final bell-state transition so any observers can clear state
// without separately tracking NSWindow lifecycle events.
if bell {
@@ -1542,6 +1539,153 @@ extension BaseTerminalController: NSMenuItemValidation {
}
}
// MARK: Clipboard Confirmation
extension BaseTerminalController {
/// Presents clipboard confirmations published by surfaces in this controller.
private func setupClipboardConfirmationPublisher() {
clipboardConfirmationCancellable = $surfaceTree
// Rebuild the merged publisher whenever the split tree changes.
.map { tree in
Publishers.MergeMany(tree.map { surface in
// Carry the stable value-type ID rather than capturing the
// surface in the operator chain. The subscription therefore
// cannot extend the SurfaceView's lifetime.
let id = surface.id
return surface.$pendingClipboardConfirmation
.map { (id, $0) }
.eraseToAnyPublisher()
})
.eraseToAnyPublisher()
}
// Cancelling the old MergeMany releases every subscription for
// surfaces removed from the current tree.
.switchToLatest()
// Published emits synchronously from the libghostty callback. Hop
// to the main queue both for AppKit and so completing a request
// cannot invalidate callback state while that callback is active.
.receive(on: DispatchQueue.main)
// The cancellable is controller-owned, so capture it weakly here to
// avoid controller -> cancellable -> sink -> controller.
.sink { [weak self] id, request in
guard let self,
let surface = surfaceTree.first(where: { $0.id == id }) else { return }
onConfirmClipboardRequest(request, for: surface)
}
}
private func onConfirmClipboardRequest(
_ request: Ghostty.ClipboardConfirmationRequest?,
for target: Ghostty.SurfaceView
) {
guard let request else {
guard target.pendingClipboardConfirmation == nil,
let confirmation = clipboardConfirmation,
confirmation.confirmation.surface === target else { return }
dismissClipboardConfirmation(confirmation)
return
}
// Ignore values queued before a newer request replaced them.
guard target.pendingClipboardConfirmation === request else { return }
// SurfaceView.didSet has already cancelled the request that this one
// replaced. If that request owns the visible sheet, update the sheet
// in place. Dismissing it would briefly return focus to the terminal,
// which can produce another request and repeat the cycle. Requests
// from other surfaces cannot replace a window-modal sheet.
if let confirmation = clipboardConfirmation {
if confirmation.confirmation === request { return }
guard confirmation.confirmation.surface === target else {
target.pendingClipboardConfirmation = nil
return
}
confirmation.replaceConfirmation(with: request)
return
}
// A clipboard confirmation can originate from a surface that isn't
// focused. Presenting its sheet immediately would bring that surface's
// window or tab forward and steal focus. Signal that it needs attention,
// retain the request on the surface, and present it only after the
// surface gains focus.
if !target.focused {
if !target.bell {
NotificationCenter.default.post(
name: .ghosttyBellDidRing,
object: target)
}
return
}
// Preserve the prior behavior for confirmation types other than an
// OSC 52 read: only the controller's selected surface may present it.
guard target == focusedSurface else {
target.pendingClipboardConfirmation = nil
return
}
_ = presentClipboardConfirmation(request)
}
private func presentClipboardConfirmation(
_ request: Ghostty.ClipboardConfirmationRequest
) -> Bool {
guard clipboardConfirmation == nil, let window else { return false }
clipboardConfirmation = ClipboardConfirmationController(
confirmation: request,
delegate: self
)
window.beginSheet(clipboardConfirmation!.window!)
return true
}
private func dismissClipboardConfirmation(
_ confirmation: ClipboardConfirmationController
) {
guard clipboardConfirmation === confirmation else { return }
clipboardConfirmation = nil
if let confirmationWindow = confirmation.window {
window?.endSheet(confirmationWindow)
}
}
private func presentPendingClipboardConfirmation(for target: Ghostty.SurfaceView) {
guard target.focused,
target == focusedSurface,
let request = target.pendingClipboardConfirmation else { return }
onConfirmClipboardRequest(request, for: target)
}
private func cancelPendingClipboardConfirmation(for target: Ghostty.SurfaceView) {
if let confirmation = clipboardConfirmation,
confirmation.confirmation.surface === target {
dismissClipboardConfirmation(confirmation)
}
target.pendingClipboardConfirmation = nil
}
func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action) {
// End our clipboard confirmation no matter what
guard let cc = self.clipboardConfirmation else { return }
dismissClipboardConfirmation(cc)
switch action {
case .cancel:
cc.confirmation.cancel()
case .confirm:
cc.confirmation.complete()
}
// Clear only if this is still the surface's current request. Completing
// the request may synchronously cause a newer request to replace it.
if let target = cc.confirmation.surface,
target.pendingClipboardConfirmation === cc.confirmation {
target.pendingClipboardConfirmation = nil
}
}
}
// MARK: Combine Methods
extension BaseTerminalController {

View File

@@ -344,14 +344,15 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
confirmUndo: Bool = true,
inheritBackgroundOpacity: Bool? = nil
) -> TerminalController {
// Calculate the target frame based on the tree's view bounds
// before moving into the new window
let treeSize: CGSize? = tree.root?.viewBounds()
let c = TerminalController.init(ghostty, withSurfaceTree: tree)
if let inheritBackgroundOpacity {
c.isBackgroundOpaque = inheritBackgroundOpacity
}
// Calculate the target frame based on the tree's view bounds
let treeSize: CGSize? = tree.root?.viewBounds()
c.scheduleInitialPresentation {
c.showWindow(self)
if let window = c.window {
@@ -480,8 +481,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
Self.applyCascade(to: window, hasFixedPos: hasFixedPos)
}
controller.showWindow(self)
window.makeKeyAndOrderFront(self)
// showWindow makes regular windows key and ordered front. AppKit can
// throw while selecting a tab if its fullscreen stack is inconsistent,
// so this must cross the Objective-C exception bridge.
controller.showWindowSafely(self)
// We also activate our app so that it becomes front. This may be
// necessary for the dock menu.
@@ -667,12 +670,20 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// More than 1 window means we have tabs and we're closing a tab
if window?.tabGroup?.windows.count ?? 0 > 1 {
closeTab(nil)
if withConfirmation {
closeTab(nil)
} else {
closeTabImmediately()
}
return
}
// 1 window, closing the window
closeWindow(nil)
if withConfirmation {
closeWindow(nil)
} else {
closeWindowImmediately()
}
}
func closeTabImmediately(registerRedo: Bool = true) {
@@ -1108,7 +1119,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// We don't run this logic in fullscreen because in fullscreen this will end up
// removing the window and putting it into its own dedicated fullscreen, which is not
// the expected or desired behavior of anyone I've found.
if !window.styleMask.contains(.fullScreen) {
//
// We also only run this when the system tabbing preference is "always",
// which is the only scenario AppKit will have auto-tabbed a fresh window
// at this point: the tab bar "+" button goes through newWindowForTab
// which we route through our own tab logic. This check matters because
// accessing `window.tabGroup` materializes the window's tab group
// machinery, which takes ~15-20ms and is otherwise not needed during
// window creation.
if NSWindow.userTabbingPreference == .always,
!window.styleMask.contains(.fullScreen) {
// If we have more than 1 window in our tab group we know we're a new window.
// Since Ghostty manages tabbing manually this will never be more than one
// at this point in the AppKit lifecycle (we add to the group after this).
@@ -1149,6 +1169,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
}
super.showWindow(sender)
syncAppearance()
}
// Shows the "+" button in the tab bar, responds to that click.

View File

@@ -147,8 +147,8 @@ class TitlebarTabsVenturaTerminalWindow: TerminalWindow {
}
// Update our window light/darkness based on our updated background color
let themeChanged = isLightTheme != OSColor(surfaceConfig.backgroundColor).isLightColor
isLightTheme = OSColor(surfaceConfig.backgroundColor).isLightColor
let themeChanged = isLightTheme != NSColor(surfaceConfig.backgroundColor).isLightColor
isLightTheme = NSColor(surfaceConfig.backgroundColor).isLightColor
// Update our titlebar color
if let preferredBackgroundColor {

View File

@@ -9,6 +9,7 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
private var lastSurfaceConfig: Ghostty.SurfaceView.DerivedConfig?
/// KVO observation for tab group window changes.
private weak var observedTabGroup: NSWindowTabGroup?
private var tabGroupWindowsObservation: NSKeyValueObservation?
private var tabBarVisibleObservation: NSKeyValueObservation?
@@ -129,9 +130,27 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
// MARK: Tab Group Observation
private func setupKVO() {
// See the docs for the respective setup functions for why.
setupTabGroupObservation()
setupTabBarVisibleObservation()
// This can run from one of the observation callbacks below. Replacing
// an observation before its callback returns leaves the window retained
// by AppKit, so always rebind on the next main-queue turn.
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Recheck because the tab group and observation state may have changed
// while this work was waiting on the main queue.
let currentTabGroup = self.tabGroup
let observationsValid = currentTabGroup == nil || (
self.tabGroupWindowsObservation != nil &&
self.tabBarVisibleObservation != nil
)
// Keep the existing observations when they already match.
guard self.observedTabGroup !== currentTabGroup || !observationsValid else { return }
self.observedTabGroup = currentTabGroup
self.setupTabGroupObservation()
self.setupTabBarVisibleObservation()
}
}
/// Monitors the tabGroup windows value for any changes and resyncs the appearance on change.

View File

@@ -1,6 +1,7 @@
import Sparkle
import Cocoa
import Combine
import SwiftUI
/// Standard controller for managing Sparkle updates in Ghostty.
///
@@ -10,15 +11,14 @@ import Combine
class UpdateController {
private(set) var updater: SPUUpdater
private let userDriver: UpdateDriver
private var installCancellable: AnyCancellable?
var viewModel: UpdateViewModel {
userDriver.viewModel
}
/// True if we're installing an update.
var isInstalling: Bool {
installCancellable != nil
/// True if we're installing an update triggered manually.
var shouldTerminateWithoutWarning: Bool {
viewModel.state.shouldTerminateWithoutWarning
}
/// Initialize a new update controller.
@@ -35,10 +35,6 @@ class UpdateController {
)
}
deinit {
installCancellable?.cancel()
}
/// Start the updater.
///
/// This must be called before the updater can check for updates. If starting fails,
@@ -60,46 +56,41 @@ class UpdateController {
}
}
/// Force install the current update. As long as we're in some "update available" state this will
/// trigger all the steps necessary to complete the update.
func installUpdate() {
// Must be in an installable state
guard viewModel.state.isInstallable else { return }
// If we're already force installing then do nothing.
guard installCancellable == nil else { return }
// Setup a combine listener to listen for state changes and to always
// confirm them. If we go to a non-installable state, cancel the listener.
// The sink runs immediately with the current state, so we don't need to
// manually confirm the first state.
installCancellable = viewModel.$state.sink { [weak self] state in
guard let self else { return }
// If we move to a non-installable state (error, idle, etc.) then we
// stop force installing.
guard state.isInstallable else {
self.installCancellable = nil
return
}
// Continue the `yes` chain!
state.confirm()
}
}
/// Check for updates.
///
/// This is typically connected to a menu item action.
@objc func checkForUpdates() {
func checkForUpdates() {
// If we're already idle, then just check for updates immediately.
if viewModel.state == .idle {
updater.checkForUpdates()
return
}
if case let .installing(installing) = viewModel.state {
// If the update is already installed, we can't actually
// cancel it, and SPUUpdater.checkForUpdates will simply fail,
// so we just show an alert to remind the user to restart.
let alert = NSAlert()
alert.alertStyle = .informational
let accessoryView = NSHostingView(
rootView: InstallingAccessoryView(installing: installing)
.frame(width: 228, alignment: .leading)
)
accessoryView.frame = .init(origin: .zero, size: accessoryView.fittingSize)
alert.accessoryView = accessoryView
alert.addButton(withTitle: "Restart Now")
alert.addButton(withTitle: "Restart Later")
.keyEquivalent = .init([KeyboardShortcut(.escape).key.character])
switch alert.runModal() {
case .alertFirstButtonReturn:
viewModel.state.confirm()
default:
break
}
return
}
// If we're not idle then we need to cancel any prior state.
installCancellable?.cancel()
viewModel.state.cancel()
// The above will take time to settle, so we delay the check for some time.
@@ -109,15 +100,47 @@ class UpdateController {
self?.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
private struct InstallingAccessoryView: View {
let installing: UpdateState.Installing
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Restart Required")
.font(.system(size: 13, weight: .semibold))
Text("The update is ready. Please restart the application to complete the installation.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if let item = installing.appcastItem, let releaseNotesURL = installing.releaseNotes?.url {
VStack(alignment: .leading, spacing: 4) {
Link(destination: releaseNotesURL) {
HStack(spacing: 6) {
Text("Version:")
.foregroundColor(.secondary)
.frame(width: 60, alignment: .trailing)
Text(item.displayVersionString)
}
.font(.system(size: 11))
}
if let date = item.date {
HStack(spacing: 6) {
Text("Released:")
.foregroundColor(.secondary)
.frame(width: 60, alignment: .trailing)
Text(date.formatted(date: .abbreviated, time: .omitted))
}
.font(.system(size: 11))
}
}
.textSelection(.enabled)
}
}
}
return true
}
}

View File

@@ -23,12 +23,12 @@ extension UpdateDriver: SPUUpdaterDelegate {
/// delegate method on the responsible driver instead.
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
viewModel.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: immediateInstallHandler,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
appcastItem: item,
retryTerminatingApplication: immediateInstallHandler
))
AppDelegate.logger.info("Version: \(item.displayVersionString) installed silently, waiting for relaunch...")
// Even when hasUnobtrusiveTarget is false, we don't show the alert immediately.
// We wait until the user manually checks for updates or relaunches.
return true
}
}

View File

@@ -95,14 +95,12 @@ class UpdateDriver: NSObject, SPUUserDriver {
delegate.checkForUpdates(self)
}
},
dismiss: { [weak viewModel] in
viewModel?.state = .idle
dismiss: {
acknowledgement()
}))
if !hasUnobtrusiveTarget {
standard.showUpdaterError(error, acknowledgement: acknowledgement)
} else {
acknowledgement()
}
}
@@ -173,10 +171,8 @@ class UpdateDriver: NSObject, SPUUserDriver {
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
viewModel.state = .installing(.init(
appcastItem: nil,
retryTerminatingApplication: retryTerminatingApplication,
dismiss: { [weak viewModel] in
viewModel?.state = .idle
}
))
if !hasUnobtrusiveTarget {

View File

@@ -15,7 +15,7 @@ struct UpdatePill: View {
private let textFont = NSFont.systemFont(ofSize: 11, weight: .medium)
var body: some View {
if !model.state.isIdle {
if !model.state.isHidden {
pillButton
.popover(isPresented: $showPopover, arrowEdge: .bottom) {
UpdatePopoverView(model: model)
@@ -24,10 +24,10 @@ struct UpdatePill: View {
.onChange(of: model.state) { newState in
resetTask?.cancel()
if case .notFound(let notFound) = newState {
resetTask = Task { [weak model] in
resetTask = Task {
try? await Task.sleep(for: .seconds(5))
guard !Task.isCancelled, case .notFound? = model?.state else { return }
model?.state = .idle
guard !Task.isCancelled, case .notFound = model.state else { return }
model.state = .idle
notFound.acknowledgement()
}
} else {

View File

@@ -35,11 +35,8 @@ struct UpdatePopoverView: View {
case .extracting(let extracting):
ExtractingView(extracting: extracting)
case .installing(let installing):
// This is only required when `installing.isAutoUpdate == true`,
// but we keep it anyway, just in case something unexpected
// happens during installing
InstallingView(installing: installing, dismiss: dismiss)
case .installing:
EmptyView()
case .notFound(let notFound):
NotFoundView(notFound: notFound, dismiss: dismiss)
@@ -274,45 +271,6 @@ private struct ExtractingView: View {
}
}
private struct InstallingView: View {
let installing: UpdateState.Installing
let dismiss: DismissAction
var body: some View {
VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 8) {
Text("Restart Required")
.font(.system(size: 13, weight: .semibold))
Text("The update is ready. Please restart the application to complete the installation.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Button("Restart Later") {
installing.dismiss()
dismiss()
}
.keyboardShortcut(.cancelAction)
.controlSize(.small)
Spacer()
Button("Restart Now") {
installing.retryTerminatingApplication()
dismiss()
}
.keyboardShortcut(.defaultAction)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
}
.padding(16)
}
}
private struct NotFoundView: View {
let notFound: UpdateState.NotFound
let dismiss: DismissAction

View File

@@ -274,28 +274,19 @@ enum UpdateSimulator {
}
}
private func simulateInstalling(_ viewModel: UpdateViewModel) {
private func simulateInstalling(_ viewModel: UpdateViewModel, appcastItem: SUAppcastItem? = nil) {
viewModel.state = .installing(.init(
appcastItem: appcastItem,
retryTerminatingApplication: {
print("Restart button clicked in simulator - resetting to idle")
viewModel.state = .idle
},
dismiss: {
viewModel.state = .idle
}
))
}
private func simulateAutoUpdate(_ viewModel: UpdateViewModel) {
viewModel.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: {
print("Restart button clicked in simulator - resetting to idle")
viewModel.state = .idle
},
dismiss: {
viewModel.state = .idle
}
))
let item = SUAppcastItem.empty()
item.setValue("x.x.x", forKey: "_displayVersionString")
simulateInstalling(viewModel, appcastItem: item)
}
}

View File

@@ -30,8 +30,8 @@ class UpdateViewModel: ObservableObject {
return "Downloading…"
case .extracting(let extracting):
return String(format: "Preparing: %.0f%%", extracting.progress * 100)
case .installing(let install):
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing…"
case let .installing(install):
return install.appcastItem != nil ? "Restart to Complete Update" : "Installing…"
case .notFound:
return "No Updates Available"
case .error(let err):
@@ -93,7 +93,11 @@ class UpdateViewModel: ObservableObject {
case .extracting:
return "Extracting and preparing the update"
case let .installing(install):
return install.isAutoUpdate ? "Restart to Complete Update" : "Installing update and preparing to restart"
if let item = install.appcastItem {
return "The update is ready. Version: \(item.displayVersionString)"
} else {
return "Installing update and preparing to restart"
}
case .notFound:
return "You are running the latest version"
case .error:
@@ -185,8 +189,22 @@ enum UpdateState: Equatable {
case extracting(Extracting)
case installing(Installing)
var isIdle: Bool {
/// True if we're installing an update triggered manually.
var shouldTerminateWithoutWarning: Bool {
if case .installing(let installing) = self {
return installing.appcastItem == nil
} else {
return false
}
}
var isHidden: Bool {
if case .idle = self { return true }
if case .installing(let installing) = self {
// Hide the update pill when installing is triggered by auto update.
// There will be an alert when users check the updates themselves.
return installing.appcastItem != nil
}
return false
}
@@ -205,30 +223,51 @@ enum UpdateState: Equatable {
}
}
var isCancellable: Bool {
cancelAction != nil
}
func cancel() {
cancelAction?()
}
private var cancelAction: (() -> Void)? {
switch self {
case .idle:
nil
case .permissionRequest:
nil
case .checking(let checking):
checking.cancel()
checking.cancel
case .updateAvailable(let available):
available.reply(.dismiss)
{ available.reply(.dismiss) }
case .downloading(let downloading):
downloading.cancel()
downloading.cancel
case .notFound(let notFound):
notFound.acknowledgement()
notFound.acknowledgement
case .error(let err):
err.dismiss()
default:
break
err.dismiss
case .extracting:
nil
case .installing:
nil
}
}
/// Confirms or accepts the current update state.
/// - For available updates: begins installation
/// - For ready-to-install: proceeds with installation
func confirm() {
/// - For installing: suppress termination warnings and restart
mutating func confirm() {
switch self {
case .updateAvailable(let available):
available.reply(.install)
case .installing(let installing):
// Remove appcastItem so we can restart without any other alerts.
var suppressTerminationWarnings = installing
suppressTerminationWarnings.appcastItem = nil
self = .installing(suppressTerminationWarnings)
installing.retryTerminatingApplication()
default:
break
}
@@ -252,8 +291,8 @@ enum UpdateState: Equatable {
return lDown.progress == rDown.progress && lDown.expectedLength == rDown.expectedLength
case (.extracting(let lExt), .extracting(let rExt)):
return lExt.progress == rExt.progress
case (.installing(let lInstall), .installing(let rInstall)):
return lInstall.isAutoUpdate == rInstall.isAutoUpdate
case (.installing(let lhs), .installing(let rhs)):
return lhs.appcastItem?.displayVersionString == rhs.appcastItem?.displayVersionString
default:
return false
}
@@ -364,9 +403,14 @@ enum UpdateState: Equatable {
}
struct Installing {
/// True if this state is triggered by ``Ghostty/UpdateDriver/updater(_:willInstallUpdateOnQuit:immediateInstallationBlock:)``
var isAutoUpdate = false
/// Non-nil if this state is triggered by auto update
var appcastItem: SUAppcastItem?
let retryTerminatingApplication: () -> Void
let dismiss: () -> Void
var releaseNotes: ReleaseNotes? {
guard let appcastItem else { return nil }
let currentCommit = Bundle.main.infoDictionary?["GhosttyCommit"] as? String
return ReleaseNotes(displayVersionString: appcastItem.displayVersionString, currentCommit: currentCommit)
}
}
}

View File

@@ -46,6 +46,7 @@ extension Ghostty.Action {
case unknown
case text
case html
case osc8
init(_ c: ghostty_action_open_url_kind_e) {
switch c {
@@ -53,6 +54,8 @@ extension Ghostty.Action {
self = .text
case GHOSTTY_ACTION_OPEN_URL_KIND_HTML:
self = .html
case GHOSTTY_ACTION_OPEN_URL_KIND_OSC8:
self = .osc8
default:
self = .unknown
}

View File

@@ -1,18 +1,16 @@
import SwiftUI
import UniformTypeIdentifiers
import UserNotifications
import GhosttyKit
import AppKit
protocol GhosttyAppDelegate: AnyObject {
#if os(macOS)
/// Called when a callback needs access to a specific surface. This should return nil
/// when the surface is no longer valid.
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView?
#endif
}
extension Ghostty {
// IMPORTANT: THIS IS NOT DONE.
// This is a refactor/redo of Ghostty.AppState so that it supports both macOS and iOS
class App: ObservableObject {
enum Readiness: String {
case loading, error, ready
@@ -76,8 +74,6 @@ extension Ghostty {
return
}
self.app = app
#if os(macOS)
// Set our initial focus state
ghostty_app_set_focus(app, NSApp.isActive)
@@ -97,18 +93,13 @@ extension Ghostty {
selector: #selector(applicationDidResignActive(notification:)),
name: NSApplication.didResignActiveNotification,
object: nil)
#endif
self.readiness = .ready
}
deinit {
// This will force the didSet callbacks to run which free.
self.app = nil
#if os(macOS)
NotificationCenter.default.removeObserver(self)
#endif
}
// MARK: App Operations
@@ -127,7 +118,6 @@ extension Ghostty {
func openConfig() {
let str = configPath ?? Ghostty.AllocatedString(ghostty_config_open_path()).string
guard !str.isEmpty else { return }
#if os(macOS)
let fileURL = URL(fileURLWithPath: str).absoluteString
var action = ghostty_action_open_url_s()
action.kind = GHOSTTY_ACTION_OPEN_URL_KIND_TEXT
@@ -136,9 +126,6 @@ extension Ghostty {
action.len = UInt(fileURL.count)
_ = App.openURL(action)
}
#else
fatalError("Unsupported platform for opening config file")
#endif
}
/// Reload the configuration.
@@ -266,39 +253,6 @@ extension Ghostty {
}
}
#if os(iOS)
// MARK: Ghostty Callbacks (iOS)
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {}
static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) -> Bool { return false }
static func readClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
state: UnsafeMutableRawPointer?
) -> Bool {
return false
}
static func confirmReadClipboard(
_ userdata: UnsafeMutableRawPointer?,
string: UnsafePointer<CChar>?,
state: UnsafeMutableRawPointer?,
request: ghostty_clipboard_request_e
) {}
static func writeClipboard(
_ userdata: UnsafeMutableRawPointer?,
location: ghostty_clipboard_e,
content: UnsafePointer<ghostty_clipboard_content_s>?,
len: Int,
confirm: Bool
) {}
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {}
#endif
#if os(macOS)
// MARK: Notifications
// Called when the selected keyboard changes. We have to notify Ghostty so that
@@ -354,21 +308,31 @@ extension Ghostty {
state: UnsafeMutableRawPointer?,
request: ghostty_clipboard_request_e
) {
let surface = self.surfaceUserdata(from: userdata)
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
guard let request = Ghostty.ClipboardRequest.from(request: request) else { return }
NotificationCenter.default.post(
name: Notification.confirmClipboard,
object: surface,
userInfo: [
Notification.ConfirmClipboardStrKey: valueStr,
Notification.ConfirmClipboardStateKey: state as Any,
Notification.ConfirmClipboardRequestKey: request,
]
)
let surfaceView = self.surfaceUserdata(from: userdata)
guard surfaceView.surface != nil,
let string,
let valueStr = String(cString: string, encoding: .utf8),
let kind = Ghostty.ClipboardRequest.from(request: request) else { return }
// libghostty reaches this callback only when the request attempted
// by readClipboard requires confirmation. Reads allowed by policy
// complete immediately and never become pending Swift state.
let request = Ghostty.ClipboardConfirmationRequest(
surface: surfaceView,
contents: valueStr,
kind: kind
) { surfaceView, contents in
guard let surface = surfaceView.surface else { return }
completeClipboardRequest(
surface,
data: contents ?? "",
state: state,
confirmed: true)
}
surfaceView.pendingClipboardConfirmation = request
}
static func completeClipboardRequest(
private static func completeClipboardRequest(
_ surface: ghostty_surface_t,
data: String,
state: UnsafeMutableRawPointer?,
@@ -386,7 +350,7 @@ extension Ghostty {
len: Int,
confirm: Bool
) {
let surface = self.surfaceUserdata(from: userdata)
let surfaceView = self.surfaceUserdata(from: userdata)
guard let pasteboard = NSPasteboard.ghostty(location) else { return }
guard let content = content, len > 0 else { return }
@@ -402,7 +366,8 @@ extension Ghostty {
"clipboard contents should have at most one text/plain entry")
if !confirm {
// Declare all types
// Apply writes allowed by policy immediately. Only writes that
// require confirmation continue to the pending request below.
let types = contentArray.compactMap { item in
NSPasteboard.PasteboardType(mimeType: item.mime)
}
@@ -421,14 +386,16 @@ extension Ghostty {
return
}
NotificationCenter.default.post(
name: Notification.confirmClipboard,
object: surface,
userInfo: [
Notification.ConfirmClipboardStrKey: textPlainContent.data,
Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write(pasteboard),
]
)
let request = Ghostty.ClipboardConfirmationRequest(
surface: surfaceView,
contents: textPlainContent.data,
kind: .osc_52_write
) { _, contents in
guard let contents else { return }
pasteboard.declareTypes([.string], owner: nil)
pasteboard.setString(contents, forType: .string)
}
surfaceView.pendingClipboardConfirmation = request
}
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {
@@ -539,6 +506,9 @@ extension Ghostty {
case GHOSTTY_ACTION_RENDER_INSPECTOR:
renderInspector(app, target: target)
case GHOSTTY_ACTION_EXPORT_TERMINAL_IO:
return exportTerminalIO(app, target: target, v: action.action.export_terminal_io)
case GHOSTTY_ACTION_DESKTOP_NOTIFICATION:
showDesktopNotification(app, target: target, n: action.action.desktop_notification)
@@ -662,18 +632,12 @@ extension Ghostty {
case GHOSTTY_ACTION_PRESENT_TERMINAL:
return presentTerminal(app, target: target)
case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW:
fallthrough
case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS:
fallthrough
case GHOSTTY_ACTION_SIZE_LIMIT:
fallthrough
case GHOSTTY_ACTION_QUIT_TIMER:
fallthrough
case GHOSTTY_ACTION_SHOW_CHILD_EXITED:
return showChildExited(app, target: target, v: action.action.child_exited)
case GHOSTTY_ACTION_COPY_TITLE_TO_CLIPBOARD:
return copyTitleToClipboard(app, target: target)
default:
Ghostty.logger.warning("unknown action action=\(action.tag.rawValue, privacy: .public)")
return false
@@ -685,18 +649,8 @@ extension Ghostty {
}
private static func quit(_ app: ghostty_app_t) {
// On iOS, applications do not terminate programmatically like they do
// on macOS. On iOS, applications are only terminated when a user physically
// closes the application (i.e. going to the home screen). If we request
// exit on iOS we ignore it.
#if os(iOS)
logger.info("quit request received, ignoring on iOS")
#endif
#if os(macOS)
// We want to quit, start that process
NSApplication.shared.terminate(nil)
#endif
}
private static func checkForUpdates(
@@ -712,6 +666,13 @@ extension Ghostty {
) -> Bool {
let action = Ghostty.Action.OpenURL(c: v)
// OSC 8 targets are producer-controlled terminal output. Keep them
// out of the unrestricted generic opener so unsafe local files and
// deceptive targets cannot reach Launch Services directly.
if action.kind == .osc8 {
return openUntrustedURL(action.url)
}
// If the URL doesn't have a valid scheme we assume its a file path. The URL
// initializer will gladly take invalid URLs (e.g. plain file paths) and turn
// them into schema-less URLs, but these won't open properly in text editors.
@@ -741,6 +702,10 @@ extension Ghostty {
case .unknown:
break
case .osc8:
assertionFailure("OSC 8 URLs must use the safe-opening policy")
return true
}
// Open with the default application for the URL
@@ -748,6 +713,30 @@ extension Ghostty {
return true
}
private static func openUntrustedURL(_ value: String) -> Bool {
let target = UntrustedURL(value)
switch target.decision {
case .allow(let url):
_ = NSWorkspace.shared.open(url)
case .confirm(let url):
UntrustedURLAlert.presentConfirmation(
for: url,
displayString: target.displayString
)
case .deny(let reason):
UntrustedURLAlert.presentBlock(
reason: reason,
displayString: target.displayString
)
}
// Always report OSC 8 actions as handled. Returning false would
// cause the core to retry with the unrestricted fallback opener.
return true
}
private static func undo(_ app: ghostty_app_t, target: ghostty_target_s) -> Bool {
let undoManager: UndoManager?
switch target.tag {
@@ -1394,6 +1383,41 @@ extension Ghostty {
}
}
private static func exportTerminalIO(
_ app: ghostty_app_t,
target: ghostty_target_s,
v: ghostty_action_export_terminal_io_s
) -> Bool {
guard target.tag == GHOSTTY_TARGET_SURFACE,
let surface = target.target.surface,
let surfaceView = self.surfaceView(from: surface),
let window = surfaceView.window,
let contents = v.contents
else { return false }
// The action data is borrowed for the duration of this callback,
// so copy it before presenting the asynchronous save panel.
let data = Data(bytes: contents, count: v.len)
DispatchQueue.main.async {
let panel = NSSavePanel()
panel.allowedContentTypes = [.plainText]
panel.canCreateDirectories = true
panel.nameFieldStringValue = "ghostty-terminal-io.txt"
panel.beginSheetModal(for: window) { response in
guard response == .OK, let url = panel.url else { return }
do {
try data.write(to: url, options: .atomic)
} catch {
Ghostty.logger.error(
"Failed to export terminal IO events: \(error, privacy: .public)"
)
}
}
}
return true
}
private static func showDesktopNotification(
_ app: ghostty_app_t,
target: ghostty_target_s,
@@ -2086,7 +2110,7 @@ extension Ghostty {
DispatchQueue.main.async {
if let searchState = surfaceView.searchState {
if let needle = startSearch.needle, !needle.isEmpty {
searchState.needle = needle
searchState.setNeedle(needle)
}
} else {
surfaceView.searchState = Ghostty.SurfaceView.SearchState(from: startSearch)
@@ -2284,7 +2308,5 @@ extension Ghostty {
break
}
}
#endif
}
}

View File

@@ -0,0 +1,114 @@
import Foundation
import GhosttyKit
extension Ghostty {
/// The type of a clipboard request.
enum ClipboardRequest {
/// A direct paste of clipboard contents.
case paste
/// An application is attempting to read from the clipboard using OSC 52.
case osc_52_read
/// An application is attempting to write to the clipboard using OSC 52.
case osc_52_write
/// The text to show in the clipboard confirmation prompt for this request.
func text() -> String {
switch self {
case .paste:
return """
Pasting this text to the terminal may be dangerous as it looks like some commands may be executed.
"""
case .osc_52_read:
return """
An application is attempting to read from the clipboard.
The current clipboard contents are shown below.
"""
case .osc_52_write:
return """
An application is attempting to write to the clipboard.
The content to write is shown below.
"""
}
}
static func from(request: ghostty_clipboard_request_e) -> ClipboardRequest? {
switch request {
case GHOSTTY_CLIPBOARD_REQUEST_PASTE:
return .paste
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ:
return .osc_52_read
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
return .osc_52_write
default:
return nil
}
}
}
/// A one-shot clipboard confirmation originating from libghostty.
///
/// This object owns the callback state until it is completed or cancelled.
/// Dropping an unresolved request schedules its cancellation so raw
/// libghostty state cannot leak when no UI is available to handle the
/// notification. Cancellation is deferred because notification delivery
/// occurs from inside the libghostty callback that created the request.
final class ClipboardConfirmationRequest {
private(set) weak var surface: SurfaceView?
let contents: String
let kind: ClipboardRequest
private var completion: ((SurfaceView, String?) -> Void)?
init(
surface: SurfaceView,
contents: String,
kind: ClipboardRequest,
completion: @escaping (SurfaceView, String?) -> Void
) {
self.surface = surface
self.contents = contents
self.kind = kind
self.completion = completion
}
deinit {
guard let surface, let completion else { return }
self.completion = nil
DispatchQueue.main.async {
completion(surface, nil)
}
}
/// Complete the request using the displayed clipboard contents.
func complete() {
finish(contents)
}
/// Cancel the request without using the displayed clipboard contents.
func cancel() {
finish(nil)
}
/// Cancel using the owning surface explicitly. SurfaceView uses this
/// for replacement and teardown because its weak reference is already
/// nil during the owner's deinitialization.
func cancel(from surface: SurfaceView) {
finish(nil, on: surface)
}
private func finish(
_ contents: String?,
on explicitSurface: SurfaceView? = nil
) {
guard let surface = explicitSurface ?? self.surface,
let completion else {
self.completion = nil
return
}
self.completion = nil
completion(surface, contents)
}
}
}

View File

@@ -26,6 +26,7 @@ extension Ghostty {
static let unsupportedActionKeys: [String] = [
"toggle_tab_overview",
"toggle_window_decorations",
"prompt_window_title",
"show_gtk_inspector",
]

View File

@@ -65,9 +65,6 @@ extension Ghostty {
}
// Load our configuration from files, CLI args, and then any referenced files.
// We only do this on macOS because other Apple platforms do not have the
// same filesystem concept.
#if os(macOS)
if let path {
ghostty_config_load_file(cfg, path)
} else {
@@ -81,7 +78,6 @@ extension Ghostty {
}
ghostty_config_load_recursive_files(cfg)
#endif
// TODO: we'd probably do some config loading here... for now we'd
// have to do this synchronously. When we support config updating we can do
@@ -108,7 +104,6 @@ extension Ghostty {
return cfg
}
#if os(macOS)
// MARK: - Keybindings
/// Return the key equivalent for the given action. The action is the name of the action
@@ -122,7 +117,6 @@ extension Ghostty {
let trigger = ghostty_config_trigger(cfg, action, UInt(action.lengthOfBytes(using: .utf8)))
return Ghostty.keyboardShortcut(for: trigger)
}
#endif
// MARK: - Configuration Values
@@ -274,10 +268,19 @@ extension Ghostty {
return v
}
var dragHandle: DragHandle {
let defaultValue = DragHandle.auto
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>?
let key = "drag-handle"
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue }
guard let ptr = v else { return defaultValue }
return DragHandle(rawValue: String(cString: ptr)) ?? defaultValue
}
/// Returns the fullscreen mode if fullscreen is enabled, or nil if disabled.
/// This parses the `fullscreen` enum config which supports both
/// native and non-native fullscreen modes.
#if canImport(AppKit)
var windowFullscreen: FullscreenMode? {
guard let config = self.config else { return nil }
var v: UnsafePointer<Int8>?
@@ -300,21 +303,9 @@ extension Ghostty {
nil
}
}
#else
var windowFullscreen: Bool {
guard let config = self.config else { return false }
var v: UnsafePointer<Int8>?
let key = "fullscreen"
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return false }
guard let ptr = v else { return false }
let str = String(cString: ptr)
return str != "false"
}
#endif
/// Returns the fullscreen mode for toggle actions (keybindings).
/// This is controlled by `macos-non-native-fullscreen` config.
#if canImport(AppKit)
var windowFullscreenMode: FullscreenMode {
let defaultValue: FullscreenMode = .native
guard let config = self.config else { return defaultValue }
@@ -336,7 +327,6 @@ extension Ghostty {
defaultValue
}
}
#endif
var windowTitleFontFamily: String? {
guard let config = self.config else { return nil }
@@ -410,7 +400,6 @@ extension Ghostty {
}
var macosCustomIcon: String {
#if os(macOS)
let defaultValue = NSString("~/.config/ghostty/Ghostty.icns").expandingTildeInPath
guard let config = self.config else { return defaultValue }
var v: UnsafePointer<Int8>?
@@ -419,9 +408,6 @@ extension Ghostty {
guard let ptr = v else { return defaultValue }
guard let path = NSString(utf8String: ptr) else { return defaultValue }
return path.expandingTildeInPath
#else
return ""
#endif
}
var macosIconFrame: MacOSIconFrame {
@@ -435,7 +421,7 @@ extension Ghostty {
return MacOSIconFrame(rawValue: str) ?? defaultValue
}
var macosIconGhostColor: OSColor? {
var macosIconGhostColor: NSColor? {
guard let config = self.config else { return nil }
var v: ghostty_config_color_s = .init()
let key = "macos-icon-ghost-color"
@@ -443,7 +429,7 @@ extension Ghostty {
return .init(ghostty: v)
}
var macosIconScreenColor: [OSColor]? {
var macosIconScreenColor: [NSColor]? {
guard let config = self.config else { return nil }
var v: ghostty_config_color_list_s = .init()
let key = "macos-icon-screen-color"
@@ -475,13 +461,7 @@ extension Ghostty {
var color: ghostty_config_color_s = .init()
let bg_key = "background"
if !ghostty_config_get(config, &color, bg_key, UInt(bg_key.lengthOfBytes(using: .utf8))) {
#if os(macOS)
return Color(NSColor.windowBackgroundColor)
#elseif os(iOS)
return Color(UIColor.systemBackground)
#else
#error("unsupported")
#endif
}
return .init(
@@ -533,7 +513,7 @@ extension Ghostty {
}
var splitDividerColor: Color {
let backgroundColor = OSColor(backgroundColor)
let backgroundColor = NSColor(backgroundColor)
let isLightBackground = backgroundColor.isLightColor
let newColor = isLightBackground ? backgroundColor.darken(by: 0.08) : backgroundColor.darken(by: 0.4)
@@ -552,7 +532,6 @@ extension Ghostty {
)
}
#if canImport(AppKit)
var quickTerminalPosition: QuickTerminalPosition {
guard let config = self.config else { return .top }
var v: UnsafePointer<Int8>?
@@ -606,7 +585,6 @@ extension Ghostty {
guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return QuickTerminalSize() }
return QuickTerminalSize(from: v)
}
#endif
var resizeOverlay: ResizeOverlay {
guard let config = self.config else { return .after_first }
@@ -933,4 +911,8 @@ extension Ghostty.Config {
static let `default` = MacOSTitlebarStyle.transparent
case native, transparent, tabs, hidden
}
enum DragHandle: String {
case always, auto, never
}
}

View File

@@ -518,7 +518,6 @@ extension Ghostty.Input.Momentum: AppEnum {
]
}
#if canImport(AppKit)
import AppKit
extension Ghostty.Input.Momentum {
@@ -535,7 +534,6 @@ extension Ghostty.Input.Momentum {
}
}
}
#endif
// MARK: Ghostty.Input.Mods

View File

@@ -6,7 +6,9 @@ extension Ghostty {
///
/// Wraps a `ghostty_inspector_t`
final class Inspector: Sendable {
private let inspector: ghostty_inspector_t
/// A inspector is sendable because it is just a reference type. Using the inspector in parameters
/// may be unsafe but the value itself is safe to send across threads.
nonisolated(unsafe) private let inspector: ghostty_inspector_t
/// Read the underlying C value for this inspector. This is unsafe because the value will be
/// freed when the Inspector class is deinitialized.

View File

@@ -10,7 +10,9 @@ extension Ghostty {
///
/// Wraps a `ghostty_surface_t`
final class Surface: Sendable {
private let surface: ghostty_surface_t
/// A surface is sendable because it is just a reference type. Using the surface in parameters
/// may be unsafe but the value itself is safe to send across threads.
nonisolated(unsafe) private let surface: ghostty_surface_t
/// Read the underlying C value for this surface. This is unsafe because the value will be
/// freed when the Surface class is deinitialized.

View File

@@ -4,13 +4,6 @@ import GhosttyKit
// MARK: C Extensions
/// A command is fully self-contained so it is Sendable.
extension ghostty_command_s: @unchecked @retroactive Sendable {}
/// A surface is sendable because it is just a reference type. Using the surface in parameters
/// may be unsafe but the value itself is safe to send across threads.
extension ghostty_surface_t: @unchecked @retroactive Sendable {}
extension Ghostty {
// The user notification category identifier
static let userNotificationCategory = "com.mitchellh.ghostty.userNotification"
@@ -216,7 +209,6 @@ extension Ghostty {
}
}
#if canImport(AppKit)
// MARK: SplitFocusDirection Extensions
extension Ghostty.SplitFocusDirection {
@@ -243,54 +235,8 @@ extension Ghostty.SplitFocusDirection {
}
}
}
#endif
extension Ghostty {
/// The type of a clipboard request
enum ClipboardRequest {
/// A direct paste of clipboard contents
case paste
/// An application is attempting to read from the clipboard using OSC 52
case osc_52_read
/// An application is attempting to write to the clipboard using OSC 52
case osc_52_write(OSPasteboard?)
/// The text to show in the clipboard confirmation prompt for a given request type
func text() -> String {
switch self {
case .paste:
return """
Pasting this text to the terminal may be dangerous as it looks like some commands may be executed.
"""
case .osc_52_read:
return """
An application is attempting to read from the clipboard.
The current clipboard contents are shown below.
"""
case .osc_52_write:
return """
An application is attempting to write to the clipboard.
The content to write is shown below.
"""
}
}
static func from(request: ghostty_clipboard_request_e) -> ClipboardRequest? {
switch request {
case GHOSTTY_CLIPBOARD_REQUEST_PASTE:
return .paste
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ:
return .osc_52_read
case GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE:
return .osc_52_write(nil)
default:
return nil
}
}
}
struct ClipboardContent {
let mime: String
let data: String
@@ -426,11 +372,6 @@ extension Ghostty.Notification {
/// Notification to show/hide the inspector
static let didControlInspector = Notification.Name("com.mitchellh.ghostty.didControlInspector")
static let confirmClipboard = Notification.Name("com.mitchellh.ghostty.confirmClipboard")
static let ConfirmClipboardStrKey = confirmClipboard.rawValue + ".str"
static let ConfirmClipboardStateKey = confirmClipboard.rawValue + ".state"
static let ConfirmClipboardRequestKey = confirmClipboard.rawValue + ".request"
/// Notification sent to the active split view to resize the split.
static let didResizeSplit = Notification.Name("com.mitchellh.ghostty.didResizeSplit")
static let ResizeSplitDirectionKey = didResizeSplit.rawValue + ".direction"

View File

@@ -3,7 +3,7 @@ import GhosttyKit
import SwiftUI
extension Ghostty {
class OSSurfaceView: OSView, ObservableObject {
class OSSurfaceView: NSView, ObservableObject {
typealias ID = UUID
/// Unique ID per surface
@@ -117,41 +117,55 @@ extension Ghostty {
extension Ghostty.OSSurfaceView {
@MainActor class SearchState: ObservableObject {
/// We should always change needle's text and its selection together
struct Needle: Equatable {
var text: String
var selection: Range<String.Index>?
static let empty = Needle(text: "", selection: nil)
}
/// The pasteboard used to persist the search needle.
///
/// The `.find` pasteboard lets us sync our needle across the system and other find bars.
private let pasteboard: OSPasteboard
private let pasteboard: NSPasteboard
@Published var needle = Needle.empty
@Published var needle: String = ""
@Published var selected: UInt?
@Published var total: UInt?
/// The range of the needle's text selection in the find bar.
@Published var needleSelection: Range<String.Index>?
init(
from startSearch: Ghostty.Action.StartSearch,
pasteboard: OSPasteboard = OSPasteboard.find
pasteboard: NSPasteboard? = nil
) {
self.pasteboard = pasteboard
self.pasteboard = pasteboard ?? .find
if let needle = startSearch.needle, !needle.isEmpty {
self.needle = needle
setNeedle(needle)
writePasteboardNeedle()
} else {
readPasteboardNeedle()
}
}
/// Replaces the search needle while keeping its selection valid.
func setNeedle(_ needle: String, selectAll: Bool = false) {
self.needle = .init(
text: needle,
selection: selectAll ? needle.startIndex..<needle.endIndex : nil
)
}
func readPasteboardNeedle() {
let pasteboardNeedle = pasteboard.string
if let pasteboardNeedle, pasteboardNeedle != needle {
needle = pasteboardNeedle
needleSelection = needle.startIndex..<needle.endIndex
if let pasteboardNeedle, pasteboardNeedle != needle.text {
setNeedle(pasteboardNeedle, selectAll: true)
}
}
func writePasteboardNeedle() {
pasteboard.string = needle
pasteboard.string = needle.text
}
}
@@ -159,9 +173,7 @@ extension Ghostty.OSSurfaceView {
guard let surface = self.surface else { return false }
let action = "navigate_search:next"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
#if canImport(AppKit)
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
#endif
return false
}
return true
@@ -171,9 +183,7 @@ extension Ghostty.OSSurfaceView {
guard let surface = self.surface else { return false }
let action = "navigate_search:previous"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
#if canImport(AppKit)
AppDelegate.logger.warning("action failed action=\(action, privacy: .public)")
#endif
return false
}
return true

View File

@@ -10,11 +10,20 @@ extension Ghostty {
private static let hoverHeightFactor: CGFloat = 0.2
@ObservedObject var surfaceView: SurfaceView
let dragHandle: Ghostty.Config.DragHandle
@State private var isHovering: Bool = false
@State private var isDragging: Bool = false
private var handleVisible: Bool {
switch dragHandle {
case .always:
return true
case .never:
return false
case .auto:
break
}
// Handle should always be visible in non-fullscreen
guard let window = surfaceView.window else { return true }
guard window.styleMask.contains(.fullScreen) else { return true }
@@ -25,6 +34,14 @@ extension Ghostty {
}
private var ellipsisVisible: Bool {
switch dragHandle {
case .always:
return true
case .never:
return false
case .auto:
break
}
// If the cursor isn't visible, never show the handle
guard surfaceView.cursorVisible else { return false }
// If we're hovering or actively dragging, always visible

View File

@@ -186,7 +186,7 @@ class SurfaceScrollView: NSView {
private func synchronizeAppearance() {
let scrollbarConfig = surfaceView.derivedConfig.scrollbar
scrollView.hasVerticalScroller = scrollbarConfig != .never
let hasLightBackground = OSColor(surfaceView.derivedConfig.backgroundColor).isLightColor
let hasLightBackground = NSColor(surfaceView.derivedConfig.backgroundColor).isLightColor
// Make sure the scrollers appearance matches the surface's background color.
scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua)
updateTrackingAreas()

View File

@@ -1,11 +1,6 @@
#if canImport(AppKit)
import AppKit
#elseif canImport(UIKit)
import UIKit
#endif
extension Ghostty.SurfaceView {
#if canImport(AppKit)
/// A snapshot image of the current surface view.
var asImage: NSImage? {
guard let bitmapRep = bitmapImageRepForCachingDisplay(in: bounds) else {
@@ -16,13 +11,4 @@ extension Ghostty.SurfaceView {
image.addRepresentation(bitmapRep)
return image
}
#elseif canImport(UIKit)
/// A snapshot image of the current surface view.
var asImage: UIImage? {
let renderer = UIGraphicsImageRenderer(bounds: bounds)
return renderer.image { _ in
drawHierarchy(in: bounds, afterScreenUpdates: true)
}
}
#endif
}

View File

@@ -1,6 +1,4 @@
#if canImport(AppKit)
import AppKit
#endif
import CoreTransferable
import UniformTypeIdentifiers
@@ -32,15 +30,8 @@ extension Ghostty.SurfaceView: Transferable {
@MainActor
static func find(uuid: UUID) -> Self? {
#if canImport(AppKit)
guard let del = NSApp.delegate as? Ghostty.Delegate else { return nil }
return del.ghosttySurface(id: uuid) as? Self
#elseif canImport(UIKit)
// We should be able to use UIApplication here.
return nil
#else
return nil
#endif
}
}
@@ -50,9 +41,7 @@ extension UTType {
static let ghosttySurfaceId = UTType(exportedAs: "com.mitchellh.ghosttySurfaceId")
}
#if canImport(AppKit)
extension NSPasteboard.PasteboardType {
/// Pasteboard type for dragging surface IDs.
static let ghosttySurfaceId = NSPasteboard.PasteboardType(UTType.ghosttySurfaceId.identifier)
}
#endif

View File

@@ -4,35 +4,6 @@ import GhosttyKit
import System
extension Ghostty {
/// Render a terminal for the active app in the environment.
struct Terminal: View {
@EnvironmentObject private var ghostty: Ghostty.App
var body: some View {
if let app = self.ghostty.app {
SurfaceForApp(app) { surfaceView in
SurfaceWrapper(surfaceView: surfaceView)
}
}
}
}
/// Yields a SurfaceView for a ghostty app that can then be used however you want.
struct SurfaceForApp<Content: View>: View {
let content: ((SurfaceView) -> Content)
@StateObject private var surfaceView: SurfaceView
init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) {
_surfaceView = StateObject(wrappedValue: SurfaceView(app))
self.content = content
}
var body: some View {
content(surfaceView)
}
}
struct SurfaceWrapper: View {
// The surface to create a view for. This must be created upstream. As long as this
// remains the same, the surface that is being rendered remains the same.
@@ -48,10 +19,8 @@ extension Ghostty {
// Maintain whether our window has focus (is key) or not
@State private var windowFocus: Bool = true
#if canImport(AppKit)
// Observe SecureInput to detect when its enabled
@ObservedObject private var secureInput = SecureInput.shared
#endif
@EnvironmentObject private var ghostty: Ghostty.App
@Environment(\.ghosttyLastFocusedSurface) private var lastFocusedSurface
@@ -68,17 +37,14 @@ extension Ghostty {
// is up to date. See TerminalSurfaceView for why we don't use the NSView
// resize callback.
GeometryReader { geo in
#if canImport(AppKit)
let pubBecomeKey = center.publisher(for: NSWindow.didBecomeKeyNotification)
let pubResign = center.publisher(for: NSWindow.didResignKeyNotification)
#endif
SurfaceRepresentable(view: surfaceView, size: geo.size)
.focused($surfaceFocus)
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
.focusedValue(\.ghosttySurfaceView, surfaceView)
.focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize)
#if canImport(AppKit)
.onReceive(pubBecomeKey) { notification in
guard let window = notification.object as? NSWindow else { return }
guard let surfaceWindow = surfaceView.window else { return }
@@ -91,7 +57,6 @@ extension Ghostty {
windowFocus = false
}
}
#endif
// If our geo size changed then we show the resize overlay as configured.
if let surfaceSize = surfaceView.surfaceSize {
@@ -118,7 +83,6 @@ extension Ghostty {
.transition(.opacity)
}
#if canImport(AppKit)
// Readonly indicator badge
if surfaceView.readonly {
ReadonlyBadge {
@@ -132,7 +96,6 @@ extension Ghostty {
keySequence: surfaceView.keySequence
)
.zIndex(1)
#endif
VStack(spacing: 0) {
// If we have a URL from hovering a link, we show that.
@@ -148,7 +111,6 @@ extension Ghostty {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
#if canImport(AppKit)
// If we have secure input enabled and we're the focused surface and window
// then we want to show the secure input overlay.
if ghostty.config.secureInputIndication &&
@@ -157,7 +119,6 @@ extension Ghostty {
windowFocus {
SecureInputOverlay()
}
#endif
// Search overlay
if let searchState = surfaceView.searchState {
@@ -200,13 +161,12 @@ extension Ghostty {
}
}
#if canImport(AppKit)
// Grab handle for dragging the window. We want this to appear at the very
// top Z-index os it isn't faded by the unfocused overlay.
//
// This is disabled except on macOS because it uses AppKit drag/drop APIs.
SurfaceGrabHandle(surfaceView: surfaceView)
#endif
SurfaceGrabHandle(
surfaceView: surfaceView,
dragHandle: ghostty.config.dragHandle,
)
}
}
}
@@ -379,8 +339,8 @@ extension Ghostty {
HStack(spacing: 4) {
BackportSelectionTextField(
"Search",
text: $searchState.needle,
selection: $searchState.needleSelection
text: $searchState.needle.text,
selection: $searchState.needle.selection
)
.textFieldStyle(.plain)
.frame(width: 180)
@@ -405,12 +365,12 @@ extension Ghostty {
.padding(.trailing, 8)
}
}
.onChange(of: searchState.needle) { _ in
.onChange(of: searchState.needle.text) { _ in
searchState.writePasteboardNeedle()
}
.onReceive(
NotificationCenter.default.publisher(
for: OSApplication.didBecomeActiveNotification
for: NSApplication.didBecomeActiveNotification
)
) { _ in
// When the app becomes active, we want to check for external changes
@@ -420,15 +380,13 @@ extension Ghostty {
.onSubmit {
_ = surfaceView.navigateSearchToNext()
}
#if canImport(AppKit)
.onExitCommand {
if searchState.needle.isEmpty {
if searchState.needle.text.isEmpty {
onClose()
} else {
Ghostty.moveFocus(to: surfaceView)
}
}
#endif
.backport.onKeyPress(.return) { modifiers in
if modifiers.contains(.shift) {
_ = surfaceView.navigateSearchToPrevious()
@@ -503,7 +461,7 @@ extension Ghostty {
}
private var clipShape: some Shape {
if #available(iOS 26.0, macOS 26.0, *) {
if #available(macOS 26.0, *) {
return ConcentricRectangle(corners: .concentric(minimum: 8), isUniform: true)
} else {
return RoundedRectangle(cornerRadius: 8)
@@ -583,7 +541,7 @@ extension Ghostty {
/// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn
/// and interacted with. The word "surface" is used because a surface may represent a window, a tab,
/// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it.
struct SurfaceRepresentable: OSViewRepresentable {
struct SurfaceRepresentable: NSViewRepresentable {
/// The view to render for the terminal surface.
let view: SurfaceView
@@ -597,13 +555,11 @@ extension Ghostty {
/// The best approach is to wrap this view in a GeometryReader and pass in the geo.size.
let size: CGSize
#if canImport(AppKit)
func makeOSView(context: Context) -> SurfaceScrollView {
// On macOS, wrap the surface view in a scroll view
func makeNSView(context: Context) -> SurfaceScrollView {
return SurfaceScrollView(contentSize: size, surfaceView: view)
}
func updateOSView(_ scrollView: SurfaceScrollView, context: Context) {
func updateNSView(_ scrollView: SurfaceScrollView, context: Context) {
// SwiftUI may defer frame updates under system load (e.g., memory
// pressure, heavy I/O) or when external window managers trigger rapid
// layout changes. When that happens, the scroll view's bounds can
@@ -612,16 +568,6 @@ extension Ghostty {
guard scrollView.bounds.size != size else { return }
scrollView.needsLayout = true
}
#else
func makeOSView(context: Context) -> SurfaceView {
// On iOS, return the surface view directly
return view
}
func updateOSView(_ view: SurfaceView, context: Context) {
view.sizeDidChange(size)
}
#endif
}
/// The configuration for a surface. For any configuration not set, defaults will be chosen from
@@ -682,25 +628,11 @@ extension Ghostty {
func withCValue<T>(view: SurfaceView, _ body: (inout ghostty_surface_config_s) throws -> T) rethrows -> T {
var config = ghostty_surface_config_new()
config.userdata = Unmanaged.passUnretained(view).toOpaque()
#if os(macOS)
config.platform_tag = GHOSTTY_PLATFORM_MACOS
config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s(
nsview: Unmanaged.passUnretained(view).toOpaque()
))
config.scale_factor = NSScreen.main!.backingScaleFactor
#elseif os(iOS)
config.platform_tag = GHOSTTY_PLATFORM_IOS
config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s(
uiview: Unmanaged.passUnretained(view).toOpaque()
))
// Note that UIScreen.main is deprecated and we're supposed to get the
// screen through the view hierarchy instead. This means that we should
// probably set this to some default, then modify the scale factor through
// libghostty APIs when a UIView is attached to a window/scene. TODO.
config.scale_factor = UIScreen.main.scale
#else
#error("unsupported target")
#endif
// Zero is our default value that means to inherit the font size.
config.font_size = fontSize ?? 0
@@ -751,7 +683,6 @@ extension Ghostty {
}
}
#if canImport(AppKit)
/// Floating indicator that shows active key tables and pending key sequences.
/// Displayed as a compact draggable pill that can be positioned at the top or bottom.
struct KeyStateIndicator: View {
@@ -969,7 +900,6 @@ extension Ghostty {
}
}
}
#endif
/// Visual overlay that shows a border around the edges when the bell rings with border feature enabled.
struct BellBorderOverlay: View {
@@ -1133,7 +1063,6 @@ extension Ghostty {
}
}
#if canImport(AppKit)
/// When changing the split state, or going full screen (native or non), the terminal view
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal
@@ -1187,7 +1116,6 @@ extension Ghostty {
queue.async(execute: work)
}
}
#endif
}
// MARK: Surface Environment Keys
@@ -1246,12 +1174,12 @@ extension FocusedValues {
typealias Value = String
}
var ghosttySurfaceCellSize: OSSize? {
var ghosttySurfaceCellSize: CGSize? {
get { self[FocusedGhosttySurfaceCellSize.self] }
set { self[FocusedGhosttySurfaceCellSize.self] = newValue }
}
struct FocusedGhosttySurfaceCellSize: FocusedValueKey {
typealias Value = OSSize
typealias Value = CGSize
}
}

View File

@@ -48,6 +48,7 @@ extension Ghostty {
// needle is less than 3 chars, we debounce it for a few hundred ms to
// avoid kicking off expensive searches.
searchNeedleCancellable = searchState.$needle
.map(\.text)
.removeDuplicates()
.map { needle -> AnyPublisher<String, Never> in
if needle.isEmpty || needle.count >= 3 {
@@ -108,6 +109,13 @@ extension Ghostty {
/// True when the bell is active. This is set inactive on focus or event.
@Published private(set) var bell: Bool = false
/// A clipboard confirmation waiting to be handled by its controller.
@Published var pendingClipboardConfirmation: ClipboardConfirmationRequest? {
didSet {
pendingClipboardConfirmationDidChange(from: oldValue)
}
}
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
var initialSize: NSSize?
@@ -190,6 +198,8 @@ extension Ghostty {
// This is set to non-null during keyDown to accumulate insertText contents
private var keyTextAccumulator: [String]?
/// Temporary lead surrogate that's waiting for the trail
private var leadSurrogate: LeadSurrogate?
// True when we've consumed a left mouse-down only to move focus and
// should suppress the matching mouse-up from being reported.
@@ -294,13 +304,21 @@ extension Ghostty {
accessibilitySelectionCancellable = NotificationCenter.default
// The publisher retains its object, so filtering with a weak capture
// avoids a cycle between self and the stored cancellable.
// But we also need to be careful to do the map below (see
// comment below)
.publisher(for: .ghosttySelectionDidChange)
.filter { [weak self] notification in
guard let self else { return false }
return notification.object as AnyObject? === self
}
.map { _ in
// Debounce retains its latest upstream value. In this
// case its a Notification, which retains its object,
// which is a surface. So this creates a retain cycle.
// This discards the notification before debounce.
}
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
.sink { [weak self] in
guard let self else { return }
NSAccessibility.post(element: self, notification: .selectedTextChanged)
}
@@ -388,6 +406,11 @@ extension Ghostty {
}
deinit {
// Resolve clipboard callback state while surfaceModel is still
// alive. The request's weak SurfaceView reference is already nil
// during deinit, so didSet passes this instance explicitly.
pendingClipboardConfirmation = nil
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
@@ -1764,26 +1787,25 @@ extension Ghostty {
// Note the callback may be executed on a background thread as documented
// so we need @MainActor since we're reading/writing view state.
UNUserNotificationCenter.current().add(request) { @MainActor error in
if let error = error {
AppDelegate.logger.error("Error scheduling user notification: \(error, privacy: .public)")
return
}
Task { @MainActor in
do {
try await UNUserNotificationCenter.current().add(request)
// We need to keep track of this notification so we can remove it
// under certain circumstances
self.notificationIdentifiers.insert(uuid)
// We need to keep track of this notification so we can remove it
// under certain circumstances
notificationIdentifiers.insert(uuid)
// If we're focused then we schedule to remove the notification
// after a few seconds. If we gain focus we automatically remove it
// in focusDidChange.
if self.focused {
Task { @MainActor [weak self] in
// If we're focused then we schedule to remove the notification
// after a few seconds. If we gain focus we automatically remove it
// in focusDidChange.
if self.focused {
try await Task.sleep(for: .seconds(3))
self?.notificationIdentifiers.remove(uuid)
notificationIdentifiers.remove(uuid)
UNUserNotificationCenter.current()
.removeDeliveredNotifications(withIdentifiers: [uuid])
}
} catch {
AppDelegate.logger.error("Error scheduling user notification: \(error, privacy: .public)")
}
}
}
@@ -1874,6 +1896,18 @@ extension Ghostty {
}
}
// MARK: Clipboard Confirmation
extension Ghostty.SurfaceView {
/// Cancel the request that a new published value replaces or clears.
private func pendingClipboardConfirmationDidChange(
from previous: Ghostty.ClipboardConfirmationRequest?
) {
guard previous !== pendingClipboardConfirmation else { return }
previous?.cancel(from: self)
}
}
// MARK: - NSTextInputClient
extension Ghostty.SurfaceView: NSTextInputClient {
@@ -2008,7 +2042,7 @@ extension Ghostty.SurfaceView: NSTextInputClient {
x += cellSize.width * Double(range.location + range.length)
}
// Ghostty coordinates are in top-left (0, 0) so we have to convert to
// bottom-left since that is what UIKit expects
// bottom-left since that is what AppKit expects
// when there's is no characters selected,
// width should be 0 so that dictation indicator
// can start in the right place
@@ -2036,8 +2070,21 @@ extension Ghostty.SurfaceView: NSTextInputClient {
switch string {
case let v as NSAttributedString:
chars = v.string
case let v as String:
chars = v
case let v as NSString:
if let leadSurrogate = LeadSurrogate(v) {
self.leadSurrogate = leadSurrogate
chars = ""
} else if let trail = TrailSurrogate(v) {
// We ignore trail surrogate without a lead like Terminal.app.
chars = leadSurrogate?.encode(trail: trail) ?? ""
leadSurrogate = nil
} else {
chars = v as String
// Clear whenever other text got inserted.
// Ideally we should encode any adjacent lead and trail surrogate into one,
// but getting the cursor position and reading could be rather expensive to do.
leadSurrogate = nil
}
default:
return
}
@@ -2370,6 +2417,7 @@ extension Ghostty.SurfaceView {
/// We use this to cache our surface content. This probably should be extracted some day
/// to a more generic helper.
class CachedValue<T> {
private let lock = NSLock()
private var value: T?
private let fetch: () -> T
private let duration: Duration
@@ -2381,10 +2429,15 @@ class CachedValue<T> {
}
deinit {
lock.lock()
expiryTask?.cancel()
lock.unlock()
}
func get() -> T {
lock.lock()
defer { lock.unlock() }
if let value {
return value
}
@@ -2399,8 +2452,7 @@ class CachedValue<T> {
expiryTask = Task { [weak self] in
do {
try await Task.sleep(until: expires)
self?.value = nil
self?.expiryTask = nil
self?.expire()
} catch {
// Task was cancelled, do nothing
}
@@ -2408,4 +2460,50 @@ class CachedValue<T> {
return result
}
private func expire() {
lock.lock()
defer { lock.unlock() }
value = nil
expiryTask = nil
}
}
/// Check if a UTF16 text is a single lead surrogate character
struct LeadSurrogate {
let char: UTF16Char
init?(_ text: NSString) {
guard text.length == 1 else {
return nil
}
let char = text.character(at: 0)
if UTF16.isLeadSurrogate(char) {
self.char = char
} else {
return nil
}
}
func encode(trail: TrailSurrogate) -> String {
String(decoding: [char, trail.char], as: UTF16.self)
}
}
/// Check if a UTF16 text is a single trail surrogate character
struct TrailSurrogate {
let char: UTF16Char
init?(_ text: NSString) {
guard text.length == 1 else {
return nil
}
let char = text.character(at: 0)
if UTF16.isTrailSurrogate(char) {
self.char = char
} else {
return nil
}
}
}

View File

@@ -1,84 +0,0 @@
import SwiftUI
import GhosttyKit
extension Ghostty {
/// The UIView implementation for a terminal surface.
class SurfaceView: OSSurfaceView {
// The current title of the surface as defined by the pty. This can be
// changed with escape codes.
@Published private(set) var title: String = "👻"
/// True when the bell is active. This is set inactive on focus or event.
@Published var bell: Bool = false
private(set) var _surface: ghostty_surface_t?
override var surface: ghostty_surface_t? {
_surface
}
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
super.init(id: uuid, frame: CGRect(x: 0, y: 0, width: 800, height: 600))
// Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration()
let surface = surface_cfg.withCValue(view: self) { surface_cfg_c in
ghostty_surface_new(app, &surface_cfg_c)
}
guard let surface = surface else {
// TODO
return
}
self._surface = surface
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
guard let surface = self.surface else { return }
ghostty_surface_free(surface)
}
override func focusDidChange(_ focused: Bool) {
guard let surface = self.surface else { return }
ghostty_surface_set_focus(surface, focused)
// On macOS 13+ we can store our continuous clock...
if focused {
focusInstant = ContinuousClock.now
}
}
override func sizeDidChange(_ size: CGSize) {
guard let surface = self.surface else { return }
// Ghostty wants to know the actual framebuffer size... It is very important
// here that we use "size" and NOT the view frame. If we're in the middle of
// an animation (i.e. a fullscreen animation), the frame will not yet be updated.
// The size represents our final size we're going for.
let scale = self.contentScaleFactor
ghostty_surface_set_content_scale(surface, scale, scale)
ghostty_surface_set_size(
surface,
UInt32(size.width * scale),
UInt32(size.height * scale)
)
}
// MARK: UIView
override class var layerClass: AnyClass {
return CAMetalLayer.self
}
override func didMoveToWindow() {
sizeDidChange(frame.size)
}
}
}

View File

@@ -26,32 +26,23 @@ enum BackportKeyPressResult {
extension Backport where Content: View {
func pointerVisibility(_ v: BackportVisibility) -> some View {
#if canImport(AppKit)
if #available(macOS 15, *) {
return content.pointerVisibility(v.official)
} else {
return content
}
#else
return content
#endif
}
func pointerStyle(_ style: BackportPointerStyle?) -> some View {
#if canImport(AppKit)
if #available(macOS 15, *) {
return content.pointerStyle(style?.official)
} else {
return content
}
#else
return content
#endif
}
/// Backported onKeyPress that works on macOS 14+ and is a no-op on macOS 13.
func onKeyPress(_ key: KeyEquivalent, action: @escaping (EventModifiers) -> BackportKeyPressResult) -> some View {
#if canImport(AppKit)
if #available(macOS 14, *) {
return content.onKeyPress(key, phases: .down, action: { keyPress in
switch action(keyPress.modifiers) {
@@ -62,9 +53,6 @@ extension Backport where Content: View {
} else {
return content
}
#else
return content
#endif
}
}
@@ -97,7 +85,6 @@ enum BackportPointerStyle {
case resizeUpDown
case resizeLeftRight
#if canImport(AppKit)
@available(macOS 15, *)
var official: PointerStyle {
switch self {
@@ -115,13 +102,11 @@ enum BackportPointerStyle {
case .resizeLeftRight: return .columnResize
}
}
#endif
}
enum BackportNSGlassStyle {
case regular, clear
#if canImport(AppKit)
@available(macOS 26, *)
var official: NSGlassEffectView.Style {
switch self {
@@ -129,11 +114,21 @@ enum BackportNSGlassStyle {
case .clear: return .clear
}
}
#endif
}
/// Backported `TextField` that supports text selection on macOS 15/iOS 18 and up. The `selection`
/// has no effect on versions below macOS 15/iOS 18.
/// Backported `TextField` that supports text selection on macOS 26 and up. The `selection`
/// has no effect on versions below macOS 26.
///
/// Although the API is available from macOS 15, we force it to be 26. Because on macOS 15,
/// SwiftUI will crash when deleting texts, even for this simple example.
///
/// struct ContentView: View {
/// @State private var text = ""
/// @State private var selection: TextSelection?
/// var body: some View {
/// TextField("Search", text: $text, selection: $selection)
/// }
/// }
struct BackportSelectionTextField: View {
private let titleKey: LocalizedStringKey
@Binding private var text: String
@@ -150,7 +145,7 @@ struct BackportSelectionTextField: View {
}
var body: some View {
if #available(iOS 18.0, macOS 15, *) {
if #available(macOS 26, *) {
TextField(
titleKey,
text: _text,

View File

@@ -1,58 +0,0 @@
// This file is a helper to bridge some types that are effectively identical
// between AppKit and UIKit.
import SwiftUI
#if canImport(AppKit)
import AppKit
typealias OSView = NSView
typealias OSColor = NSColor
typealias OSSize = NSSize
typealias OSPasteboard = NSPasteboard
typealias OSApplication = NSApplication
protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType {
associatedtype OSViewType: NSView
func makeOSView(context: Context) -> OSViewType
func updateOSView(_ osView: OSViewType, context: Context)
}
extension OSViewRepresentable {
func makeNSView(context: Context) -> OSViewType {
makeOSView(context: context)
}
func updateNSView(_ nsView: OSViewType, context: Context) {
updateOSView(nsView, context: context)
}
}
#elseif canImport(UIKit)
import UIKit
typealias OSView = UIView
typealias OSColor = UIColor
typealias OSSize = CGSize
typealias OSPasteboard = UIPasteboard
typealias OSApplication = UIApplication
protocol OSViewRepresentable: UIViewRepresentable {
associatedtype OSViewType: UIView
func makeOSView(context: Context) -> OSViewType
func updateOSView(_ osView: OSViewType, context: Context)
}
extension OSViewRepresentable {
func makeUIView(context: Context) -> OSViewType {
makeOSView(context: context)
}
func updateUIView(_ uiView: OSViewType, context: Context) {
updateOSView(uiView, context: context)
}
}
#endif

View File

@@ -1,4 +1,4 @@
import Cocoa
import AppKit
extension NSAppearance {
/// Returns true if the appearance is some kind of dark.
@@ -17,7 +17,7 @@ extension NSAppearance {
self.init(named: .aqua)
case "auto":
let color = OSColor(config.backgroundColor)
let color = NSColor(config.backgroundColor)
if color.isLightColor {
self.init(named: .aqua)
} else {

View File

@@ -7,5 +7,10 @@ extension NSMenuItem {
if #available(macOS 26, *) {
image = NSImage(systemSymbolName: symbol, accessibilityDescription: title)
}
#if compiler(>=6.4)
if #available(macOS 27.0, *) {
preferredImageVisibility = .automatic
}
#endif
}
}

View File

@@ -58,6 +58,21 @@ extension NSWindow {
}
}
extension NSWindowController {
/// Wraps `showWindow` with an Objective-C exception catcher because selecting
/// a tab can raise an AppKit fullscreen window-stack exception.
@discardableResult
func showWindowSafely(_ sender: Any?) -> Bool {
var error: NSError?
let success = GhosttyShowWindowSafely(self, sender, &error)
if let error {
Ghostty.logger.error("showWindow failed: \(error.localizedDescription, privacy: .public)")
}
return success
}
}
/// Native tabbing private API usage. :(
extension NSWindow {
var titlebarView: NSView? {

View File

@@ -1,9 +1,10 @@
import Foundation
import AppKit
#if !DOCK_TILE_PLUGIN
import GhosttyKit
#endif
extension OSColor {
extension NSColor {
var isLightColor: Bool {
return self.luminance > 0.5
}
@@ -15,42 +16,20 @@ extension OSColor {
var a: CGFloat = 0
// getRed:green:blue:alpha requires sRGB space
#if canImport(AppKit)
guard let rgb = self.usingColorSpace(.sRGB) else { return 0 }
#else
let rgb = self
#endif
rgb.getRed(&r, green: &g, blue: &b, alpha: &a)
return (0.299 * r) + (0.587 * g) + (0.114 * b)
}
var hexString: String? {
#if canImport(AppKit)
guard let rgb = usingColorSpace(.deviceRGB) else { return nil }
let red = Int(rgb.redComponent * 255)
let green = Int(rgb.greenComponent * 255)
let blue = Int(rgb.blueComponent * 255)
return String(format: "#%02X%02X%02X", red, green, blue)
#elseif canImport(UIKit)
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return nil
}
// Convert to 0255 range
let r = Int(red * 255)
let g = Int(green * 255)
let b = Int(blue * 255)
// Format to hexadecimal
return String(format: "#%02X%02X%02X", r, g, b)
#endif
}
/// Create an OSColor from a hex string.
/// Create an NSColor from a hex string.
convenience init?(hex: String) {
var cleanedHex = hex.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -81,10 +60,10 @@ extension OSColor {
self.init(red: red, green: green, blue: blue, alpha: alpha)
}
func darken(by amount: CGFloat) -> OSColor {
func darken(by amount: CGFloat) -> NSColor {
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
self.getHue(&h, saturation: &s, brightness: &b, alpha: &a)
return OSColor(
return NSColor(
hue: h,
saturation: s,
brightness: min(b * (1 - amount), 1),
@@ -95,7 +74,7 @@ extension OSColor {
// MARK: Ghostty Types
#if !DOCK_TILE_PLUGIN
extension OSColor {
extension NSColor {
/// Create a color from a Ghostty color.
convenience init(ghostty: ghostty_config_color_s) {
let red = Double(ghostty.r) / 255

View File

@@ -1,9 +1,8 @@
#if canImport(AppKit)
import AppKit
/// Normalizes the interface between NSPasteboard and UIPasteboard for working with pasteboard
/// strings.
extension OSPasteboard {
@MainActor static let find = OSPasteboard(name: .find)
/// Convenience interface for working with pasteboard strings.
extension NSPasteboard {
@MainActor static let find = NSPasteboard(name: .find)
/// The pasteboard's current string value.
@MainActor var string: String? {
@@ -18,11 +17,3 @@ extension OSPasteboard {
}
}
}
#elseif canImport(UIKit)
extension OSPasteboard {
static let find = OSPasteboard.withUniqueName()
}
#endif

View File

@@ -7,7 +7,6 @@ extension String {
return self.prefix(maxLength) + trailing
}
#if canImport(AppKit)
func temporaryFile(_ filename: String = "temp") -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(filename)
@@ -25,7 +24,6 @@ extension String {
}
return self
}
#endif
/// Converts a four-character ASCII string to its `FourCharCode` (`UInt32`) value.
var fourCharCode: UInt32 {

View File

@@ -11,3 +11,10 @@ FOUNDATION_EXPORT BOOL GhosttyAddTabbedWindowSafely(
NSInteger ordered,
NSError * _Nullable * _Nullable error
);
/// NSWindowController.showWindow wrapper
FOUNDATION_EXPORT BOOL GhosttyShowWindowSafely(
id _Nonnull controller,
id _Nullable sender,
NSError * _Nullable * _Nullable error
);

View File

@@ -2,6 +2,16 @@
#import <AppKit/AppKit.h>
static NSError *GhosttyErrorFromException(NSException *exception, NSInteger code) {
NSString *reason = exception.reason ?: @"Unknown Objective-C exception";
return [NSError errorWithDomain:@"Ghostty.ObjCException"
code:code
userInfo:@{
NSLocalizedDescriptionKey: reason,
@"exception_name": exception.name,
}];
}
BOOL GhosttyAddTabbedWindowSafely(
id parent,
id child,
@@ -18,13 +28,27 @@ BOOL GhosttyAddTabbedWindowSafely(
return YES;
} @catch (NSException *exception) {
if (error != NULL) {
NSString *reason = exception.reason ?: @"Unknown Objective-C exception";
*error = [NSError errorWithDomain:@"Ghostty.ObjCException"
code:1
userInfo:@{
NSLocalizedDescriptionKey: reason,
@"exception_name": exception.name,
}];
*error = GhosttyErrorFromException(exception, 1);
}
return NO;
}
}
BOOL GhosttyShowWindowSafely(
id controller,
id _Nullable sender,
NSError * _Nullable * _Nullable error
) {
// Selecting a newly added tab can throw from NSWindowStackController when
// the tab group contains windows in inconsistent native fullscreen states.
// Catch the exception here so the AppKit assertion doesn't abort the app.
@try {
[((NSWindowController *)controller) showWindow:sender];
return YES;
} @catch (NSException *exception) {
if (error != NULL) {
*error = GhosttyErrorFromException(exception, 2);
}
return NO;

View File

@@ -6,6 +6,11 @@ struct URLHoverBanner: View {
let padding: CGFloat = 5
let cornerRadius: CGFloat = 9
let url: String
private var displayedURL: String {
UntrustedURL(url).displayString
}
var body: some View {
ZStack {
HStack {
@@ -13,7 +18,7 @@ struct URLHoverBanner: View {
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
Text(verbatim: displayedURL)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
UnevenRoundedRectangle(cornerRadii: .init(topLeading: cornerRadius))
@@ -29,7 +34,7 @@ struct URLHoverBanner: View {
VStack(alignment: .leading) {
Spacer()
Text(verbatim: url)
Text(verbatim: displayedURL)
.padding(.init(top: padding, leading: padding, bottom: padding, trailing: padding))
.background(
UnevenRoundedRectangle(cornerRadii: .init(topTrailing: cornerRadius))

View File

@@ -0,0 +1,262 @@
import Foundation
import UniformTypeIdentifiers
/// A URL supplied by a source that should not be allowed to dispatch directly
/// to the operating system, such as terminal output.
struct UntrustedURL: Equatable {
enum DenialReason: Equatable {
case malformedURL
case unsafeCharacters
case invalidWebURL
case inaccessibleFile
case unsafeFile
var message: String {
switch self {
case .malformedURL:
"The target is not an absolute URL with a scheme."
case .unsafeCharacters:
"The target contains invisible or line-breaking characters."
case .invalidWebURL:
"The web target does not contain a valid host."
case .inaccessibleFile:
"The local target does not exist or is not a regular file or directory."
case .unsafeFile:
"Opening this local target could execute code."
}
}
}
enum Decision: Equatable {
/// Open schemes with non-executing, well-understood behavior directly.
case allow(URL)
/// Ask before dispatching a custom scheme to its registered handler.
case confirm(URL)
/// Never dispatch malformed targets or executable local files.
case deny(DenialReason)
}
let string: String
init(_ string: String) {
self.string = string
}
var decision: Decision {
guard !string.isEmpty else { return .deny(.malformedURL) }
// Foundation accepts many Unicode control and formatting characters in
// a URL. UI frameworks can render those same characters as line breaks,
// zero-width text, or bidirectional overrides, so reject them before
// parsing changes their representation.
guard !string.unicodeScalars.contains(where: Self.isUnsafeCharacter) else {
return .deny(.unsafeCharacters)
}
// URL(string:) also accepts relative references. An untrusted target
// must include an explicit scheme so it cannot be reinterpreted as a
// local path by a later layer.
guard
let url = URL(string: string),
let scheme = url.scheme?.lowercased(),
!scheme.isEmpty
else {
return .deny(.malformedURL)
}
switch scheme {
case "http", "https":
// Reject values such as "https:relative". They have a scheme, but
// no authority, and different consumers may resolve them against a
// base URL differently.
guard let host = url.host, !host.isEmpty else {
return .deny(.invalidWebURL)
}
return .allow(url)
case "mailto":
// URLComponents places the address portion of a mailto URL in the
// path. Require one so a bare "mailto:" cannot dispatch an empty
// request to the user's mail application.
guard
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
!components.path.isEmpty
else {
return .deny(.malformedURL)
}
return .allow(url)
case "file":
return fileDecision(for: url)
default:
// A custom scheme can invoke any application registered with
// Launch Services. The caller must show the target and handler
// before allowing that dispatch.
return .confirm(url)
}
}
/// A single-line representation of the effective target. Paths are
/// standardized before display so traversal and repeated separators cannot
/// cause the visible and opened targets to differ.
var displayString: String {
let normalized: String
if let url = URL(string: string), url.scheme != nil {
// File URLs are standardized exactly as they are before opening,
// including symlink resolution. Keep non-file URLs byte-for-byte
// equivalent because repeated separators can be meaningful to a
// web or custom-scheme handler.
normalized = url.isFileURL
? url.standardizedFileURL.resolvingSymlinksInPath().path
: string
} else {
// Scheme-less values are never allowed to open, but they still
// appear in the blocked-target UI. Standardizing them prevents
// slash padding and dot traversal from hiding the effective path.
normalized = URL(filePath: string).standardizedFileURL.path
}
// Escaping happens after normalization so any unsafe scalar that
// remains is visible as text and cannot create a second display line.
var result = String()
result.reserveCapacity(normalized.count)
for scalar in normalized.unicodeScalars {
if Self.isUnsafeCharacter(scalar) {
result += "\\u{\(String(scalar.value, radix: 16, uppercase: true))}"
} else {
result.unicodeScalars.append(scalar)
}
}
return result
}
}
private extension UntrustedURL {
func fileDecision(for url: URL) -> Decision {
// Only local file URLs are meaningful here. Queries and fragments do
// not identify part of a filesystem object and may be interpreted
// inconsistently by Launch Services handlers.
guard url.isFileURL, url.query == nil, url.fragment == nil else {
return .deny(.malformedURL)
}
// An empty host and localhost both refer to this machine. Do not allow
// file URLs that name a remote host and could trigger network access.
if let host = url.host,
!host.isEmpty,
host.caseInsensitiveCompare("localhost") != .orderedSame {
return .deny(.malformedURL)
}
// Classify the effective object, not the spelling supplied by terminal
// output. This collapses dot traversal and prevents a harmless-looking
// symlink name from hiding an executable target.
let canonicalURL = url.standardizedFileURL.resolvingSymlinksInPath()
let resourceValues: URLResourceValues
do {
// Reading all relevant resource keys together also proves that the
// canonical target exists and is accessible.
resourceValues = try canonicalURL.resourceValues(forKeys: [
.contentTypeKey,
.isDirectoryKey,
.isExecutableKey,
.isRegularFileKey,
])
} catch {
return .deny(.inaccessibleFile)
}
// Exclude devices, sockets, and other special filesystem objects. A
// directory is safe to reveal in Finder unless its extension or UTI
// identifies it as an application bundle.
guard resourceValues.isDirectory == true || resourceValues.isRegularFile == true else {
return .deny(.inaccessibleFile)
}
guard !Self.isUnsafeFile(canonicalURL, resourceValues: resourceValues) else {
return .deny(.unsafeFile)
}
return .allow(canonicalURL)
}
static func isUnsafeFile(
_ url: URL,
resourceValues: URLResourceValues
) -> Bool {
// Launch Services uses extensions when choosing a handler. Block known
// executable containers even when their POSIX executable bit is clear.
if unsafePathExtensions.contains(url.pathExtension.lowercased()) {
return true
}
// UTIs cover files whose extension is missing or intentionally
// misleading. Use broad system-declared types so subclasses such as
// shell scripts and application bundles are included automatically.
if let contentType = resourceValues.contentType,
unsafeContentTypes.contains(where: { contentType.conforms(to: $0) }) {
return true
}
// Finally, reject any regular file the filesystem marks executable,
// regardless of its name or detected content type.
return resourceValues.isDirectory != true && resourceValues.isExecutable == true
}
static func isUnsafeCharacter(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.value {
// C0/C1 controls include CR, LF, NEL, and other non-printing bytes.
case 0x00...0x1F, 0x7F...0x9F:
return true
// Directional marks and zero-width characters can reorder or conceal
// portions of the target without changing what the handler receives.
case 0x061C, 0x200B...0x200F, 0x202A...0x202E, 0x2066...0x2069:
return true
// Unicode line/paragraph separators create additional visual lines in
// SwiftUI and AppKit text even though OSC accepts their UTF-8 bytes.
case 0x2028...0x2029:
return true
// Word Joiner and BOM are invisible formatting characters that can be
// used as padding or to disguise otherwise identical-looking targets.
case 0x2060, 0xFEFF:
return true
default:
return false
}
}
// Keep the large policy tables after the behavior so the primary type and
// its decision flow remain easy to scan.
static let unsafePathExtensions: Set<String> = [
"action",
"app",
"applescript",
"class",
"command",
"desktop",
"inetloc",
"jar",
"mobileconfig",
"mpkg",
"pkg",
"scpt",
"terminal",
"tool",
"url",
"webloc",
"workflow",
]
static let unsafeContentTypes: [UTType] = [
.application,
.executable,
.script,
]
}

View File

@@ -0,0 +1,104 @@
import AppKit
/// Presents decisions for untrusted URLs at the AppKit boundary.
enum UntrustedURLAlert {
static func presentConfirmation(for url: URL, displayString: String) {
deferPresentation {
let workspace = NSWorkspace.shared
let handler = workspace.urlForApplication(toOpen: url)
.map { "\u{201c}\($0.deletingPathExtension().lastPathComponent)\u{201d}" }
?? "the default application"
let alert = NSAlert()
alert.alertStyle = .warning
alert.icon = NSImage(named: NSImage.cautionName)
alert.messageText = "Open Link from Terminal Output?"
alert.informativeText = """
This link will open in \(handler). Only continue if you recognize \
and trust the destination.
"""
alert.accessoryView = targetView(displayString)
alert.addButton(withTitle: "Cancel")
alert.addButton(withTitle: "Open Link")
present(alert) { response in
// Cancel is deliberately the default action.
guard response == .alertSecondButtonReturn else { return }
_ = workspace.open(url)
}
}
}
static func presentBlock(
reason: UntrustedURL.DenialReason,
displayString: String
) {
deferPresentation {
let alert = NSAlert()
alert.alertStyle = .warning
alert.icon = NSImage(named: NSImage.cautionName)
alert.messageText = "Ghostty Blocked This Link"
alert.informativeText = reason.message
alert.accessoryView = targetView(displayString)
alert.addButton(withTitle: "OK")
alert.addButton(withTitle: "Copy Link")
present(alert) { response in
// Keep blocked targets out of Launch Services. Copying the
// displayed, sanitized value gives the user an explicit path
// forward without adding a one-click policy bypass.
guard response == .alertSecondButtonReturn else { return }
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(displayString, forType: .string)
}
}
}
/// The core action callback runs with the renderer mutex held. Queue modal
/// presentation for the next main-loop turn so AppKit cannot reenter a
/// render callback before that mutex is released.
private static func deferPresentation(_ action: @escaping () -> Void) {
DispatchQueue.main.async(execute: action)
}
private static func present(
_ alert: NSAlert,
completion: @escaping (NSApplication.ModalResponse) -> Void
) {
if let window = NSApp.keyWindow {
alert.beginSheetModal(for: window, completionHandler: completion)
} else {
completion(alert.runModal())
}
}
private static func targetView(_ target: String) -> NSView {
let scrollView = NSScrollView(frame: NSRect(
x: 0,
y: 0,
width: 480,
height: 96
))
scrollView.borderType = .bezelBorder
scrollView.hasVerticalScroller = true
scrollView.autohidesScrollers = true
let textView = NSTextView(frame: scrollView.contentView.bounds)
textView.isEditable = false
textView.isSelectable = true
textView.isRichText = false
textView.font = .monospacedSystemFont(
ofSize: NSFont.systemFontSize,
weight: .regular
)
textView.textContainerInset = NSSize(width: 6, height: 6)
textView.string = target
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.containerSize = NSSize(
width: scrollView.contentSize.width,
height: .greatestFiniteMagnitude
)
scrollView.documentView = textView
return scrollView
}
}

View File

@@ -0,0 +1,85 @@
import Testing
@testable import Ghostty
@Suite
struct CommandLineOpenFileFilterTests {
@Test func requiresExecuteFlag() {
let filter = CommandLineOpenFileFilter(
arguments: ["ghostty", "/tmp/file.txt"],
workingDirectory: "/tmp",
fileExists: { _ in true }
)
#expect(!filter.shouldIgnore("/tmp/file.txt"))
}
@Test func ignoresExistingPathsAfterExecuteFlag() {
let existing: Set<String> = [
"/usr/bin/vim",
"/tmp/project/file.txt",
"/tmp/other.txt",
]
let filter = CommandLineOpenFileFilter(
arguments: [
"ghostty",
"/tmp/before.txt",
"-e",
"/usr/bin/vim",
"./file.txt",
"../other.txt",
"missing.txt",
],
workingDirectory: "/tmp/project",
fileExists: { existing.contains($0) }
)
#expect(!filter.shouldIgnore("/tmp/before.txt"))
#expect(filter.shouldIgnore("/usr/bin/vim"))
#expect(filter.shouldIgnore("/tmp/project/file.txt"))
#expect(filter.shouldIgnore("/tmp/other.txt"))
#expect(!filter.shouldIgnore("/tmp/project/missing.txt"))
}
@Test func ignoresEachPathOnce() {
let filter = CommandLineOpenFileFilter(
arguments: ["ghostty", "-e", "./file.txt"],
workingDirectory: "/tmp/project",
fileExists: { $0 == "/tmp/project/file.txt" }
)
#expect(filter.shouldIgnore("./file.txt"))
#expect(!filter.shouldIgnore("/tmp/project/file.txt"))
}
@Test func preservesUnrelatedOpenFileRequests() {
let filter = CommandLineOpenFileFilter(
arguments: ["ghostty", "-e", "vim", "/tmp/command-file.txt"],
workingDirectory: "/tmp",
fileExists: { $0 == "/tmp/command-file.txt" }
)
#expect(!filter.shouldIgnore("/tmp/finder-file.txt"))
#expect(filter.shouldIgnore("/tmp/command-file.txt"))
}
@Test func normalizesFileURLs() {
let existing: Set<String> = [
"/tmp/project/file #100%.txt",
"/tmp/project/directory",
]
let filter = CommandLineOpenFileFilter(
arguments: [
"ghostty",
"-e",
"command",
"./file #100%.txt",
"./directory/",
],
workingDirectory: "/tmp/project",
fileExists: { existing.contains($0) }
)
#expect(filter.shouldIgnore("/tmp/project/file #100%.txt"))
#expect(filter.shouldIgnore("/tmp/project/directory"))
}
}

View File

@@ -0,0 +1,49 @@
//
// CommandPaletteTests.swift
// GhosttyTests
//
// Tests for command palette query filtering and match ranking.
//
import Testing
import SwiftUI
@testable import Ghostty
struct CommandPaletteFilterTests {
private func option(
title: String,
subtitle: String? = nil,
description: String? = nil,
leadingColor: Color? = nil
) -> CommandOption {
CommandOption(
title: title,
subtitle: subtitle,
description: description,
leadingColor: leadingColor
) {}
}
/// Title matches outrank subtitle matches, which outrank description
/// matches. Options that don't match at all are dropped.
@Test func textMatchTiers() {
let byDescription = option(title: "Alpha", description: "make it fast")
let bySubtitle = option(title: "Beta", subtitle: "fast scrolling")
let byTitle = option(title: "Fast Redraw")
let noMatch = option(title: "Quit")
let results = [noMatch, byDescription, bySubtitle, byTitle]
.filteredAndSorted(query: "fast")
#expect(results == [byTitle, bySubtitle, byDescription])
}
/// Options with equal scores keep their original relative order.
@Test func tiesPreserveOriginalOrder() {
let first = option(title: "New Window")
let second = option(title: "New Tab")
#expect([first, second].filteredAndSorted(query: "new") == [first, second])
#expect([second, first].filteredAndSorted(query: "new") == [second, first])
}
}

View File

@@ -8,7 +8,7 @@ import Testing
typealias StartSearch = Ghostty.Action.StartSearch
/// A unique pasteboard for each test case prevents flakiness.
let pasteboard = OSPasteboard.withUniqueName()
let pasteboard = NSPasteboard.withUniqueName()
init() {
pasteboard.setString("pb", forType: .string)
@@ -19,7 +19,7 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
#expect(sut.needle == "pb")
#expect(sut.needle.text == "pb")
}
@Test func init_withEmptyNeedle_readsPasteboardNeedle() {
@@ -28,7 +28,7 @@ import Testing
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(sut.needle == "pb")
#expect(sut.needle.text == "pb")
}
}
@@ -38,7 +38,7 @@ import Testing
from: StartSearch(c: .init(needle: needle)),
pasteboard: pasteboard
)
#expect(sut.needle == "start")
#expect(sut.needle.text == "start")
}
}
@@ -57,20 +57,33 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.setNeedle("sut")
sut.writePasteboardNeedle()
#expect(pasteboard.string(forType: .string) == "sut")
}
@Test func setNeedle_clearsNeedleSelection() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle.selection = sut.needle.text.startIndex..<sut.needle.text.endIndex
sut.setNeedle("x")
#expect(sut.needle.text == "x")
#expect(sut.needle.selection == nil)
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsNil() {
let sut = SearchState(
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
pasteboard.clearContents()
sut.needle = "sut"
sut.setNeedle("sut")
sut.readPasteboardNeedle()
#expect(sut.needle == "sut")
#expect(sut.needle.text == "sut")
}
@Test func readPasteboardNeedle_whenPasteboardNeedleIsValid() {
@@ -78,9 +91,9 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.setNeedle("sut")
sut.readPasteboardNeedle()
#expect(sut.needle == "pb")
#expect(sut.needle.text == "pb")
}
@Test func readPasteboardNeedle_setsNeedleSelectionRange() {
@@ -88,10 +101,10 @@ import Testing
from: StartSearch(c: .init(needle: nil)),
pasteboard: pasteboard
)
sut.needle = "sut"
sut.setNeedle("sut")
sut.readPasteboardNeedle()
let expected = "pb".startIndex..<"pb".endIndex
#expect(sut.needleSelection == expected)
#expect(sut.needle.selection == expected)
}
}

View File

@@ -0,0 +1,122 @@
import Foundation
import Testing
@testable import Ghostty
@Suite
struct UntrustedURLTests {
@Test(arguments: ["http://example.com", "https://example.com/path", "mailto:user@example.com"])
func allowsSafeSchemes(_ value: String) {
guard case .allow(let url) = UntrustedURL(value).decision else {
Issue.record("expected an allowed URL")
return
}
#expect(url.absoluteString == value)
}
@Test(arguments: ["https:relative", "http:///missing-host"])
func rejectsWebURLsWithoutHosts(_ value: String) {
#expect(UntrustedURL(value).decision == .deny(.invalidWebURL))
}
@Test(arguments: ["/tmp/file.txt", "../file.txt", "payload.command"])
func rejectsSchemeLessTargets(_ value: String) {
#expect(UntrustedURL(value).decision == .deny(.malformedURL))
}
@Test(arguments: ["vscode://file/tmp/example.swift", "ssh://example.com"])
func confirmsCustomSchemes(_ value: String) {
guard case .confirm(let url) = UntrustedURL(value).decision else {
Issue.record("expected a confirmation decision")
return
}
#expect(url.absoluteString == value)
}
@Test(arguments: ["\u{0085}", "\u{2028}", "\u{2029}", "\u{202E}", "\u{2066}"])
func rejectsInvisibleAndLineBreakingCharacters(_ scalar: String) {
let value = "https://example.com/before\(scalar)after"
#expect(UntrustedURL(value).decision == .deny(.unsafeCharacters))
}
@Test
func allowsNonExecutableLocalFiles() throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "document.txt")
try "safe".write(to: file, atomically: true, encoding: .utf8)
guard case .allow(let result) = UntrustedURL(file.absoluteString).decision else {
Issue.record("expected a safe local file")
return
}
#expect(result == file.standardizedFileURL.resolvingSymlinksInPath())
}
@Test(arguments: ["payload.command", "payload.tool", "payload.app", "payload.workflow"])
func rejectsDangerousLocalFileExtensions(_ filename: String) throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: filename)
try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8)
#expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile))
}
@Test
func rejectsScriptContentTypes() throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "payload.sh")
try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8)
#expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile))
}
@Test
func rejectsExecutableFilesRegardlessOfExtension() throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let file = directory.appending(path: "payload.txt")
try "#!/bin/sh\n".write(to: file, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o755],
ofItemAtPath: file.path
)
#expect(UntrustedURL(file.absoluteString).decision == .deny(.unsafeFile))
}
@Test
func resolvesSymlinksBeforeClassifyingFiles() throws {
let directory = try makeTemporaryDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let payload = directory.appending(path: "payload.command")
let link = directory.appending(path: "document.txt")
try "#!/bin/sh\n".write(to: payload, atomically: true, encoding: .utf8)
try FileManager.default.createSymbolicLink(at: link, withDestinationURL: payload)
#expect(UntrustedURL(link.absoluteString).decision == .deny(.unsafeFile))
}
@Test(arguments: ["\u{0085}", "\u{2028}", "\u{2029}"])
func previewShowsTheEffectiveStandardizedPath(_ separator: String) {
let value = "/tmp/preview\(separator)////../payload.command////"
#expect(UntrustedURL(value).displayString == "/tmp/payload.command")
}
@Test
func previewEscapesBidirectionalControls() {
let value = "https://example.com/a\u{202E}b"
#expect(UntrustedURL(value).displayString == "https://example.com/a\\u{202E}b")
}
private func makeTemporaryDirectory() throws -> URL {
let result = FileManager.default.temporaryDirectory
.appending(path: UUID().uuidString, directoryHint: .isDirectory)
try FileManager.default.createDirectory(
at: result,
withIntermediateDirectories: false
)
return result
}
}

View File

@@ -25,11 +25,9 @@ struct UpdateStateTests {
}
@Test func testInstallingEquality() {
let state1: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
let state2: UpdateState = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
let state1: UpdateState = .installing(.init(retryTerminatingApplication: {}))
let state2: UpdateState = .installing(.init(retryTerminatingApplication: {}))
#expect(state1 == state2)
let state3: UpdateState = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {}))
#expect(state3 != state2)
}
@Test func testPermissionRequestEquality() {
@@ -98,15 +96,30 @@ struct UpdateStateTests {
#expect(state1 != state2)
}
// MARK: - isIdle Tests
// MARK: - isHidden Tests
@Test func testIsIdleTrue() {
let state: UpdateState = .idle
#expect(state.isIdle == true)
@Test(
arguments: [
(UpdateState.idle, true),
(.installing(.init(appcastItem: .empty(), retryTerminatingApplication: {})), true),
(.checking(.init(cancel: {})), false),
(.installing(.init(retryTerminatingApplication: {})), false)
]
)
func testIsHidden(_ state: UpdateState, expected: Bool) {
#expect(state.isHidden == expected)
}
@Test func testIsIdleFalse() {
let state: UpdateState = .checking(.init(cancel: {}))
#expect(state.isIdle == false)
// MARK: - shouldTerminateWithoutWarning Tests
@Test(
arguments: [
(UpdateState.idle, false),
(.installing(.init(appcastItem: .empty(), retryTerminatingApplication: {})), false),
(.installing(.init(retryTerminatingApplication: {})), true)
]
)
func testShouldTerminateWithoutWarning(_ state: UpdateState, expected: Bool) {
#expect(state.shouldTerminateWithoutWarning == expected)
}
}

View File

@@ -52,9 +52,9 @@ struct UpdateViewModelTests {
@Test func testInstallingText() {
let viewModel = UpdateViewModel()
viewModel.state = .installing(.init(isAutoUpdate: false, retryTerminatingApplication: {}, dismiss: {}))
viewModel.state = .installing(.init(retryTerminatingApplication: {}))
#expect(viewModel.text == "Installing…")
viewModel.state = .installing(.init(isAutoUpdate: true, retryTerminatingApplication: {}, dismiss: {}))
viewModel.state = .installing(.init(appcastItem: .empty(), retryTerminatingApplication: {}))
#expect(viewModel.text == "Restart to Complete Update")
}

View File

@@ -4,7 +4,7 @@
# to avoid Nix shell interference (NIX_LDFLAGS, NIX_CFLAGS_COMPILE, etc.).
def main [
--scheme: string = "Ghostty" # Xcode scheme (Ghostty, Ghostty-iOS, DockTilePlugin)
--scheme: string = "Ghostty" # Xcode scheme (Ghostty, DockTilePlugin)
--configuration: string = "Debug" # Build configuration (Debug, Release, ReleaseLocal)
--action: string = "build" # xcodebuild action (build, test, clean, etc.)
] {

View File

@@ -87,7 +87,6 @@
inherit pkgs lib stdenv;
};
python = python3.withPackages (python-pkgs: [
python-pkgs.blake3
python-pkgs.kaitaistruct
python-pkgs.ucs-detect
]);
@@ -223,8 +222,7 @@ in
'')
+ (lib.optionalString stdenv.hostPlatform.isDarwin ''
# On macOS, we unset the macOS SDK env vars that Nix sets up because
# we rely on a system installation. Nix only provides a macOS SDK
# and we need iOS too.
# we rely on a system installation. Nix only provides a macOS SDK.
unset SDKROOT
unset DEVELOPER_DIR

View File

@@ -143,9 +143,8 @@ fn findNDKPath(b: *std.Build) ?[]const u8 {
&.{
home,
switch (builtin.os.tag) {
.linux => "Android/sdk",
.linux, .windows => "Android/Sdk",
.macos => "Library/Android/Sdk",
.windows => "Android/Sdk",
else => return null,
},
},

View File

@@ -39,18 +39,24 @@ pub fn build(b: *std.Build) !void {
else
link_system_libs_full[0..1],
});
if (!b.systemIntegrationOption("gtk4-layer-shell", .{})) {
// local deps (non-system layer-shell/wayland)
const deps = try LocalDeps.get(b) orelse break :translate;
headers.addIncludePath(deps.upstream.path("include"));
headers.addIncludePath(deps.upstream.path("src"));
headers.addIncludePath(deps.client_header_directory);
}
module.addImport("c", headers.mod);
}
if (!b.systemIntegrationOption("gtk4-layer-shell", .{})) {
_ = try buildLib(b, module, .{
.target = target,
.optimize = optimize,
});
_ = try buildLib(b, .{ .target = target, .optimize = optimize });
}
}
fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Build.Step.Compile {
fn buildLib(b: *std.Build, options: anytype) !*std.Build.Step.Compile {
const lib_version = try std.SemanticVersion.parse(version);
const target = options.target;
const optimize = options.optimize;
@@ -67,58 +73,20 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
});
b.installArtifact(lib);
// We need to call both lazy dependencies to tell Zig we need both
const upstream_ = b.lazyDependency("gtk4_layer_shell", .{});
const wayland_protocols_ = b.lazyDependency("wayland_protocols", .{});
const upstream = upstream_ orelse return lib;
const wayland_protocols = wayland_protocols_ orelse return lib;
lib.root_module.addIncludePath(upstream.path("include"));
lib.root_module.addIncludePath(upstream.path("src"));
module.addIncludePath(upstream.path("include"));
// GTK
lib.root_module.linkSystemLibrary("gtk4", dynamic_link_opts);
// Wayland headers and source files
{
const protocols = [_]struct { []const u8, std.Build.LazyPath }{
.{
"wlr-layer-shell-unstable-v1",
upstream.path("protocol/wlr-layer-shell-unstable-v1.xml"),
},
.{
"xdg-shell",
wayland_protocols.path("stable/xdg-shell/xdg-shell.xml"),
},
// Even though we don't use session lock, we still need its headers
.{
"ext-session-lock-v1",
wayland_protocols.path("staging/ext-session-lock/ext-session-lock-v1.xml"),
},
};
const wf = b.addWriteFiles();
for (protocols) |protocol| {
const name, const xml = protocol;
const header_scanner = b.addSystemCommand(&.{ "wayland-scanner", "client-header" });
header_scanner.addFileArg(xml);
_ = wf.addCopyFile(
header_scanner.addOutputFileArg(name),
b.fmt("{s}-client.h", .{name}),
);
const source_scanner = b.addSystemCommand(&.{ "wayland-scanner", "private-code" });
source_scanner.addFileArg(xml);
const source = source_scanner.addOutputFileArg(b.fmt("{s}.c", .{name}));
lib.root_module.addCSourceFile(.{ .file = source });
}
lib.root_module.addIncludePath(wf.getDirectory());
// local deps (non-system layer-shell/wayland)
const deps = try LocalDeps.get(b) orelse return lib;
lib.root_module.addIncludePath(deps.upstream.path("include"));
lib.root_module.addIncludePath(deps.upstream.path("src"));
lib.root_module.addIncludePath(deps.client_header_directory);
for (deps.private_code_files) |source| {
lib.root_module.addCSourceFile(.{ .file = source });
}
lib.installHeadersDirectory(
upstream.path("include"),
deps.upstream.path("include"),
"",
.{ .include_extensions = &.{".h"} },
);
@@ -134,7 +102,7 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
"xdg-surface-server.c",
};
lib.root_module.addCSourceFiles(.{
.root = upstream.path("src"),
.root = deps.upstream.path("src"),
.files = srcs,
.flags = &.{
b.fmt("-DGTK_LAYER_SHELL_MAJOR={}", .{lib_version.major}),
@@ -145,3 +113,68 @@ fn buildLib(b: *std.Build, module: *std.Build.Module, options: anytype) !*std.Bu
return lib;
}
const LocalDeps = struct {
var cached: ?LocalDeps = null;
upstream: *std.Build.Dependency,
wayland_protocols: *std.Build.Dependency,
client_header_directory: std.Build.LazyPath,
private_code_files: []std.Build.LazyPath,
fn init(b: *std.Build) !?LocalDeps {
var result: LocalDeps = .{
.upstream = b.lazyDependency("gtk4_layer_shell", .{}) orelse return null,
.wayland_protocols = b.lazyDependency("wayland_protocols", .{}) orelse return null,
.client_header_directory = undefined,
.private_code_files = &.{},
};
// Wayland headers and source files
{
const protocols = [_]struct { []const u8, std.Build.LazyPath }{
.{
"wlr-layer-shell-unstable-v1",
result.upstream.path("protocol/wlr-layer-shell-unstable-v1.xml"),
},
.{
"xdg-shell",
result.wayland_protocols.path("stable/xdg-shell/xdg-shell.xml"),
},
// Even though we don't use session lock, we still need its headers
.{
"ext-session-lock-v1",
result.wayland_protocols.path("staging/ext-session-lock/ext-session-lock-v1.xml"),
},
};
const wf = b.addWriteFiles();
const private_code_files = try b.allocator.alloc(std.Build.LazyPath, protocols.len);
for (protocols, 0..) |protocol, idx| {
const name, const xml = protocol;
const header_scanner = b.addSystemCommand(&.{ "wayland-scanner", "client-header" });
header_scanner.addFileArg(xml);
_ = wf.addCopyFile(
header_scanner.addOutputFileArg(name),
b.fmt("{s}-client.h", .{name}),
);
const source_scanner = b.addSystemCommand(&.{ "wayland-scanner", "private-code" });
source_scanner.addFileArg(xml);
const source = source_scanner.addOutputFileArg(b.fmt("{s}.c", .{name}));
private_code_files[idx] = source;
}
result.private_code_files = private_code_files;
result.client_header_directory = wf.getDirectory();
}
cached = result;
return result;
}
fn get(b: *std.Build) !?LocalDeps {
if (cached) |c| return c;
return init(b);
}
};

View File

@@ -21,13 +21,13 @@ pub const String = opaque {
pub fn createWithCharactersNoCopy(
unichars: []const u16,
) *String {
return @as(*String, @ptrFromInt(@intFromPtr(c.CFStringCreateWithCharactersNoCopy(
) Allocator.Error!*String {
return @ptrCast(@constCast(c.CFStringCreateWithCharactersNoCopy(
null,
@ptrCast(unichars.ptr),
@intCast(unichars.len),
foundation.c.kCFAllocatorNull,
))));
) orelse return error.OutOfMemory));
}
pub fn release(self: *String) void {

View File

@@ -18,6 +18,17 @@ pub const Font = opaque {
) orelse Allocator.Error.OutOfMemory;
}
pub fn createWithName(name: *foundation.String, size: f32) Allocator.Error!*Font {
return @as(
?*Font,
@ptrFromInt(@intFromPtr(c.CTFontCreateWithName(
@ptrCast(name),
size,
null,
))),
) orelse Allocator.Error.OutOfMemory;
}
pub fn createForString(
self: *Font,
str: *foundation.String,
@@ -156,7 +167,7 @@ pub const Font = opaque {
return @ptrFromInt(@intFromPtr(c.CTFontCopyFamilyName(@ptrCast(self))));
}
pub fn copyDisplayName(self: *Font) *foundation.String {
pub fn copyDisplayName(self: *Font) ?*foundation.String {
return @ptrFromInt(@intFromPtr(c.CTFontCopyDisplayName(@ptrCast(self))));
}

Some files were not shown because too many files have changed in this diff Show More