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
3 changes: 2 additions & 1 deletion .github/workflows/build.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ jobs:
env:
DEVCELL_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-${{ matrix.variant }}
DEVCELL_BASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base
DEVCELL_TEST_BASE_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-base
DEVCELL_USER_IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_LC }}:v0.0.0-${{ matrix.arch }}-${{ matrix.variant }}
MCP_SECRET_TEST_PASSWORD: fake-secret-ci-value
MCP_SECRET_GITHUB_TOKEN: fake-token-ci-value
Expand Down Expand Up @@ -221,7 +222,7 @@ jobs:
deploy-site:
name: Deploy Site
needs: secrets
if: contains(fromJSON('["refs/heads/main","refs/heads/feature/wip","refs/heads/feature/add-web","refs/heads/feature/web"]'), github.ref)
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment:
name: github-pages
Expand Down
112 changes: 104 additions & 8 deletions cmd/rdp.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/exec"
"runtime"
"strings"

"github.com/DimmKirr/devcell/internal/config"
Expand All @@ -23,6 +24,8 @@ func init() {
rdpCmd.Flags().Bool("list", false, "list all running cell containers and their RDP ports")
rdpCmd.Flags().String("app", "", "open RDP to a named container (by AppName)")
rdpCmd.Flags().Bool("verbose", false, "show debug info for RDP port lookup")
rdpCmd.Flags().Bool("fullscreen", false, "open RDP session in fullscreen mode")
rdpCmd.Flags().String("viewer", "", "RDP viewer: freerdp (default), macrdp, royaltsx")
}

func runRDP(cmd *cobra.Command, _ []string) error {
Expand All @@ -34,6 +37,8 @@ func runRDP(cmd *cobra.Command, _ []string) error {
}
list, _ := cmd.Flags().GetBool("list")
appName, _ := cmd.Flags().GetString("app")
rdpFullscreen, _ = cmd.Flags().GetBool("fullscreen")
rdpViewer, _ = cmd.Flags().GetString("viewer")

if list {
return rdpList()
Expand All @@ -44,16 +49,103 @@ func runRDP(cmd *cobra.Command, _ []string) error {
return rdpDefault()
}

func rdpDefault() error {
if port := os.Getenv("EXT_RDP_PORT"); port != "" {
rdpDebug("EXT_RDP_PORT=%s (fast path)", port)
return openURL(internalrdp.RDPUrl(port))
var (
rdpFullscreen bool // set by --fullscreen flag
rdpViewer string // set by --viewer flag
)

// openRDP dispatches to the selected viewer.
// Default: FreeRDP → macOS Windows App fallback (darwin only).
func openRDP(c config.Config, port string) error {
switch rdpViewer {
case "macrdp":
return openMacRDP(port)
case "royaltsx":
return openRoyalTSX(c, port)
case "freerdp":
return openFreeRDP(c, port)
case "":
// Auto: Royal TSX (darwin) → FreeRDP → macOS Windows App (darwin)
if runtime.GOOS == "darwin" && internalrdp.HasRoyalTSX() {
rdpDebug("auto-detected Royal TSX")
return openRoyalTSX(c, port)
}
if client, found := internalrdp.FindClient(); found {
return openFreeRDPWith(c, port, client)
}
if runtime.GOOS == "darwin" {
rdpDebug("no Royal TSX or FreeRDP found, falling back to macOS Windows App")
fmt.Fprintf(os.Stderr, "Tip: install Royal TSX or FreeRDP for a better experience:\n brew install freerdp\n\n")
return openMacRDP(port)
}
return fmt.Errorf("%s", internalrdp.InstallHint())
default:
return fmt.Errorf("unknown viewer %q — use freerdp, macrdp, or royaltsx", rdpViewer)
}
}

// openFreeRDP connects via FreeRDP (auto-login, clipboard, cert verification).
func openFreeRDP(c config.Config, port string) error {
client, found := internalrdp.FindClient()
if !found {
return fmt.Errorf("%s", internalrdp.InstallHint())
}
return openFreeRDPWith(c, port, client)
}

func openFreeRDPWith(c config.Config, port string, client internalrdp.ClientBinary) error {
certFlag := internalrdp.CertFlag(c.ConfigDir)
rdpDebug("using %s (%s), cert: %s", client.Name, client.Path, certFlag)
args := []string{
"/v:127.0.0.1:" + port,
"/u:" + c.HostUser,
"/p:rdp",
"/admin",
certFlag,
"+clipboard",
"/log-level:FATAL",
}
if rdpFullscreen {
args = append(args, "/f", "/smart-sizing")
} else {
args = append(args, "/w:1920", "/h:1080")
}
cmd := exec.Command(client.Path, args...)
if runtime.GOOS == "darwin" && strings.HasPrefix(client.Name, "sdl-") {
fmt.Fprintf(os.Stderr, "Using SDL on macOS — the screen may flicker for a moment, this is normal.\n")
}
if runtime.GOOS == "darwin" {
return cmd.Start()
}
return cmd.Run()
}

// openMacRDP opens the connection via macOS Windows App (rdp:// URI).
func openMacRDP(port string) error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("macrdp viewer is only available on macOS")
}
rdpDebug("opening macOS Windows App for port %s", port)
return openURL(internalrdp.RDPUrl(port))
}

// openRoyalTSX opens the connection via Royal TSX (rtsx:// URI).
func openRoyalTSX(c config.Config, port string) error {
rdpDebug("opening Royal TSX for port %s", port)
return openURL(internalrdp.RoyalTSXUrl(port, c.HostUser, "rdp"))
}

func rdpDefault() error {
c, err := config.LoadFromOS()
if err != nil {
return err
}

if port := os.Getenv("EXT_RDP_PORT"); port != "" {
rdpDebug("EXT_RDP_PORT=%s (fast path)", port)
return openRDP(c, port)
}

rdpDebug("basedir: %s", c.BaseDir)
rdpDebug("cellID: %s (computed port: %s)", c.CellID, c.RDPPort)

Expand All @@ -67,7 +159,7 @@ func rdpDefault() error {
if m, _ := internalrdp.ParseDockerPS(string(bytes.TrimSpace(out))); len(m) > 0 {
for appName, port := range m {
rdpDebug("label-exact match: %s → %s", appName, port)
return openURL(internalrdp.RDPUrl(port))
return openRDP(c, port)
}
}
}
Expand All @@ -82,7 +174,7 @@ func rdpDefault() error {
if len(m) == 1 {
for appName, port := range m {
rdpDebug("label-dir single match: %s → %s", appName, port)
return openURL(internalrdp.RDPUrl(port))
return openRDP(c, port)
}
}
var opts []string
Expand Down Expand Up @@ -114,7 +206,7 @@ func rdpDefault() error {
case 0:
return fmt.Errorf("no running container found for %q — run 'cell rdp --list' to see all", c.BaseDir)
case 1:
return openURL(internalrdp.RDPUrl(matches[0].Port))
return openRDP(c, matches[0].Port)
default:
var opts []string
for _, m := range matches {
Expand Down Expand Up @@ -153,6 +245,10 @@ func rdpList() error {
}

func rdpApp(appName string) error {
c, err := config.LoadFromOS()
if err != nil {
return err
}
containerName := "cell-" + appName + "-run"
out, err := exec.Command("docker", "inspect", containerName).Output()
if err != nil {
Expand All @@ -162,5 +258,5 @@ func rdpApp(appName string) error {
if err != nil {
return fmt.Errorf("RDP port not published for %q: %w", appName, err)
}
return openURL(internalrdp.RDPUrl(port))
return openRDP(c, port)
}
75 changes: 70 additions & 5 deletions cmd/vnc.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/DimmKirr/devcell/internal/config"
internalrdp "github.com/DimmKirr/devcell/internal/rdp"
internalvnc "github.com/DimmKirr/devcell/internal/vnc"
"github.com/DimmKirr/devcell/internal/ux"
"github.com/spf13/cobra"
Expand All @@ -24,6 +25,7 @@ func init() {
vncCmd.Flags().Bool("list", false, "list all running cell containers and their VNC ports")
vncCmd.Flags().String("app", "", "open VNC to a named container (by AppName)")
vncCmd.Flags().Bool("verbose", false, "show debug info for VNC port lookup")
vncCmd.Flags().String("viewer", "", "VNC viewer: royaltsx, tigervnc, screensharing (macOS)")
}

func runVNC(cmd *cobra.Command, _ []string) error {
Expand All @@ -35,6 +37,7 @@ func runVNC(cmd *cobra.Command, _ []string) error {
}
list, _ := cmd.Flags().GetBool("list")
appName, _ := cmd.Flags().GetString("app")
vncViewer, _ = cmd.Flags().GetString("viewer")

if list {
return vncList()
Expand All @@ -45,13 +48,75 @@ func runVNC(cmd *cobra.Command, _ []string) error {
return vncDefault()
}

// vncViewer is set by the --viewer flag.
var vncViewer string

// openVNC dispatches to the selected VNC viewer.
// Default: Royal TSX (darwin) → TigerVNC → macOS Screen Sharing (darwin).
func openVNC(port string) error {
switch vncViewer {
case "royaltsx":
return openVNCRoyalTSX(port)
case "tigervnc":
return openVNCTigerVNC(port)
case "screensharing":
return openVNCScreenSharing(port)
case "":
// Auto: Royal TSX → TigerVNC → Screen Sharing
if runtime.GOOS == "darwin" && internalrdp.HasRoyalTSX() {
vncDebug("auto-detected Royal TSX")
return openVNCRoyalTSX(port)
}
if path, err := exec.LookPath("vncviewer"); err == nil {
vncDebug("auto-detected TigerVNC at %s", path)
return openVNCTigerVNC(port)
}
if runtime.GOOS == "darwin" {
vncDebug("falling back to macOS Screen Sharing")
fmt.Fprintf(os.Stderr, "Tip: for a better VNC experience, install one of:\n"+
" 1. Royal TSX — https://royalapps.com/ts/mac\n"+
" 2. TigerVNC — brew install tiger-vnc\n\n")
return openVNCScreenSharing(port)
}
return fmt.Errorf("no VNC viewer found — install one of:\n\n" +
" TigerVNC:\n" +
" Debian: sudo apt install tigervnc-viewer\n" +
" Fedora: sudo dnf install tigervnc\n" +
" Arch: sudo pacman -S tigervnc\n")
default:
return fmt.Errorf("unknown viewer %q — use royaltsx, tigervnc, or screensharing", vncViewer)
}
}

func openVNCRoyalTSX(port string) error {
vncDebug("opening Royal TSX VNC for port %s", port)
return openURL(internalvnc.RoyalTSXVNCUrl(port))
}

func openVNCTigerVNC(port string) error {
vncDebug("opening TigerVNC for port %s", port)
cmd := exec.Command("vncviewer", "-passwd", internalvnc.VNCPasswdFile(), "127.0.0.1:"+port)
if runtime.GOOS == "darwin" {
return cmd.Start()
}
return cmd.Run()
}

func openVNCScreenSharing(port string) error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("screensharing viewer is only available on macOS")
}
vncDebug("opening macOS Screen Sharing for port %s", port)
return openURL(internalvnc.VNCUrl(port))
}

func vncDefault() error {
// Fast path: EXT_VNC_PORT is injected at container start with the correct
// published host port. When set, we're inside a devcell container and can
// use it directly without any docker lookup.
if port := os.Getenv("EXT_VNC_PORT"); port != "" {
vncDebug("EXT_VNC_PORT=%s (fast path)", port)
return openURL(internalvnc.VNCUrl(port))
return openVNC(port)
}

c, err := config.LoadFromOS()
Expand All @@ -72,7 +137,7 @@ func vncDefault() error {
if m, _ := internalvnc.ParseDockerPS(string(bytes.TrimSpace(out))); len(m) > 0 {
for appName, port := range m {
vncDebug("label-exact match: %s → %s", appName, port)
return openURL(internalvnc.VNCUrl(port))
return openVNC(port)
}
}
}
Expand All @@ -87,7 +152,7 @@ func vncDefault() error {
if len(m) == 1 {
for appName, port := range m {
vncDebug("label-dir single match: %s → %s", appName, port)
return openURL(internalvnc.VNCUrl(port))
return openVNC(port)
}
}
// Multiple sessions — list options.
Expand Down Expand Up @@ -120,7 +185,7 @@ func vncDefault() error {
case 0:
return fmt.Errorf("no running container found for %q — run 'cell vnc --list' to see all", c.BaseDir)
case 1:
return openURL(internalvnc.VNCUrl(matches[0].Port))
return openVNC(matches[0].Port)
default:
var opts []string
for _, m := range matches {
Expand Down Expand Up @@ -169,7 +234,7 @@ func vncApp(appName string) error {
if err != nil {
return fmt.Errorf("VNC port not published for %q: %w", appName, err)
}
return openURL(internalvnc.VNCUrl(port))
return openVNC(port)
}

func openURL(url string) error {
Expand Down
15 changes: 9 additions & 6 deletions docker-bake.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ target "_base-args" {
USER_UID = USER_UID
USER_GID = USER_GID
}

}

# ── Profile image targets ────────────────────────────────────────────────────
Expand Down Expand Up @@ -157,16 +158,18 @@ group "release" {

# local-base: base tagged for local scaffold Dockerfile use (FROM ghcr.io/dimmkirr/devcell:base-local)
target "local-base" {
inherits = ["base"]
tags = ["ghcr.io/dimmkirr/devcell:base-local"]
pull = false
inherits = ["base"]
tags = ["ghcr.io/dimmkirr/devcell:base-local"]
platforms = []
pull = false
}

# local-ultimate: ultimate profile for local testing (uses local nixhome/)
target "local-ultimate" {
inherits = ["ultimate"]
tags = ["ghcr.io/dimmkirr/devcell:ultimate-local"]
pull = false
inherits = ["ultimate"]
tags = ["ghcr.io/dimmkirr/devcell:ultimate-local"]
platforms = []
pull = false
}

# local: load into local Docker daemon (no push, no multi-arch)
Expand Down
6 changes: 5 additions & 1 deletion images/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ RUN export NIX_CONFIG="$(printf 'experimental-features = nix-command flakes\nsan
ENV PATH="${HOME}/.nix-profile/bin:${PATH}"

# Install home-manager and create nix per-user profile compat symlink.
RUN nix profile install "nixpkgs/nixos-25.11#home-manager" && \
# Pin nixpkgs to the exact commit from nixhome/flake.lock to avoid GitHub API
# branch→commit resolution (anonymous rate limit is 60 req/hr per IP; shared
# GHA runners exhaust this). Update this hash when bumping nixhome/flake.lock.
ARG NIXPKGS_REV=c217913993d6c6f6805c3b1a3bda5e639adfde6d
RUN nix profile install "github:NixOS/nixpkgs/${NIXPKGS_REV}#home-manager" && \
mkdir -p /nix/var/nix/profiles/per-user/devcell && \
ln -sfT "$(readlink -f $HOME/.nix-profile)" \
/nix/var/nix/profiles/per-user/devcell/profile
Expand Down
Loading