mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-26 09:01:44 +00:00
Merge branch 'ghostty-org:main' into localization-da
This commit is contained in:
@@ -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
|
||||
|
||||
2
.github/DISCUSSION_TEMPLATE/issue-triage.yml
vendored
2
.github/DISCUSSION_TEMPLATE/issue-triage.yml
vendored
@@ -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.
|
||||
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
5
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -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.
|
||||
9
.github/ISSUE_TEMPLATE/preapproved.md
vendored
9
.github/ISSUE_TEMPLATE/preapproved.md
vendored
@@ -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
16
.github/VOUCHED.td
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/release-tip.yml
vendored
2
.github/workflows/release-tip.yml
vendored
@@ -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
|
||||
|
||||
45
.github/workflows/test.yml
vendored
45
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
6
build.zig.zon.json
generated
@@ -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
6
build.zig.zon.nix
generated
@@ -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
2
build.zig.zon.txt
generated
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
15
example/c-vt-snapshot/README.md
Normal file
15
example/c-vt-snapshot/README.md
Normal 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
|
||||
```
|
||||
33
example/c-vt-snapshot/build.zig
Normal file
33
example/c-vt-snapshot/build.zig
Normal 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);
|
||||
}
|
||||
14
example/c-vt-snapshot/build.zig.zon
Normal file
14
example/c-vt-snapshot/build.zig.zon
Normal 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",
|
||||
},
|
||||
}
|
||||
162
example/c-vt-snapshot/src/main.c
Normal file
162
example/c-vt-snapshot/src/main.c
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
107
include/ghostty/vt/io.h
Normal 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 */
|
||||
@@ -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 *
|
||||
*/
|
||||
|
||||
525
include/ghostty/vt/snapshot.h
Normal file
525
include/ghostty/vt/snapshot.h
Normal 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 */
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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.
|
||||
59
macos/Sources/App/CommandLineOpenFileFilter.swift
Normal file
59
macos/Sources/App/CommandLineOpenFileFilter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
114
macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift
Normal file
114
macos/Sources/Ghostty/Ghostty.ClipboardConfirmationRequest.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ extension Ghostty {
|
||||
static let unsupportedActionKeys: [String] = [
|
||||
"toggle_tab_overview",
|
||||
"toggle_window_decorations",
|
||||
"prompt_window_title",
|
||||
"show_gtk_inspector",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 scroller’s appearance matches the surface's background color.
|
||||
scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua)
|
||||
updateTrackingAreas()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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 0–255 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
262
macos/Sources/Helpers/UntrustedURL.swift
Normal file
262
macos/Sources/Helpers/UntrustedURL.swift
Normal 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,
|
||||
]
|
||||
}
|
||||
104
macos/Sources/Helpers/UntrustedURLAlert.swift
Normal file
104
macos/Sources/Helpers/UntrustedURLAlert.swift
Normal 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
|
||||
}
|
||||
}
|
||||
85
macos/Tests/CommandLineOpenFileFilterTests.swift
Normal file
85
macos/Tests/CommandLineOpenFileFilterTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
49
macos/Tests/CommandPaletteTests.swift
Normal file
49
macos/Tests/CommandPaletteTests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
122
macos/Tests/Ghostty/UntrustedURLTests.swift
Normal file
122
macos/Tests/Ghostty/UntrustedURLTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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.)
|
||||
] {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user