Build the Apple Container extension

Turns the empty scaffold into a working Nova extension for Apple Container
and container-compose, in the shape of the Docker Suite extension.

Sidebar (Scripts/Sidebar):
- System, Containers, Images, Volumes and Networks sections
- Containers grouped into compose projects via the com.docker.compose.project
  label container-compose stamps; project rows drive compose up/down/build
  against the matching workspace file
- Lifecycle, shell, logs, inspect, browse and copy commands per resource
- Apple Container publishes no event stream, so the sidebar polls while
  visible and only reloads a section when a fingerprint of its data changes,
  which keeps selection and expansion intact

Hostnames (Scripts/Hostnames.js):
- A container's hostname is <name>.<domain> and the domain lives in Apple
  Container's config.toml, which has no CLI setter, so the Hostname Domain
  preference reads and writes that file directly
- Changing it offers to register the domain with macOS and restart the
  services; the write preserves the file's other tables and its mode

Language support:
- Dockerfile and Compose syntaxes backed by tree-sitter grammars built by
  Tools/build-syntaxes.sh, pinned to revisions that generate ABI 14
- The Compose syntax is named dockercompose because that is the languageId
  docker-language-server recognises compose files by
- docker-language-server is downloaded on first use rather than bundled, at
  roughly 40 MB

Icons are generated by Tools/make-icons.py so they can be regenerated rather
than hand-maintained.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UnSBcR5Lywz5Fbj5FmVbfc
This commit is contained in:
2026-08-18 22:45:14 -03:00
co-authored by Claude Opus 5
parent 0879e5a163
commit 0506f44db3
80 changed files with 5201 additions and 3 deletions
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
#
# Builds the tree-sitter grammars the syntaxes depend on.
#
# Nova loads grammars as dylibs named libtree-sitter-<language>.dylib and
# expects ABI 14, so both grammars are pinned to a revision that generates it.
# Run from anywhere: ./Tools/build-syntaxes.sh
#
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
work="${TMPDIR:-/tmp}/apple-container-grammars"
out="$root/Syntaxes"
dockerfile_repo="https://github.com/camdencheek/tree-sitter-dockerfile.git"
dockerfile_ref="971acdd908568b4531b0ba28a445bf0bb720aba5"
yaml_repo="https://github.com/tree-sitter-grammars/tree-sitter-yaml.git"
yaml_ref="v0.7.2"
# Universal so the extension works on Apple silicon and Intel alike.
arches=(-arch arm64 -arch x86_64)
fetch() {
local name="$1"
local repo="$2"
local ref="$3"
local dir="$work/$name"
if [ ! -d "$dir" ]; then
git clone --quiet "$repo" "$dir"
fi
git -C "$dir" fetch --quiet --tags origin
git -C "$dir" checkout --quiet "$ref"
local version
version="$(grep -m1 'define LANGUAGE_VERSION' "$dir/src/parser.c" | awk '{print $3}')"
if [ "$version" != "14" ]; then
echo "error: $name generates tree-sitter ABI $version, Nova expects 14" >&2
exit 1
fi
}
mkdir -p "$work" "$out"
fetch dockerfile "$dockerfile_repo" "$dockerfile_ref"
fetch yaml "$yaml_repo" "$yaml_ref"
echo "building libtree-sitter-dockerfile.dylib"
cc "${arches[@]}" -dynamiclib -O2 -fPIC -std=c11 \
-I "$work/dockerfile/src" \
"$work/dockerfile/src/parser.c" "$work/dockerfile/src/scanner.c" \
-o "$out/libtree-sitter-dockerfile.dylib"
# Nova already ships a grammar registered as "yaml", so the compose grammar is
# built from the same sources under its own symbol names. That keeps the
# Compose syntax on the grammar these queries were written against instead of
# whichever yaml grammar happens to win.
echo "building libtree-sitter-compose.dylib"
renames=(
-Dtree_sitter_yaml=tree_sitter_compose
-Dtree_sitter_yaml_external_scanner_create=tree_sitter_compose_external_scanner_create
-Dtree_sitter_yaml_external_scanner_destroy=tree_sitter_compose_external_scanner_destroy
-Dtree_sitter_yaml_external_scanner_scan=tree_sitter_compose_external_scanner_scan
-Dtree_sitter_yaml_external_scanner_serialize=tree_sitter_compose_external_scanner_serialize
-Dtree_sitter_yaml_external_scanner_deserialize=tree_sitter_compose_external_scanner_deserialize
)
cc "${arches[@]}" -dynamiclib -O2 -fPIC -std=c11 -DYAML_SCHEMA=core "${renames[@]}" \
-I "$work/yaml/src" \
"$work/yaml/src/parser.c" "$work/yaml/src/scanner.c" \
-o "$out/libtree-sitter-compose.dylib"
for library in "$out"/libtree-sitter-*.dylib; do
echo "$(basename "$library"): $(lipo -archs "$library")"
done
+322
View File
@@ -0,0 +1,322 @@
#!/usr/bin/env python3
"""Generates the extension's PNG assets.
Everything is drawn at 8x and box filtered down, which is enough anti-aliasing
for icons this small and keeps the script free of dependencies.
Usage: python3 Tools/make-icons.py
"""
import math
import os
import struct
import zlib
SS = 8 # supersampling factor
GREEN = (52, 199, 89)
RED = (255, 69, 58)
GREY = (142, 142, 147)
BLUE = (10, 132, 255)
PURPLE = (175, 82, 222)
ORANGE = (255, 159, 10)
TEAL = (100, 210, 255)
WHITE = (255, 255, 255)
class Canvas:
def __init__(self, size):
self.size = size * SS
self.scale = SS
self.px = [[(0, 0, 0, 0)] * self.size for _ in range(self.size)]
def _blend(self, x, y, colour, alpha):
if not (0 <= x < self.size and 0 <= y < self.size) or alpha <= 0:
return
r, g, b = colour
dr, dg, db, da = self.px[y][x]
na = alpha + da * (1 - alpha)
if na == 0:
return
self.px[y][x] = (
int((r * alpha + dr * da * (1 - alpha)) / na),
int((g * alpha + dg * da * (1 - alpha)) / na),
int((b * alpha + db * da * (1 - alpha)) / na),
na,
)
def fill(self, test, colour, alpha=1.0):
for y in range(self.size):
for x in range(self.size):
if test(x + 0.5, y + 0.5):
self._blend(x, y, colour, alpha)
def erase(self, test):
for y in range(self.size):
for x in range(self.size):
if test(x + 0.5, y + 0.5):
self.px[y][x] = (0, 0, 0, 0)
# Shapes are described in 0..1 unit space and scaled up here.
def u(self, value):
return value * self.size
def circle(self, cx, cy, r, colour, alpha=1.0):
cx, cy, r = self.u(cx), self.u(cy), self.u(r)
self.fill(lambda x, y: (x - cx) ** 2 + (y - cy) ** 2 <= r * r, colour, alpha)
def ring(self, cx, cy, r, width, colour, alpha=1.0):
cx, cy, r, width = self.u(cx), self.u(cy), self.u(r), self.u(width)
inner = r - width
self.fill(
lambda x, y: inner * inner <= (x - cx) ** 2 + (y - cy) ** 2 <= r * r,
colour,
alpha,
)
def ellipse(self, cx, cy, rx, ry, colour, alpha=1.0):
cx, cy, rx, ry = self.u(cx), self.u(cy), self.u(rx), self.u(ry)
self.fill(
lambda x, y: ((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2 <= 1, colour, alpha
)
def rect(self, x0, y0, x1, y1, colour, alpha=1.0):
x0, y0, x1, y1 = self.u(x0), self.u(y0), self.u(x1), self.u(y1)
self.fill(lambda x, y: x0 <= x <= x1 and y0 <= y <= y1, colour, alpha)
def rounded(self, x0, y0, x1, y1, radius, colour, alpha=1.0):
x0, y0, x1, y1, radius = (
self.u(x0),
self.u(y0),
self.u(x1),
self.u(y1),
self.u(radius),
)
def test(x, y):
if not (x0 <= x <= x1 and y0 <= y <= y1):
return False
cx = min(max(x, x0 + radius), x1 - radius)
cy = min(max(y, y0 + radius), y1 - radius)
return (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius
self.fill(test, colour, alpha)
def line(self, x0, y0, x1, y1, width, colour, alpha=1.0):
ax, ay, bx, by, w = (
self.u(x0),
self.u(y0),
self.u(x1),
self.u(y1),
self.u(width) / 2,
)
dx, dy = bx - ax, by - ay
length = math.hypot(dx, dy) or 1
def test(x, y):
t = max(0, min(1, ((x - ax) * dx + (y - ay) * dy) / (length * length)))
px, py = ax + t * dx, ay + t * dy
return (x - px) ** 2 + (y - py) ** 2 <= w * w
self.fill(test, colour, alpha)
def downsample(self):
out = self.size // SS
rows = []
for y in range(out):
row = bytearray()
for x in range(out):
r = g = b = a = 0.0
for sy in range(SS):
for sx in range(SS):
pr, pg, pb, pa = self.px[y * SS + sy][x * SS + sx]
r += pr * pa
g += pg * pa
b += pb * pa
a += pa
count = SS * SS
if a > 0:
row += bytes((int(r / a), int(g / a), int(b / a), int(255 * a / count)))
else:
row += bytes((0, 0, 0, 0))
rows.append(bytes(row))
return out, rows
def write_png(path, size, rows):
raw = b"".join(b"\x00" + row for row in rows)
def chunk(tag, data):
payload = tag + data
return struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload))
png = b"\x89PNG\r\n\x1a\n"
png += chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0))
png += chunk(b"IDAT", zlib.compress(raw, 9))
png += chunk(b"IEND", b"")
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as handle:
handle.write(png)
def emit(path, base_size, draw):
"""Writes name.png at base_size and [email protected] at double."""
for suffix, size in ((".png", base_size), ("@2x.png", base_size * 2)):
canvas = Canvas(size)
draw(canvas)
out_size, rows = canvas.downsample()
write_png(path + suffix, out_size, rows)
# --- glyphs -----------------------------------------------------------------
def traffic(colour):
def draw(c):
c.circle(0.5, 0.5, 0.34, colour)
c.circle(0.42, 0.4, 0.11, WHITE, 0.35)
return draw
def toggle(colour, on):
def draw(c):
c.rounded(0.06, 0.28, 0.94, 0.72, 0.22, colour)
c.circle(0.72 if on else 0.28, 0.5, 0.17, WHITE)
return draw
def power(colour):
def draw(c):
c.ring(0.5, 0.54, 0.34, 0.12, colour)
c.erase(lambda x, y: abs(x - c.u(0.5)) <= c.u(0.11) and y <= c.u(0.42))
c.rounded(0.44, 0.12, 0.56, 0.52, 0.06, colour)
return draw
def box(colour):
"""Stylised container: a crate seen straight on."""
def draw(c):
c.rounded(0.1, 0.2, 0.9, 0.82, 0.1, colour)
c.erase(lambda x, y: c.u(0.2) <= x <= c.u(0.8) and c.u(0.3) <= y <= c.u(0.72))
c.rect(0.32, 0.3, 0.38, 0.72, colour)
c.rect(0.47, 0.3, 0.53, 0.72, colour)
c.rect(0.62, 0.3, 0.68, 0.72, colour)
return draw
def layers(colour):
def draw(c):
c.rounded(0.12, 0.14, 0.72, 0.5, 0.08, colour)
c.rounded(0.28, 0.5, 0.88, 0.86, 0.08, colour, 0.75)
return draw
def tag(colour):
def draw(c):
c.rounded(0.1, 0.1, 0.9, 0.9, 0.16, colour)
c.erase(
lambda x, y: (x - c.u(0.34)) ** 2 + (y - c.u(0.34)) ** 2 <= c.u(0.09) ** 2
)
return draw
def volume(colour):
def draw(c):
c.rect(0.18, 0.24, 0.82, 0.76, colour)
c.ellipse(0.5, 0.76, 0.32, 0.14, colour)
c.ellipse(0.5, 0.24, 0.32, 0.14, colour)
c.ellipse(0.5, 0.24, 0.19, 0.08, WHITE, 0.55)
return draw
def network(colour):
def draw(c):
c.line(0.5, 0.28, 0.22, 0.74, 0.08, colour)
c.line(0.5, 0.28, 0.78, 0.74, 0.08, colour)
c.line(0.22, 0.74, 0.78, 0.74, 0.08, colour)
for cx, cy in ((0.5, 0.24), (0.2, 0.76), (0.8, 0.76)):
c.circle(cx, cy, 0.16, colour)
return draw
def gear(colour):
def draw(c):
for index in range(8):
angle = index * math.pi / 4
c.line(
0.5 + 0.2 * math.cos(angle),
0.5 + 0.2 * math.sin(angle),
0.5 + 0.42 * math.cos(angle),
0.5 + 0.42 * math.sin(angle),
0.16,
colour,
)
c.circle(0.5, 0.5, 0.3, colour)
c.erase(
lambda x, y: (x - c.u(0.5)) ** 2 + (y - c.u(0.5)) ** 2 <= c.u(0.12) ** 2
)
return draw
def folder(colour):
def draw(c):
c.rounded(0.08, 0.2, 0.5, 0.34, 0.05, colour)
c.rounded(0.08, 0.26, 0.92, 0.8, 0.09, colour)
return draw
def logo(c):
"""Extension icon: a crate on a rounded plate."""
plate = (28, 30, 34)
c.rounded(0.02, 0.02, 0.98, 0.98, 0.22, plate)
c.rounded(0.16, 0.26, 0.84, 0.78, 0.09, WHITE)
# The crate is hollow: paint the interior back to the plate colour rather
# than erasing, which would punch a hole through the plate as well.
c.rounded(0.24, 0.34, 0.76, 0.7, 0.04, plate)
c.rect(0.34, 0.34, 0.4, 0.7, WHITE)
c.rect(0.47, 0.34, 0.53, 0.7, WHITE)
c.rect(0.6, 0.34, 0.66, 0.7, WHITE)
ICONS = {
"Images/icons/status/traffic-on": (16, traffic(GREEN)),
"Images/icons/status/traffic-off": (16, traffic(GREY)),
"Images/icons/status/toggle-on": (16, toggle(GREEN, True)),
"Images/icons/status/toggle-off": (16, toggle(GREY, False)),
"Images/icons/status/power-on": (16, power(GREEN)),
"Images/icons/status/power-off": (16, power(GREY)),
"Images/icons/status/warning": (16, traffic(ORANGE)),
"Images/icons/box": (16, box(BLUE)),
"Images/icons/layers": (16, layers(PURPLE)),
"Images/icons/tag": (16, tag(TEAL)),
"Images/icons/volume": (16, volume(ORANGE)),
"Images/icons/network": (16, network(BLUE)),
"Images/icons/system": (16, gear(GREY)),
"Images/icons/folder": (16, folder(GREY)),
"Images/sidebar-small": (16, box(WHITE)),
"Images/sidebar-large": (32, box(WHITE)),
"Images/extension": (128, logo),
}
def main():
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for name, (size, draw) in ICONS.items():
emit(os.path.join(root, name), size, draw)
print(f"wrote {name}.png")
if __name__ == "__main__":
main()