Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ internal/app/ the Wails binding layer — the object bound to the
services below it; they never import it.
internal/buildinfo/ link-time build identity. A dependency root: it
imports nothing first-party.
internal/pty/ PTY service: the sessions behind the embedded
terminal. Imports nothing first-party.
internal/stream/ loopback WebSocket transport (DESIGN.md §3.3) for PTY
I/O and backend-push events. Declares its own seam for
the PTY service rather than importing it. The wire
contract is internal/stream/PROTOCOL.md.
frontend/src/ React + TypeScript UI (Vite)
frontend/wailsjs/ GENERATED bindings — never hand-edit; run
`make bindings`
Expand Down
3 changes: 3 additions & 0 deletions frontend/wailsjs/go/app/App.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {stream} from '../models';
import {buildinfo} from '../models';

export function StreamEndpoint():Promise<stream.Endpoint>;

export function Version():Promise<buildinfo.Info>;
4 changes: 4 additions & 0 deletions frontend/wailsjs/go/app/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT

export function StreamEndpoint() {
return window['go']['app']['App']['StreamEndpoint']();
}

export function Version() {
return window['go']['app']['App']['Version']();
}
19 changes: 19 additions & 0 deletions frontend/wailsjs/go/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,22 @@ export namespace buildinfo {

}

export namespace stream {

export class Endpoint {
port: number;
token: string;

static createFrom(source: any = {}) {
return new Endpoint(source);
}

constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.port = source["port"];
this.token = source["token"];
}
}

}

2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ ignore frontend/node_modules

require (
github.com/aymanbagabas/go-pty v0.2.3
github.com/gorilla/websocket v1.5.3
github.com/wailsapp/wails/v2 v2.13.0
)

Expand All @@ -18,7 +19,6 @@ require (
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
Expand Down
18 changes: 16 additions & 2 deletions godobject_budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ const (
// lives in internal/pty and is reached through that one field, so this is
// the one-handle-per-service case the paragraph above describes, not
// state accumulating on the coordinator.
maxAppFields = 2
//
// 2 -> 3 in #3: the loopback stream server arrives as a single
// *stream.Server handle. It is a second service rather than terminal state
// spread across the App — the port, the token, the live connections and the
// event subscribers all live behind that field in internal/stream, and the
// App holds it only to start it, stop it, and report its endpoint.
maxAppFields = 3

// maxAppMethods caps methods with an App receiver, counting value and
// pointer receivers alike. Pinned at today's actual with zero slack.
Expand All @@ -42,7 +48,15 @@ const (
// bridge into TypeScript — so this ceiling doubles as the budget on the
// backend's public surface. Behavior belongs on the service that owns it,
// reached through a handle, not on the coordinator.
maxAppMethods = 1
//
// 1 -> 2 in #3: StreamEndpoint. The whole design of the transport is that
// terminal I/O does NOT cross this bridge (DESIGN.md §3.3), so the one
// method the stream server needs here is the one that tells the frontend
// where to open a socket and with what token. Every subsequent terminal
// operation — write, resize, close — is a WebSocket frame and adds nothing
// to this number. A future PR that adds a per-operation binding is not
// raising a ceiling, it is bypassing the transport.
maxAppMethods = 2

// appCoordinatorType is the struct these ceilings bound.
appCoordinatorType = "App"
Expand Down
42 changes: 41 additions & 1 deletion internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ package app
import (
"context"
"embed"
"fmt"

"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"

"github.com/txn2/m6t/internal/buildinfo"
"github.com/txn2/m6t/internal/pty"
"github.com/txn2/m6t/internal/stream"
)

// Window geometry. m6t is a single-window app: the three-pane project
Expand Down Expand Up @@ -42,13 +44,23 @@ type App struct {
// does not implement them: everything the terminal does lives in
// internal/pty and is reached through here.
terminals *pty.Manager

// streams is the loopback WebSocket transport (DESIGN.md §3.3). Terminal
// I/O does not cross the Wails bridge — the bridge carries only the
// endpoint the frontend needs to open a socket to it.
streams *stream.Server
}

// newApp builds the binding. It is unexported because Options is the only
// supported way to construct the application: an App that is not bound into
// the window options is unreachable from the frontend.
func newApp() *App {
return &App{info: buildinfo.Get(), terminals: pty.New()}
terminals := pty.New()
return &App{
info: buildinfo.Get(),
terminals: terminals,
streams: stream.New(terminalBridge{terminals: terminals}),
}
}

// Version reports the build identity to the frontend, which shows it in the
Expand All @@ -57,6 +69,20 @@ func (a *App) Version() buildinfo.Info {
return a.info
}

// StreamEndpoint reports the loopback port and per-launch token the frontend
// needs to open its stream sockets (DESIGN.md §3.3).
//
// This is the only path by which the token leaves the backend, and the reason
// nothing in the stream server logs: the credential crosses the bridge to the
// webview that needs it and goes nowhere else.
func (a *App) StreamEndpoint() (stream.Endpoint, error) {
endpoint, err := a.streams.Endpoint()
if err != nil {
return stream.Endpoint{}, fmt.Errorf("stream endpoint: %w", err)
}
return endpoint, nil
}

// Options assembles the Wails application options around a freshly bound App.
// It lives here rather than in main so the window contract — single window,
// bound methods, embedded assets — is covered by tests rather than asserted in
Expand All @@ -74,11 +100,25 @@ func Options(assets embed.FS) *options.App {
BackgroundColour: windowBackground,
Bind: []any{application},

// The stream listener has to be up before the frontend asks for its
// endpoint. A bind failure is not fatal to the window — the app still
// runs, without terminals — and it is not logged here because the
// server keeps it and StreamEndpoint reports it to the frontend, which
// is where a user can actually see it.
OnStartup: func(context.Context) {
_ = application.streams.Start()
},

// PTYs are backend-owned and outlive every window in the app, so the
// only thing that ends them is the app ending. Without this hook,
// quitting m6t would leave the user's shells — and whatever they were
// running — orphaned behind it.
//
// Sockets close before sessions: a connection whose session is being
// killed underneath it would otherwise report an exit nobody is left to
// receive.
OnShutdown: func(context.Context) {
application.streams.Shutdown()
application.terminals.Shutdown()
},
}
Expand Down
Loading
Loading