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
25 changes: 25 additions & 0 deletions .github/workflows/server-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Test for the server/ directory

on:
push:
branches: ["main"]
pull_request:
branches: ["main"]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "server/go.mod"
cache: true

- name: Run server Makefile tests
run: make test
working-directory: server
34 changes: 34 additions & 0 deletions server/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Created by https://www.toptal.com/developers/gitignore/api/go
# Edit at https://www.toptal.com/developers/gitignore?templates=go

### Go ###
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib

# Test binary, built with `go test -c`
*.test

# Output of the go coverage tool, specifically when used with LiteIDE
*.out

# Dependency directories (remove the comment below to include it)
# vendor/

# Go workspace file
go.work

# End of https://www.toptal.com/developers/gitignore/api/go

.tmp/
bin/
recordings/

# downconverted openapi spec
openapi-3.0.yaml
40 changes: 40 additions & 0 deletions server/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
SHELL := /bin/bash
.PHONY: oapi-generate build dev test clean

BIN_DIR ?= $(CURDIR)/bin
OAPI_CODEGEN ?= $(BIN_DIR)/oapi-codegen
RECORDING_DIR ?= $(CURDIR)/recordings

$(BIN_DIR):
mkdir -p $(BIN_DIR)

$(RECORDING_DIR):
mkdir -p $(RECORDING_DIR)

$(OAPI_CODEGEN): | $(BIN_DIR)
GOBIN=$(BIN_DIR) go install github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest

# Generate Go code from the OpenAPI spec
# 1. Convert 3.1 → 3.0 since oapi-codegen doesn't support 3.1 yet (https://github.com/oapi-codegen/oapi-codegen/issues/373)
# 2. Run oapi-codegen with our config
# 3. go mod tidy to pull deps
oapi-generate: $(OAPI_CODEGEN)
pnpm i -g @apiture/openapi-down-convert
openapi-down-convert --input openapi.yaml --output openapi-3.0.yaml
$(OAPI_CODEGEN) -config ./oapi-codegen.yaml ./openapi-3.0.yaml
go mod tidy

build: | $(BIN_DIR)
go build -o $(BIN_DIR)/api ./cmd/api

dev: build $(RECORDING_DIR)
OUTPUT_DIR=$(RECORDING_DIR) ./bin/api

test:
go vet ./...
go test -v -race ./...

clean:
@rm -rf $(BIN_DIR)
@rm -f openapi-3.0.yaml
@echo "Clean complete"
90 changes: 90 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Kernel Images Server

A REST API server to start, stop, and download screen recordings.

## 🛠️ Prerequisites

### Required Software

- **Go 1.24.3+** - Programming language runtime
- **FFmpeg** - Video recording engine
- macOS: `brew install ffmpeg`
- Linux: `sudo apt install ffmpeg` or `sudo yum install ffmpeg`
- **Node.js/pnpm** - For OpenAPI code generation
- `npm install -g pnpm`

### System Requirements

- **macOS**: Uses AVFoundation for screen capture
- **Linux**: Uses X11 for screen capture
- **Windows**: Not currently supported

## 🚀 Quick Start

### Running the Server

```bash
make dev
```

The server will start on port 10001 by default and log its configuration.

#### Example use

```bash
# 1. Start a new recording
curl http://localhost:10001/recording/start

# (recording in progress)

# 2. Stop recording and clean up resources
curl http://localhost:10001/recording/stop

# 3. Download the recorded file
curl http://localhost:10001/recording/download --output foo.mp4
```

### ⚙️ Configuration

Configure the server using environment variables:

| Variable | Default | Description |
| ------------- | ------- | --------------------------------- |
| `PORT` | `10001` | HTTP server port |
| `FRAME_RATE` | `10` | Default recording framerate (fps) |
| `DISPLAY_NUM` | `1` | Display/screen number to capture |
| `MAX_SIZE_MB` | `500` | Default maximum file size (MB) |
| `OUTPUT_DIR` | `.` | Directory to save recordings |

#### Example Configuration

```bash
export PORT=8080
export FRAME_RATE=30
export MAX_SIZE_MB=1000
export OUTPUT_DIR=/tmp/recordings
./bin/api
```

### API Documentation

- **YAML Spec**: `GET /spec.yaml`
- **JSON Spec**: `GET /spec.json`

## 🔧 Development

### Code Generation

The server uses OpenAPI code generation. After modifying `openapi.yaml`:

```bash
make oapi-generate
```

## 🧪 Testing

### Running Tests

```bash
make test
```
123 changes: 123 additions & 0 deletions server/cmd/api/api/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package api

import (
"context"

"github.com/onkernel/kernel-images/server/lib/logger"
oapi "github.com/onkernel/kernel-images/server/lib/oapi"
"github.com/onkernel/kernel-images/server/lib/recorder"
)

// ApiService implements the API endpoints
// It manages a single recording session and provides endpoints for starting, stopping, and downloading it
type ApiService struct {
mainRecorderID string // ID used for the primary recording session
recordManager recorder.RecordManager
factory recorder.FFmpegRecorderFactory
}

func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService {
return &ApiService{
recordManager: recordManager,
factory: factory,
mainRecorderID: "main", // use a single recorder for now
}
}

func (s *ApiService) StartRecording(ctx context.Context, req oapi.StartRecordingRequestObject) (oapi.StartRecordingResponseObject, error) {
log := logger.FromContext(ctx)

if rec, exists := s.recordManager.GetRecorder(s.mainRecorderID); exists && rec.IsRecording(ctx) {
log.Error("attempted to start recording while one is already active")
return oapi.StartRecording409JSONResponse{ConflictErrorJSONResponse: oapi.ConflictErrorJSONResponse{Message: "recording already in progress"}}, nil
}

var params recorder.FFmpegRecordingParams
if req.Body != nil {
params.FrameRate = req.Body.Framerate
params.MaxSizeInMB = req.Body.MaxFileSizeInMB
}

// Create, register, and start a new recorder
rec, err := s.factory(s.mainRecorderID, params)
if err != nil {
log.Error("failed to create recorder", "err", err)
return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create recording"}}, nil
}
if err := s.recordManager.RegisterRecorder(ctx, rec); err != nil {
log.Error("failed to register recorder", "err", err)
return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to register recording"}}, nil
}

if err := rec.Start(ctx); err != nil {
log.Error("failed to start recording", "err", err)
// ensure the recorder is deregistered if we fail to start
defer s.recordManager.DeregisterRecorder(ctx, rec)
return oapi.StartRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start recording"}}, nil
}

return oapi.StartRecording201Response{}, nil
}

func (s *ApiService) StopRecording(ctx context.Context, req oapi.StopRecordingRequestObject) (oapi.StopRecordingResponseObject, error) {
log := logger.FromContext(ctx)

rec, exists := s.recordManager.GetRecorder(s.mainRecorderID)
if !exists || !rec.IsRecording(ctx) {
log.Warn("attempted to stop recording when none is active")
return oapi.StopRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no active recording to stop"}}, nil
}

// Check if force stop is requested
forceStop := false
if req.Body != nil && req.Body.ForceStop != nil {
forceStop = *req.Body.ForceStop
}

var err error
if forceStop {
log.Info("force stopping recording")
err = rec.ForceStop(ctx)
} else {
log.Info("gracefully stopping recording")
err = rec.Stop(ctx)
}

if err != nil {
log.Error("error occurred while stopping recording", "err", err, "force", forceStop)
}

return oapi.StopRecording200Response{}, nil
}

func (s *ApiService) DownloadRecording(ctx context.Context, req oapi.DownloadRecordingRequestObject) (oapi.DownloadRecordingResponseObject, error) {
log := logger.FromContext(ctx)

// Get the recorder to access its output path
rec, exists := s.recordManager.GetRecorder(s.mainRecorderID)
if !exists {
log.Error("attempted to download non-existent recording")
return oapi.DownloadRecording404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "no recording found"}}, nil
}

if rec.IsRecording(ctx) {
log.Warn("attempted to download recording while is still in progress")
return oapi.DownloadRecording400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "recording still in progress, please stop first"}}, nil
}

out, meta, err := rec.Recording(ctx)
if err != nil {
log.Error("failed to get recording", "err", err)
return oapi.DownloadRecording500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to get recording"}}, nil
}

log.Info("serving recording file for download", "size", meta.Size)
return oapi.DownloadRecording200Videomp4Response{
Body: out,
ContentLength: meta.Size,
}, nil
}

func (s *ApiService) Shutdown(ctx context.Context) error {
return s.recordManager.StopAll(ctx)
}
Loading
Loading