feat: add windows support (#320)

Drew Smirnoff created

Change summary

.github/workflows/ci.yml  |  8 +++++
.goreleaser.yml           |  5 +++
docs/docs/installation.md | 19 ++++++++++++
main.go                   | 57 +++++++++++++++++++++++++++++++++++++---
view/html.go              | 30 +--------------------
view/terminal_unix.go     | 32 +++++++++++++++++++++++
view/terminal_windows.go  |  9 ++++++
7 files changed, 125 insertions(+), 35 deletions(-)

Detailed changes

.github/workflows/ci.yml 🔗

@@ -8,7 +8,10 @@ on:
 
 jobs:
   build:
-    runs-on: ubuntu-latest
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ubuntu-latest, windows-latest, macos-latest]
     steps:
       - uses: actions/checkout@v6
 
@@ -22,3 +25,6 @@ jobs:
 
       - name: Test
         run: go test -v ./...
+
+      - name: Build
+        run: go build -v ./...

.goreleaser.yml 🔗

@@ -20,6 +20,7 @@ builds:
     goos:
       - linux
       - darwin # macOS
+      - windows
     goarch:
       - amd64 # Intel
       - arm64 # Apple Silicon
@@ -39,10 +40,14 @@ archives:
   - # The archive configuration.
     # This will create .tar.gz files.
     formats: [tar.gz]
+
     # A template for the archive file names.
     # e.g., matcha_1.2.3_darwin_amd64.tar.gz
     name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
     # Files to include in the archive.
+    format_overrides:
+      - goos: windows
+        formats: ["zip"]
     files:
       - LICENSE
 

docs/docs/installation.md 🔗

@@ -36,7 +36,9 @@ You can download pre-compiled binaries from the [Releases page](https://github.c
 3. Move the binary to your path:
    ```bash
    mv matcha /usr/local/bin/
+   chmod +x /usr/local/bin/executable_name
    ```
+  
 4. Run it:
    ```bash
    matcha
@@ -100,7 +102,22 @@ You can download pre-compiled binaries from the [Releases page](https://github.c
 
 ## 🪟 Windows
 
-Currently, there is no native support for Windows. Please see issue [#123](https://github.com/floatpane/matcha/issues/123) for more details.
+### Manual Binary Download
+
+You can download pre-compiled binaries from the [Releases page](https://github.com/floatpane/matcha/releases).
+
+1. Move the executable to a permanent, dedicated location on your computer (e.g., C:\CLI Tools\MyTool\).
+2. Open the System Properties window by searching for "environment variables" in the Windows Start menu and selecting "Edit the system environment variables".
+3. Click the "Environment Variables..." button at the bottom of the System Properties window, under the "Advanced" tab.
+4. Locate the "Path" variable in the "User variables for [Your Username]" section (for access only by your user account) or the "System variables" section (for all users).
+5. Double-click on the "Path" variable to edit it.
+6. Add the path to your executable's folder:
+
+    In the "Edit environment variable" window, click "New".
+    Type or paste the full path to the folder where your executable is located (e.g., C:\CLI Tools\MyTool\).
+    Click "OK" on all open windows to save the changes.
+
+> Matcha will be added to WinGet as soons as possible!
 
 ### WSL
 

main.go 🔗

@@ -2,6 +2,7 @@ package main
 
 import (
 	"archive/tar"
+	"archive/zip"
 	"bytes"
 	"compress/gzip"
 	"encoding/base64"
@@ -1954,7 +1955,13 @@ func runUpdateCLI() error {
 		return fmt.Errorf("could not write asset to disk: %w", err)
 	}
 
-	// If it's a tar.gz, extract and find the `matcha` binary
+	// Determine the expected binary name based on the OS.
+	binaryName := "matcha"
+	if runtime.GOOS == "windows" {
+		binaryName = "matcha.exe"
+	}
+
+	// Extract the binary from the archive.
 	var binPath string
 	if strings.HasSuffix(assetName, ".tar.gz") || strings.HasSuffix(assetName, ".tgz") {
 		f, err := os.Open(assetPath)
@@ -1976,9 +1983,8 @@ func runUpdateCLI() error {
 				return fmt.Errorf("error reading tar: %w", err)
 			}
 			name := filepath.Base(hdr.Name)
-			if name == "matcha" || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
-				// write out the file
-				binPath = filepath.Join(tmpDir, "matcha")
+			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && (hdr.Typeflag == tar.TypeReg) {
+				binPath = filepath.Join(tmpDir, binaryName)
 				out, err := os.Create(binPath)
 				if err != nil {
 					return fmt.Errorf("could not create binary file: %w", err)
@@ -1994,6 +2000,38 @@ func runUpdateCLI() error {
 				break
 			}
 		}
+	} else if strings.HasSuffix(assetName, ".zip") {
+		zr, err := zip.OpenReader(assetPath)
+		if err != nil {
+			return fmt.Errorf("could not open zip archive: %w", err)
+		}
+		defer zr.Close()
+		for _, zf := range zr.File {
+			name := filepath.Base(zf.Name)
+			if name == binaryName || strings.Contains(strings.ToLower(name), "matcha") && !zf.FileInfo().IsDir() {
+				rc, err := zf.Open()
+				if err != nil {
+					return fmt.Errorf("could not open file in zip: %w", err)
+				}
+				binPath = filepath.Join(tmpDir, binaryName)
+				out, err := os.Create(binPath)
+				if err != nil {
+					rc.Close()
+					return fmt.Errorf("could not create binary file: %w", err)
+				}
+				if _, err := io.Copy(out, rc); err != nil {
+					out.Close()
+					rc.Close()
+					return fmt.Errorf("could not extract binary: %w", err)
+				}
+				out.Close()
+				rc.Close()
+				if err := os.Chmod(binPath, 0755); err != nil {
+					return fmt.Errorf("could not make binary executable: %w", err)
+				}
+				break
+			}
+		}
 	} else {
 		// For non-archive assets, assume the asset is the binary itself.
 		binPath = assetPath
@@ -2033,7 +2071,16 @@ func runUpdateCLI() error {
 	in.Close()
 	out.Close()
 
-	// Attempt to atomically replace
+	// On Windows, a running executable cannot be overwritten directly.
+	// Move the old binary out of the way first, then rename the new one in.
+	if runtime.GOOS == "windows" {
+		oldPath := execPath + ".old"
+		_ = os.Remove(oldPath) // clean up any previous leftover
+		if err := os.Rename(execPath, oldPath); err != nil {
+			return fmt.Errorf("could not move old executable out of the way: %w", err)
+		}
+	}
+
 	if err := os.Rename(tmpNew, execPath); err != nil {
 		return fmt.Errorf("could not replace executable: %w", err)
 	}

view/html.go 🔗

@@ -23,7 +23,6 @@ import (
 	"github.com/floatpane/matcha/theme"
 	"github.com/yuin/goldmark"
 	"github.com/yuin/goldmark/renderer/html"
-	"golang.org/x/sys/unix"
 )
 
 func linkStyle() lipgloss.Style {
@@ -57,32 +56,6 @@ func getTerminalCellSize() int {
 	return defaultCellHeight
 }
 
-// getCellHeightFromFd attempts to get the terminal cell height from a file descriptor.
-// Returns 0 if it fails or if pixel dimensions are not available.
-func getCellHeightFromFd(fd int) int {
-	ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
-	if err != nil {
-		return 0
-	}
-
-	// ws.Row = number of character rows
-	// ws.Ypixel = height in pixels
-	// Some terminals don't report pixel dimensions (return 0)
-	if ws.Row > 0 && ws.Ypixel > 0 {
-		cellHeight := int(ws.Ypixel) / int(ws.Row)
-		if cellHeight > 0 {
-			debugImageProtocol("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
-			return cellHeight
-		}
-	}
-
-	// Terminal reported dimensions but no pixel info - this is common
-	if ws.Row > 0 && ws.Ypixel == 0 {
-		debugImageProtocol("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
-	}
-
-	return 0
-}
 
 // hyperlinkSupported checks if the terminal supports OSC 8 hyperlinks.
 func hyperlinkSupported() bool {
@@ -129,7 +102,8 @@ func hyperlinkSupported() bool {
 	// Check for specific environment variables that indicate hyperlink support
 	if os.Getenv("KITTY_WINDOW_ID") != "" ||
 		os.Getenv("GHOSTTY_RESOURCES_DIR") != "" ||
-		os.Getenv("WEZTERM_EXECUTABLE") != "" {
+		os.Getenv("WEZTERM_EXECUTABLE") != "" ||
+		os.Getenv("WT_SESSION") != "" {
 		return true
 	}
 

view/terminal_unix.go 🔗

@@ -0,0 +1,32 @@
+//go:build !windows
+
+package view
+
+import "golang.org/x/sys/unix"
+
+// getCellHeightFromFd attempts to get the terminal cell height from a file descriptor.
+// Returns 0 if it fails or if pixel dimensions are not available.
+func getCellHeightFromFd(fd int) int {
+	ws, err := unix.IoctlGetWinsize(fd, unix.TIOCGWINSZ)
+	if err != nil {
+		return 0
+	}
+
+	// ws.Row = number of character rows
+	// ws.Ypixel = height in pixels
+	// Some terminals don't report pixel dimensions (return 0)
+	if ws.Row > 0 && ws.Ypixel > 0 {
+		cellHeight := int(ws.Ypixel) / int(ws.Row)
+		if cellHeight > 0 {
+			debugImageProtocol("terminal cell height: %d pixels (rows=%d, ypixel=%d, fd=%d)", cellHeight, ws.Row, ws.Ypixel, fd)
+			return cellHeight
+		}
+	}
+
+	// Terminal reported dimensions but no pixel info - this is common
+	if ws.Row > 0 && ws.Ypixel == 0 {
+		debugImageProtocol("terminal fd=%d has rows=%d but no pixel info (ypixel=0)", fd, ws.Row)
+	}
+
+	return 0
+}

view/terminal_windows.go 🔗

@@ -0,0 +1,9 @@
+//go:build windows
+
+package view
+
+// getCellHeightFromFd is not supported on Windows.
+// Returns 0 to fall back to the default cell height.
+func getCellHeightFromFd(fd int) int {
+	return 0
+}