Skip to content
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/lipgloss v0.12.1
github.com/gorilla/websocket v1.5.3
github.com/wailsapp/wails/v2 v2.11.0
golang.org/x/text v0.22.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down Expand Up @@ -52,5 +53,4 @@ require (
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
)
1 change: 1 addition & 0 deletions internal/app/form.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func (m *Model) handleProviderNav(dir string) {
config.ProviderLocalhostRun,
config.ProviderServeo,
config.ProviderPinggy,
config.ProviderBore,
}

current := config.Provider(m.FormValues.Provider)
Expand Down
1 change: 1 addition & 0 deletions internal/config/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const (
ProviderLocalhostRun Provider = "localhostrun"
ProviderServeo Provider = "serveo"
ProviderPinggy Provider = "pinggy"
ProviderBore Provider = "bore"
)

type TunnelState string
Expand Down
2 changes: 2 additions & 0 deletions internal/i18n/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ func ProviderText(provider string) string {
return T("provider_tunnelmole")
case "localhostrun":
return T("provider_localhostrun")
case "bore":
return T("provider_bore")
default:
return provider
}
Expand Down
1 change: 1 addition & 0 deletions internal/i18n/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ provider_serveo: "Serveo"
provider_cloudflared: "Cloudflared"
provider_tunnelmole: "Tunnelmole"
provider_localhostrun: "LocalhostRun"
provider_bore: "bore"

# Actions
connect: "Connect"
Expand Down
1 change: 1 addition & 0 deletions internal/i18n/locales/es.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ provider_serveo: "Serveo"
provider_cloudflared: "Cloudflared"
provider_tunnelmole: "Tunnelmole"
provider_localhostrun: "LocalhostRun"
provider_bore: "bore"

# Actions
connect: "Conectar"
Expand Down
2 changes: 2 additions & 0 deletions internal/process/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/sthbryan/ftm/internal/config"
"github.com/sthbryan/ftm/internal/providers"
"github.com/sthbryan/ftm/internal/providers/bore"
"github.com/sthbryan/ftm/internal/providers/cloudflared"
"github.com/sthbryan/ftm/internal/providers/pinggy"
"github.com/sthbryan/ftm/internal/providers/ssh"
Expand Down Expand Up @@ -85,6 +86,7 @@ func NewManager() *Manager {
config.ProviderLocalhostRun: ssh.NewLocalhostRun(),
config.ProviderServeo: ssh.NewServeo(),
config.ProviderPinggy: pinggy.New(),
config.ProviderBore: bore.New(),
},
}
}
Expand Down
133 changes: 133 additions & 0 deletions internal/providers/bore/bore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package bore

import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strings"

"github.com/sthbryan/ftm/internal/config"
"github.com/sthbryan/ftm/internal/providers"
)

type BoreProvider struct {
installer *Installer
}

func New() providers.Provider {
configDir, _ := os.UserHomeDir()
if configDir == "" {
configDir = "."
}
baseDir := filepath.Join(configDir, ".config", "foundry-tunnel", "bin")

return &BoreProvider{
installer: NewInstaller(baseDir),
}
}

func (p *BoreProvider) Name() string {
return "bore"
}

func (p *BoreProvider) BinaryName() string {
if runtime.GOOS == "windows" {
return "bore.exe"
}
return "bore"
}

func (p *BoreProvider) IsInstalled() bool {
if _, err := exec.LookPath(p.BinaryName()); err == nil {
return true
}
return p.installer.IsInstalled()
}

func (p *BoreProvider) Install(progress chan<- providers.DownloadProgress) error {
return p.installer.Install(progress)
}

func (p *BoreProvider) FindBinary() string {
if path, err := exec.LookPath(p.BinaryName()); err == nil {
return path
}

if p.installer.IsInstalled() {
return p.installer.BoreBin()
}

home, _ := os.UserHomeDir()
candidates := []string{
filepath.Join(home, ".config", "foundry-tunnel", "bin", p.BinaryName()),
"/usr/local/bin/" + p.BinaryName(),
"/usr/bin/" + p.BinaryName(),
}

if runtime.GOOS == "windows" {
candidates = []string{
p.BinaryName(),
filepath.Join(os.Getenv("ProgramFiles"), "bore", p.BinaryName()),
}
}

for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}

return ""
}

func (p *BoreProvider) Start(ctx context.Context, tunnel config.TunnelConfig, logWriter io.Writer) (*providers.Process, error) {
binary := p.FindBinary()
if binary == "" {
if err := p.installer.Install(nil); err != nil {
return nil, fmt.Errorf("failed to install bore: %w", err)
}
binary = p.installer.BoreBin()
Comment thread
sthbryan marked this conversation as resolved.
}

ctx, cancel := context.WithCancel(ctx)

args := []string{
"local",
fmt.Sprintf("%d", tunnel.LocalPort),
"--to", "bore.pub",
}

cmd := exec.CommandContext(ctx, binary, args...)
cmd.Stdout = logWriter
cmd.Stderr = logWriter

if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("failed to start bore: %w", err)
}

return &providers.Process{
Cancel: cancel,
}, nil
}

var boreURLRegex = regexp.MustCompile(`bore\.pub:\d+`)

func (p *BoreProvider) ParseURL(line string) string {
matches := boreURLRegex.FindString(line)
if matches != "" {
return matches
}
return ""
}

func (p *BoreProvider) IsReady(line string) bool {
lineLower := strings.ToLower(line)
return strings.Contains(lineLower, "bore.pub") &&
(strings.Contains(lineLower, "listening") || strings.Contains(lineLower, "port"))
}
119 changes: 119 additions & 0 deletions internal/providers/bore/installer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package bore

import (
"fmt"
"os"
"path/filepath"
"runtime"

"github.com/sthbryan/ftm/internal/providers"
)

const boreVersion = "v0.6.0"

type Installer struct {
BaseDir string
}

func NewInstaller(baseDir string) *Installer {
return &Installer{BaseDir: baseDir}
}

func (i *Installer) BoreBin() string {
if runtime.GOOS == "windows" {
return filepath.Join(i.BaseDir, "bore.exe")
}
return filepath.Join(i.BaseDir, "bore")
}

func (i *Installer) IsInstalled() bool {
if _, err := os.Stat(i.BoreBin()); err == nil {
return true
}
return false
}

func (i *Installer) Install(progress chan<- providers.DownloadProgress) error {
if err := os.MkdirAll(i.BaseDir, 0755); err != nil {
return fmt.Errorf("failed to create base dir: %w", err)
}

if i.IsInstalled() {
return nil
}

platform := runtime.GOOS
arch := runtime.GOARCH

var url string
var filename string

switch platform {
case "darwin":
switch arch {
case "arm64":
filename = "bore-" + boreVersion + "-aarch64-apple-darwin.tar.gz"
url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename
case "amd64":
filename = "bore-" + boreVersion + "-x86_64-apple-darwin.tar.gz"
url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename
default:
return fmt.Errorf("unsupported architecture for macOS: %s (supported: arm64, amd64)", arch)
}
case "linux":
switch arch {
case "arm64":
filename = "bore-" + boreVersion + "-aarch64-unknown-linux-musl.tar.gz"
url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename
case "amd64":
filename = "bore-" + boreVersion + "-x86_64-unknown-linux-musl.tar.gz"
url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename
default:
return fmt.Errorf("unsupported architecture for Linux: %s (supported: arm64, amd64)", arch)
}
case "windows":
if arch == "amd64" {
filename = "bore-" + boreVersion + "-x86_64-pc-windows-msvc.zip"
url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename
} else {
return fmt.Errorf("unsupported architecture for Windows: %s (supported: amd64)", arch)
}
Comment thread
sthbryan marked this conversation as resolved.
Comment thread
sthbryan marked this conversation as resolved.
default:
return fmt.Errorf("unsupported platform: %s", platform)
}
Comment thread
sthbryan marked this conversation as resolved.

destArchive := filepath.Join(i.BaseDir, filename)

if progress != nil {
progress <- providers.DownloadProgress{
Percent: 10,
Current: 0,
Total: 100,
Name: "bore",
}
}

if err := providers.DownloadWithProgress(url, destArchive, progress, "bore"); err != nil {
return fmt.Errorf("failed to download bore: %w", err)
}

defer os.Remove(destArchive)

if err := providers.ExtractArchive(destArchive, i.BaseDir, "bore"); err != nil {
return fmt.Errorf("failed to extract bore: %w", err)
}
Comment thread
sthbryan marked this conversation as resolved.

if _, err := os.Stat(i.BoreBin()); err != nil {
return fmt.Errorf("binary not found after install: %w", err)
}

if progress != nil {
progress <- providers.DownloadProgress{
Percent: 100,
Done: true,
Name: "bore",
}
}

return nil
}
13 changes: 9 additions & 4 deletions internal/providers/cloudflared/cloudflared.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,7 @@ func (p *CloudflaredProvider) FindBinary() string {
func (p *CloudflaredProvider) Start(ctx context.Context, tunnel config.TunnelConfig, logWriter io.Writer) (*providers.Process, error) {
binary := p.FindBinary()
if binary == "" {
if err := p.installer.Install(nil); err != nil {
return nil, fmt.Errorf("failed to install cloudflared: %w", err)
}
binary = p.installer.CloudflaredBin()
return nil, fmt.Errorf("installing")
Comment thread
sthbryan marked this conversation as resolved.
}

ctx, cancel := context.WithCancel(ctx)
Expand Down Expand Up @@ -131,3 +128,11 @@ func (p *CloudflaredProvider) IsReady(line string) bool {
return strings.Contains(line, "trycloudflare.com") ||
strings.Contains(line, "started tunnel")
}

func (p *CloudflaredProvider) IsInstalled() bool {
return p.installer.IsInstalled()
}

func (p *CloudflaredProvider) Install(progress chan<- providers.DownloadProgress) error {
return p.installer.Install(progress)
}
Loading
Loading