diff --git a/Readme.md b/Readme.md
index 74e04e7..21c551d 100644
--- a/Readme.md
+++ b/Readme.md
@@ -1,153 +1,172 @@
-
+# ShellSync: The Real-Time Collaborative Terminal
+
+**ShellSync** transforms your terminal into an interactive, real-time collaborative canvas. Instantly share your terminal session with anyone, anywhere, right in their browser. Perfect for pair programming, debugging sessions, live demos, and educational purposes.
-# ShellSync
+## ✨ Key Features
-**A Collaborative Playground of Terminals**
+ * **Real-Time Collaboration**: Share your terminal session live with multiple users. Keystrokes and output are synced instantly for everyone.
+ * **Infinite Canvas Interface**: Say goodbye to traditional tabbed interfaces. Arrange terminals on an infinite, pan-and-zoom canvas.
+ * **Draggable & Resizable Terminals**: Move and resize terminals anywhere on the canvas to create the perfect layout for your needs.
+ * **Cross-Platform Agent**: A single, lightweight command-line agent written in Go that runs on **macOS**, **Windows**, and **Linux**.
+ * **Web-Based Client**: Collaborators don't need to install a thing. They can join from their web browser with a simple URL.
+ * **Self-Hostable**: Control and run the entire stack on your own infrastructure, ensuring your data remains private and secure.
-ShellSync is a modern, open-source platform that enables real-time, collaborative terminal sessions. Designed for developers, educators, DevOps, and remote teams, ShellSync transforms your command-line workflow into an interactive, multi-user environment. Whether you're pair programming, conducting live demos, teaching shell skills, or remotely troubleshooting systems, ShellSync brings the power of collaboration directly to your terminal.
+-----
----
+## 🏗️ How It Works
-## Table of Contents
+ShellSync uses a three-component architecture optimized for performance, scalability, and ease of use.
-- [Features](#features)
-- [Architecture](#architecture)
-- [Getting Started](#getting-started)
-- [Usage](#usage)
-- [Configuration](#configuration)
-- [Contributing](#contributing)
-- [License](#license)
-- [Authors](#authors)
-- [Acknowledgements](#acknowledgements)
-- [Contact](#contact)
+```mermaid
+graph TD
+ subgraph Your_Machine
+ A[Go Agent]
+ end
----
+ subgraph Your_Server
+ B[gRPC Server]
+ C{Go Backend}
+ D[WebSocket Hub]
+ end
-## Features
+ subgraph Collaborators
+ E[Browser 1]
+ F[Browser 2]
+ G[Browser N...]
+ end
-- **Live Collaborative Terminals:** Work together in real-time, sharing a single terminal instance with multiple users.
-- **Multi-Platform Support:** Seamlessly supports Linux, macOS, and Windows environments (with PowerShell/Shell support).
-- **Role-Based Access:** Assign roles like owner, collaborator (read/write), or viewer (read-only) for granular control.
-- **Session Recording & Playback:** Automatically record sessions for later review, sharing, or auditing.
-- **Secure & Private:** TLS encryption and optional authentication ensure that your data and commands stay private.
-- **Web & Native Interfaces:** Access sessions via a modern web UI (built with TypeScript and CSS), or via native terminal clients.
-- **Command History & Replay:** Maintain a full log of commands run in each session, with replay capabilities.
-- **Extensible API:** Easily integrate with CI/CD, bots, or monitoring tools thanks to a structured API.
-- **Customizable Themes:** Tweak the terminal look-and-feel with CSS for dark/light mode or personalized themes.
-- **Resource Efficient:** Backend powered by Go for high concurrency and low resource footprint.
+ %% Connections
+ A -->|PTY Output (gRPC)| B
+ B -->|PTY Input (gRPC)| A
+ B <--> C
+ C <--> D
+ E <-->|WebSocket| D
+ F <-->|WebSocket| D
+ G <-->|WebSocket| D
----
+ %% Flow annotations
+ A -.->|1. Connects via gRPC| B
+ C -.->|2. Creates Session| A
+ A -.->|3. Provides Sharable URL| E
+ E -.->|4. Joins via WebSocket| D
+```
-## Architecture
+1. **The Agent (Go)**: A command-line tool you run on your machine. It creates a local shell process (PTY) and securely streams the terminal input/output over a high-performance gRPC connection to the backend.
+2. **The Backend (Go)**: The central hub of the system. It handles gRPC connections from agents, WebSocket connections from web clients, and manages session state. It takes output from the agent and broadcasts it to all connected browsers in that session.
+3. **The Frontend (React)**: A modern, web-based interface that provides the infinite canvas where terminals are displayed. It communicates with the backend over WebSockets to provide a seamless, real-time experience.
-ShellSync is built as a modular system:
+-----
-- **Frontend:** Written in TypeScript and CSS, providing a responsive and interactive web-based terminal emulator.
-- **Backend:** Implemented in Go, responsible for session management, security, and process control.
-- **Shell Adapters:** Supports various shells (bash, zsh, sh, PowerShell) and can be extended to others.
-- **Communication:** Uses secure websockets for fast, real-time data exchange between users and servers.
+## 🛠️ Technology Stack
----
+| Component | Technology |
+| :------------- | :----------------------------------------------------------------------------------------- |
+| **Frontend** | React, Next.js, TypeScript, Tailwind CSS, Xterm.js |
+| **Backend** | Go, Gorilla WebSocket, Gorilla Mux |
+| **Agent** | Go, Cobra (for CLI), gRPC |
+| **Communication** | gRPC (Agent ↔ Backend), WebSockets (Backend ↔ Frontend) |
+| **API Definition** | Protobuf |
-## Getting Started
+-----
+
+## 🚀 Getting Started
+
+Follow these instructions to get ShellSync up and running locally.
### Prerequisites
-- [Node.js](https://nodejs.org/) (v16+ recommended)
-- [Go](https://golang.org/) (v1.18+ recommended)
-- [Yarn](https://yarnpkg.com/) or [npm](https://npmjs.com/)
-- Modern web browser (for client)
-- (Optional) TLS certificate for production deployment
+ * Go 1.21+
+ * Node.js 18+ and npm
+ * `protoc` (Protocol Buffers compiler)
+
+### Installation & Setup
+
+1. **Clone the repository:**
-### Installation
+ ```sh
+ git clone https://github.com/your-username/shellsync.git
+ cd shellsync
+ ```
-1. **Clone the Repository**
- ```bash
- git clone https://github.com/Ayush-Vish/ShellSync.git
- cd ShellSync
- ```
+2. **Generate Protobuf Code:**
+ The project uses a `Makefile` to simplify tasks. Generate the necessary Go and gRPC code from the `.proto` file.
-2. **Install Frontend Dependencies**
- ```bash
- cd client
- yarn install # or npm install
- ```
+ ```sh
+ make proto
+ ```
-3. **Build the Frontend**
- ```bash
- yarn build # or npm run build
- ```
+3. **Install Frontend Dependencies:**
-4. **Build the Backend**
- ```bash
- cd ../server
- go build -o shellsync-server main.go
- ```
+ ```sh
+ cd frontend
+ npm install
+ cd ..
+ ```
-5. **Run the Server**
- ```bash
- ./shellsync-server
- ```
+4. **Install Backend Dependencies:**
-6. **Access the App**
- - Open your browser at `http://localhost:PORT` (default port as per config or server output).
+ ```sh
+ go mod tidy
+ ```
----
+### Running the Application
-## Usage
+1. **Start the Backend Server:**
-- **Start a Session:** After launching the backend, navigate to the web UI, and create a new collaborative terminal session.
-- **Invite Collaborators:** Share your session invite link or access code. You can control permissions (view/edit).
-- **Interact:** All participants see a synchronized terminal. Commands, outputs, and even cursor positions are shared live.
-- **Record & Replay:** Use the session recording feature to save and play back terminal activity for auditing or teaching.
+ ```sh
+ make run-server
+ ```
----
+ This will start the gRPC and WebSocket server on ports `5001` and `5000` respectively.
-## Configuration
+2. **Run the Agent (in a new terminal):**
-ShellSync supports a variety of configuration options via environment variables or config files:
+ ```sh
+ make run-client
+ ```
-- `PORT`: Set the listening port for the backend server.
-- `TLS_CERT`, `TLS_KEY`: Paths to TLS certificate and key for HTTPS.
-- `SESSION_TIMEOUT`: Auto-terminate inactive sessions after X minutes.
-- `AUTH_MODE`: Set authentication mode (none, password, OAuth, etc.).
-- `ALLOWED_ORIGINS`: Restrict which web origins can connect.
+ The agent will connect to the server, create a session, and output a sharable URL.
-See `config.example.json` or [docs/CONFIG.md](docs/CONFIG.md) for all available options.
+3. **Start Collaborating:**
+ Open the URL provided by the agent in your web browser. Share this URL with others to have them join your session instantly.
----
+-----
-## Contributing
+## 📦 Building for Production
-We ❤️ contributions! To get involved:
+You can use the `Makefile` to build production-ready binaries.
-1. Fork the repository.
-2. Create a new branch: `git checkout -b feature/your-feature`.
-3. Commit your changes: `git commit -am 'Add a cool feature'`.
-4. Push to your fork: `git push origin feature/your-feature`.
-5. Open a Pull Request describing your change.
+ * **Build the Server:**
-Please see [CONTRIBUTING.md](CONTRIBUTING.md) for style guidelines, documentation, and more.
+ ```sh
+ make build-server
+ ```
----
+ The binary will be placed in `bin/server`.
-## License
+ * **Build the Client (Agent):**
+ The `build-client` command is a powerful cross-compiler that builds the agent for multiple operating systems and architectures.
-This project is licensed under the MIT License. See [LICENSE](LICENSE) for details.
+ ```sh
+ make build-client
+ ```
----
+ The binaries will be placed in the `bin/` directory, ready for distribution.
-## Authors
+-----
-- [Ayush Vish](https://github.com/Ayush-Vish) (Maintainer)
-- [List of Contributors](https://github.com/Ayush-Vish/ShellSync/graphs/contributors)
+## 🤝 Contributing
----
+Contributions are what make the open-source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
-## Acknowledgements
+1. Fork the Project
+2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
+3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
+4. Push to the Branch (`git push origin feature/AmazingFeature`)
+5. Open a Pull Request
-- Inspired by collaborative terminal tools like [tmate](https://tmate.io/) and [gotty](https://github.com/yudai/gotty)
-- Thanks to all open source contributors, testers, and users!
+-----
----
+## 📜 License
+Distributed under the MIT License. See `LICENSE` for more information.
diff --git a/api/proto/shellsync.pb.go b/api/proto/shellsync.pb.go
index 7bf6aba..6222ba1 100644
--- a/api/proto/shellsync.pb.go
+++ b/api/proto/shellsync.pb.go
@@ -7,11 +7,12 @@
package proto
import (
- protoreflect "google.golang.org/protobuf/reflect/protoreflect"
- protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
+
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
)
const (
@@ -430,6 +431,7 @@ type ServerUpdate struct {
// *ServerUpdate_ServerHello
// *ServerUpdate_PtyInput
// *ServerUpdate_CreateTerminalRequest
+ // *ServerUpdate_ResizeTerminal
Payload isServerUpdate_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -499,6 +501,15 @@ func (x *ServerUpdate) GetCreateTerminalRequest() *CreateTerminalRequest {
return nil
}
+func (x *ServerUpdate) GetResizeTerminal() *TerminalResize {
+ if x != nil {
+ if x, ok := x.Payload.(*ServerUpdate_ResizeTerminal); ok {
+ return x.ResizeTerminal
+ }
+ }
+ return nil
+}
+
type isServerUpdate_Payload interface {
isServerUpdate_Payload()
}
@@ -515,12 +526,18 @@ type ServerUpdate_CreateTerminalRequest struct {
CreateTerminalRequest *CreateTerminalRequest `protobuf:"bytes,3,opt,name=create_terminal_request,json=createTerminalRequest,proto3,oneof"`
}
+type ServerUpdate_ResizeTerminal struct {
+ ResizeTerminal *TerminalResize `protobuf:"bytes,4,opt,name=resize_terminal,json=resizeTerminal,proto3,oneof"` // NEW: Add resize support
+}
+
func (*ServerUpdate_ServerHello) isServerUpdate_Payload() {}
func (*ServerUpdate_PtyInput) isServerUpdate_Payload() {}
func (*ServerUpdate_CreateTerminalRequest) isServerUpdate_Payload() {}
+func (*ServerUpdate_ResizeTerminal) isServerUpdate_Payload() {}
+
type TerminalInput struct {
state protoimpl.MessageState `protogen:"open.v1"`
TerminalId string `protobuf:"bytes,1,opt,name=terminal_id,json=terminalId,proto3" json:"terminal_id,omitempty"`
@@ -617,6 +634,83 @@ func (x *CreateTerminalRequest) GetTerminalId() string {
return ""
}
+// NEW: Add resize message
+type TerminalResize struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ TerminalId string `protobuf:"bytes,1,opt,name=terminal_id,json=terminalId,proto3" json:"terminal_id,omitempty"`
+ Cols uint32 `protobuf:"varint,2,opt,name=cols,proto3" json:"cols,omitempty"` // Number of columns
+ Rows uint32 `protobuf:"varint,3,opt,name=rows,proto3" json:"rows,omitempty"` // Number of rows
+ Width uint32 `protobuf:"varint,4,opt,name=width,proto3" json:"width,omitempty"` // Pixel width (optional)
+ Height uint32 `protobuf:"varint,5,opt,name=height,proto3" json:"height,omitempty"` // Pixel height (optional)
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *TerminalResize) Reset() {
+ *x = TerminalResize{}
+ mi := &file_api_proto_shellsync_proto_msgTypes[10]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *TerminalResize) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TerminalResize) ProtoMessage() {}
+
+func (x *TerminalResize) ProtoReflect() protoreflect.Message {
+ mi := &file_api_proto_shellsync_proto_msgTypes[10]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TerminalResize.ProtoReflect.Descriptor instead.
+func (*TerminalResize) Descriptor() ([]byte, []int) {
+ return file_api_proto_shellsync_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *TerminalResize) GetTerminalId() string {
+ if x != nil {
+ return x.TerminalId
+ }
+ return ""
+}
+
+func (x *TerminalResize) GetCols() uint32 {
+ if x != nil {
+ return x.Cols
+ }
+ return 0
+}
+
+func (x *TerminalResize) GetRows() uint32 {
+ if x != nil {
+ return x.Rows
+ }
+ return 0
+}
+
+func (x *TerminalResize) GetWidth() uint32 {
+ if x != nil {
+ return x.Width
+ }
+ return 0
+}
+
+func (x *TerminalResize) GetHeight() uint32 {
+ if x != nil {
+ return x.Height
+ }
+ return 0
+}
+
var File_api_proto_shellsync_proto protoreflect.FileDescriptor
const file_api_proto_shellsync_proto_rawDesc = "" +
@@ -648,11 +742,12 @@ const file_api_proto_shellsync_proto_rawDesc = "" +
"\x04data\x18\x02 \x01(\fR\x04data\":\n" +
"\x17TerminalCreatedResponse\x12\x1f\n" +
"\vterminal_id\x18\x01 \x01(\tR\n" +
- "terminalId\"\xd3\x01\n" +
+ "terminalId\"\x99\x02\n" +
"\fServerUpdate\x12#\n" +
"\fserver_hello\x18\x01 \x01(\tH\x00R\vserverHello\x127\n" +
"\tpty_input\x18\x02 \x01(\v2\x18.shellsync.TerminalInputH\x00R\bptyInput\x12Z\n" +
- "\x17create_terminal_request\x18\x03 \x01(\v2 .shellsync.CreateTerminalRequestH\x00R\x15createTerminalRequestB\t\n" +
+ "\x17create_terminal_request\x18\x03 \x01(\v2 .shellsync.CreateTerminalRequestH\x00R\x15createTerminalRequest\x12D\n" +
+ "\x0fresize_terminal\x18\x04 \x01(\v2\x19.shellsync.TerminalResizeH\x00R\x0eresizeTerminalB\t\n" +
"\apayload\"D\n" +
"\rTerminalInput\x12\x1f\n" +
"\vterminal_id\x18\x01 \x01(\tR\n" +
@@ -660,7 +755,14 @@ const file_api_proto_shellsync_proto_rawDesc = "" +
"\x04data\x18\x02 \x01(\fR\x04data\"8\n" +
"\x15CreateTerminalRequest\x12\x1f\n" +
"\vterminal_id\x18\x01 \x01(\tR\n" +
- "terminalId2\x91\x01\n" +
+ "terminalId\"\x87\x01\n" +
+ "\x0eTerminalResize\x12\x1f\n" +
+ "\vterminal_id\x18\x01 \x01(\tR\n" +
+ "terminalId\x12\x12\n" +
+ "\x04cols\x18\x02 \x01(\rR\x04cols\x12\x12\n" +
+ "\x04rows\x18\x03 \x01(\rR\x04rows\x12\x14\n" +
+ "\x05width\x18\x04 \x01(\rR\x05width\x12\x16\n" +
+ "\x06height\x18\x05 \x01(\rR\x06height2\x91\x01\n" +
"\tShellSync\x12D\n" +
"\rCreateSession\x12\x18.shellsync.CreateRequest\x1a\x19.shellsync.CreateResponse\x12>\n" +
"\x06Stream\x12\x17.shellsync.ClientUpdate\x1a\x17.shellsync.ServerUpdate(\x010\x01B+Z)github.com/Ayush-Vish/shellsync/api/protob\x06proto3"
@@ -677,7 +779,7 @@ func file_api_proto_shellsync_proto_rawDescGZIP() []byte {
return file_api_proto_shellsync_proto_rawDescData
}
-var file_api_proto_shellsync_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
+var file_api_proto_shellsync_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_api_proto_shellsync_proto_goTypes = []any{
(*CreateRequest)(nil), // 0: shellsync.CreateRequest
(*CreateResponse)(nil), // 1: shellsync.CreateResponse
@@ -689,23 +791,25 @@ var file_api_proto_shellsync_proto_goTypes = []any{
(*ServerUpdate)(nil), // 7: shellsync.ServerUpdate
(*TerminalInput)(nil), // 8: shellsync.TerminalInput
(*CreateTerminalRequest)(nil), // 9: shellsync.CreateTerminalRequest
+ (*TerminalResize)(nil), // 10: shellsync.TerminalResize
}
var file_api_proto_shellsync_proto_depIdxs = []int32{
- 4, // 0: shellsync.ClientUpdate.initial_message:type_name -> shellsync.InitialAgentMessage
- 5, // 1: shellsync.ClientUpdate.pty_output:type_name -> shellsync.TerminalOutput
- 6, // 2: shellsync.ClientUpdate.terminal_created_response:type_name -> shellsync.TerminalCreatedResponse
- 3, // 3: shellsync.ClientUpdate.terminal_error:type_name -> shellsync.TerminalError
- 8, // 4: shellsync.ServerUpdate.pty_input:type_name -> shellsync.TerminalInput
- 9, // 5: shellsync.ServerUpdate.create_terminal_request:type_name -> shellsync.CreateTerminalRequest
- 0, // 6: shellsync.ShellSync.CreateSession:input_type -> shellsync.CreateRequest
- 2, // 7: shellsync.ShellSync.Stream:input_type -> shellsync.ClientUpdate
- 1, // 8: shellsync.ShellSync.CreateSession:output_type -> shellsync.CreateResponse
- 7, // 9: shellsync.ShellSync.Stream:output_type -> shellsync.ServerUpdate
- 8, // [8:10] is the sub-list for method output_type
- 6, // [6:8] is the sub-list for method input_type
- 6, // [6:6] is the sub-list for extension type_name
- 6, // [6:6] is the sub-list for extension extendee
- 0, // [0:6] is the sub-list for field type_name
+ 4, // 0: shellsync.ClientUpdate.initial_message:type_name -> shellsync.InitialAgentMessage
+ 5, // 1: shellsync.ClientUpdate.pty_output:type_name -> shellsync.TerminalOutput
+ 6, // 2: shellsync.ClientUpdate.terminal_created_response:type_name -> shellsync.TerminalCreatedResponse
+ 3, // 3: shellsync.ClientUpdate.terminal_error:type_name -> shellsync.TerminalError
+ 8, // 4: shellsync.ServerUpdate.pty_input:type_name -> shellsync.TerminalInput
+ 9, // 5: shellsync.ServerUpdate.create_terminal_request:type_name -> shellsync.CreateTerminalRequest
+ 10, // 6: shellsync.ServerUpdate.resize_terminal:type_name -> shellsync.TerminalResize
+ 0, // 7: shellsync.ShellSync.CreateSession:input_type -> shellsync.CreateRequest
+ 2, // 8: shellsync.ShellSync.Stream:input_type -> shellsync.ClientUpdate
+ 1, // 9: shellsync.ShellSync.CreateSession:output_type -> shellsync.CreateResponse
+ 7, // 10: shellsync.ShellSync.Stream:output_type -> shellsync.ServerUpdate
+ 9, // [9:11] is the sub-list for method output_type
+ 7, // [7:9] is the sub-list for method input_type
+ 7, // [7:7] is the sub-list for extension type_name
+ 7, // [7:7] is the sub-list for extension extendee
+ 0, // [0:7] is the sub-list for field type_name
}
func init() { file_api_proto_shellsync_proto_init() }
@@ -723,6 +827,7 @@ func file_api_proto_shellsync_proto_init() {
(*ServerUpdate_ServerHello)(nil),
(*ServerUpdate_PtyInput)(nil),
(*ServerUpdate_CreateTerminalRequest)(nil),
+ (*ServerUpdate_ResizeTerminal)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
@@ -730,7 +835,7 @@ func file_api_proto_shellsync_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_shellsync_proto_rawDesc), len(file_api_proto_shellsync_proto_rawDesc)),
NumEnums: 0,
- NumMessages: 10,
+ NumMessages: 11,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/api/proto/shellsync.proto b/api/proto/shellsync.proto
index fd1c716..a7ee8fb 100644
--- a/api/proto/shellsync.proto
+++ b/api/proto/shellsync.proto
@@ -5,7 +5,6 @@ option go_package = "github.com/Ayush-Vish/shellsync/api/proto";
package shellsync;
service ShellSync {
-
// Creates a new SSH Session for a given host
rpc CreateSession(CreateRequest) returns (CreateResponse);
@@ -22,13 +21,12 @@ message CreateResponse {
string frontend_url = 2;
}
-
message ClientUpdate {
oneof payload {
InitialAgentMessage initial_message = 1;
TerminalOutput pty_output = 2;
TerminalCreatedResponse terminal_created_response = 3;
- TerminalError terminal_error = 4;
+ TerminalError terminal_error = 4;
}
}
@@ -50,12 +48,12 @@ message TerminalCreatedResponse {
string terminal_id = 1;
}
-
-message ServerUpdate{
- oneof payload{
- string server_hello =1;
+message ServerUpdate {
+ oneof payload {
+ string server_hello = 1;
TerminalInput pty_input = 2;
CreateTerminalRequest create_terminal_request = 3;
+ TerminalResize resize_terminal = 4; // NEW: Add resize support
}
}
@@ -65,5 +63,14 @@ message TerminalInput {
}
message CreateTerminalRequest {
- string terminal_id = 1;
+ string terminal_id = 1;
+}
+
+// NEW: Add resize message
+message TerminalResize {
+ string terminal_id = 1;
+ uint32 cols = 2; // Number of columns
+ uint32 rows = 3; // Number of rows
+ uint32 width = 4; // Pixel width (optional)
+ uint32 height = 5; // Pixel height (optional)
}
diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go
index 6cadcd4..9812b08 100644
--- a/backend/internal/service/service.go
+++ b/backend/internal/service/service.go
@@ -89,6 +89,7 @@ func (s *ShellSyncService) Stream(stream pb.ShellSync_StreamServer) error {
}
switch payload := msgFromAgent.Payload.(type) {
+
case *pb.ClientUpdate_PtyOutput:
output := payload.PtyOutput
terminalID := output.GetTerminalId()
@@ -152,6 +153,7 @@ func (s *ShellSyncService) Stream(stream pb.ShellSync_StreamServer) error {
}
s.hub.BroadcastToSession(sessionID, errorMsg)
}
+
}
}
}()
@@ -180,6 +182,18 @@ func (s *ShellSyncService) Stream(stream pb.ShellSync_StreamServer) error {
},
},
}
+ case types.ResizeTerminalCmd:
+ serverUpdate = &pb.ServerUpdate{
+ Payload: &pb.ServerUpdate_ResizeTerminal{
+ ResizeTerminal: &pb.TerminalResize{
+ TerminalId: cmd.TerminalID,
+ Cols: cmd.Cols,
+ Rows: cmd.Rows,
+ Width: cmd.Width,
+ Height: cmd.Height,
+ },
+ },
+ }
}
if err := stream.Send(serverUpdate); err != nil {
@@ -236,9 +250,27 @@ func (s *ShellSyncService) RequestNewTerminal(sessionID, frontendID string, x fl
Status: "creating",
X: float32(x),
Y: float32(y),
+ Width: 640, // Default width
+ Height: 400, // Default height
Data: make([]types.Message, 0, 2000),
}
session.Mu.Unlock()
+
+ // Immediately broadcast that a terminal is being created
+ if s.hub != nil {
+ creatingMsg := types.Message{
+ Type: "terminal_created",
+ TerminalID: backendTerminalID,
+ FrontendID: frontendID,
+ Status: "creating",
+ X: x,
+ Y: y,
+ Width: 640,
+ Height: 400,
+ }
+ s.hub.BroadcastToSession(sessionID, creatingMsg)
+ }
+
log.Printf("Requesting agent to create terminal with ID %s for session %s", backendTerminalID, sessionID)
select {
diff --git a/backend/internal/types/shared.go b/backend/internal/types/shared.go
index 5d76478..4498dd0 100644
--- a/backend/internal/types/shared.go
+++ b/backend/internal/types/shared.go
@@ -7,44 +7,60 @@ import (
type PTYService interface {
ForwardInputToAgent(sessionID, terminalID string, input []byte)
-
RequestNewTerminal(sessionID, frontendID string, x float32, y float32)
GetSession(sessionID string) (*Session, bool)
GetSessions() []*Session
AddClientToSession(sessionID, clientID string) bool
-
SetHub(hub PtyOutputBroadcaster)
}
type Message struct {
Type string `json:"type"`
- TerminalID string `json:"terminal_id,omitempty"`
Content string `json:"content,omitempty"`
Sender string `json:"sender,omitempty"`
- FrontendID string `json:"frontend_id,omitempty"`
- Error string `json:"error,omitempty"`
- Terminals []TerminalInfo `json:"terminals,omitempty"`
+ TerminalID string `json:"terminalId,omitempty"`
+ FrontendID string `json:"frontendId,omitempty"`
Status string `json:"status,omitempty"`
+ Error string `json:"error,omitempty"`
X float32 `json:"x,omitempty"`
Y float32 `json:"y,omitempty"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
+ Terminals []TerminalInfo `json:"terminals,omitempty"`
}
+// ResizeTerminalCmd with AgentCommand interface implementation
+type ResizeTerminalCmd struct {
+ TerminalID string
+ Cols uint32
+ Rows uint32
+ Width uint32
+ Height uint32
+}
+
+// Add this method to implement AgentCommand interface
+func (ResizeTerminalCmd) isAgentCommand() {}
+
type TerminalInfo struct {
- TerminalID string `json:"terminal_id"`
- FrontendID string `json:"frontend_id"`
+ TerminalID string `json:"terminalId"`
+ FrontendID string `json:"frontendId"`
Status string `json:"status"`
- X float32 `json:"x"` // Add position for canvas
+ X float32 `json:"x"`
Y float32 `json:"y"`
+ Width int `json:"width,omitempty"`
+ Height int `json:"height,omitempty"`
}
type Terminal struct {
ID string
FrontendID string
- CreatedAt time.Time
Status string
- X float32 // Add position
+ X float32
Y float32
- Data []Message // Store terminal output for new clients
+ Width int
+ Height int
+ CreatedAt time.Time
+ Data []Message
}
type PtyOutputBroadcaster interface {
diff --git a/backend/internal/websocket/hub.go b/backend/internal/websocket/hub.go
index 584cebe..d51b4c8 100644
--- a/backend/internal/websocket/hub.go
+++ b/backend/internal/websocket/hub.go
@@ -59,20 +59,9 @@ func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
return
}
- // Register the client and start the read loop to process messages from them.
h.registerClient(conn, session, clientID)
- // FIX: The original code was missing the call to the read loop.
go h.readLoop(conn, sessionID, clientID)
}
-func (h *Hub) ensureSessionExists(sessionID string) {
- h.mu.Lock()
- defer h.mu.Unlock()
-
- if h.sessions[sessionID] == nil {
- h.sessions[sessionID] = make(map[string]bool)
- log.Printf("Created session %s in WebSocket hub", sessionID)
- }
-}
func (h *Hub) registerClient(conn *websocket.Conn, session *types.Session, clientID string) {
h.mu.Lock()
defer h.mu.Unlock()
@@ -106,6 +95,8 @@ func (h *Hub) registerClient(conn *websocket.Conn, session *types.Session, clien
Status: term.Status,
X: term.X,
Y: term.Y,
+ Width: term.Width,
+ Height: term.Height,
})
}
session.Mu.RUnlock()
@@ -171,27 +162,6 @@ func (h *Hub) writeLoop(c *client, clientID string) {
c.mu.Unlock()
}
}
-
-func normalizeMessage(msg types.Message) map[string]interface{} {
- result := map[string]interface{}{
- "type": msg.Type,
- "content": msg.Content,
- "sender": msg.Sender,
- }
-
- if msg.TerminalID != "" {
- result["terminalId"] = msg.TerminalID
- }
- if msg.FrontendID != "" {
- result["frontendId"] = msg.FrontendID
- }
- if msg.Error != "" {
- result["error"] = msg.Error
- }
-
- return result
-}
-
func (h *Hub) readLoop(conn *websocket.Conn, sessionID, clientID string) {
defer func() {
h.unregisterClient(clientID, sessionID)
@@ -226,9 +196,9 @@ func (h *Hub) readLoop(conn *websocket.Conn, sessionID, clientID string) {
case "create_terminal":
var payload struct {
- FrontendID string `json:"frontendId"`
- X float32 `json:"x"`
- Y float32 `json:"y"`
+ FrontendID string `json:"frontendId"`
+ X float32 `json:"x"`
+ Y float32 `json:"y"`
}
if err := json.Unmarshal([]byte(msg.Content), &payload); err != nil {
log.Printf("Error unmarshalling create_terminal payload from client %s: %v", clientID, err)
@@ -240,6 +210,116 @@ func (h *Hub) readLoop(conn *websocket.Conn, sessionID, clientID string) {
}
h.service.RequestNewTerminal(sessionID, payload.FrontendID, payload.X, payload.Y)
+ // ADD THIS CASE: Handle resize messages
+ case "resize":
+ var payload struct {
+ Cols uint32 `json:"cols"`
+ Rows uint32 `json:"rows"`
+ Width uint32 `json:"width"`
+ Height uint32 `json:"height"`
+ }
+ if err := json.Unmarshal([]byte(msg.Content), &payload); err != nil {
+ log.Printf("Error unmarshalling resize payload from client %s: %v", clientID, err)
+ continue
+ }
+ if msg.TerminalID == "" {
+ log.Printf("Received resize from client %s without a terminalId", clientID)
+ continue
+ }
+
+ log.Printf("Processing resize for terminal %s: %dx%d (cols x rows)",
+ msg.TerminalID, payload.Cols, payload.Rows)
+
+ // Forward resize command to service
+ session, exists := h.service.GetSession(sessionID)
+ if !exists {
+ log.Printf("Session %s not found when resizing terminal", sessionID)
+ continue
+ }
+
+ // Send resize command to agent
+ resizeCmd := types.ResizeTerminalCmd{
+ TerminalID: msg.TerminalID,
+ Cols: payload.Cols,
+ Rows: payload.Rows,
+ Width: payload.Width,
+ Height: payload.Height,
+ }
+
+ select {
+ case session.AgentInputChan <- resizeCmd:
+ log.Printf("Sent resize command for terminal %s: %dx%d", msg.TerminalID, payload.Cols, payload.Rows)
+ default:
+ log.Printf("Agent input channel for session %s is full. Resize command dropped.", sessionID)
+ }
+
+ // Also update terminal dimensions in session
+ session.Mu.Lock()
+ if terminal, ok := session.Terminals[msg.TerminalID]; ok {
+ terminal.Width = int(payload.Width)
+ terminal.Height = int(payload.Height)
+
+ // Broadcast resize to all clients
+ resizeMsg := types.Message{
+ Type: "terminal_resized",
+ TerminalID: msg.TerminalID,
+ Width: int(payload.Width),
+ Height: int(payload.Height),
+ }
+ h.BroadcastToSession(sessionID, resizeMsg)
+ }
+ session.Mu.Unlock()
+
+ // ADD THIS CASE: Handle position_update messages
+ case "position_update":
+ var payload struct {
+ X float32 `json:"x"`
+ Y float32 `json:"y"`
+ Width int `json:"width"`
+ Height int `json:"height"`
+ }
+ if err := json.Unmarshal([]byte(msg.Content), &payload); err != nil {
+ log.Printf("Error unmarshalling position_update payload from client %s: %v", clientID, err)
+ continue
+ }
+ if msg.TerminalID == "" {
+ log.Printf("Received position_update from client %s without a terminalId", clientID)
+ continue
+ }
+
+ log.Printf("Processing position update for terminal %s: (%.2f, %.2f)",
+ msg.TerminalID, payload.X, payload.Y)
+
+ // Update terminal position and dimensions in session
+ session, exists := h.service.GetSession(sessionID)
+ if !exists {
+ log.Printf("Session %s not found when updating terminal position", sessionID)
+ continue
+ }
+
+ session.Mu.Lock()
+ if terminal, ok := session.Terminals[msg.TerminalID]; ok {
+ terminal.X = payload.X
+ terminal.Y = payload.Y
+ terminal.Width = payload.Width
+ terminal.Height = payload.Height
+
+ // Broadcast position update to all clients
+ updateMsg := types.Message{
+ Type: "terminal_position_updated",
+ TerminalID: msg.TerminalID,
+ X: payload.X,
+ Y: payload.Y,
+ Width: payload.Width,
+ Height: payload.Height,
+ }
+ h.BroadcastToSession(sessionID, updateMsg)
+ log.Printf("Broadcast position update for terminal %s", msg.TerminalID)
+ } else {
+ log.Printf("Terminal %s not found in session %s", msg.TerminalID, sessionID)
+ }
+ session.Mu.Unlock()
+
case "subscribe":
h.sendTerminalHistory(sessionID, clientID, msg.TerminalID)
@@ -248,19 +328,6 @@ func (h *Hub) readLoop(conn *websocket.Conn, sessionID, clientID string) {
}
}
}
-
-func getInt(m map[string]interface{}, key string) int64 {
- if val, ok := m[key]; ok {
- switch v := val.(type) {
- case float64:
- return int64(v)
- case int:
- return int64(v)
- }
- }
- return 0
-}
-
func getString(m map[string]interface{}, key string) string {
if val, ok := m[key]; ok {
if str, ok := val.(string); ok {
diff --git a/client/controller/controller.go b/client/controller/controller.go
index d0613f2..0984525 100644
--- a/client/controller/controller.go
+++ b/client/controller/controller.go
@@ -32,14 +32,14 @@ func NewAgent() *Agent {
terminalMap: make(map[string]string),
}
}
+
func getDefaultShell(ctx context.Context) *exec.Cmd {
var shell string
var args []string
switch runtime.GOOS {
case "windows":
- // Use PowerShell or cmd based on environment
- shell = os.Getenv("COMSPEC") // Usually cmd.exe
+ shell = os.Getenv("COMSPEC")
if shell == "" {
shell = "cmd.exe"
}
@@ -55,12 +55,52 @@ func getDefaultShell(ctx context.Context) *exec.Cmd {
return exec.CommandContext(ctx, shell, args...)
}
+
+func (agent *Agent) getSize() int64 {
+ var size int64
+ agent.mu.RLock()
+ for _, ptmx := range agent.ptys {
+ if stat, err := ptmx.Stat(); err == nil {
+ size += stat.Size()
+ }
+ }
+ agent.mu.RUnlock()
+ return size
+}
+
+// NEW: Function to resize PTY
+func (a *Agent) resizePty(terminalID string, cols uint32, rows uint32) error {
+ a.mu.RLock()
+ localID, found := a.terminalMap[terminalID]
+ ptmx, ok := a.ptys[localID]
+ a.mu.RUnlock()
+
+ if !found || !ok {
+ return fmt.Errorf("terminal %s not found", terminalID)
+ }
+
+ size := &pty.Winsize{
+ Cols: uint16(cols),
+ Rows: uint16(rows),
+ }
+ fmt.Println("Agent: Resizing terminal", terminalID, "to", cols, "x", rows)
+
+ if err := pty.Setsize(ptmx, size); err != nil {
+ return fmt.Errorf("failed to resize PTY: %w", err)
+ }
+
+ log.Printf("Agent: Resized terminal %s (local ID: %s) to %dx%d",
+ terminalID, localID, cols, rows)
+ return nil
+}
+
func (a *Agent) spawnNewPty(ctx context.Context, stream pb.ShellSync_StreamClient, backendID string) error {
localID := "term-" + uuid.New().String()[:8]
cmd := getDefaultShell(ctx)
ptmx, err := pty.Start(cmd)
+
if err != nil {
log.Printf("Agent: Failed to start PTY for terminal %s: %v", backendID, err)
errorMsg := &pb.ClientUpdate{
@@ -78,6 +118,21 @@ func (a *Agent) spawnNewPty(ctx context.Context, stream pb.ShellSync_StreamClien
}
log.Printf("Agent: New PTY started with ID: %s (maps to backend ID: %s)", localID, backendID)
+ initialSize := &pty.Winsize{
+ Cols: 80,
+ Rows: 24,
+ }
+ if err := pty.Setsize(ptmx, initialSize); err != nil {
+ log.Printf("Agent: Failed to set initial PTY size for terminal %s: %v", backendID, err)
+ }
+
+ psize, err := pty.GetsizeFull(ptmx)
+ if err != nil {
+ log.Printf("Agent: Failed to get PTY size for terminal %s: %v", backendID, err)
+ } else {
+ log.Printf("PTY Size: Cols=%d, Rows=%d", psize.Cols, psize.Rows)
+ }
+
a.mu.Lock()
a.ptys[localID] = ptmx
a.terminalMap[backendID] = localID
@@ -192,6 +247,29 @@ func startStream(client pb.ShellSyncClient, sessionID string) error {
log.Printf("Agent: Failed to spawn new terminal: %v", err)
}
+ // NEW: Handle terminal resize
+ case *pb.ServerUpdate_ResizeTerminal:
+ resize := payload.ResizeTerminal
+ log.Printf("Agent: Received resize request for terminal %s: %dx%d",
+ resize.GetTerminalId(), resize.GetCols(), resize.GetRows())
+
+ if err := agent.resizePty(resize.GetTerminalId(), resize.GetCols(), resize.GetRows()); err != nil {
+ log.Printf("Agent: Failed to resize terminal %s: %v", resize.GetTerminalId(), err)
+
+ // Send error back to server
+ errorMsg := &pb.ClientUpdate{
+ Payload: &pb.ClientUpdate_TerminalError{
+ TerminalError: &pb.TerminalError{
+ TerminalId: resize.GetTerminalId(),
+ Error: fmt.Sprintf("Resize failed: %v", err),
+ },
+ },
+ }
+ if sendErr := stream.Send(errorMsg); sendErr != nil {
+ log.Printf("Agent: Failed to send resize error: %v", sendErr)
+ }
+ }
+
case *pb.ServerUpdate_ServerHello:
log.Printf("Agent: Server says: %s", payload.ServerHello)
}
@@ -223,6 +301,7 @@ func Start(host string, port int) {
if err != nil {
log.Fatalf("Session creation failed: %v", err)
}
+
log.Printf("Session %s created successfully.", resp.GetSessionId())
fmt.Printf("\nShare this URL:\n ► %s ◄\n\n", resp.GetFrontendUrl())
diff --git a/frontend/src/app/ws/[slug]/page.tsx b/frontend/src/app/ws/[slug]/page.tsx
index 2bbd2c2..7d26e56 100644
--- a/frontend/src/app/ws/[slug]/page.tsx
+++ b/frontend/src/app/ws/[slug]/page.tsx
@@ -22,6 +22,7 @@ export default function CanvasPage() {
const [clientId] = useState(() =>
searchParams.get('client_id') || `client_${Math.random().toString(36).substr(2, 9)}`
);
+
const handleSocketMessage = useCallback((message: SocketMessage) => {
console.log('Canvas received socket message:', message);
@@ -36,6 +37,8 @@ export default function CanvasPage() {
color: "#4bd2f3",
terminalId: term.terminalId,
status: term.status as 'creating' | 'ready' | 'error',
+ width: term.width || 640, // Add width from backend
+ height: term.height || 400, // Add height from backend
}));
newItems.forEach(item => {
@@ -69,6 +72,8 @@ export default function CanvasPage() {
...updatedItems[existingItemIndex],
terminalId: message.terminalId,
status: 'ready',
+ width: message.width || 640, // Set width
+ height: message.height || 400, // Set height
};
} else {
console.log(`Creating new terminal for other client, frontendId: ${message.frontendId}`);
@@ -78,6 +83,8 @@ export default function CanvasPage() {
color: "#4bd2f3",
terminalId: message.terminalId,
status: 'ready',
+ width: message.width || 640, // Set width
+ height: message.height || 400, // Set height
};
updatedItems.push(newItem);
}
@@ -102,18 +109,73 @@ export default function CanvasPage() {
);
setIsCreatingTerminal(false);
}
- }, []); // Empty dependency array as we've removed the need for `sendMessage`.
+
+ if (message.type === 'terminal_removed' && message.terminalId) {
+ const terminalIdToRemove = message.terminalId;
+ setItems(prevItems => prevItems.filter(item => item.terminalId !== terminalIdToRemove));
+ terminalRefs.current.delete(terminalIdToRemove);
+ subscribedTerminalIds.current.delete(terminalIdToRemove);
+ return;
+ }
+
+ if (message.type === 'terminal_position_updated' && message.terminalId && message.x !== undefined && message.y !== undefined) {
+ console.log('Received position update:', { terminalId: message.terminalId, x: message.x, y: message.y });
+ setItems(prevItems => {
+ const updatedItems = [...prevItems];
+ const itemIndex = updatedItems.findIndex(item => item.terminalId === message.terminalId);
+
+ if (itemIndex !== -1) {
+ const currentItem = updatedItems[itemIndex];
+ if (currentItem.position.x !== message.x || currentItem.position.y !== message.y) {
+ updatedItems[itemIndex] = {
+ ...currentItem,
+ position: {
+ x: message.x as number,
+ y: message.y as number
+ }
+ };
+ return updatedItems;
+ }
+ }
+ return prevItems;
+ });
+ return;
+ }
+
+ // ADD THIS: Handle terminal resize updates from other clients
+ if (message.type === 'terminal_resized' && message.terminalId && message.width !== undefined && message.height !== undefined) {
+ console.log('Received resize update:', { terminalId: message.terminalId, width: message.width, height: message.height });
+ setItems(prevItems => {
+ const updatedItems = [...prevItems];
+ const itemIndex = updatedItems.findIndex(item => item.terminalId === message.terminalId);
+
+ if (itemIndex !== -1) {
+ const currentItem = updatedItems[itemIndex];
+ if (currentItem.width !== message.width || currentItem.height !== message.height) {
+ updatedItems[itemIndex] = {
+ ...currentItem,
+ width: message.width,
+ height: message.height,
+ };
+ return updatedItems;
+ }
+ }
+ return prevItems;
+ });
+ return;
+ }
+ }, []);
const {
sendMessage,
isConnected,
+ removeTerminal,
} = useTerminalSocket(
sessionId,
clientId,
handleSocketMessage
);
-
useEffect(() => {
items.forEach(item => {
if (item.terminalId && !subscribedTerminalIds.current.has(item.terminalId)) {
@@ -124,7 +186,6 @@ export default function CanvasPage() {
});
}, [items, sendMessage]);
-
const handleAddItem = useCallback(() => {
if (!isConnected || isCreatingTerminal) return;
@@ -135,11 +196,20 @@ export default function CanvasPage() {
position: canvasRef.current?.getCanvasCenter() ?? { x: 200, y: 200 },
color: "#4bd2f3",
status: 'creating',
+ width: 640, // Set initial width
+ height: 400, // Set initial height
+ type: 'terminal',
};
setItems(prevItems => [...prevItems, newItem]);
- const payload = { frontendId, x: newItem.position.x, y: newItem.position.y };
+ const payload = {
+ frontendId,
+ x: newItem.position.x,
+ y: newItem.position.y,
+ width: newItem.width, // Send initial dimensions
+ height: newItem.height,
+ };
sendMessage('create_terminal', JSON.stringify(payload));
}, [isConnected, isCreatingTerminal, sendMessage]);
@@ -149,21 +219,35 @@ export default function CanvasPage() {
}, []);
const handlePositionChange = useCallback((id: string, position: { x: number; y: number }) => {
- setItems(currentItems =>
- currentItems.map(item =>
+ setItems(currentItems => {
+ const updatedItems = currentItems.map(item =>
item.id === id ? { ...item, position } : item
- )
- );
- }, []);
+ );
+
+ const movedItem = updatedItems.find(item => item.id === id);
+ if (movedItem?.terminalId) {
+ const payload = {
+ terminalId: movedItem.terminalId,
+ x: position.x,
+ y: position.y,
+ };
+ sendMessage(
+ 'terminal_position_updated',
+ JSON.stringify(payload),
+ movedItem.terminalId
+ );
+ }
+
+ return updatedItems;
+ });
+ }, [sendMessage]);
const handleRemoveItem = useCallback((id: string) => {
const itemToRemove = items.find(item => item.id === id);
if (itemToRemove && itemToRemove.terminalId) {
- terminalRefs.current.delete(itemToRemove.terminalId);
- subscribedTerminalIds.current.delete(itemToRemove.terminalId); // Clean up subscription tracking
+ removeTerminal(itemToRemove.terminalId);
}
- setItems(currentItems => currentItems.filter(item => item.id !== id));
- }, [items]);
+ }, [items, removeTerminal]);
return (