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
+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()