Repository navigation
Platform Integration
Find what differs on macOS, Windows, and Linux below. The platform matrix gives a quick comparison. Build packages with Packaging; verification steps are in Development and Release, and release steps in Release Process.
flowchart TD
app["sonicterm-app<br/>winit lifecycle · windows/tabs/panes · PTY wiring · input · redraw · target opening"]
mac["sonicterm-mac<br/>AppKit · NSMenu · Apple Events · NSPasteboard"]
win["sonicterm-windows<br/>Win32/DPI · muda · DWM · OLE · WiX metadata"]
linux["sonicterm-linux<br/>X11/Wayland identity · Linux startup · desktop metadata"]
app --> mac
app --> win
app --> linux
A behavior stays in sonicterm-app or a lower crate when it needs no AppKit,
Win32, X11, or Wayland handle. Platform crates own work that requires a native
main-thread object, platform ABI, desktop identity, or installer metadata.
Terminal parsing is in sonicterm-vt; local PTY/ConPTY transport is behind
sonicterm-io::PtyHandle; rendering is in sonicterm-gpu.
The binaries share diagnostic, config, logging, asset, state-machine, and shell
startup; the ordered sequence is in Runtime Lifecycle.
User state is under ~/.sonicterm; sonicterm-cfg::assets resolves packaged assets.
Terminal IME geometry is shared app behavior: each window sends the active pane's physical cursor rectangle, including its origin and content padding once. Deduplication uses pane identity, physical position, and physical size rather than only row/column. Palette and search fields retain their own anchors.
Path scanning and openability probing are cross-platform app behavior. The
bounded worker revalidates the exact target kind and action immediately before
native dispatch and blocks special files. macOS and Linux follow symlinks and act on
the resolved target. On Windows, resolving a link to a network share, or opening a path
on a mapped network drive, contacts that server, so the probe keeps custody of each path
part instead of checking path strings. It resolves the drive letter once, with
QueryDosDeviceW, a namespace query that touches neither the filesystem nor the
network, and walks the drive only when the letter names exactly a
\Device\HarddiskVolume<N> volume whose root reports a local disk; it opens that root
through the NT device path, never through the letter again. Each later part is opened by
one name relative to its parent's held handle, without following a link there, and stays
open without delete sharing until the check or the native action ends, so no held part
can be renamed or deleted and no held folder above the final part can be turned into a
link. Each part is opened to read it (to list it, for a folder) or, when that is denied, to
execute it (to traverse it, for a folder), never to write or delete it. A part the user can
neither read nor execute, or one another program holds without read sharing, is refused, and
while a check runs, other programs cannot rename or delete the parts it holds. Windows
follows a symlink or junction only between local fixed disks, to an NT
drive path or a path relative to the link's folder. Paths on mapped network drives,
subst drives, optical or RAM drives, and dynamic-disk volumes or shadow copies mounted on
a letter, and paths through a folder where a volume is
mounted by GUID, are refused, as are UNC and device targets, links on removable drives,
other reparse points such as cloud-file placeholders, a link whose target is not the kind
its own folder flag promises, and a path that needs more than 31 link hops, the limit
Windows documents for links to fully qualified paths. The walk never follows a link off a
local fixed disk and never opens a remote volume, and a path on a letter that names no drive
is refused rather than reported missing. Dispatch walks the whole chain again
and, while it holds every part, hands the shell the link-free path the walk built, not
the displayed text, so a file reached through a link is selected in its real folder. The
guarantee ends there: the shell, Explorer and the file's handler then open that path
themselves, the final part can still change in place, and nothing is held once the shell
call returns. A process in the user's own logon session can redefine drive letters and
reach the network directly, so it is out of scope.
Regular files are selected regardless of executable suffix, mode, or contents. Candidates are probed
shortest first, so an existing prose-trimmed path such as main.rs wins over a longer
punctuation-bearing literal such as main.rs,; the literal opens only when the shorter path is missing.
Path and URL detection is one component for every operating system, because a
Windows pane can show POSIX paths, for example from a WSL shell.
crates/sonicterm-cfg/src/url_scan.rs implements both the POSIX and the Windows
grammar without OS gates, and its tests run on every host; PathStyle::native()
is its only read of the build target. The per-OS files hold the native probes,
reveal and open (path_target/{unix,macos,linux,windows}.rs in sonicterm-app)
and URI dispatch (url_open/{macos,linux,windows}.rs in sonicterm-cfg). The
app chooses the grammar with PathStyle::native(), so a Windows build scans
every pane with the Windows grammar, including a pane running a WSL shell.
| Platform | Dispatch boundary |
|---|---|
| macOS | directories use fixed /usr/bin/open --; files use /usr/bin/open -R -- to select without opening |
| Windows | directories use ShellExecuteExW; files use SHOpenFolderAndSelectItems, from a dedicated COM apartment |
| Linux | directories use the desktop portal, with fixed xdg-open fallback only when unavailable; files use org.freedesktop.FileManager1.ShowItems without a file-opening fallback |
File selection never invokes the file's associated application or executes its contents. macOS application/package directories use Finder selection rather than launch. A portal rejection is not treated as unavailability and does not fall back.
On Windows directory navigation and non-file URI dispatch
reach the shell the same way: ShellExecuteExW is called directly from a
worker thread that owns its own COM apartment, so no command interpreter parses
the target and no argument string is re-tokenized. The URI is passed as one
NUL-terminated UTF-16 string, and environment substitution stays disabled, so
percent-delimited text such as %20 or %USERNAME% reaches the handler exactly
as validated instead of expanding against the process environment.
The shared macOS App::do_resumed path uses winit's
set_allows_automatic_window_tabbing(false) before menu hooks and native window
creation. The binary retains its normal/smoke window-ready callbacks that set the
initial NSWindow's setTabbingMode: 2. The process setting and per-window mode
are separate; SonicTerm remains the owner of terminal tabs.
Native smoke reads back the process property before window creation. This proves the setting reached AppKit, not the appearance of every native tab strip.
The NSMenu is installed only after winit has created the AppKit event loop. An
Objective-C target receives menu selectors, translates menu tags to shared
Action values, and wakes the event loop through its proxy. Per-window AppKit
work runs from the one-shot window-ready callback after a valid NSWindow exists.
On macOS 14 and later, each registered window (the main window, a new terminal
window and a torn-out window) takes an NSView.displayLink from its AppKit view.
The link is added to the main run loop in common modes, created paused, and runs
only while that window has a display-link-paced streaming frame pending
(Rendering Modes). Its
Objective-C target holds only the window id, a clone of the event-loop proxy and
the window's link generation; when the link fires, the target posts
UserEvent::DisplayLinkTick through the proxy and never touches the App.
Dropping the window's handle invalidates the link and releases its target.
Below macOS 14, available!(macos = 14.0) is false, so no link is installed and
the window keeps the timer path; SonicTerm does not use CVDisplayLink. Windows
and Linux install no link. The native check runs on a real NSView on the
process main thread in crates/sonicterm-app/tests/display_link_native.rs,
which has no libtest harness; it does not assert that the display fires a tick.
The app bundle advertises public.shell-script and
com.apple.terminal.shell-script at LSHandlerRank=Alternate. A process-lifetime
observer receives NSApplicationWillFinishLaunchingNotification and then
installs the kAEOpenDocuments Apple Event handler. Paths are copied into the
shared open-script queue; window and PTY creation remains on the event-loop
thread. Cold multi-file opens preserve order and avoid an unrelated blank tab;
later events append tabs. Relative paths use the process's initial working
directory. This is Finder Open With integration, not a global default-terminal
selector.
The macOS OS-handoff backend writes a serialized TabPayload to the general
NSPasteboard under com.sonic-terminal.tab.v1. sonicterm-mac checks for that
payload exactly once at process startup, before MacShell::run. A valid startup
payload is removed from the pasteboard and passed to the shell as pending input;
an already-running peer does not check again when it becomes active. The backend
is startup-only on the receiving side and does not create an NSDraggingSession,
so it provides no native cursor preview. A pasteboard write returns
NotAcknowledged, so the source tab stays live and the app uses its normal
in-process tear-out path. Same-process movement uses the shared in-process
tab-transfer path.
The bundle reads runtime assets from Contents/Resources/assets. Its four
Rec Mono St.Helens faces are stored only in assets/fonts;
ATSApplicationFontsPath=assets/fonts lets AppKit/CoreText resolve the same files.
The package contains Cairo's non-system dylib closure in Contents/Frameworks
with bundle-relative imports, plus licenses and provenance. Its minimum macOS
version reflects the actual executable/library deployment targets, not merely
the packaging host's architecture. See Packaging for verification.
Before winit creates an HWND, sonicterm-windows requests
DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2. This early process policy precedes
event-loop construction and is retained rather than assuming winit's later setup
is equivalent for every startup path. Release builds use the Windows GUI
subsystem and open no console window. The one-shot window-ready callback receives
a live HWND and applies the DWM backdrop and native muda menu. Window movement,
snap layouts, and minimize/maximize/close controls remain native Windows chrome.
The menu translates muda events to shared actions. DWM can request Mica,
Acrylic, or Tabbed material and falls back to opaque. Forced software rendering
uses an opaque window because the GDI presenter cannot composite transparency.
The installed executable accepts one lossless --open-script <PATH> argument.
The startup path resolves a relative argument against the process's initial
working directory and queues it before WindowsShell::run, so cold startup
opens the script tab instead of a HOME tab. The private tear-out payload cannot
be combined with --open-script.
--refresh-shell-associations runs without a window and broadcasts
SHCNE_ASSOCCHANGED. The MSI registers SonicTerm ProgIDs, Default Apps
capabilities, and OpenWithProgids for .ps1, .cmd, .bat, and .sh. It does
not write an extension default or UserChoice. This is file-handler integration,
not Windows' global Default terminal application protocol.
The Windows backend initializes OLE on the UI thread and implements COM
IDataObject, IDropSource, and IDropTarget. It registers the private
com.sonic-terminal.tab.v1 clipboard format (CF_SONIC_TAB) and uses
DoDragDrop and RegisterDragDrop. The UTF-8 tab JSON owns a NUL terminator in
zero-initialized movable global memory; GlobalSize is allocation capacity, not
payload length, so allocator padding cannot change same-process payload matching.
The installed backend explicitly declares whether it owns native drop targets. Before each main, new, tear-out or hidden warm HWND is created, shared window setup disables winit's default target only for that custom owner. Without a custom backend, winit's default file-drop behavior remains enabled. Hidden warm windows have no custom registration until adoption. Failed registration aborts the hidden destination before shell startup or pane transfer; a failed tear-out restores its source. Successful registration retains the window until revocation, and backend teardown releases any remaining targets before the OLE guard is dropped.
File drops carry the registered destination WindowId through the shared queue;
later focus changes cannot redirect them, and a closed target discards its drop.
Overlapping tab bars are hit-tested only within the receiving native window.
Same-process tab moves additionally require the active gesture and its exact
payload; stable source WindowId/TabId bookkeeping remains authoritative.
Foreign-process or malformed tab payloads are refused without acknowledging a
move: there is no supported live-PTY transfer between processes. An OLE MOVE
without a resolved local outcome cancels rather than inventing a destination.
The default Windows runtime smoke installs the production OLE backend and requires three successful registration/revocation pairs: main, warm-adopted child, and fresh child, with zero retained registrations or failures. Native unit tests use real hidden HWNDs and COM data objects for ownership, Unicode file paths, exact target routing, duplicate refusal and cleanup. Direct COM calls prove decoding and routing, not physical drag gesture delivery.
The DirectWrite/GDI bridge passes complete UTF-16 to the analysis source. Mapping positions, remaining text, and locale lengths use UTF-16 code units, not Rust scalar counts. A zero, out-of-range, or split-surrogate mapping span fails the whole native fallback request, including any candidates accumulated before the failure. The caller reports the failure and continues its remaining configured locators. A successful request returns an ordered, deduplicated candidate-font list, not per-character assignments; already-loaded faces and BMP behavior keep their existing path.
The Windows binary owns GUI glue, not terminal parsing or ConPTY. Local process
hosting remains behind sonicterm-io::PtyHandle.
When software-render degradation is active on Windows, sonicterm-gpu composes
a CPU BGRA frame in crates/sonicterm-gpu/src/software_frame.rs. The Windows-only
software_windows.rs bridge presents the complete frame through GDI; this path
does not use retained GPU damage as a second presentation policy.
crates/sonicterm-windows/src/software_presenter.rs holds configuration decisions,
not frame composition or native blits.
The shipping crate is sonicterm-linux; its executable is sonicterm. winit
uses X11 or Wayland. Desktop entry, AppStream component, hicolor icon, Wayland
application id, and X11 class all use com.d0n9x1n.SonicTerm; the X11 instance
name is sonicterm. Keeping one identity aligns launcher activation, task
grouping, and compositor identity.
Linux has no SonicTerm native menu, desktop-notification bridge, foreground-process title adapter, native material backdrop, or cross-process tab drag. Its shell installs a pure platform normalizer on the shared app runner. Startup and every explicit reload pass through that one seam before config is stored or applied: Mica, Acrylic, and Tabbed become opaque with one warning; already-opaque input is unchanged and silent. Warm, new, and torn-out windows therefore consume the same normalized value. macOS and Windows install identity behavior and retain their supported backdrop policy. Shared panes, tabs, windows, and in-process tab movement remain available.
Automatic shell selection chooses the first executable candidate in this order:
$SHELL, the current user's passwd shell from getpwuid_r, then /bin/sh.
Explicit shell configuration wins.
A portable package resolves the executable-adjacent assets/; a Debian install
resolves /usr/share/sonicterm/assets. Startup verifies all four bundled Rec
Mono faces, passes their directory to FontStack before native Fontconfig
discovery, and retains that directory across font reloads. Native fallback
remains available.
All three shipping binaries accept the hidden --runtime-smoke mode. The
platform supplies its real shell command (/bin/sh on macOS/Linux, cmd.exe on
Windows), while the shared runner requires a native window, renderer/device, a
non-literal PTY marker observed in the live grid, and a later native
presentation. The default scenario then uses the production default warm pool to
create and report one hidden renderer, adopts that exact window through tab tear-out, presents the
child, closes it, clears any replenished spare, and requires
live_renderer_count to return to the pre-window baseline. Warm-lifecycle
failure is stable exit code 16.
The Windows default run still requires three OLE registrations/revocations; its early frame-validation run requires exactly one main-window pair. Device recovery requires exactly two visible-window pairs; its hidden spare is never adopted or registered. Every scenario requires no native failures or live registrations after App teardown and keeps the OLE guard alive through backend release.
The smoke tears out a temporary second tab, preserving the original main shell and its marker history through warm-child teardown. Fault phases count marker rows across the live grid and scrollback; re-reading an old marker cannot prove liveness. They compare both present-call and acknowledged-frame totals across all windows, and require an actual render attempt plus at least 250 ms without presentation. Each fault waits at most 5 seconds; the destroy hook has its own 5-second poll bound. The app's watchdog requests exit after 30 seconds; the runner enforces the hard 45-second outer deadline. The 15-second difference allows for a queued watchdog event, the bounded destroy poll, and teardown. These are configured deadlines, not measured runtime claims.
After the warm lifecycle, the default run checks that every open window shares
the main window's device generation, then drives the renderer's doc-hidden GPU
fault hook. An isolated fault must be followed by a later native main-window
presentation. A retained-resource fault, injected through
force_rebuild_for_scale, must leave the device recorded Unusable, no window
presenting, and a re-executed PTY marker in the live grid. The generation check
and these two phases exit 17 on failure. A device destroy must then produce
the lost record and another re-executed marker, or the smoke exits 18.
A second, separate process started with
scripts/native-smoke-runner.py --scenario frame-validation begins with a
usable device and, after the first main presentation, injects a persistent
frame-validation fault. Arming only creates a valid probe: it sends no marker
and starts no quiet interval. After an actual faulty render is observed to
leave the device Unusable, the smoke snapshots a new marker-row baseline,
resends the shell command, and starts a fresh 250 ms no-presentation interval.
Markers emitted before that confirmed stop cannot satisfy the proof, and time
spent waiting for the faulty render does not count as quiet time. The separate
five-second deadline stays anchored to arming; confirming the stop does not
extend it. Both present counters remain frozen against their original pre-fault
baseline. A missing post-stop marker or failed containment exits 17. The runner sets
SONICTERM_RUNTIME_SMOKE_SCENARIO=frame-validation for that run and removes any
inherited value for a plain run. An unknown application environment value fails
before the event loop starts with exit 10; an unknown runner --scenario
argument is an invocation error (exit 2) and launches no child.
A third process selects --scenario device-recovery. It presents fresh shell
markers in two live windows with one warm renderer, destroys their shared device,
and requires exactly one replacement request and one committed generation across
all three renderers. Both original PTYs must remain alive and present fresh markers
on that generation. An old-generation event must be observed without another
rebuild; child and spare release then restore the renderer count, and final shell
shutdown must settle. A failed recovery check exits 19. The default and
frame-validation scenarios disable recovery so their stopped-device assertions
cannot pass through a replacement context.
Automation passes separate scratch config/ and logs/ roots without replacing
HOME. scripts/native-smoke-runner.py removes inherited NO_COLOR, captures
stdout/stderr and log artifacts, enforces a 45-second outer deadline, and kills
the child's process group on POSIX; descendants that leave the group are outside
that bound. PR and release gates run all three scenarios in separate steps with
native process deadlines
for the built macOS and Windows binaries and both Linux package layouts on X11
and Wayland. Packaging describes the Linux scenario argument and
isolated evidence paths. Otherwise successful smoke with unsettled native PTY
teardown exits 20; an earlier fault, loss or recovery failure keeps its original code.
| Capability | macOS | Windows | Linux |
|---|---|---|---|
| Window backend | winit + AppKit hooks | winit + Win32 hooks | winit + X11 or Wayland |
| Local PTY | portable-pty Unix PTY | portable-pty ConPTY | portable-pty Unix PTY |
| Default glyph rasterizer | FreeType | DirectWrite, with FreeType fallback | FreeType |
| Font discovery | CoreText | DirectWrite/GDI | packaged font directory + Fontconfig |
| Tab OS handoff | NSPasteboard publication, no NSDraggingSession
|
OLE/COM drag/drop | in-process only |
| Native menu | NSMenu | muda |
unavailable; in-app actions remain |
| Backdrop | AppKit blur/config | DWM Mica/Acrylic/Tabbed | opaque |
| Software present | wgpu adapter path | CPU BGRA + GDI | wgpu Vulkan/lavapipe or selected platform adapter |
| Package format |
.app in per-architecture .dmg
|
x64 WiX .msi
|
x86_64 .deb and .tar.gz
|
| Signing | ad-hoc bundle signature | unsigned | unsigned |
| Boundary | Primary paths |
|---|---|
| Shared platform shell | crates/sonicterm-app/src/shell.rs |
| Path and URL detection, shared by every OS |
crates/sonicterm-cfg/src/url_scan.rs, crates/sonicterm-app/src/app/path_target.rs
|
| Safe native target open |
crates/sonicterm-app/src/app/path_target.rs, crates/sonicterm-app/src/app/path_target/{unix,macos,linux,windows}.rs, crates/sonicterm-cfg/src/url_open/{macos,linux,windows}.rs
|
| macOS entry/menu/open documents/tab handoff | crates/sonicterm-mac/src/{main,menubar,open_documents,os_drag_mac,tab_drag_os}.rs |
| macOS display link | crates/sonicterm-app/src/app/display_link.rs |
| Windows entry/CLI/menu/backdrop/tab drag | crates/sonicterm-windows/src/{main,cli,startup,menubar,backdrop,os_drag_win,tab_drag_os}.rs |
| Windows software present |
crates/sonicterm-gpu/src/{software_frame,software_windows}.rs, crates/sonicterm-windows/src/software_presenter.rs
|
| Linux entry and identity |
crates/sonicterm-linux/src/main.rs, crates/sonicterm-linux/resources/
|
| Asset lookup | crates/sonicterm-cfg/src/assets.rs |